Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d7fab39b4 | ||
|
|
1906a74759 | ||
|
|
3c46cd7bf0 | ||
|
|
7f8491d197 | ||
|
|
3fd7a2231a | ||
|
|
c295b60dd8 | ||
| cc9b846c31 | |||
| 87cb12906d | |||
| 075fc709dd | |||
| cbaeafa9c8 | |||
| 71f73e2f1d | |||
| 2e973b6d78 | |||
| 8508ee2921 | |||
| b153afbf51 | |||
|
|
9b5d57902d | ||
|
|
4e1a46bd71 | ||
|
|
857ec7a0e5 | ||
|
|
01f21fa5ac | ||
|
|
a1edca3be9 | ||
|
|
7fbf813952 | ||
|
|
e58fd35ee4 | ||
|
|
e3559035f7 | ||
|
|
5edffe822b | ||
|
|
99fd80bca7 | ||
|
|
d8edd5d5f0 | ||
|
|
9cb17ee03f | ||
|
|
8f596cec68 | ||
|
|
8fd27d11a7 | ||
|
|
600f842b82 | ||
|
|
acf7c8a4da | ||
|
|
5984a57a8b | ||
|
|
84dda8cf0a | ||
|
|
abeb26d82d | ||
|
|
29edd73744 | ||
|
|
e8d0e28415 | ||
|
|
08feda9af6 | ||
|
|
af79b6f3bf | ||
|
|
bca82f9dc0 | ||
| 17969277e6 | |||
| 0dbfe45353 |
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/**
|
||||
|
||||
75
CLAUDE.md
75
CLAUDE.md
@@ -1,77 +1,24 @@
|
||||
# QuoteForge - Claude Code Instructions
|
||||
|
||||
## Overview
|
||||
Корпоративный конфигуратор серверов с offline-first архитектурой.
|
||||
Приложение работает через локальную SQLite базу, синхронизация с MariaDB выполняется фоново.
|
||||
## Bible
|
||||
|
||||
## Product Scope
|
||||
- Конфигуратор компонентов и расчёт КП
|
||||
- Проекты и конфигурации
|
||||
- Read-only просмотр прайслистов из локального кэша
|
||||
- Sync (pull компонентов/прайслистов, push локальных изменений)
|
||||
The **[bible/](bible/README.md)** is the single source of truth for this project's architecture, schemas, patterns, and rules. Read it before making any changes.
|
||||
|
||||
Из области исключены:
|
||||
- admin pricing UI/API
|
||||
- stock import
|
||||
- alerts
|
||||
- cron/importer утилиты
|
||||
**Rules:**
|
||||
- Every architectural decision must be recorded in `bible/` in the same commit as the code.
|
||||
- Bible files are written and updated in **English only**.
|
||||
- Before working on the codebase, check `releases/memory/` for the latest release notes.
|
||||
|
||||
## Architecture
|
||||
- Local-first: чтение и запись происходят в SQLite
|
||||
- MariaDB используется как сервер синхронизации
|
||||
- Background worker: периодический sync push+pull
|
||||
## Quick Reference
|
||||
|
||||
## 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`
|
||||
- `local_pricelists`, `local_pricelist_items`
|
||||
- `local_configurations`
|
||||
- `local_projects`
|
||||
- `pending_changes`
|
||||
|
||||
## API Endpoints
|
||||
| Group | Endpoints |
|
||||
|-------|-----------|
|
||||
| Setup | `GET /setup`, `POST /setup`, `POST /setup/test`, `GET /setup/status` |
|
||||
| Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` |
|
||||
| Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` |
|
||||
| Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` |
|
||||
| Configs | CRUD + refresh/clone/reactivate/rename/project binding via `/api/configs/*` |
|
||||
| Projects | CRUD + nested configs via `/api/projects/*` |
|
||||
| Sync | `GET /api/sync/status`, `GET /api/sync/readiness`, `GET /api/sync/info`, `GET /api/sync/users-status`, `POST /api/sync/components`, `POST /api/sync/pricelists`, `POST /api/sync/all`, `POST /api/sync/push`, `GET /api/sync/pending`, `GET /api/sync/pending/count` |
|
||||
| Export | `POST /api/export/csv` |
|
||||
|
||||
## Web Routes
|
||||
- `/configs`
|
||||
- `/configurator`
|
||||
- `/projects`
|
||||
- `/projects/:uuid`
|
||||
- `/pricelists`
|
||||
- `/pricelists/:id`
|
||||
- `/setup`
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
# Development
|
||||
# Verify build
|
||||
go build ./cmd/qfs && go vet ./...
|
||||
|
||||
# Run
|
||||
go run ./cmd/qfs
|
||||
make run
|
||||
|
||||
# Build
|
||||
make build-release
|
||||
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
|
||||
|
||||
# Verification
|
||||
go build ./cmd/qfs
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Code Style
|
||||
- gofmt
|
||||
- structured logging (`slog`)
|
||||
- explicit error wrapping with context
|
||||
|
||||
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)"
|
||||
|
||||
430
README.md
430
README.md
@@ -1,434 +1,66 @@
|
||||
# QuoteForge
|
||||
|
||||
**Server Configuration & Quotation Tool**
|
||||
**Корпоративный конфигуратор серверов и расчёт КП**
|
||||
|
||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП).
|
||||
Приложение работает в strict local-first режиме: пользовательские операции выполняются через локальную SQLite, MariaDB используется для синхронизации и серверного администрирования прайслистов.
|
||||
Offline-first архитектура: пользовательские операции через локальную SQLite, MariaDB только для синхронизации.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Возможности
|
||||
---
|
||||
|
||||
### Для пользователей
|
||||
- 📱 **Mobile-first интерфейс** — удобная работа с телефона и планшета
|
||||
- 🖥️ **Конфигуратор серверов** — пошаговый выбор компонентов с проверкой совместимости
|
||||
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
|
||||
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
|
||||
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
|
||||
- 🔌 **Полная офлайн-работа** — можно продолжать работу без сети и синхронизировать позже
|
||||
- 🛡️ **Защищенная синхронизация** — sync блокируется preflight-проверкой, если локальная схема не готова
|
||||
## Документация
|
||||
|
||||
### Для ценовых администраторов
|
||||
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
|
||||
- 🎯 **Система алертов** — уведомления о популярных компонентах с устаревшими ценами
|
||||
- 📉 **Аналитика использования** — какие компоненты востребованы в КП
|
||||
- ⚙️ **Гибкие настройки** — периоды расчёта, методы, ручные переопределения
|
||||
Полная архитектурная документация хранится в **[bible/](bible/README.md)**:
|
||||
|
||||
### Индикация актуальности цен
|
||||
| Цвет | Статус | Условие |
|
||||
|------|--------|---------|
|
||||
| 🟢 Зелёный | Свежая | < 30 дней, ≥ 3 источника |
|
||||
| 🟡 Жёлтый | Нормальная | 30-60 дней |
|
||||
| 🟠 Оранжевый | Устаревающая | 60-90 дней |
|
||||
| 🔴 Красный | Устаревшая | > 90 дней или нет данных |
|
||||
| Файл | Тема |
|
||||
|------|------|
|
||||
| [bible/01-overview.md](bible/01-overview.md) | Продукт, возможности, технологии, структура репо |
|
||||
| [bible/02-architecture.md](bible/02-architecture.md) | Local-first, sync, ценообразование, версионность |
|
||||
| [bible/03-database.md](bible/03-database.md) | SQLite и MariaDB схемы, права, миграции |
|
||||
| [bible/04-api.md](bible/04-api.md) | Все API endpoints и web-маршруты |
|
||||
| [bible/05-config.md](bible/05-config.md) | Конфигурация, env vars, установка |
|
||||
| [bible/06-backup.md](bible/06-backup.md) | Резервное копирование |
|
||||
| [bible/07-dev.md](bible/07-dev.md) | Команды разработки, стиль кода, guardrails |
|
||||
|
||||
## Технологии
|
||||
---
|
||||
|
||||
- **Backend:** Go 1.22+, Gin, GORM
|
||||
- **Frontend:** HTML, Tailwind CSS, htmx
|
||||
- **Database:** SQLite (runtime/local-first), MariaDB 11+ (sync + server admin)
|
||||
- **Export:** excelize (XLSX), encoding/csv
|
||||
|
||||
## Требования
|
||||
|
||||
- Go 1.22 или выше
|
||||
- MariaDB 11.x (или MySQL 8.x)
|
||||
- ~50 MB дискового пространства
|
||||
|
||||
## Установка
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-company/quoteforge.git
|
||||
cd quoteforge
|
||||
```
|
||||
|
||||
### 2. Настройка runtime-конфига (опционально)
|
||||
|
||||
`config.yaml` создаётся автоматически при первом старте в той же user-state папке, где находится `qfs.db`.
|
||||
Если найден старый формат, приложение автоматически мигрирует файл в актуальный runtime-формат
|
||||
(оставляя только используемые секции `server` и `logging`).
|
||||
|
||||
При необходимости можно создать/отредактировать файл вручную:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
mode: "release"
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
output: "stdout"
|
||||
```
|
||||
|
||||
### 3. Миграции базы данных
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
# Применить миграции
|
||||
go run ./cmd/qfs -migrate
|
||||
```
|
||||
|
||||
### Мигратор OPS -> проекты (preview/apply)
|
||||
|
||||
Переносит квоты, чьи названия начинаются с `OPS-xxxx` (где `x` — цифра), в проект `OPS-xxxx`.
|
||||
Если проекта нет, он будет создан; если архивный — реактивирован.
|
||||
|
||||
Сначала всегда смотрите preview:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects
|
||||
```
|
||||
|
||||
Применение изменений:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -apply
|
||||
```
|
||||
|
||||
Без интерактивного подтверждения:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -apply -yes
|
||||
```
|
||||
|
||||
### Минимальные права БД для пользователя квотаций
|
||||
|
||||
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
|
||||
|
||||
```sql
|
||||
-- 1) Создать пользователя (если его ещё нет)
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
|
||||
-- 2) Если пользователь уже существовал, принудительно обновить пароль
|
||||
ALTER USER 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
|
||||
-- 3) (Опционально, но рекомендуется) удалить дубли пользователя с другими host,
|
||||
-- чтобы не возникало конфликтов вида user@localhost vs user@'%'
|
||||
DROP USER IF EXISTS 'quote_user'@'localhost';
|
||||
DROP USER IF EXISTS 'quote_user'@'127.0.0.1';
|
||||
DROP USER IF EXISTS 'quote_user'@'::1';
|
||||
|
||||
-- 4) Сбросить лишние права
|
||||
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
|
||||
|
||||
-- 5) Чтение данных для конфигуратора и синка
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||
|
||||
-- 6) Работа с конфигурациями
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
SHOW GRANTS FOR 'quote_user'@'%';
|
||||
SHOW CREATE USER 'quote_user'@'%';
|
||||
```
|
||||
|
||||
Полный набор прав для пользователя квотаций:
|
||||
|
||||
```sql
|
||||
GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
```
|
||||
|
||||
Важно:
|
||||
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
|
||||
- если видите ошибку `Access denied for user ...@'<ip>'`, проверьте, что не осталось других записей `quote_user@host` кроме `quote_user@'%'`;
|
||||
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
|
||||
```bash
|
||||
go run ./cmd/importer
|
||||
```
|
||||
|
||||
### 5. Запуск
|
||||
|
||||
```bash
|
||||
# Development
|
||||
# Запустить
|
||||
go run ./cmd/qfs
|
||||
|
||||
# Production (with Makefile - recommended)
|
||||
make build-release # Builds with version info
|
||||
./bin/qfs -version # Check version
|
||||
|
||||
# Production (manual)
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
|
||||
./bin/qfs -version
|
||||
# или
|
||||
make run
|
||||
```
|
||||
|
||||
**Makefile команды:**
|
||||
```bash
|
||||
make build-release # Оптимизированная сборка с версией
|
||||
make build-all # Сборка для всех платформ (Linux, macOS, Windows)
|
||||
make build-windows # Только для Windows
|
||||
make run # Запуск dev сервера
|
||||
make test # Запуск тестов
|
||||
make clean # Очистка bin/
|
||||
make help # Показать все команды
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу: http://localhost:8080
|
||||
|
||||
### Локальная SQLite база (state)
|
||||
|
||||
Локальная база приложения хранится в профиле пользователя и не зависит от расположения бинарника.
|
||||
Имя файла: `qfs.db`.
|
||||
|
||||
- macOS: `~/Library/Application Support/QuoteForge/qfs.db`
|
||||
- Linux: `$XDG_STATE_HOME/quoteforge/qfs.db` (или `~/.local/state/quoteforge/qfs.db`)
|
||||
- Windows: `%LOCALAPPDATA%\\QuoteForge\\qfs.db`
|
||||
|
||||
Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`.
|
||||
|
||||
#### Sync readiness guard
|
||||
|
||||
Перед `push/pull` выполняется preflight-проверка:
|
||||
- доступен ли сервер (MariaDB);
|
||||
- можно ли проверить и применить централизованные миграции локальной БД;
|
||||
- подходит ли версия приложения под `min_app_version` миграций.
|
||||
|
||||
Если проверка не пройдена:
|
||||
- локальная работа (CRUD) продолжается;
|
||||
- sync API возвращает `423 Locked` с `reason_code` и `reason_text`;
|
||||
- в UI показывается красный индикатор и причина блокировки в модалке синхронизации.
|
||||
|
||||
#### Схема потоков данных синхронизации
|
||||
|
||||
```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-версии:
|
||||
|
||||
- таблица: `local_configuration_versions`
|
||||
- для каждого изменения создаётся новая версия (`version_no = max + 1`)
|
||||
- `local_configurations.current_version_id` указывает на активную версию
|
||||
- старые версии не изменяются и не удаляются в обычном потоке
|
||||
- rollback не "перематывает" историю, а создаёт новую версию из выбранного snapshot
|
||||
|
||||
При backfill (миграция `006_add_local_configuration_versions.sql`) для существующих конфигураций создаётся `v1` и проставляется `current_version_id`.
|
||||
|
||||
#### Rollback
|
||||
|
||||
Rollback выполняется API-методом:
|
||||
Приложение: http://localhost:8080 → откроется `/setup` для настройки подключения к MariaDB.
|
||||
|
||||
```bash
|
||||
POST /api/configs/:uuid/rollback
|
||||
{
|
||||
"target_version": 3,
|
||||
"note": "optional"
|
||||
}
|
||||
# Сборка
|
||||
make build-release
|
||||
|
||||
# Проверка
|
||||
go build ./cmd/qfs && go vet ./...
|
||||
```
|
||||
|
||||
Результат:
|
||||
- создаётся новая версия `vN` с `data` из целевой версии
|
||||
- `change_note = "rollback to v{target_version}"` (+ note, если передан)
|
||||
- `current_version_id` переключается на новую версию
|
||||
- конфигурация уходит в `sync_status = pending`
|
||||
---
|
||||
|
||||
### Локальный config.yaml
|
||||
## Releases & Changelog
|
||||
|
||||
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
|
||||
Если файла нет, он создаётся автоматически. Если формат устарел, он автоматически мигрируется в runtime-формат (`server` + `logging`).
|
||||
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
|
||||
Changelog между версиями: `releases/memory/v{major}.{minor}.{patch}.md`
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Сборка образа
|
||||
docker build -t quoteforge .
|
||||
|
||||
# Запуск с docker-compose
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
quoteforge/
|
||||
├── cmd/
|
||||
│ ├── server/main.go # Main HTTP server
|
||||
│ └── importer/main.go # Import metadata from lot table
|
||||
├── internal/
|
||||
│ ├── config/ # Конфигурация
|
||||
│ ├── models/ # GORM модели
|
||||
│ ├── handlers/ # HTTP handlers
|
||||
│ ├── services/ # Бизнес-логика
|
||||
│ ├── middleware/ # Auth, CORS, etc.
|
||||
│ └── repository/ # Работа с БД
|
||||
├── web/
|
||||
│ ├── templates/ # HTML шаблоны
|
||||
│ └── static/ # CSS, JS, изображения
|
||||
├── migrations/ # SQL миграции
|
||||
├── config.example.yaml # Пример конфигурации
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
## Роли пользователей
|
||||
|
||||
| Роль | Описание |
|
||||
|------|----------|
|
||||
| `viewer` | Просмотр, создание квот, экспорт |
|
||||
| `editor` | + сохранение конфигураций |
|
||||
| `pricing_admin` | + управление ценами и алертами |
|
||||
| `admin` | Полный доступ, управление пользователями |
|
||||
|
||||
## API
|
||||
|
||||
Документация API доступна по адресу `/api/docs` (в разработке).
|
||||
|
||||
Основные endpoints:
|
||||
|
||||
```
|
||||
POST /api/auth/login # Авторизация
|
||||
GET /api/components # Список компонентов
|
||||
POST /api/quote/calculate # Расчёт цены
|
||||
POST /api/export/xlsx # Экспорт в Excel
|
||||
GET /api/configs # Сохранённые конфигурации
|
||||
GET /api/configs/:uuid/versions # Список версий конфигурации
|
||||
GET /api/configs/:uuid/versions/:version # Получить конкретную версию
|
||||
POST /api/configs/:uuid/rollback # Rollback на указанную версию
|
||||
POST /api/configs/:uuid/reactivate # Вернуть архивную конфигурацию в активные
|
||||
GET /api/sync/readiness # Статус readiness guard (ready|blocked|unknown)
|
||||
GET /api/sync/status # Сводный статус синхронизации
|
||||
GET /api/sync/info # Данные для модалки синхронизации
|
||||
POST /api/sync/push # Push pending changes (423, если blocked)
|
||||
POST /api/sync/all # Full sync push+pull (423, если blocked)
|
||||
POST /api/sync/components # Pull components (423, если blocked)
|
||||
POST /api/sync/pricelists # Pull pricelists (423, если blocked)
|
||||
```
|
||||
|
||||
### Краткая карта sync 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` для конфигураций содержат:
|
||||
- `configuration_uuid`
|
||||
- `operation` (`create` / `update` / `rollback`)
|
||||
- `current_version_id` и `current_version_no`
|
||||
- `snapshot` (текущее состояние конфигурации)
|
||||
- `idempotency_key` и `conflict_policy` (`last_write_wins`)
|
||||
|
||||
Это позволяет push-слою отправлять на сервер актуальное состояние и готовит основу для будущего conflict resolution.
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
# Запуск в режиме разработки (hot reload)
|
||||
go run ./cmd/qfs
|
||||
|
||||
# Запуск тестов
|
||||
go test ./...
|
||||
|
||||
# Сборка для Linux
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|------------|----------|--------------|
|
||||
| `QF_DB_HOST` | Хост базы данных | localhost |
|
||||
| `QF_DB_PORT` | Порт базы данных | 3306 |
|
||||
| `QF_DB_NAME` | Имя базы данных | RFQ_LOG |
|
||||
| `QF_DB_USER` | Пользователь БД | — |
|
||||
| `QF_DB_PASSWORD` | Пароль БД | — |
|
||||
| `QF_JWT_SECRET` | Секрет для JWT | — |
|
||||
| `QF_SERVER_PORT` | Порт сервера | 8080 |
|
||||
| `QFS_DB_PATH` | Полный путь к локальной SQLite БД | OS-specific user state dir |
|
||||
| `QFS_STATE_DIR` | Каталог state (если `QFS_DB_PATH` не задан) | OS-specific user state dir |
|
||||
| `QFS_CONFIG_PATH` | Полный путь к `config.yaml` | OS-specific user state dir |
|
||||
|
||||
## Интеграция с существующей БД
|
||||
|
||||
QuoteForge интегрируется с существующей базой RFQ_LOG:
|
||||
|
||||
- `lot` — справочник компонентов (только чтение)
|
||||
- `lot_log` — история цен от поставщиков (только чтение)
|
||||
- `supplier` — справочник поставщиков (только чтение)
|
||||
|
||||
Новые таблицы QuoteForge имеют префикс `qt_`:
|
||||
|
||||
- `qt_users` — пользователи приложения
|
||||
- `qt_lot_metadata` — расширенные данные компонентов
|
||||
- `qt_configurations` — сохранённые конфигурации
|
||||
- `qt_pricing_alerts` — алерты для администраторов
|
||||
---
|
||||
|
||||
## Поддержка
|
||||
|
||||
По вопросам работы приложения обращайтесь:
|
||||
- Email: mike@mchus.pro
|
||||
- Internal: @mchus
|
||||
|
||||
## Лицензия
|
||||
|
||||
Данное программное обеспечение является собственностью компании и предназначено исключительно для внутреннего использования. Распространение, копирование или модификация без письменного разрешения запрещены.
|
||||
|
||||
См. файл [LICENSE](LICENSE) для подробностей.
|
||||
Собственность компании, только для внутреннего использования. См. [LICENSE](LICENSE).
|
||||
|
||||
119
bible/01-overview.md
Normal file
119
bible/01-overview.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 01 — Product Overview
|
||||
|
||||
## What is QuoteForge
|
||||
|
||||
A corporate server configuration and quotation tool.
|
||||
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only for synchronization.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### For Users
|
||||
- Mobile-first interface — works comfortably on phones and tablets
|
||||
- Server configurator — step-by-step component selection
|
||||
- Automatic price calculation — based on pricelists from local cache
|
||||
- CSV export — ready-to-use specifications for clients
|
||||
- Configuration history — versioned snapshots with rollback support
|
||||
- Full offline operation — continue working without network, sync later
|
||||
- Guarded synchronization — sync is blocked by preflight check if local schema is not ready
|
||||
|
||||
### User Roles
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| `viewer` | View, create quotes, export |
|
||||
| `editor` | + save configurations |
|
||||
| `pricing_admin` | + manage prices and alerts |
|
||||
| `admin` | Full access, user management |
|
||||
|
||||
### Price Freshness Indicators
|
||||
|
||||
| Color | Status | Condition |
|
||||
|-------|--------|-----------|
|
||||
| Green | Fresh | < 30 days, ≥ 3 sources |
|
||||
| Yellow | Normal | 30–60 days |
|
||||
| Orange | Aging | 60–90 days |
|
||||
| Red | Stale | > 90 days or no data |
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Stack |
|
||||
|-------|-------|
|
||||
| Backend | Go 1.22+, Gin, GORM |
|
||||
| Frontend | HTML, Tailwind CSS, htmx |
|
||||
| Local DB | SQLite (`qfs.db`) |
|
||||
| Server DB | MariaDB 11+ (sync + server admin) |
|
||||
| Export | encoding/csv, excelize (XLSX) |
|
||||
|
||||
---
|
||||
|
||||
## Product Scope
|
||||
|
||||
**In scope:**
|
||||
- Component configurator and quotation calculation
|
||||
- Projects and configurations
|
||||
- Read-only pricelist viewing from local cache
|
||||
- Sync (pull components/pricelists, push local changes)
|
||||
|
||||
**Out of scope (removed intentionally — do not restore):**
|
||||
- Admin pricing UI/API
|
||||
- Stock import
|
||||
- Alerts
|
||||
- Cron/importer utilities
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
quoteforge/
|
||||
├── cmd/
|
||||
│ ├── qfs/main.go # HTTP server entry point
|
||||
│ ├── migrate/ # Migration tool
|
||||
│ └── migrate_ops_projects/ # OPS project migrator
|
||||
├── internal/
|
||||
│ ├── appmeta/ # App version metadata
|
||||
│ ├── appstate/ # State management, backup
|
||||
│ ├── article/ # Article generation
|
||||
│ ├── config/ # Config parsing
|
||||
│ ├── db/ # DB initialization
|
||||
│ ├── handlers/ # HTTP handlers
|
||||
│ ├── localdb/ # SQLite layer
|
||||
│ ├── lotmatch/ # Lot matching logic
|
||||
│ ├── middleware/ # Auth, CORS, etc.
|
||||
│ ├── models/ # GORM models
|
||||
│ ├── repository/ # Repository layer
|
||||
│ └── services/ # Business logic
|
||||
├── web/
|
||||
│ ├── templates/ # HTML templates + partials
|
||||
│ └── static/ # CSS, JS, assets
|
||||
├── migrations/ # SQL migration files (30+)
|
||||
├── bible/ # Architectural documentation (this section)
|
||||
├── releases/memory/ # Per-version changelogs
|
||||
├── config.example.yaml # Config template (the only one in repo)
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with Existing DB
|
||||
|
||||
QuoteForge integrates with the existing `RFQ_LOG` database:
|
||||
|
||||
**Read-only:**
|
||||
- `lot` — component catalog
|
||||
- `qt_lot_metadata` — extended component data
|
||||
- `qt_categories` — categories
|
||||
- `qt_pricelists`, `qt_pricelist_items` — pricelists
|
||||
|
||||
**Read + Write:**
|
||||
- `qt_configurations` — configurations
|
||||
- `qt_projects` — projects
|
||||
|
||||
**Sync service tables:**
|
||||
- `qt_client_local_migrations` — migration catalog (SELECT only)
|
||||
- `qt_client_schema_state` — applied migrations state
|
||||
- `qt_pricelist_sync_status` — pricelist sync status
|
||||
205
bible/02-architecture.md
Normal file
205
bible/02-architecture.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# 02 — Architecture
|
||||
|
||||
## Local-First Principle
|
||||
|
||||
**SQLite** is the single source of truth for the user.
|
||||
**MariaDB** is a sync server only — it never blocks local operations.
|
||||
|
||||
```
|
||||
User
|
||||
│
|
||||
▼
|
||||
SQLite (qfs.db) ← all CRUD operations go here
|
||||
│
|
||||
│ background sync (every 5 min)
|
||||
▼
|
||||
MariaDB (RFQ_LOG) ← pull/push only
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- All CRUD operations go through SQLite only
|
||||
- If MariaDB is unavailable → local work continues without restrictions
|
||||
- Changes are queued in `pending_changes` and pushed on next sync
|
||||
|
||||
---
|
||||
|
||||
## Synchronization
|
||||
|
||||
### Data Flow Diagram
|
||||
|
||||
```
|
||||
[ SERVER / MariaDB ]
|
||||
┌───────────────────────────┐
|
||||
│ qt_projects │
|
||||
│ qt_configurations │
|
||||
│ qt_pricelists │
|
||||
│ qt_pricelist_items │
|
||||
│ qt_pricelist_sync_status │
|
||||
└─────────────┬─────────────┘
|
||||
│
|
||||
pull (projects/configs/pricelists)
|
||||
│
|
||||
┌────────────────────┴────────────────────┐
|
||||
│ │
|
||||
[ CLIENT A / SQLite ] [ CLIENT B / SQLite ]
|
||||
local_projects local_projects
|
||||
local_configurations local_configurations
|
||||
local_pricelists local_pricelists
|
||||
local_pricelist_items local_pricelist_items
|
||||
pending_changes pending_changes
|
||||
│ │
|
||||
└────── push (projects/configs only) ─────┘
|
||||
│
|
||||
[ SERVER / MariaDB ]
|
||||
```
|
||||
|
||||
### Sync Direction by Entity
|
||||
|
||||
| Entity | Direction |
|
||||
|--------|-----------|
|
||||
| Configurations | Client ↔ Server ↔ Other Clients |
|
||||
| Projects | Client ↔ Server ↔ Other Clients |
|
||||
| Pricelists | Server → Clients only (no push) |
|
||||
| Components | Server → Clients only |
|
||||
|
||||
Local pricelists not present on the server and not referenced by active configurations are deleted automatically on sync.
|
||||
|
||||
### Soft Deletes (Archive Pattern)
|
||||
|
||||
Configurations and projects are **never hard-deleted**. Deletion is archive via `is_active = false`.
|
||||
|
||||
- `DELETE /api/configs/:uuid` → sets `is_active = false` (archived); can be restored via `reactivate`
|
||||
- `DELETE /api/projects/:uuid` → archives a project **variant** only (`variant` field must be non-empty); main projects cannot be deleted via this endpoint
|
||||
|
||||
## Sync Readiness Guard
|
||||
|
||||
Before every push/pull, a preflight check runs:
|
||||
1. Is the server (MariaDB) reachable?
|
||||
2. Can centralized local DB migrations be applied?
|
||||
3. Does the application version satisfy `min_app_version` of pending migrations?
|
||||
|
||||
**If the check fails:**
|
||||
- Local CRUD continues without restriction
|
||||
- Sync API returns `423 Locked` with `reason_code` and `reason_text`
|
||||
- UI shows a red indicator with the block reason
|
||||
|
||||
---
|
||||
|
||||
## Pricing
|
||||
|
||||
### Principle
|
||||
|
||||
**Prices come only from `local_pricelist_items`.**
|
||||
Components (`local_components`) are metadata-only — they contain no pricing information.
|
||||
|
||||
### Lookup Pattern
|
||||
|
||||
```go
|
||||
// Look up a price for a line item
|
||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
||||
if found && price > 0 {
|
||||
// use price
|
||||
}
|
||||
|
||||
// Inside lookupPriceByPricelistID:
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
|
||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
||||
```
|
||||
|
||||
### Multi-Level Pricelists
|
||||
|
||||
A configuration can reference up to three pricelists simultaneously:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `pricelist_id` | Primary (estimate) |
|
||||
| `warehouse_pricelist_id` | Warehouse pricing |
|
||||
| `competitor_pricelist_id` | Competitor pricing |
|
||||
|
||||
Pricelist sources: `estimate` | `warehouse` | `competitor`
|
||||
|
||||
### "Auto" Pricelist Selection
|
||||
|
||||
Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`):
|
||||
|
||||
- **Explicit mode:** concrete `pricelist_id` is set by user in settings.
|
||||
- **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist.
|
||||
|
||||
`auto` must stay `auto` after price-level refresh and after manual "refresh prices":
|
||||
- resolved IDs are runtime-only and must not overwrite user's mode;
|
||||
- switching to explicit selection must clear runtime auto resolution for that source.
|
||||
|
||||
### Latest Pricelist Resolution Rules
|
||||
|
||||
For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with:
|
||||
|
||||
1. only pricelists that have at least one item (`EXISTS ...pricelist_items`);
|
||||
2. deterministic sort: `created_at DESC, id DESC`.
|
||||
|
||||
This prevents selecting empty/incomplete snapshots and removes nondeterministic ties.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Versioning
|
||||
|
||||
### Principle
|
||||
|
||||
Append-only: every save creates an immutable snapshot in `local_configuration_versions`.
|
||||
|
||||
```
|
||||
local_configurations
|
||||
└── current_version_id ──► local_configuration_versions (v3) ← active
|
||||
local_configuration_versions (v2)
|
||||
local_configuration_versions (v1)
|
||||
```
|
||||
|
||||
- `version_no = max + 1` on every save
|
||||
- Old versions are never modified or deleted in normal flow
|
||||
- Rollback does **not** rewind history — it creates a **new** version from the snapshot
|
||||
|
||||
### Rollback
|
||||
|
||||
```bash
|
||||
POST /api/configs/:uuid/rollback
|
||||
{
|
||||
"target_version": 3,
|
||||
"note": "optional comment"
|
||||
}
|
||||
```
|
||||
|
||||
Result:
|
||||
- A new version `vN` is created with `data` from the target version
|
||||
- `change_note = "rollback to v{target_version}"` (+ note if provided)
|
||||
- `current_version_id` is switched to the new version
|
||||
- Configuration moves to `sync_status = pending`
|
||||
|
||||
### Sync Status Flow
|
||||
|
||||
```
|
||||
local → pending → synced
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sync Payload for Versioning
|
||||
|
||||
Events in `pending_changes` for configurations contain:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `configuration_uuid` | Identifier |
|
||||
| `operation` | `create` / `update` / `rollback` |
|
||||
| `current_version_id` | Active version ID |
|
||||
| `current_version_no` | Version number |
|
||||
| `snapshot` | Current configuration state |
|
||||
| `idempotency_key` | For idempotent push |
|
||||
| `conflict_policy` | `last_write_wins` |
|
||||
|
||||
---
|
||||
|
||||
## Background Processes
|
||||
|
||||
| Process | Interval | What it does |
|
||||
|---------|----------|--------------|
|
||||
| Sync worker | 5 min | push pending + pull all |
|
||||
| Backup scheduler | configurable (`backup.time`) | creates ZIP archives |
|
||||
181
bible/03-database.md
Normal file
181
bible/03-database.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# 03 — Database
|
||||
|
||||
## SQLite (local, client-side)
|
||||
|
||||
File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
||||
|
||||
### Tables
|
||||
|
||||
#### Components and Reference Data
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_components` | Component metadata (NO prices) | `lot_name` (PK), `lot_description`, `category`, `model` |
|
||||
| `connection_settings` | MariaDB connection settings | key-value store |
|
||||
| `app_settings` | Application settings | `key` (PK), `value`, `updated_at` |
|
||||
|
||||
#### Pricelists
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` |
|
||||
| `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` |
|
||||
|
||||
#### Configurations and Projects
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
|
||||
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
|
||||
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
|
||||
|
||||
#### Sync
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `pending_changes` | Queue of changes to push to MariaDB |
|
||||
| `local_schema_migrations` | Applied migrations (idempotency guard) |
|
||||
|
||||
---
|
||||
|
||||
### Key SQLite Indexes
|
||||
|
||||
```sql
|
||||
-- Pricelists
|
||||
INDEX local_pricelist_items(pricelist_id)
|
||||
UNIQUE INDEX local_pricelists(server_id)
|
||||
INDEX local_pricelists(source, created_at) -- used for "latest by source" queries
|
||||
-- latest-by-source runtime query also applies deterministic tie-break by id DESC
|
||||
|
||||
-- Configurations
|
||||
INDEX local_configurations(pricelist_id)
|
||||
INDEX local_configurations(warehouse_pricelist_id)
|
||||
INDEX local_configurations(competitor_pricelist_id)
|
||||
UNIQUE INDEX local_configurations(uuid)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `items` JSON Structure in Configurations
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"lot_name": "CPU_AMD_9654",
|
||||
"quantity": 2,
|
||||
"unit_price": 123456.78,
|
||||
"section": "Processors"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Prices are stored inside the `items` JSON field and refreshed from the pricelist on configuration refresh.
|
||||
|
||||
---
|
||||
|
||||
## MariaDB (server-side, sync-only)
|
||||
|
||||
Database: `RFQ_LOG`
|
||||
|
||||
### Tables and Permissions
|
||||
|
||||
| Table | Purpose | Permissions |
|
||||
|-------|---------|-------------|
|
||||
| `lot` | Component catalog | SELECT |
|
||||
| `qt_lot_metadata` | Extended component data | SELECT |
|
||||
| `qt_categories` | Component categories | SELECT |
|
||||
| `qt_pricelists` | Pricelists | SELECT |
|
||||
| `qt_pricelist_items` | Pricelist line items | SELECT |
|
||||
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
|
||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
|
||||
| `qt_configurations` | Saved configurations | SELECT, INSERT, UPDATE |
|
||||
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
||||
| `qt_client_local_migrations` | Migration catalog | SELECT only |
|
||||
| `qt_client_schema_state` | Applied migrations state | SELECT, INSERT, UPDATE |
|
||||
| `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE |
|
||||
|
||||
### Grant Permissions to Existing User
|
||||
|
||||
```sql
|
||||
GRANT SELECT ON RFQ_LOG.lot TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.stock_log TO '<DB_USER>'@'%';
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
|
||||
|
||||
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### Create a New User
|
||||
|
||||
```sql
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
||||
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.stock_log TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
SHOW GRANTS FOR 'quote_user'@'%';
|
||||
```
|
||||
|
||||
**Note:** If pricelists sync but show `0` positions (or logs contain `enriching pricelist items with stock` + `SELECT denied`), verify `SELECT` on `lot_partnumbers` and `stock_log` in addition to `qt_pricelist_items`.
|
||||
|
||||
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
### SQLite Migrations (local)
|
||||
|
||||
- Stored in `migrations/` (SQL files)
|
||||
- Applied via `-migrate` flag or automatically on first run
|
||||
- Idempotent: checked by `id` in `local_schema_migrations`
|
||||
- Already-applied migrations are skipped
|
||||
|
||||
```bash
|
||||
go run ./cmd/qfs -migrate
|
||||
```
|
||||
|
||||
### Centralized Migrations (server-side)
|
||||
|
||||
- Stored in `qt_client_local_migrations` (MariaDB)
|
||||
- Applied automatically during sync readiness check
|
||||
- `min_app_version` — minimum app version required for the migration
|
||||
|
||||
---
|
||||
|
||||
## DB Debugging
|
||||
|
||||
```bash
|
||||
# Inspect schema
|
||||
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_components"
|
||||
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_configurations"
|
||||
|
||||
# Check pricelist item count
|
||||
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM local_pricelist_items"
|
||||
|
||||
# Check pending sync queue
|
||||
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM pending_changes"
|
||||
```
|
||||
127
bible/04-api.md
Normal file
127
bible/04-api.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# 04 — API and Web Routes
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Setup
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/setup` | Initial setup page |
|
||||
| POST | `/setup` | Save connection settings |
|
||||
| POST | `/setup/test` | Test MariaDB connection |
|
||||
| GET | `/setup/status` | Setup status |
|
||||
|
||||
### Components
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/components` | List components (metadata only) |
|
||||
| GET | `/api/components/:lot_name` | Component by lot_name |
|
||||
| GET | `/api/categories` | List categories |
|
||||
|
||||
### Quote
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| POST | `/api/quote/validate` | Validate line items |
|
||||
| POST | `/api/quote/calculate` | Calculate quote (prices from pricelist) |
|
||||
| POST | `/api/quote/price-levels` | Prices by level (estimate/warehouse/competitor) |
|
||||
|
||||
### Pricelists (read-only)
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/pricelists` | List pricelists (`source`, `active_only`, pagination) |
|
||||
| GET | `/api/pricelists/latest` | Latest pricelist by source |
|
||||
| GET | `/api/pricelists/:id` | Pricelist by ID |
|
||||
| GET | `/api/pricelists/:id/items` | Pricelist line items |
|
||||
| GET | `/api/pricelists/:id/lots` | Lot names in pricelist |
|
||||
|
||||
`GET /api/pricelists?active_only=true` returns only pricelists that have synced items (`item_count > 0`).
|
||||
|
||||
### Configurations
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/configs` | List configurations |
|
||||
| POST | `/api/configs` | Create configuration |
|
||||
| GET | `/api/configs/:uuid` | Get configuration |
|
||||
| PUT | `/api/configs/:uuid` | Update configuration |
|
||||
| DELETE | `/api/configs/:uuid` | Archive configuration |
|
||||
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
|
||||
| POST | `/api/configs/:uuid/clone` | Clone configuration |
|
||||
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
|
||||
| POST | `/api/configs/:uuid/rename` | Rename configuration |
|
||||
| POST | `/api/configs/preview-article` | Preview generated article for a configuration |
|
||||
| POST | `/api/configs/:uuid/rollback` | Roll back to a version |
|
||||
| GET | `/api/configs/:uuid/versions` | List versions |
|
||||
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
|
||||
|
||||
### Projects
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/projects` | List projects |
|
||||
| POST | `/api/projects` | Create project |
|
||||
| GET | `/api/projects/:uuid` | Get project |
|
||||
| PUT | `/api/projects/:uuid` | Update project |
|
||||
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
|
||||
| GET | `/api/projects/:uuid/configs` | Project configurations |
|
||||
|
||||
### Sync
|
||||
|
||||
| Method | Endpoint | Purpose | Flow |
|
||||
|--------|----------|---------|------|
|
||||
| GET | `/api/sync/status` | Overall sync status | read-only |
|
||||
| GET | `/api/sync/readiness` | Preflight status (ready/blocked/unknown) | read-only |
|
||||
| GET | `/api/sync/info` | Data for sync modal | read-only |
|
||||
| GET | `/api/sync/users-status` | Users status | read-only |
|
||||
| GET | `/api/sync/pending` | List pending changes | read-only |
|
||||
| GET | `/api/sync/pending/count` | Count of pending changes | read-only |
|
||||
| POST | `/api/sync/push` | Push pending → MariaDB | SQLite → MariaDB |
|
||||
| POST | `/api/sync/components` | Pull components | MariaDB → SQLite |
|
||||
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
|
||||
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
|
||||
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
|
||||
|
||||
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
|
||||
|
||||
### Export
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| POST | `/api/export/csv` | Export configuration to CSV |
|
||||
|
||||
**Export filename format:** `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
||||
(uses `project.Code`, not `project.Name`)
|
||||
|
||||
---
|
||||
|
||||
## Web Routes
|
||||
|
||||
| Route | Page |
|
||||
|-------|------|
|
||||
| `/configs` | Configuration list |
|
||||
| `/configurator` | Configurator |
|
||||
| `/configs/:uuid/revisions` | Configuration revision history |
|
||||
| `/projects` | Project list |
|
||||
| `/projects/:uuid` | Project details |
|
||||
| `/pricelists` | Pricelist list |
|
||||
| `/pricelists/:id` | Pricelist details |
|
||||
| `/setup` | Connection settings |
|
||||
|
||||
---
|
||||
|
||||
## Rollback API (details)
|
||||
|
||||
```bash
|
||||
POST /api/configs/:uuid/rollback
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"target_version": 3,
|
||||
"note": "optional comment"
|
||||
}
|
||||
```
|
||||
|
||||
Response: updated configuration with the new version.
|
||||
129
bible/05-config.md
Normal file
129
bible/05-config.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 05 — Configuration and Environment
|
||||
|
||||
## File Paths
|
||||
|
||||
### SQLite database (`qfs.db`)
|
||||
|
||||
| OS | Default path |
|
||||
|----|-------------|
|
||||
| macOS | `~/Library/Application Support/QuoteForge/qfs.db` |
|
||||
| Linux | `$XDG_STATE_HOME/quoteforge/qfs.db` or `~/.local/state/quoteforge/qfs.db` |
|
||||
| Windows | `%LOCALAPPDATA%\QuoteForge\qfs.db` |
|
||||
|
||||
Override: `-localdb <path>` or `QFS_DB_PATH`.
|
||||
|
||||
### config.yaml
|
||||
|
||||
Searched in the same user-state directory as `qfs.db` by default.
|
||||
If the file does not exist, it is created automatically.
|
||||
If the format is outdated, it is automatically migrated to the runtime format (`server` + `logging` sections only).
|
||||
|
||||
Override: `-config <path>` or `QFS_CONFIG_PATH`.
|
||||
|
||||
**Important:** `config.yaml` is a runtime user file — it is **not stored in the repository**.
|
||||
`config.example.yaml` is the only config template in the repo.
|
||||
|
||||
---
|
||||
|
||||
## config.yaml Structure
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
mode: "release" # release | debug
|
||||
|
||||
logging:
|
||||
level: "info" # debug | info | warn | error
|
||||
format: "json" # json | text
|
||||
output: "stdout" # stdout | stderr | /path/to/file
|
||||
|
||||
backup:
|
||||
time: "00:00" # HH:MM in local time
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `QFS_DB_PATH` | Full path to SQLite DB | OS-specific user state dir |
|
||||
| `QFS_STATE_DIR` | State directory (if `QFS_DB_PATH` is not set) | OS-specific user state dir |
|
||||
| `QFS_CONFIG_PATH` | Full path to `config.yaml` | OS-specific user state dir |
|
||||
| `QFS_BACKUP_DIR` | Root directory for rotating backups | `<db dir>/backups` |
|
||||
| `QFS_BACKUP_DISABLE` | Disable automatic backups | — |
|
||||
| `QF_DB_HOST` | MariaDB host | localhost |
|
||||
| `QF_DB_PORT` | MariaDB port | 3306 |
|
||||
| `QF_DB_NAME` | Database name | RFQ_LOG |
|
||||
| `QF_DB_USER` | DB user | — |
|
||||
| `QF_DB_PASSWORD` | DB password | — |
|
||||
| `QF_JWT_SECRET` | JWT secret | — |
|
||||
| `QF_SERVER_PORT` | HTTP server port | 8080 |
|
||||
|
||||
`QFS_BACKUP_DISABLE` accepts: `1`, `true`, `yes`.
|
||||
|
||||
---
|
||||
|
||||
## CLI Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-config <path>` | Path to config.yaml |
|
||||
| `-localdb <path>` | Path to SQLite DB |
|
||||
| `-reset-localdb` | Reset local DB (destructive!) |
|
||||
| `-migrate` | Apply pending migrations and exit |
|
||||
| `-version` | Print version and exit |
|
||||
|
||||
---
|
||||
|
||||
## Installation and First Run
|
||||
|
||||
### Requirements
|
||||
- Go 1.22 or higher
|
||||
- MariaDB 11.x (or MySQL 8.x)
|
||||
- ~50 MB disk space
|
||||
|
||||
### Steps
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone <repo-url>
|
||||
cd quoteforge
|
||||
|
||||
# 2. Apply migrations
|
||||
go run ./cmd/qfs -migrate
|
||||
|
||||
# 3. Start
|
||||
go run ./cmd/qfs
|
||||
# or
|
||||
make run
|
||||
```
|
||||
|
||||
Application is available at: http://localhost:8080
|
||||
|
||||
On first run, `/setup` opens for configuring the MariaDB connection.
|
||||
|
||||
### OPS Project Migrator
|
||||
|
||||
Migrates quotes whose names start with `OPS-xxxx` (where `x` is a digit) into a project named `OPS-xxxx`.
|
||||
|
||||
```bash
|
||||
# Preview first (always)
|
||||
go run ./cmd/migrate_ops_projects
|
||||
|
||||
# Apply
|
||||
go run ./cmd/migrate_ops_projects -apply
|
||||
|
||||
# Apply without interactive confirmation
|
||||
go run ./cmd/migrate_ops_projects -apply -yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t quoteforge .
|
||||
docker-compose up -d
|
||||
```
|
||||
221
bible/06-backup.md
Normal file
221
bible/06-backup.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# 06 — Backup
|
||||
|
||||
## Overview
|
||||
|
||||
Automatic rotating ZIP backup system for local data.
|
||||
|
||||
**What is included in each archive:**
|
||||
- SQLite DB (`qfs.db`)
|
||||
- SQLite sidecars (`qfs.db-wal`, `qfs.db-shm`) if present
|
||||
- `config.yaml` if present
|
||||
|
||||
**Archive name format:** `qfs-backp-YYYY-MM-DD.zip`
|
||||
|
||||
**Retention policy:**
|
||||
| Period | Keep |
|
||||
|--------|------|
|
||||
| Daily | 7 archives |
|
||||
| Weekly | 4 archives |
|
||||
| Monthly | 12 archives |
|
||||
| Yearly | 10 archives |
|
||||
|
||||
**Directories:** `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
backup:
|
||||
time: "00:00" # Trigger time in local time (HH:MM format)
|
||||
```
|
||||
|
||||
**Environment variables:**
|
||||
- `QFS_BACKUP_DIR` — backup root directory (default: `<db dir>/backups`)
|
||||
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`)
|
||||
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
- **At startup:** if no backup exists for the current period, one is created immediately
|
||||
- **Daily:** at the configured time, a new backup is created
|
||||
- **Deduplication:** prevented via a `.period.json` marker file in each period directory
|
||||
- **Rotation:** excess old archives are deleted automatically
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
Module: `internal/appstate/backup.go`
|
||||
|
||||
Main function:
|
||||
```go
|
||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error)
|
||||
```
|
||||
|
||||
Scheduler (in `main.go`):
|
||||
```go
|
||||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string)
|
||||
```
|
||||
|
||||
### Config struct
|
||||
|
||||
```go
|
||||
type BackupConfig struct {
|
||||
Time string `yaml:"time"`
|
||||
}
|
||||
// Default: "00:00"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `backup.time` is in **local time** without timezone offset parsing
|
||||
- `.period.json` is the marker that prevents duplicate backups within the same period
|
||||
- Archive filenames contain only the date; uniqueness is ensured by per-period directories + the period marker
|
||||
- When changing naming or retention: update both the filename logic and the prune logic together
|
||||
|
||||
---
|
||||
|
||||
## Full Listing: `internal/appstate/backup.go`
|
||||
|
||||
```go
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type backupPeriod struct {
|
||||
name string
|
||||
retention int
|
||||
key func(time.Time) string
|
||||
date func(time.Time) string
|
||||
}
|
||||
|
||||
var backupPeriods = []backupPeriod{
|
||||
{
|
||||
name: "daily",
|
||||
retention: 7,
|
||||
key: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
},
|
||||
{
|
||||
name: "weekly",
|
||||
retention: 4,
|
||||
key: func(t time.Time) string {
|
||||
y, w := t.ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", y, w)
|
||||
},
|
||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
},
|
||||
{
|
||||
name: "monthly",
|
||||
retention: 12,
|
||||
key: func(t time.Time) string { return t.Format("2006-01") },
|
||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
},
|
||||
{
|
||||
name: "yearly",
|
||||
retention: 10,
|
||||
key: func(t time.Time) string { return t.Format("2006") },
|
||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
},
|
||||
}
|
||||
|
||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
||||
if isBackupDisabled() || dbPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
root := resolveBackupRoot(dbPath)
|
||||
now := time.Now()
|
||||
created := make([]string, 0)
|
||||
for _, period := range backupPeriods {
|
||||
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
created = append(created, newFiles...)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full Listing: Scheduler Hook (`main.go`)
|
||||
|
||||
```go
|
||||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
hour, minute, err := parseBackupTime(cfg.Backup.Time)
|
||||
if err != nil {
|
||||
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
|
||||
hour, minute = 0, 0
|
||||
}
|
||||
|
||||
// Startup check: create backup immediately if none exists for current periods
|
||||
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
|
||||
slog.Error("local backup failed", "error", backupErr)
|
||||
} else {
|
||||
for _, path := range created {
|
||||
slog.Info("local backup completed", "archive", path)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
next := nextBackupTime(time.Now(), hour, minute)
|
||||
timer := time.NewTimer(time.Until(next))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
start := time.Now()
|
||||
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
|
||||
duration := time.Since(start)
|
||||
if backupErr != nil {
|
||||
slog.Error("local backup failed", "error", backupErr, "duration", duration)
|
||||
} else {
|
||||
for _, path := range created {
|
||||
slog.Info("local backup completed", "archive", path, "duration", duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseBackupTime(value string) (int, int, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return 0, 0, fmt.Errorf("empty backup time")
|
||||
}
|
||||
parsed, err := time.Parse("15:04", value)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return parsed.Hour(), parsed.Minute(), nil
|
||||
}
|
||||
|
||||
func nextBackupTime(now time.Time, hour, minute int) time.Time {
|
||||
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
||||
if !now.Before(target) {
|
||||
target = target.Add(24 * time.Hour)
|
||||
}
|
||||
return target
|
||||
}
|
||||
```
|
||||
136
bible/07-dev.md
Normal file
136
bible/07-dev.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# 07 — Development
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Run (dev)
|
||||
go run ./cmd/qfs
|
||||
make run
|
||||
|
||||
# Build
|
||||
make build-release # Optimized build with version info
|
||||
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
|
||||
|
||||
# Cross-platform build
|
||||
make build-all # Linux, macOS, Windows
|
||||
make build-windows # Windows only
|
||||
|
||||
# Verification
|
||||
go build ./cmd/qfs # Must compile without errors
|
||||
go vet ./... # Linter
|
||||
|
||||
# Tests
|
||||
go test ./...
|
||||
make test
|
||||
|
||||
# Utilities
|
||||
make install-hooks # Git hooks (block committing secrets)
|
||||
make clean # Clean bin/
|
||||
make help # All available commands
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Formatting:** `gofmt` (mandatory)
|
||||
- **Logging:** `slog` only (structured logging)
|
||||
- **Errors:** explicit wrapping with context (`fmt.Errorf("context: %w", err)`)
|
||||
- **Style:** no unnecessary abstractions; minimum code for the task
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
### What Must Never Be Restored
|
||||
|
||||
The following components were **intentionally removed** and must not be brought back:
|
||||
- cron jobs
|
||||
- importer utility
|
||||
- admin pricing UI/API
|
||||
- alerts
|
||||
- stock import
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `config.yaml` — runtime user file, **not stored in the repository**
|
||||
- `config.example.yaml` — the only config template in the repo
|
||||
|
||||
### Sync and Local-First
|
||||
|
||||
- Any sync changes must preserve local-first behavior
|
||||
- Local CRUD must not be blocked when MariaDB is unavailable
|
||||
|
||||
### Formats and UI
|
||||
|
||||
- **CSV export:** filename must use **project code** (`project.Code`), not project name
|
||||
Format: `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
||||
- **Breadcrumbs UI:** names longer than 16 characters must be truncated with an ellipsis
|
||||
|
||||
### Architecture Documentation
|
||||
|
||||
- **Every architectural decision must be recorded in `bible/`**
|
||||
- The corresponding Bible file must be updated **in the same commit** as the code change
|
||||
- On every user-requested commit, review and update the Bible in that same commit
|
||||
|
||||
---
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Add a Field to Configuration
|
||||
|
||||
1. Add the field to `LocalConfiguration` struct (`internal/models/`)
|
||||
2. Add GORM tags for the DB column
|
||||
3. Write a SQL migration (`migrations/`)
|
||||
4. Update `ConfigurationToLocal` / `LocalToConfiguration` converters
|
||||
5. Update API handlers and services
|
||||
|
||||
### Add a Field to Component
|
||||
|
||||
1. Add the field to `LocalComponent` struct (`internal/models/`)
|
||||
2. Update the SQL query in `SyncComponents()`
|
||||
3. Update the `componentRow` struct to match
|
||||
4. Update converter functions
|
||||
|
||||
### Add a Pricelist Price Lookup
|
||||
|
||||
```go
|
||||
// Modern pattern
|
||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
||||
if found && price > 0 {
|
||||
// use price
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
1. **`CurrentPrice` removed from components** — any code using it will fail to compile
|
||||
2. **`HasPrice` filter removed** — `component.go ListComponents` no longer supports this filter
|
||||
3. **Quote calculation:** always offline-first (SQLite); online path is separate
|
||||
4. **Items JSON:** prices are stored in the `items` field of the configuration, not fetched from components
|
||||
5. **Migrations are additive:** already-applied migrations are skipped (checked by `id` in `local_schema_migrations`)
|
||||
6. **`SyncedAt` removed:** last component sync time is now in `app_settings` (key=`last_component_sync`)
|
||||
|
||||
---
|
||||
|
||||
## Debugging Price Issues
|
||||
|
||||
**Problem: quote returns no prices**
|
||||
1. Check that `pricelist_id` is set on the configuration
|
||||
2. Check that pricelist items exist: `SELECT COUNT(*) FROM local_pricelist_items`
|
||||
3. Check `lookupPriceByPricelistID()` in `quote.go`
|
||||
4. Verify the correct source is used (estimate/warehouse/competitor)
|
||||
|
||||
**Problem: component sync not working**
|
||||
1. Components sync as metadata only — no prices
|
||||
2. Prices come via a separate pricelist sync
|
||||
3. Check `SyncComponents()` and the MariaDB query
|
||||
|
||||
**Problem: configuration refresh does not update prices**
|
||||
1. Refresh uses the latest estimate pricelist by default
|
||||
2. Latest resolution ignores pricelists without items (`EXISTS local_pricelist_items`)
|
||||
3. Old prices in `config.items` are preserved if a line item is not found in the pricelist
|
||||
4. To force a pricelist update: set `configuration.pricelist_id`
|
||||
5. In configurator, `Авто` must remain auto-mode (runtime resolved ID must not be persisted as explicit selection)
|
||||
55
bible/README.md
Normal file
55
bible/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# QuoteForge Bible — Architectural Documentation
|
||||
|
||||
The single source of truth for architecture, schemas, and patterns.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
| File | Topic |
|
||||
|------|-------|
|
||||
| [01-overview.md](01-overview.md) | Product: purpose, features, tech stack, repository structure |
|
||||
| [02-architecture.md](02-architecture.md) | Architecture: local-first, sync, pricing, versioning |
|
||||
| [03-database.md](03-database.md) | DB schemas: SQLite + MariaDB, permissions, indexes |
|
||||
| [04-api.md](04-api.md) | API endpoints and web routes |
|
||||
| [05-config.md](05-config.md) | Configuration, environment variables, paths, installation |
|
||||
| [06-backup.md](06-backup.md) | Backup: implementation, rotation policy |
|
||||
| [07-dev.md](07-dev.md) | Development: commands, code style, guardrails |
|
||||
|
||||
---
|
||||
|
||||
## Bible Rules
|
||||
|
||||
> **Every architectural decision must be recorded in the Bible.**
|
||||
>
|
||||
> Any change to DB schema, data access patterns, sync behavior, API contracts,
|
||||
> configuration format, or any other system-level aspect — the corresponding `bible/` file
|
||||
> **must be updated in the same commit** as the code.
|
||||
>
|
||||
> On every user-requested commit, the Bible must be reviewed and updated in that commit.
|
||||
>
|
||||
> The Bible is the single source of truth for architecture. Outdated documentation is worse than none.
|
||||
|
||||
> **Documentation language: English.**
|
||||
>
|
||||
> All files in `bible/` are written and updated **in English only**.
|
||||
> Mixing languages is not allowed.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Where is user data stored?**
|
||||
SQLite → `~/Library/Application Support/QuoteForge/qfs.db` (macOS). MariaDB is sync-only.
|
||||
|
||||
**How to look up a price for a line item?**
|
||||
`local_pricelist_items` → by `pricelist_id` from config + `lot_name`. Prices are **never** taken from `local_components`.
|
||||
|
||||
**Pre-commit check?**
|
||||
`go build ./cmd/qfs && go vet ./...`
|
||||
|
||||
**What must never be restored?**
|
||||
cron jobs, admin pricing, alerts, stock import, importer utility — all removed intentionally.
|
||||
|
||||
**Where is the release changelog?**
|
||||
`releases/memory/v{major}.{minor}.{patch}.md`
|
||||
@@ -163,7 +163,7 @@ func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string
|
||||
}
|
||||
for i := range projects {
|
||||
p := projects[i]
|
||||
existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p
|
||||
existingProjects[projectKey(p.OwnerUsername, derefString(p.Name))] = &p
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,12 +253,13 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
|
||||
|
||||
for _, action := range actions {
|
||||
key := projectKey(action.OwnerUsername, action.TargetProjectName)
|
||||
project := projectCache[key]
|
||||
project := projectCache[key]
|
||||
if project == nil {
|
||||
project = &models.Project{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: action.OwnerUsername,
|
||||
Name: action.TargetProjectName,
|
||||
Code: action.TargetProjectName,
|
||||
Name: ptrString(action.TargetProjectName),
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
}
|
||||
@@ -268,7 +269,7 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
|
||||
projectCache[key] = project
|
||||
} else if !project.IsActive {
|
||||
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
|
||||
return fmt.Errorf("reactivate project %s (%s): %w", project.Name, project.UUID, err)
|
||||
return fmt.Errorf("reactivate project %s (%s): %w", derefString(project.Name), project.UUID, err)
|
||||
}
|
||||
project.IsActive = true
|
||||
}
|
||||
@@ -294,3 +295,14 @@ func setKeys(set map[string]struct{}) []string {
|
||||
func projectKey(owner, name string) string {
|
||||
return owner + "||" + name
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
334
cmd/qfs/main.go
334
cmd/qfs/main.go
@@ -46,10 +46,14 @@ var Version = "dev"
|
||||
|
||||
const backgroundSyncInterval = 5 * time.Minute
|
||||
const onDemandPullCooldown = 30 * time.Second
|
||||
const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать"
|
||||
|
||||
func main() {
|
||||
showStartupConsoleWarning()
|
||||
|
||||
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
|
||||
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||
resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
|
||||
migrate := flag.Bool("migrate", false, "run database migrations")
|
||||
version := flag.Bool("version", false, "show version information")
|
||||
flag.Parse()
|
||||
@@ -100,6 +104,13 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
if shouldResetLocalDB(*resetLocalDB) {
|
||||
if err := localdb.ResetData(resolvedLocalDBPath); err != nil {
|
||||
slog.Error("failed to reset local database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize local SQLite database (always used)
|
||||
local, err := localdb.New(resolvedLocalDBPath)
|
||||
if err != nil {
|
||||
@@ -232,6 +243,10 @@ func main() {
|
||||
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
|
||||
go syncWorker.Start(workerCtx)
|
||||
|
||||
backupCtx, backupCancel := context.WithCancel(context.Background())
|
||||
defer backupCancel()
|
||||
go startBackupScheduler(backupCtx, cfg, resolvedLocalDBPath, resolvedConfigPath)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Address(),
|
||||
Handler: router,
|
||||
@@ -274,6 +289,7 @@ func main() {
|
||||
// Stop background sync worker first
|
||||
syncWorker.Stop()
|
||||
workerCancel()
|
||||
backupCancel()
|
||||
|
||||
// Then shutdown HTTP server
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -290,6 +306,38 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func showStartupConsoleWarning() {
|
||||
// Visible in console output.
|
||||
fmt.Println(startupConsoleWarning)
|
||||
// Keep the warning always visible in the console window title when supported.
|
||||
fmt.Printf("\033]0;%s\007", startupConsoleWarning)
|
||||
}
|
||||
|
||||
func shouldResetLocalDB(flagValue bool) bool {
|
||||
if flagValue {
|
||||
return true
|
||||
}
|
||||
value := strings.TrimSpace(os.Getenv("QFS_RESET_LOCAL_DB"))
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(value) {
|
||||
case "1", "true", "yes", "y":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
|
||||
|
||||
func setConfigDefaults(cfg *config.Config) {
|
||||
if cfg.Server.Host == "" {
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
@@ -324,6 +372,9 @@ func setConfigDefaults(cfg *config.Config) {
|
||||
if cfg.Pricing.MinQuotesForMedian == 0 {
|
||||
cfg.Pricing.MinQuotesForMedian = 3
|
||||
}
|
||||
if cfg.Backup.Time == "" {
|
||||
cfg.Backup.Time = "00:00"
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDefaultConfigFile(configPath string) error {
|
||||
@@ -347,6 +398,9 @@ func ensureDefaultConfigFile(configPath string) error {
|
||||
read_timeout: 30s
|
||||
write_timeout: 30s
|
||||
|
||||
backup:
|
||||
time: "00:00"
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
@@ -373,9 +427,14 @@ type runtimeLoggingConfig struct {
|
||||
Output string `yaml:"output"`
|
||||
}
|
||||
|
||||
type runtimeBackupConfig struct {
|
||||
Time string `yaml:"time"`
|
||||
}
|
||||
|
||||
type runtimeConfigFile struct {
|
||||
Server runtimeServerConfig `yaml:"server"`
|
||||
Logging runtimeLoggingConfig `yaml:"logging"`
|
||||
Backup runtimeBackupConfig `yaml:"backup"`
|
||||
}
|
||||
|
||||
// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
|
||||
@@ -398,6 +457,9 @@ func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) erro
|
||||
Format: cfg.Logging.Format,
|
||||
Output: cfg.Logging.Output,
|
||||
},
|
||||
Backup: runtimeBackupConfig{
|
||||
Time: cfg.Backup.Time,
|
||||
},
|
||||
}
|
||||
|
||||
rendered, err := yaml.Marshal(&runtimeCfg)
|
||||
@@ -416,6 +478,69 @@ func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
hour, minute, err := parseBackupTime(cfg.Backup.Time)
|
||||
if err != nil {
|
||||
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
|
||||
hour = 0
|
||||
minute = 0
|
||||
}
|
||||
|
||||
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
|
||||
slog.Error("local backup failed", "error", backupErr)
|
||||
} else if len(created) > 0 {
|
||||
for _, path := range created {
|
||||
slog.Info("local backup completed", "archive", path)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
next := nextBackupTime(time.Now(), hour, minute)
|
||||
timer := time.NewTimer(time.Until(next))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
start := time.Now()
|
||||
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
|
||||
duration := time.Since(start)
|
||||
if backupErr != nil {
|
||||
slog.Error("local backup failed", "error", backupErr, "duration", duration)
|
||||
} else {
|
||||
for _, path := range created {
|
||||
slog.Info("local backup completed", "archive", path, "duration", duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseBackupTime(value string) (int, int, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return 0, 0, fmt.Errorf("empty backup time")
|
||||
}
|
||||
parsed, err := time.Parse("15:04", value)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return parsed.Hour(), parsed.Minute(), nil
|
||||
}
|
||||
|
||||
func nextBackupTime(now time.Time, hour, minute int) time.Time {
|
||||
location := now.Location()
|
||||
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location)
|
||||
if !now.Before(target) {
|
||||
target = target.Add(24 * time.Hour)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// runSetupMode starts a minimal server that only serves the setup page
|
||||
func runSetupMode(local *localdb.LocalDB) {
|
||||
restartSig := make(chan struct{}, 1)
|
||||
@@ -432,11 +557,11 @@ func runSetupMode(local *localdb.LocalDB) {
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
staticPath := filepath.Join("web", "static")
|
||||
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
||||
router.Static("/static", staticPath)
|
||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
router.StaticFS("/static", http.FS(staticFS))
|
||||
} else {
|
||||
slog.Error("failed to load embedded static assets", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Setup routes only
|
||||
@@ -581,12 +706,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
if mariaDB != nil {
|
||||
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
|
||||
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
||||
exportService = services.NewExportService(cfg.Export, categoryRepo, local)
|
||||
} else {
|
||||
// In offline mode, we still need to create services that don't require DB.
|
||||
componentService = services.NewComponentService(nil, nil, nil)
|
||||
quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
|
||||
exportService = services.NewExportService(cfg.Export, nil)
|
||||
exportService = services.NewExportService(cfg.Export, nil, local)
|
||||
}
|
||||
|
||||
// isOnline function for local-first architecture
|
||||
@@ -695,7 +820,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
// Handlers
|
||||
componentHandler := handlers.NewComponentHandler(componentService, local)
|
||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, projectService)
|
||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||
if err != nil {
|
||||
@@ -722,11 +847,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
router.Use(middleware.OfflineDetector(connMgr, local))
|
||||
|
||||
// Static files (use filepath.Join for Windows compatibility)
|
||||
staticPath := filepath.Join("web", "static")
|
||||
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
||||
router.Static("/static", staticPath)
|
||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
router.StaticFS("/static", http.FS(staticFS))
|
||||
} else {
|
||||
return nil, nil, fmt.Errorf("load embedded static assets: %w", err)
|
||||
}
|
||||
|
||||
// Health check
|
||||
@@ -815,6 +939,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
router.GET("/configurator", webHandler.Configurator)
|
||||
router.GET("/projects", webHandler.Projects)
|
||||
router.GET("/projects/:uuid", webHandler.ProjectDetail)
|
||||
router.GET("/configs/:uuid/revisions", webHandler.ConfigRevisions)
|
||||
router.GET("/pricelists", webHandler.Pricelists)
|
||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||
|
||||
@@ -926,6 +1051,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusCreated, config)
|
||||
})
|
||||
|
||||
configs.POST("/preview-article", func(c *gin.Context) {
|
||||
var req services.ArticlePreviewRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := configService.BuildArticlePreview(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"article": result.Article,
|
||||
"warnings": result.Warnings,
|
||||
})
|
||||
})
|
||||
|
||||
configs.GET("/:uuid", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
config, err := configService.GetByUUIDNoAuth(uuid)
|
||||
@@ -946,7 +1088,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
|
||||
config, err := configService.UpdateNoAuth(uuid, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
switch {
|
||||
case errors.Is(err, services.ErrConfigNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -997,15 +1148,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
configs.POST("/:uuid/clone", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
FromVersion int `json:"from_version"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := configService.CloneNoAuth(uuid, req.Name, dbUsername)
|
||||
config, err := configService.CloneNoAuthToProjectFromVersion(uuid, req.Name, dbUsername, nil, req.FromVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrConfigVersionNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -1152,6 +1308,25 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
"current_version": currentVersion,
|
||||
})
|
||||
})
|
||||
|
||||
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
||||
|
||||
configs.PATCH("/:uuid/server-count", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
var req struct {
|
||||
ServerCount int `json:"server_count" binding:"required,min=1"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
config, err := configService.UpdateServerCount(uuid, req.ServerCount)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, config)
|
||||
})
|
||||
}
|
||||
|
||||
projects := api.Group("/projects")
|
||||
@@ -1164,7 +1339,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" {
|
||||
@@ -1204,7 +1380,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
if status == "archived" && p.IsActive {
|
||||
continue
|
||||
}
|
||||
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
|
||||
if search != "" &&
|
||||
!strings.Contains(strings.ToLower(derefString(p.Name)), search) &&
|
||||
!strings.Contains(strings.ToLower(p.Code), search) &&
|
||||
!strings.Contains(strings.ToLower(p.Variant), search) {
|
||||
continue
|
||||
}
|
||||
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
|
||||
@@ -1217,8 +1396,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
left := filtered[i]
|
||||
right := filtered[j]
|
||||
if sortField == "name" {
|
||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
||||
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
|
||||
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
|
||||
if leftName == rightName {
|
||||
if sortDir == "asc" {
|
||||
return left.CreatedAt.Before(right.CreatedAt)
|
||||
@@ -1231,8 +1410,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
return leftName > rightName
|
||||
}
|
||||
if left.CreatedAt.Equal(right.CreatedAt) {
|
||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
||||
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
|
||||
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
|
||||
if sortDir == "asc" {
|
||||
return leftName < rightName
|
||||
}
|
||||
@@ -1291,6 +1470,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
"id": p.ID,
|
||||
"uuid": p.UUID,
|
||||
"owner_username": p.OwnerUsername,
|
||||
"code": p.Code,
|
||||
"variant": p.Variant,
|
||||
"name": p.Name,
|
||||
"tracker_url": p.TrackerURL,
|
||||
"is_active": p.IsActive,
|
||||
@@ -1316,19 +1497,55 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/projects/all - Returns all projects without pagination for UI dropdowns
|
||||
projects.GET("/all", func(c *gin.Context) {
|
||||
allProjects, err := projectService.ListByUser(dbUsername, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Return simplified list of all projects (UUID + Name only)
|
||||
type ProjectSimple struct {
|
||||
UUID string `json:"uuid"`
|
||||
Code string `json:"code"`
|
||||
Variant string `json:"variant"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
simplified := make([]ProjectSimple, 0, len(allProjects))
|
||||
for _, p := range allProjects {
|
||||
simplified = append(simplified, ProjectSimple{
|
||||
UUID: p.UUID,
|
||||
Code: p.Code,
|
||||
Variant: p.Variant,
|
||||
Name: derefString(p.Name),
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, simplified)
|
||||
})
|
||||
|
||||
projects.POST("", func(c *gin.Context) {
|
||||
var req services.CreateProjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
||||
if strings.TrimSpace(req.Code) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"})
|
||||
return
|
||||
}
|
||||
project, err := projectService.Create(dbUsername, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, project)
|
||||
@@ -1356,13 +1573,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
||||
return
|
||||
}
|
||||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectForbidden):
|
||||
@@ -1405,6 +1620,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
|
||||
})
|
||||
|
||||
projects.DELETE("/:uuid", func(c *gin.Context) {
|
||||
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrCannotDeleteMainVariant):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "variant deleted"})
|
||||
})
|
||||
|
||||
projects.GET("/:uuid/configs", func(c *gin.Context) {
|
||||
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
|
||||
|
||||
@@ -1447,6 +1679,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusCreated, config)
|
||||
})
|
||||
|
||||
projects.GET("/:uuid/export", exportHandler.ExportProjectCSV)
|
||||
|
||||
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
@@ -1479,6 +1713,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
||||
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
||||
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
|
||||
syncAPI.POST("/repair", syncHandler.RepairPendingChanges)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1530,11 +1765,37 @@ func requestLogger() gin.HandlerFunc {
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
blw := &captureResponseWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: bytes.NewBuffer(nil),
|
||||
}
|
||||
c.Writer = blw
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
|
||||
if status >= http.StatusBadRequest {
|
||||
responseBody := strings.TrimSpace(blw.body.String())
|
||||
if len(responseBody) > 2048 {
|
||||
responseBody = responseBody[:2048] + "...(truncated)"
|
||||
}
|
||||
errText := strings.TrimSpace(c.Errors.String())
|
||||
|
||||
slog.Error("request failed",
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
"query", query,
|
||||
"status", status,
|
||||
"latency", latency,
|
||||
"ip", c.ClientIP(),
|
||||
"errors", errText,
|
||||
"response", responseBody,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("request",
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
@@ -1545,3 +1806,22 @@ func requestLogger() gin.HandlerFunc {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type captureResponseWriter struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *captureResponseWriter) Write(b []byte) (int, error) {
|
||||
if len(b) > 0 {
|
||||
_, _ = w.body.Write(b)
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (w *captureResponseWriter) WriteString(s string) (int, error) {
|
||||
if s != "" {
|
||||
_, _ = w.body.WriteString(s)
|
||||
}
|
||||
return w.ResponseWriter.WriteString(s)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
|
||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1"}`)))
|
||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1","code":"P1"}`)))
|
||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||
createProjectRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||
@@ -243,7 +243,7 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
|
||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project"}`)))
|
||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project","code":"MOVE"}`)))
|
||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||
createProjectRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||
|
||||
@@ -37,6 +37,9 @@ export:
|
||||
max_file_age: "1h"
|
||||
company_name: "Your Company Name"
|
||||
|
||||
backup:
|
||||
time: "00:00"
|
||||
|
||||
alerts:
|
||||
enabled: true
|
||||
check_interval: "1h"
|
||||
|
||||
273
internal/appstate/backup.go
Normal file
273
internal/appstate/backup.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type backupPeriod struct {
|
||||
name string
|
||||
retention int
|
||||
key func(time.Time) string
|
||||
date func(time.Time) string
|
||||
}
|
||||
|
||||
var backupPeriods = []backupPeriod{
|
||||
{
|
||||
name: "daily",
|
||||
retention: 7,
|
||||
key: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
date: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "weekly",
|
||||
retention: 4,
|
||||
key: func(t time.Time) string {
|
||||
y, w := t.ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", y, w)
|
||||
},
|
||||
date: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "monthly",
|
||||
retention: 12,
|
||||
key: func(t time.Time) string {
|
||||
return t.Format("2006-01")
|
||||
},
|
||||
date: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "yearly",
|
||||
retention: 10,
|
||||
key: func(t time.Time) string {
|
||||
return t.Format("2006")
|
||||
},
|
||||
date: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const (
|
||||
envBackupDisable = "QFS_BACKUP_DISABLE"
|
||||
envBackupDir = "QFS_BACKUP_DIR"
|
||||
)
|
||||
|
||||
var backupNow = time.Now
|
||||
|
||||
// EnsureRotatingLocalBackup creates or refreshes daily/weekly/monthly/yearly backups
|
||||
// for the local database and config. It keeps a limited number per period.
|
||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
||||
if isBackupDisabled() {
|
||||
return nil, nil
|
||||
}
|
||||
if dbPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("stat db: %w", err)
|
||||
}
|
||||
|
||||
root := resolveBackupRoot(dbPath)
|
||||
now := backupNow()
|
||||
|
||||
created := make([]string, 0)
|
||||
for _, period := range backupPeriods {
|
||||
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
if len(newFiles) > 0 {
|
||||
created = append(created, newFiles...)
|
||||
}
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func resolveBackupRoot(dbPath string) string {
|
||||
if fromEnv := strings.TrimSpace(os.Getenv(envBackupDir)); fromEnv != "" {
|
||||
return filepath.Clean(fromEnv)
|
||||
}
|
||||
return filepath.Join(filepath.Dir(dbPath), "backups")
|
||||
}
|
||||
|
||||
func isBackupDisabled() bool {
|
||||
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
|
||||
return val == "1" || val == "true" || val == "yes"
|
||||
}
|
||||
|
||||
func ensurePeriodBackup(root string, period backupPeriod, now time.Time, dbPath, configPath string) ([]string, error) {
|
||||
key := period.key(now)
|
||||
periodDir := filepath.Join(root, period.name)
|
||||
if err := os.MkdirAll(periodDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create %s backup dir: %w", period.name, err)
|
||||
}
|
||||
|
||||
if hasBackupForKey(periodDir, key) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
archiveName := fmt.Sprintf("qfs-backp-%s.zip", period.date(now))
|
||||
archivePath := filepath.Join(periodDir, archiveName)
|
||||
|
||||
if err := createBackupArchive(archivePath, dbPath, configPath); err != nil {
|
||||
return nil, fmt.Errorf("create %s backup archive: %w", period.name, err)
|
||||
}
|
||||
|
||||
if err := writePeriodMarker(periodDir, key); err != nil {
|
||||
return []string{archivePath}, err
|
||||
}
|
||||
|
||||
if err := pruneOldBackups(periodDir, period.retention); err != nil {
|
||||
return []string{archivePath}, err
|
||||
}
|
||||
|
||||
return []string{archivePath}, nil
|
||||
}
|
||||
|
||||
func hasBackupForKey(periodDir, key string) bool {
|
||||
marker := periodMarker{Key: ""}
|
||||
data, err := os.ReadFile(periodMarkerPath(periodDir))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(data, &marker); err != nil {
|
||||
return false
|
||||
}
|
||||
return marker.Key == key
|
||||
}
|
||||
|
||||
type periodMarker struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func periodMarkerPath(periodDir string) string {
|
||||
return filepath.Join(periodDir, ".period.json")
|
||||
}
|
||||
|
||||
func writePeriodMarker(periodDir, key string) error {
|
||||
data, err := json.MarshalIndent(periodMarker{Key: key}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(periodMarkerPath(periodDir), data, 0644)
|
||||
}
|
||||
|
||||
func pruneOldBackups(periodDir string, keep int) error {
|
||||
entries, err := os.ReadDir(periodDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read backups dir: %w", err)
|
||||
}
|
||||
|
||||
files := make([]os.DirEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(entry.Name(), ".zip") {
|
||||
files = append(files, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) <= keep {
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
infoI, errI := files[i].Info()
|
||||
infoJ, errJ := files[j].Info()
|
||||
if errI != nil || errJ != nil {
|
||||
return files[i].Name() < files[j].Name()
|
||||
}
|
||||
return infoI.ModTime().Before(infoJ.ModTime())
|
||||
})
|
||||
|
||||
for i := 0; i < len(files)-keep; i++ {
|
||||
path := filepath.Join(periodDir, files[i].Name())
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("remove old backup %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createBackupArchive(destPath, dbPath, configPath string) error {
|
||||
file, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(file)
|
||||
if err := addZipFile(zipWriter, dbPath); err != nil {
|
||||
_ = zipWriter.Close()
|
||||
return err
|
||||
}
|
||||
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
|
||||
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
|
||||
|
||||
if strings.TrimSpace(configPath) != "" {
|
||||
_ = addZipOptionalFile(zipWriter, configPath)
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func addZipOptionalFile(writer *zip.Writer, path string) error {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil
|
||||
}
|
||||
return addZipFile(writer, path)
|
||||
}
|
||||
|
||||
func addZipFile(writer *zip.Writer, path string) error {
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
info, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = filepath.Base(path)
|
||||
header.Method = zip.Deflate
|
||||
|
||||
out, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
83
internal/appstate/backup_test.go
Normal file
83
internal/appstate/backup_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
dbPath := filepath.Join(temp, "qfs.db")
|
||||
cfgPath := filepath.Join(temp, "config.yaml")
|
||||
|
||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
||||
t.Fatalf("write db: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
prevNow := backupNow
|
||||
defer func() { backupNow = prevNow }()
|
||||
backupNow = func() time.Time { return time.Date(2026, 2, 11, 10, 0, 0, 0, time.UTC) }
|
||||
|
||||
created, err := EnsureRotatingLocalBackup(dbPath, cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatalf("expected backup to be created")
|
||||
}
|
||||
|
||||
dailyArchive := filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-11.zip")
|
||||
if _, err := os.Stat(dailyArchive); err != nil {
|
||||
t.Fatalf("daily archive missing: %v", err)
|
||||
}
|
||||
|
||||
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
|
||||
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("backup rotate: %v", err)
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatalf("expected backup to be created for new day")
|
||||
}
|
||||
|
||||
dailyArchive = filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-12.zip")
|
||||
if _, err := os.Stat(dailyArchive); err != nil {
|
||||
t.Fatalf("daily archive missing after rotate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
dbPath := filepath.Join(temp, "qfs.db")
|
||||
cfgPath := filepath.Join(temp, "config.yaml")
|
||||
|
||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
||||
t.Fatalf("write db: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
backupRoot := filepath.Join(temp, "custom_backups")
|
||||
t.Setenv(envBackupDir, backupRoot)
|
||||
|
||||
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||
t.Fatalf("backup with env: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
|
||||
t.Fatalf("expected backup in custom dir: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(envBackupDisable, "1")
|
||||
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||
t.Fatalf("backup disabled: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
|
||||
t.Fatalf("backup should remain from previous run: %v", err)
|
||||
}
|
||||
}
|
||||
124
internal/article/categories.go
Normal file
124
internal/article/categories.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
)
|
||||
|
||||
// ErrMissingCategoryForLot is returned when a lot has no category in local_pricelist_items.lot_category.
|
||||
var ErrMissingCategoryForLot = errors.New("missing_category_for_lot")
|
||||
|
||||
type MissingCategoryForLotError struct {
|
||||
LotName string
|
||||
}
|
||||
|
||||
func (e *MissingCategoryForLotError) Error() string {
|
||||
if e == nil || strings.TrimSpace(e.LotName) == "" {
|
||||
return ErrMissingCategoryForLot.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", ErrMissingCategoryForLot.Error(), e.LotName)
|
||||
}
|
||||
|
||||
func (e *MissingCategoryForLotError) Unwrap() error {
|
||||
return ErrMissingCategoryForLot
|
||||
}
|
||||
|
||||
type Group string
|
||||
|
||||
const (
|
||||
GroupCPU Group = "CPU"
|
||||
GroupMEM Group = "MEM"
|
||||
GroupGPU Group = "GPU"
|
||||
GroupDISK Group = "DISK"
|
||||
GroupNET Group = "NET"
|
||||
GroupPSU Group = "PSU"
|
||||
)
|
||||
|
||||
// GroupForLotCategory maps pricelist lot_category codes into article groups.
|
||||
// Unknown/unrelated categories return ok=false.
|
||||
func GroupForLotCategory(cat string) (group Group, ok bool) {
|
||||
c := strings.ToUpper(strings.TrimSpace(cat))
|
||||
switch c {
|
||||
case "CPU":
|
||||
return GroupCPU, true
|
||||
case "MEM":
|
||||
return GroupMEM, true
|
||||
case "GPU":
|
||||
return GroupGPU, true
|
||||
case "M2", "SSD", "HDD", "EDSFF", "HHHL":
|
||||
return GroupDISK, true
|
||||
case "NIC", "HCA", "DPU":
|
||||
return GroupNET, true
|
||||
case "HBA":
|
||||
return GroupNET, true
|
||||
case "PSU", "PS":
|
||||
return GroupPSU, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveLotCategoriesStrict resolves categories for lotNames using local_pricelist_items.lot_category
|
||||
// for a given server pricelist id. If any lot is missing or has empty category, returns an error.
|
||||
func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||
if local == nil {
|
||||
return nil, fmt.Errorf("local db is nil")
|
||||
}
|
||||
cats, err := local.GetLocalLotCategoriesByServerPricelistID(serverPricelistID, lotNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
missing := make([]string, 0)
|
||||
for _, lot := range lotNames {
|
||||
cat := strings.TrimSpace(cats[lot])
|
||||
if cat == "" {
|
||||
missing = append(missing, lot)
|
||||
continue
|
||||
}
|
||||
cats[lot] = cat
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
fallback, err := local.GetLocalComponentCategoriesByLotNames(missing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, lot := range missing {
|
||||
if cat := strings.TrimSpace(fallback[lot]); cat != "" {
|
||||
cats[lot] = cat
|
||||
}
|
||||
}
|
||||
for _, lot := range missing {
|
||||
if strings.TrimSpace(cats[lot]) == "" {
|
||||
return nil, &MissingCategoryForLotError{LotName: lot}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cats, nil
|
||||
}
|
||||
|
||||
// NormalizeServerModel produces a stable article segment for the server model.
|
||||
func NormalizeServerModel(model string) string {
|
||||
trimmed := strings.TrimSpace(model)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
upper := strings.ToUpper(trimmed)
|
||||
var b strings.Builder
|
||||
for _, r := range upper {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if r == '.' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
98
internal/article/categories_test.go
Normal file
98
internal/article/categories_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
)
|
||||
|
||||
func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 1,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-001",
|
||||
Name: "test",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: localPL.ID, LotName: "CPU_A", LotCategory: "", Price: 10},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local items: %v", err)
|
||||
}
|
||||
|
||||
_, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrMissingCategoryForLot) {
|
||||
t.Fatalf("expected ErrMissingCategoryForLot, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) {
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 2,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-002",
|
||||
Name: "test",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(2)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: localPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local items: %v", err)
|
||||
}
|
||||
if err := local.DB().Create(&localdb.LocalComponent{
|
||||
LotName: "CPU_B",
|
||||
Category: "CPU",
|
||||
LotDescription: "cpu",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("save local components: %v", err)
|
||||
}
|
||||
|
||||
cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected fallback, got error: %v", err)
|
||||
}
|
||||
if cats["CPU_B"] != "CPU" {
|
||||
t.Fatalf("expected CPU, got %q", cats["CPU_B"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupForLotCategory(t *testing.T) {
|
||||
if g, ok := GroupForLotCategory("cpu"); !ok || g != GroupCPU {
|
||||
t.Fatalf("expected cpu -> GroupCPU")
|
||||
}
|
||||
if g, ok := GroupForLotCategory("SFP"); ok || g != "" {
|
||||
t.Fatalf("expected SFP to be excluded")
|
||||
}
|
||||
}
|
||||
602
internal/article/generator.go
Normal file
602
internal/article/generator.go
Normal file
@@ -0,0 +1,602 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
type BuildOptions struct {
|
||||
ServerModel string
|
||||
SupportCode string
|
||||
ServerPricelist *uint
|
||||
}
|
||||
|
||||
type BuildResult struct {
|
||||
Article string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
var (
|
||||
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
|
||||
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
|
||||
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
|
||||
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
|
||||
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
|
||||
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
|
||||
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
|
||||
)
|
||||
|
||||
func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) {
|
||||
segments := make([]string, 0, 8)
|
||||
warnings := make([]string, 0)
|
||||
|
||||
model := NormalizeServerModel(opts.ServerModel)
|
||||
if model == "" {
|
||||
return BuildResult{}, fmt.Errorf("server_model required")
|
||||
}
|
||||
segments = append(segments, model)
|
||||
|
||||
lotNames := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
lotNames = append(lotNames, it.LotName)
|
||||
}
|
||||
|
||||
if opts.ServerPricelist == nil || *opts.ServerPricelist == 0 {
|
||||
return BuildResult{}, fmt.Errorf("pricelist_id required for article")
|
||||
}
|
||||
|
||||
cats, err := ResolveLotCategoriesStrict(local, *opts.ServerPricelist, lotNames)
|
||||
if err != nil {
|
||||
return BuildResult{}, err
|
||||
}
|
||||
|
||||
cpuSeg := buildCPUSegment(items, cats)
|
||||
if cpuSeg != "" {
|
||||
segments = append(segments, cpuSeg)
|
||||
}
|
||||
memSeg, memWarn := buildMemSegment(items, cats)
|
||||
if memWarn != "" {
|
||||
warnings = append(warnings, memWarn)
|
||||
}
|
||||
if memSeg != "" {
|
||||
segments = append(segments, memSeg)
|
||||
}
|
||||
gpuSeg := buildGPUSegment(items, cats)
|
||||
if gpuSeg != "" {
|
||||
segments = append(segments, gpuSeg)
|
||||
}
|
||||
diskSeg, diskWarn := buildDiskSegment(items, cats)
|
||||
if diskWarn != "" {
|
||||
warnings = append(warnings, diskWarn)
|
||||
}
|
||||
if diskSeg != "" {
|
||||
segments = append(segments, diskSeg)
|
||||
}
|
||||
netSeg, netWarn := buildNetSegment(items, cats)
|
||||
if netWarn != "" {
|
||||
warnings = append(warnings, netWarn)
|
||||
}
|
||||
if netSeg != "" {
|
||||
segments = append(segments, netSeg)
|
||||
}
|
||||
psuSeg, psuWarn := buildPSUSegment(items, cats)
|
||||
if psuWarn != "" {
|
||||
warnings = append(warnings, psuWarn)
|
||||
}
|
||||
if psuSeg != "" {
|
||||
segments = append(segments, psuSeg)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(opts.SupportCode) != "" {
|
||||
code := strings.TrimSpace(opts.SupportCode)
|
||||
if !isSupportCodeValid(code) {
|
||||
return BuildResult{}, fmt.Errorf("invalid_support_code")
|
||||
}
|
||||
segments = append(segments, code)
|
||||
}
|
||||
|
||||
article := strings.Join(segments, "-")
|
||||
if len([]rune(article)) > 80 {
|
||||
article = compressArticle(segments)
|
||||
warnings = append(warnings, "compressed")
|
||||
}
|
||||
if len([]rune(article)) > 80 {
|
||||
return BuildResult{}, fmt.Errorf("article_overflow")
|
||||
}
|
||||
|
||||
return BuildResult{Article: article, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func isSupportCodeValid(code string) bool {
|
||||
if len(code) < 3 {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(code, "y") {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(code, "y")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range parts[0] {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch parts[1] {
|
||||
case "W", "B", "S", "P":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
||||
type agg struct {
|
||||
qty int
|
||||
}
|
||||
models := map[string]*agg{}
|
||||
for _, it := range items {
|
||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||
if !ok || group != GroupCPU {
|
||||
continue
|
||||
}
|
||||
model := parseCPUModel(it.LotName)
|
||||
if model == "" {
|
||||
model = "UNK"
|
||||
}
|
||||
if _, ok := models[model]; !ok {
|
||||
models[model] = &agg{}
|
||||
}
|
||||
models[model].qty += it.Quantity
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(models))
|
||||
for model, a := range models {
|
||||
parts = append(parts, fmt.Sprintf("%dx%s", a.qty, model))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func buildMemSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||
totalGiB := 0
|
||||
for _, it := range items {
|
||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||
if !ok || group != GroupMEM {
|
||||
continue
|
||||
}
|
||||
per := parseMemGiB(it.LotName)
|
||||
if per <= 0 {
|
||||
return "", "mem_unknown"
|
||||
}
|
||||
totalGiB += per * it.Quantity
|
||||
}
|
||||
if totalGiB == 0 {
|
||||
return "", ""
|
||||
}
|
||||
if totalGiB%1024 == 0 {
|
||||
return fmt.Sprintf("%dT", totalGiB/1024), ""
|
||||
}
|
||||
return fmt.Sprintf("%dG", totalGiB), ""
|
||||
}
|
||||
|
||||
func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
||||
models := map[string]int{}
|
||||
for _, it := range items {
|
||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||
if !ok || group != GroupGPU {
|
||||
continue
|
||||
}
|
||||
model := parseGPUModel(it.LotName)
|
||||
if model == "" {
|
||||
model = "UNK"
|
||||
}
|
||||
models[model] += it.Quantity
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(models))
|
||||
for model, qty := range models {
|
||||
parts = append(parts, fmt.Sprintf("%dx%s", qty, model))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||
type key struct {
|
||||
t string
|
||||
c string
|
||||
}
|
||||
groupQty := map[key]int{}
|
||||
warn := ""
|
||||
for _, it := range items {
|
||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||
if !ok || group != GroupDISK {
|
||||
continue
|
||||
}
|
||||
capToken := parseCapacity(it.LotName)
|
||||
if capToken == "" {
|
||||
warn = "disk_unknown"
|
||||
}
|
||||
typeCode := diskTypeCode(cats[it.LotName], it.LotName)
|
||||
k := key{t: typeCode, c: capToken}
|
||||
groupQty[k] += it.Quantity
|
||||
}
|
||||
if len(groupQty) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
parts := make([]string, 0, len(groupQty))
|
||||
for k, qty := range groupQty {
|
||||
if k.c == "" {
|
||||
parts = append(parts, fmt.Sprintf("%dx%s", qty, k.t))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("%dx%s%s", qty, k.c, k.t))
|
||||
}
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "+"), warn
|
||||
}
|
||||
|
||||
func buildNetSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||
groupQty := map[string]int{}
|
||||
warn := ""
|
||||
for _, it := range items {
|
||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||
if !ok || group != GroupNET {
|
||||
continue
|
||||
}
|
||||
profile := parsePortSpeed(it.LotName)
|
||||
if profile == "" {
|
||||
profile = "UNKNET"
|
||||
warn = "net_unknown"
|
||||
}
|
||||
groupQty[profile] += it.Quantity
|
||||
}
|
||||
if len(groupQty) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
parts := make([]string, 0, len(groupQty))
|
||||
for profile, qty := range groupQty {
|
||||
parts = append(parts, fmt.Sprintf("%dx%s", qty, profile))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "+"), warn
|
||||
}
|
||||
|
||||
func buildPSUSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||
groupQty := map[string]int{}
|
||||
warn := ""
|
||||
for _, it := range items {
|
||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||
if !ok || group != GroupPSU {
|
||||
continue
|
||||
}
|
||||
rating := parseWatts(it.LotName)
|
||||
if rating == "" {
|
||||
rating = "UNKPSU"
|
||||
warn = "psu_unknown"
|
||||
}
|
||||
groupQty[rating] += it.Quantity
|
||||
}
|
||||
if len(groupQty) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
parts := make([]string, 0, len(groupQty))
|
||||
for rating, qty := range groupQty {
|
||||
parts = append(parts, fmt.Sprintf("%dx%s", qty, rating))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "+"), warn
|
||||
}
|
||||
|
||||
func normalizeModelToken(lotName string) string {
|
||||
if idx := strings.Index(lotName, "_"); idx >= 0 && idx+1 < len(lotName) {
|
||||
lotName = lotName[idx+1:]
|
||||
}
|
||||
parts := strings.Split(lotName, "_")
|
||||
token := parts[len(parts)-1]
|
||||
return strings.ToUpper(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
func parseCPUModel(lotName string) string {
|
||||
parts := strings.Split(lotName, "_")
|
||||
if len(parts) >= 2 {
|
||||
last := strings.ToUpper(strings.TrimSpace(parts[len(parts)-1]))
|
||||
if last != "" {
|
||||
return last
|
||||
}
|
||||
}
|
||||
return normalizeModelToken(lotName)
|
||||
}
|
||||
|
||||
func parseGPUModel(lotName string) string {
|
||||
upper := strings.ToUpper(lotName)
|
||||
if idx := strings.Index(upper, "GPU_"); idx >= 0 {
|
||||
upper = upper[idx+4:]
|
||||
}
|
||||
parts := strings.Split(upper, "_")
|
||||
model := ""
|
||||
mem := ""
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
switch p {
|
||||
case "NV", "NVIDIA", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
||||
continue
|
||||
default:
|
||||
if strings.Contains(p, "GB") {
|
||||
mem = p
|
||||
continue
|
||||
}
|
||||
if model == "" && (i > 0) {
|
||||
model = p
|
||||
}
|
||||
}
|
||||
}
|
||||
if model != "" && mem != "" {
|
||||
return model + "_" + mem
|
||||
}
|
||||
if model != "" {
|
||||
return model
|
||||
}
|
||||
return normalizeModelToken(lotName)
|
||||
}
|
||||
|
||||
func parseMemGiB(lotName string) int {
|
||||
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
|
||||
return atoi(m[1]) * 1024
|
||||
}
|
||||
if m := reMemGiB.FindStringSubmatch(lotName); len(m) == 3 {
|
||||
return atoi(m[1])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseCapacity(lotName string) string {
|
||||
if m := reCapacityT.FindStringSubmatch(lotName); len(m) == 2 {
|
||||
return normalizeTToken(strings.ReplaceAll(m[1], ",", ".")) + "T"
|
||||
}
|
||||
if m := reCapacityG.FindStringSubmatch(lotName); len(m) == 2 {
|
||||
return normalizeNumberToken(strings.ReplaceAll(m[1], ",", ".")) + "G"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func diskTypeCode(cat string, lotName string) string {
|
||||
c := strings.ToUpper(strings.TrimSpace(cat))
|
||||
if c == "M2" {
|
||||
return "M2"
|
||||
}
|
||||
upper := strings.ToUpper(lotName)
|
||||
if strings.Contains(upper, "NVME") {
|
||||
return "NV"
|
||||
}
|
||||
if strings.Contains(upper, "SAS") {
|
||||
return "SAS"
|
||||
}
|
||||
if strings.Contains(upper, "SATA") {
|
||||
return "SAT"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func parsePortSpeed(lotName string) string {
|
||||
if m := rePortSpeed.FindStringSubmatch(lotName); len(m) == 4 {
|
||||
return fmt.Sprintf("%sp%sG", m[1], m[2])
|
||||
}
|
||||
if m := rePortFC.FindStringSubmatch(lotName); len(m) == 3 {
|
||||
return fmt.Sprintf("%spFC%s", m[1], m[2])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseWatts(lotName string) string {
|
||||
if m := reWatts.FindStringSubmatch(lotName); len(m) == 2 {
|
||||
w := atoi(m[1])
|
||||
if w >= 1000 {
|
||||
kw := fmt.Sprintf("%.1f", float64(w)/1000.0)
|
||||
kw = strings.TrimSuffix(kw, ".0")
|
||||
return fmt.Sprintf("%skW", kw)
|
||||
}
|
||||
return fmt.Sprintf("%dW", w)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeNumberToken(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimLeft(raw, "0")
|
||||
if raw == "" || raw[0] == '.' {
|
||||
raw = "0" + raw
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func normalizeTToken(raw string) string {
|
||||
raw = normalizeNumberToken(raw)
|
||||
parts := strings.SplitN(raw, ".", 2)
|
||||
intPart := parts[0]
|
||||
frac := ""
|
||||
if len(parts) == 2 {
|
||||
frac = parts[1]
|
||||
}
|
||||
if frac == "" {
|
||||
frac = "0"
|
||||
}
|
||||
if len(intPart) >= 2 {
|
||||
return intPart + "." + frac
|
||||
}
|
||||
if len(frac) > 1 {
|
||||
frac = frac[:1]
|
||||
}
|
||||
return intPart + "." + frac
|
||||
}
|
||||
|
||||
func atoi(v string) int {
|
||||
n := 0
|
||||
for _, r := range v {
|
||||
if r < '0' || r > '9' {
|
||||
continue
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func compressArticle(segments []string) string {
|
||||
if len(segments) == 0 {
|
||||
return ""
|
||||
}
|
||||
normalized := make([]string, 0, len(segments))
|
||||
for _, s := range segments {
|
||||
normalized = append(normalized, strings.ReplaceAll(s, "GbE", "G"))
|
||||
}
|
||||
segments = normalized
|
||||
article := strings.Join(segments, "-")
|
||||
if len([]rune(article)) <= 80 {
|
||||
return article
|
||||
}
|
||||
|
||||
// segment order: model, cpu, mem, gpu, disk, net, psu, support
|
||||
index := func(i int) (int, bool) {
|
||||
if i >= 0 && i < len(segments) {
|
||||
return i, true
|
||||
}
|
||||
return -1, false
|
||||
}
|
||||
|
||||
// 1) remove PSU
|
||||
if i, ok := index(6); ok {
|
||||
segments = append(segments[:i], segments[i+1:]...)
|
||||
article = strings.Join(segments, "-")
|
||||
if len([]rune(article)) <= 80 {
|
||||
return article
|
||||
}
|
||||
}
|
||||
|
||||
// 2) compress NET/HBA/HCA
|
||||
if i, ok := index(5); ok {
|
||||
segments[i] = compressNetSegment(segments[i])
|
||||
article = strings.Join(segments, "-")
|
||||
if len([]rune(article)) <= 80 {
|
||||
return article
|
||||
}
|
||||
}
|
||||
|
||||
// 3) compress DISK
|
||||
if i, ok := index(4); ok {
|
||||
segments[i] = compressDiskSegment(segments[i])
|
||||
article = strings.Join(segments, "-")
|
||||
if len([]rune(article)) <= 80 {
|
||||
return article
|
||||
}
|
||||
}
|
||||
|
||||
// 4) compress GPU to vendor only (GPU_NV)
|
||||
if i, ok := index(3); ok {
|
||||
segments[i] = compressGPUSegment(segments[i])
|
||||
}
|
||||
return strings.Join(segments, "-")
|
||||
}
|
||||
|
||||
func compressNetSegment(seg string) string {
|
||||
if seg == "" {
|
||||
return seg
|
||||
}
|
||||
parts := strings.Split(seg, "+")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
qty := "1"
|
||||
profile := p
|
||||
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
||||
qty = x[0]
|
||||
profile = x[1]
|
||||
}
|
||||
upper := strings.ToUpper(profile)
|
||||
label := "NIC"
|
||||
if strings.Contains(upper, "FC") {
|
||||
label = "HBA"
|
||||
} else if strings.Contains(upper, "HCA") || strings.Contains(upper, "IB") {
|
||||
label = "HCA"
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%sx%s", qty, label))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return seg
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, "+")
|
||||
}
|
||||
|
||||
func compressDiskSegment(seg string) string {
|
||||
if seg == "" {
|
||||
return seg
|
||||
}
|
||||
parts := strings.Split(seg, "+")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
qty := "1"
|
||||
spec := p
|
||||
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
||||
qty = x[0]
|
||||
spec = x[1]
|
||||
}
|
||||
upper := strings.ToUpper(spec)
|
||||
label := "DSK"
|
||||
for _, t := range []string{"M2", "NV", "SAS", "SAT", "SSD", "HDD", "EDS", "HHH"} {
|
||||
if strings.Contains(upper, t) {
|
||||
label = t
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%sx%s", qty, label))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return seg
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, "+")
|
||||
}
|
||||
|
||||
func compressGPUSegment(seg string) string {
|
||||
if seg == "" {
|
||||
return seg
|
||||
}
|
||||
parts := strings.Split(seg, "+")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
qty := "1"
|
||||
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
||||
qty = x[0]
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%sxGPU_NV", qty))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return seg
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, "+")
|
||||
}
|
||||
66
internal/article/generator_test.go
Normal file
66
internal/article/generator_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
func TestBuild_ParsesNetAndPSU(t *testing.T) {
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 1,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-001",
|
||||
Name: "test",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: localPL.ID, LotName: "NIC_2p25G_MCX512A-AC", LotCategory: "NIC", Price: 1},
|
||||
{PricelistID: localPL.ID, LotName: "HBA_2pFC32_Gen6", LotCategory: "HBA", Price: 1},
|
||||
{PricelistID: localPL.ID, LotName: "PS_1000W_Platinum", LotCategory: "PS", Price: 1},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local items: %v", err)
|
||||
}
|
||||
|
||||
items := models.ConfigItems{
|
||||
{LotName: "NIC_2p25G_MCX512A-AC", Quantity: 1},
|
||||
{LotName: "HBA_2pFC32_Gen6", Quantity: 1},
|
||||
{LotName: "PS_1000W_Platinum", Quantity: 2},
|
||||
}
|
||||
result, err := Build(local, items, BuildOptions{
|
||||
ServerModel: "DL380GEN11",
|
||||
SupportCode: "1yW",
|
||||
ServerPricelist: &localPL.ServerID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build article: %v", err)
|
||||
}
|
||||
if result.Article == "" {
|
||||
t.Fatalf("expected article to be non-empty")
|
||||
}
|
||||
if contains(result.Article, "UNKNET") || contains(result.Article, "UNKPSU") {
|
||||
t.Fatalf("unexpected UNK in article: %s", result.Article)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return strings.Contains(s, sub)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Config struct {
|
||||
Alerts AlertsConfig `yaml:"alerts"`
|
||||
Notifications NotificationsConfig `yaml:"notifications"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Backup BackupConfig `yaml:"backup"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -101,6 +102,10 @@ type LoggingConfig struct {
|
||||
FilePath string `yaml:"file_path"`
|
||||
}
|
||||
|
||||
type BackupConfig struct {
|
||||
Time string `yaml:"time"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -182,6 +187,10 @@ func (c *Config) setDefaults() {
|
||||
if c.Logging.Output == "" {
|
||||
c.Logging.Output = "stdout"
|
||||
}
|
||||
|
||||
if c.Backup.Time == "" {
|
||||
c.Backup.Time = "00:00"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Address() string {
|
||||
|
||||
@@ -61,7 +61,6 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
||||
Category: lc.Category,
|
||||
CategoryName: lc.Category,
|
||||
Model: lc.Model,
|
||||
CurrentPrice: lc.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +86,6 @@ func (h *ComponentHandler) Get(c *gin.Context) {
|
||||
Category: component.Category,
|
||||
CategoryName: component.Category,
|
||||
Model: component.Model,
|
||||
CurrentPrice: component.CurrentPrice,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,34 +3,41 @@ package handlers
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ExportHandler struct {
|
||||
exportService *services.ExportService
|
||||
configService services.ConfigurationGetter
|
||||
componentService *services.ComponentService
|
||||
exportService *services.ExportService
|
||||
configService services.ConfigurationGetter
|
||||
projectService *services.ProjectService
|
||||
}
|
||||
|
||||
func NewExportHandler(
|
||||
exportService *services.ExportService,
|
||||
configService services.ConfigurationGetter,
|
||||
componentService *services.ComponentService,
|
||||
projectService *services.ProjectService,
|
||||
) *ExportHandler {
|
||||
return &ExportHandler{
|
||||
exportService: exportService,
|
||||
configService: configService,
|
||||
componentService: componentService,
|
||||
exportService: exportService,
|
||||
configService: configService,
|
||||
projectService: projectService,
|
||||
}
|
||||
}
|
||||
|
||||
type ExportRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Items []struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ProjectName string `json:"project_name"`
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
Article string `json:"article"`
|
||||
ServerCount int `json:"server_count"`
|
||||
PricelistID *uint `json:"pricelist_id"`
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name" binding:"required"`
|
||||
Quantity int `json:"quantity" binding:"required,min=1"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
@@ -47,75 +54,162 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
|
||||
data := h.buildExportData(&req)
|
||||
|
||||
csvData, err := h.exportService.ToCSV(data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
// Validate before streaming (can return JSON error)
|
||||
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name)
|
||||
// Get project code for filename
|
||||
projectCode := req.ProjectName // legacy field: may contain code from frontend
|
||||
if projectCode == "" && req.ProjectUUID != "" {
|
||||
username := middleware.GetUsername(c)
|
||||
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
||||
projectCode = project.Code
|
||||
}
|
||||
}
|
||||
if projectCode == "" {
|
||||
projectCode = req.Name
|
||||
}
|
||||
|
||||
// Set headers before streaming
|
||||
exportDate := data.CreatedAt
|
||||
articleSegment := sanitizeFilenameSegment(req.Article)
|
||||
if articleSegment == "" {
|
||||
articleSegment = "BOM"
|
||||
}
|
||||
filename := fmt.Sprintf("%s (%s) %s %s.csv", exportDate.Format("2006-01-02"), projectCode, req.Name, articleSegment)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
|
||||
// Stream CSV (cannot return JSON after this point)
|
||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||
c.Error(err) // Log only
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
||||
items := make([]services.ExportItem, len(req.Items))
|
||||
var total float64
|
||||
|
||||
// buildExportData converts an ExportRequest into a ProjectExportData using a temporary Configuration model
|
||||
// so that ExportService.ConfigToExportData can resolve categories via localDB.
|
||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ProjectExportData {
|
||||
configItems := make(models.ConfigItems, len(req.Items))
|
||||
for i, item := range req.Items {
|
||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||
|
||||
// Получаем информацию о компоненте для заполнения категории и описания
|
||||
componentView, err := h.componentService.GetByLotName(item.LotName)
|
||||
if err != nil {
|
||||
// Если не удалось получить информацию о компоненте, используем только основные данные
|
||||
items[i] = services.ExportItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: itemTotal,
|
||||
}
|
||||
} else {
|
||||
items[i] = services.ExportItem{
|
||||
LotName: item.LotName,
|
||||
Description: componentView.Description,
|
||||
Category: componentView.Category,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: itemTotal,
|
||||
}
|
||||
configItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
total += itemTotal
|
||||
}
|
||||
|
||||
return &services.ExportData{
|
||||
Name: req.Name,
|
||||
Items: items,
|
||||
Total: total,
|
||||
Notes: req.Notes,
|
||||
CreatedAt: time.Now(),
|
||||
serverCount := req.ServerCount
|
||||
if serverCount < 1 {
|
||||
serverCount = 1
|
||||
}
|
||||
|
||||
cfg := &models.Configuration{
|
||||
Article: req.Article,
|
||||
ServerCount: serverCount,
|
||||
PricelistID: req.PricelistID,
|
||||
Items: configItems,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
return h.exportService.ConfigToExportData(cfg)
|
||||
}
|
||||
|
||||
func sanitizeFilenameSegment(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return ""
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
"/", "_",
|
||||
"\\", "_",
|
||||
":", "_",
|
||||
"*", "_",
|
||||
"?", "_",
|
||||
"\"", "_",
|
||||
"<", "_",
|
||||
">", "_",
|
||||
"|", "_",
|
||||
)
|
||||
return strings.TrimSpace(replacer.Replace(value))
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
// Get config before streaming (can return JSON error)
|
||||
config, err := h.configService.GetByUUID(uuid, username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
data := h.exportService.ConfigToExportData(config, h.componentService)
|
||||
data := h.exportService.ConfigToExportData(config)
|
||||
|
||||
csvData, err := h.exportService.ToCSV(data)
|
||||
// Validate before streaming (can return JSON error)
|
||||
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get project code for filename
|
||||
projectCode := config.Name // fallback: use config name if no project
|
||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
|
||||
projectCode = project.Code
|
||||
}
|
||||
}
|
||||
|
||||
// Set headers before streaming
|
||||
// Use price update time if available, otherwise creation time
|
||||
exportDate := config.CreatedAt
|
||||
if config.PriceUpdatedAt != nil {
|
||||
exportDate = *config.PriceUpdatedAt
|
||||
}
|
||||
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectCode, config.Name)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
// Stream CSV (cannot return JSON after this point)
|
||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||
c.Error(err) // Log only
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ExportProjectCSV exports all active configurations of a project as a single CSV file.
|
||||
func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
projectUUID := c.Param("uuid")
|
||||
|
||||
project, err := h.projectService.GetByUUID(projectUUID, username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.projectService.ListConfigurations(projectUUID, username, "active")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name)
|
||||
if len(result.Configs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
||||
return
|
||||
}
|
||||
|
||||
data := h.exportService.ProjectToExportData(result.Configs)
|
||||
|
||||
// Filename: YYYY-MM-DD (ProjectCode) BOM.csv
|
||||
filename := fmt.Sprintf("%s (%s) BOM.csv", time.Now().Format("2006-01-02"), project.Code)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
|
||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
305
internal/handlers/export_test.go
Normal file
305
internal/handlers/export_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Mock services for testing
|
||||
type mockConfigService struct {
|
||||
config *models.Configuration
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
return m.config, m.err
|
||||
}
|
||||
|
||||
|
||||
func TestExportCSV_Success(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Create handler with mocks
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create JSON request body
|
||||
jsonBody := `{
|
||||
"name": "Test Export",
|
||||
"items": [
|
||||
{
|
||||
"lot_name": "LOT-001",
|
||||
"quantity": 2,
|
||||
"unit_price": 100.50
|
||||
}
|
||||
],
|
||||
"notes": "Test notes"
|
||||
}`
|
||||
|
||||
// Create HTTP request
|
||||
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Create response recorder
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Create Gin context
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
// Call handler
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Check status code
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check Content-Type header
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "text/csv; charset=utf-8" {
|
||||
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
|
||||
}
|
||||
|
||||
// Check for BOM
|
||||
responseBody := w.Body.Bytes()
|
||||
if len(responseBody) < 3 {
|
||||
t.Fatalf("Response too short to contain BOM")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := responseBody[:3]
|
||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||
}
|
||||
|
||||
// Check semicolon delimiter in CSV
|
||||
reader := csv.NewReader(bytes.NewReader(responseBody[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to parse CSV header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 8 {
|
||||
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create invalid request (missing required field)
|
||||
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name": "Test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
var errResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||
if _, hasError := errResp["error"]; !hasError {
|
||||
t.Errorf("Expected error in JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportCSV_EmptyItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create request with empty items array - should fail binding validation
|
||||
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name":"Empty Export","items":[],"notes":""}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Should return 400 Bad Request (validation error from gin binding)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_Success(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Mock configuration
|
||||
mockConfig := &models.Configuration{
|
||||
UUID: "test-uuid",
|
||||
Name: "Test Config",
|
||||
OwnerUsername: "testuser",
|
||||
Items: models.ConfigItems{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Create HTTP request
|
||||
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "test-uuid"},
|
||||
}
|
||||
|
||||
// Mock middleware.GetUsername
|
||||
c.Set("username", "testuser")
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Check status code
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check Content-Type header
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "text/csv; charset=utf-8" {
|
||||
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
|
||||
}
|
||||
|
||||
// Check for BOM
|
||||
responseBody := w.Body.Bytes()
|
||||
if len(responseBody) < 3 {
|
||||
t.Fatalf("Response too short to contain BOM")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := responseBody[:3]
|
||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||
t.Errorf("UTF-8 BOM mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{err: errors.New("config not found")},
|
||||
nil,
|
||||
)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "nonexistent-uuid"},
|
||||
}
|
||||
c.Set("username", "testuser")
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Should return 404 Not Found
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
var errResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||
if _, hasError := errResp["error"]; !hasError {
|
||||
t.Errorf("Expected error in JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Mock configuration with empty items
|
||||
mockConfig := &models.Configuration{
|
||||
UUID: "test-uuid",
|
||||
Name: "Empty Config",
|
||||
OwnerUsername: "testuser",
|
||||
Items: models.ConfigItems{},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
nil,
|
||||
)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "test-uuid"},
|
||||
}
|
||||
c.Set("username", "testuser")
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
var errResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||
if _, hasError := errResp["error"]; !hasError {
|
||||
t.Errorf("Expected error in JSON response")
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,30 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
}
|
||||
localPLs = filtered
|
||||
}
|
||||
if activeOnly {
|
||||
// Local cache stores only active snapshots for normal operations.
|
||||
type pricelistWithCount struct {
|
||||
pricelist localdb.LocalPricelist
|
||||
itemCount int64
|
||||
usageCount int
|
||||
}
|
||||
withCounts := make([]pricelistWithCount, 0, len(localPLs))
|
||||
for _, lpl := range localPLs {
|
||||
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
|
||||
if activeOnly && itemCount == 0 {
|
||||
continue
|
||||
}
|
||||
usageCount := 0
|
||||
if lpl.IsUsed {
|
||||
usageCount = 1
|
||||
}
|
||||
withCounts = append(withCounts, pricelistWithCount{
|
||||
pricelist: lpl,
|
||||
itemCount: itemCount,
|
||||
usageCount: usageCount,
|
||||
})
|
||||
}
|
||||
localPLs = localPLs[:0]
|
||||
for _, row := range withCounts {
|
||||
localPLs = append(localPLs, row.pricelist)
|
||||
}
|
||||
sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) })
|
||||
total := len(localPLs)
|
||||
@@ -62,10 +84,14 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
pageSlice := localPLs[start:end]
|
||||
summaries := make([]map[string]interface{}, 0, len(pageSlice))
|
||||
for _, lpl := range pageSlice {
|
||||
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
|
||||
itemCount := int64(0)
|
||||
usageCount := 0
|
||||
if lpl.IsUsed {
|
||||
usageCount = 1
|
||||
for _, row := range withCounts {
|
||||
if row.pricelist.ID == lpl.ID {
|
||||
itemCount = row.itemCount
|
||||
usageCount = row.usageCount
|
||||
break
|
||||
}
|
||||
}
|
||||
summaries = append(summaries, map[string]interface{}{
|
||||
"id": lpl.ServerID,
|
||||
@@ -157,15 +183,11 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||
}
|
||||
resultItems := make([]gin.H, 0, len(items))
|
||||
for _, item := range items {
|
||||
category := ""
|
||||
if parts := strings.SplitN(item.LotName, "_", 2); len(parts) > 0 {
|
||||
category = parts[0]
|
||||
}
|
||||
resultItems = append(resultItems, gin.H{
|
||||
"id": item.ID,
|
||||
"lot_name": item.LotName,
|
||||
"price": item.Price,
|
||||
"category": category,
|
||||
"category": item.LotCategory,
|
||||
"available_qty": item.AvailableQty,
|
||||
"partnumbers": []string(item.Partnumbers),
|
||||
})
|
||||
|
||||
161
internal/handlers/pricelist_test.go
Normal file
161
internal/handlers/pricelist_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestPricelistGetItems_ReturnsLotCategoryFromLocalPricelistItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 1,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-001",
|
||||
Name: "test",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
IsUsed: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{
|
||||
PricelistID: localPL.ID,
|
||||
LotName: "NO_UNDERSCORE_NAME",
|
||||
LotCategory: "CPU",
|
||||
Price: 10,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist items: %v", err)
|
||||
}
|
||||
|
||||
h := NewPricelistHandler(local)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/pricelists/1/items?page=1&per_page=50", nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "id", Value: "1"}}
|
||||
|
||||
h.GetItems(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Category string `json:"category"`
|
||||
UnitPrice any `json:"price"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(resp.Items))
|
||||
}
|
||||
if resp.Items[0].LotName != "NO_UNDERSCORE_NAME" {
|
||||
t.Fatalf("expected lot_name NO_UNDERSCORE_NAME, got %q", resp.Items[0].LotName)
|
||||
}
|
||||
if resp.Items[0].Category != "CPU" {
|
||||
t.Fatalf("expected category CPU, got %q", resp.Items[0].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local_active_only.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 10,
|
||||
Source: "estimate",
|
||||
Version: "E-1",
|
||||
Name: "with-items",
|
||||
CreatedAt: time.Now().Add(-time.Minute),
|
||||
SyncedAt: time.Now().Add(-time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("save with-items pricelist: %v", err)
|
||||
}
|
||||
withItems, err := local.GetLocalPricelistByServerID(10)
|
||||
if err != nil {
|
||||
t.Fatalf("load with-items pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{
|
||||
PricelistID: withItems.ID,
|
||||
LotName: "CPU_X",
|
||||
LotCategory: "CPU",
|
||||
Price: 100,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save with-items pricelist items: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 11,
|
||||
Source: "estimate",
|
||||
Version: "E-2",
|
||||
Name: "without-items",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save without-items pricelist: %v", err)
|
||||
}
|
||||
|
||||
h := NewPricelistHandler(local)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/pricelists?source=estimate&active_only=true", nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
h.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Pricelists []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"pricelists"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Total != 1 {
|
||||
t.Fatalf("expected total=1, got %d", resp.Total)
|
||||
}
|
||||
if len(resp.Pricelists) != 1 {
|
||||
t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists))
|
||||
}
|
||||
if resp.Pricelists[0].ID != 10 {
|
||||
t.Fatalf("expected pricelist id=10, got %d", resp.Pricelists[0].ID)
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -28,7 +26,7 @@ type SetupHandler struct {
|
||||
restartSig chan struct{}
|
||||
}
|
||||
|
||||
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
@@ -37,14 +35,9 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te
|
||||
templates := make(map[string]*template.Template)
|
||||
|
||||
// Load setup template (standalone, no base needed)
|
||||
setupPath := filepath.Join(templatesPath, "setup.html")
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
||||
} else {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||
}
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing setup template: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
stdsync "sync"
|
||||
"time"
|
||||
|
||||
@@ -32,16 +30,9 @@ type SyncHandler struct {
|
||||
}
|
||||
|
||||
// NewSyncHandler creates a new sync handler
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, _ string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
||||
// Load sync_status partial template
|
||||
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||
tmpl, err = template.ParseFiles(partialPath)
|
||||
} else {
|
||||
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
||||
}
|
||||
tmpl, err := template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -419,6 +410,26 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// RepairPendingChanges attempts to repair errored pending changes
|
||||
// POST /api/sync/repair
|
||||
func (h *SyncHandler) RepairPendingChanges(c *gin.Context) {
|
||||
repaired, remainingErrors, err := h.localDB.RepairPendingChanges()
|
||||
if err != nil {
|
||||
slog.Error("repair pending changes failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"repaired": repaired,
|
||||
"remaining_errors": remainingErrors,
|
||||
})
|
||||
}
|
||||
|
||||
// SyncInfoResponse represents sync information for the modal
|
||||
type SyncInfoResponse struct {
|
||||
// Connection
|
||||
|
||||
@@ -2,8 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
@@ -17,7 +15,7 @@ type WebHandler struct {
|
||||
componentService *services.ComponentService
|
||||
}
|
||||
|
||||
func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) {
|
||||
func NewWebHandler(_ string, componentService *services.ComponentService) (*WebHandler, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
@@ -60,27 +58,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
}
|
||||
|
||||
templates := make(map[string]*template.Template)
|
||||
basePath := filepath.Join(templatesPath, "base.html")
|
||||
useDisk := false
|
||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||
useDisk = true
|
||||
}
|
||||
|
||||
// Load each page template with base
|
||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html"}
|
||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
|
||||
for _, page := range simplePages {
|
||||
pagePath := filepath.Join(templatesPath, page)
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if useDisk {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
||||
} else {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/"+page,
|
||||
)
|
||||
}
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/"+page,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -88,20 +75,14 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
}
|
||||
|
||||
// Index page needs components_list.html as well
|
||||
indexPath := filepath.Join(templatesPath, "index.html")
|
||||
componentsListPath := filepath.Join(templatesPath, "components_list.html")
|
||||
var indexTmpl *template.Template
|
||||
var err error
|
||||
if useDisk {
|
||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
||||
} else {
|
||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/index.html",
|
||||
"web/templates/components_list.html",
|
||||
)
|
||||
}
|
||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/index.html",
|
||||
"web/templates/components_list.html",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -110,17 +91,12 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
// Load partial templates (no base needed)
|
||||
partials := []string{"components_list.html"}
|
||||
for _, partial := range partials {
|
||||
partialPath := filepath.Join(templatesPath, partial)
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if useDisk {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
||||
} else {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/"+partial,
|
||||
)
|
||||
}
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/"+partial,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -147,8 +123,8 @@ func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
||||
}
|
||||
|
||||
func (h *WebHandler) Index(c *gin.Context) {
|
||||
// Redirect to configs page - configurator is accessed via /configurator?uuid=...
|
||||
c.Redirect(302, "/configs")
|
||||
// Redirect to projects page - configurator is accessed via /configurator?uuid=...
|
||||
c.Redirect(302, "/projects")
|
||||
}
|
||||
|
||||
func (h *WebHandler) Configurator(c *gin.Context) {
|
||||
@@ -197,6 +173,13 @@ func (h *WebHandler) ProjectDetail(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebHandler) ConfigRevisions(c *gin.Context) {
|
||||
h.render(c, "config_revisions.html", gin.H{
|
||||
"ActivePage": "configs",
|
||||
"ConfigUUID": c.Param("uuid"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebHandler) Pricelists(c *gin.Context) {
|
||||
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
||||
}
|
||||
|
||||
@@ -28,14 +28,13 @@ type ComponentSyncResult struct {
|
||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Query to join lot with qt_lot_metadata
|
||||
// Query to join lot with qt_lot_metadata (metadata only, no pricing)
|
||||
// Use LEFT JOIN to include lots without metadata
|
||||
type componentRow struct {
|
||||
LotName string
|
||||
LotDescription string
|
||||
Category *string
|
||||
Model *string
|
||||
CurrentPrice *float64
|
||||
}
|
||||
|
||||
var rows []componentRow
|
||||
@@ -44,8 +43,7 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
||||
l.lot_name,
|
||||
l.lot_description,
|
||||
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
||||
m.model,
|
||||
m.current_price
|
||||
m.model
|
||||
FROM lot l
|
||||
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
||||
LEFT JOIN qt_categories c ON m.category_id = c.id
|
||||
@@ -100,8 +98,6 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
||||
LotDescription: row.LotDescription,
|
||||
Category: category,
|
||||
Model: model,
|
||||
CurrentPrice: row.CurrentPrice,
|
||||
SyncedAt: syncTime,
|
||||
}
|
||||
components = append(components, comp)
|
||||
|
||||
@@ -221,11 +217,6 @@ func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]L
|
||||
)
|
||||
}
|
||||
|
||||
// Apply price filter
|
||||
if filter.HasPrice {
|
||||
db = db.Where("current_price IS NOT NULL")
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int64
|
||||
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
||||
@@ -251,6 +242,31 @@ func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
||||
return &component, nil
|
||||
}
|
||||
|
||||
// GetLocalComponentCategoriesByLotNames returns category for each lot_name in the local component cache.
|
||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
|
||||
result := make(map[string]string, len(lotNames))
|
||||
if len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
Category string `gorm:"column:category"`
|
||||
}
|
||||
var rows []row
|
||||
if err := l.db.Model(&LocalComponent{}).
|
||||
Select("lot_name, category").
|
||||
Where("lot_name IN ?", lotNames).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
result[r.LotName] = r.Category
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLocalComponentCategories returns distinct categories from local components
|
||||
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
||||
var categories []string
|
||||
@@ -311,99 +327,3 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
||||
}
|
||||
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
||||
}
|
||||
|
||||
// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items
|
||||
// This allows offline price updates using synced pricelists without MariaDB connection
|
||||
func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) {
|
||||
// Get all items from the specified pricelist
|
||||
var items []LocalPricelistItem
|
||||
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
|
||||
return 0, fmt.Errorf("fetching pricelist items: %w", err)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
slog.Warn("no items found in pricelist", "pricelist_id", pricelistID)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Update current_price for each component
|
||||
updated := 0
|
||||
err := l.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, item := range items {
|
||||
result := tx.Model(&LocalComponent{}).
|
||||
Where("lot_name = ?", item.LotName).
|
||||
Update("current_price", item.Price)
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error)
|
||||
}
|
||||
|
||||
if result.RowsAffected > 0 {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
slog.Info("updated component prices from pricelist",
|
||||
"pricelist_id", pricelistID,
|
||||
"total_items", len(items),
|
||||
"updated_components", updated)
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components
|
||||
// if no components exist or all current prices are NULL
|
||||
func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
|
||||
// Check if we have any components with prices
|
||||
var count int64
|
||||
if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("checking component prices: %w", err)
|
||||
}
|
||||
|
||||
// If we have components with prices, don't load from pricelists
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we have any components at all
|
||||
var totalComponents int64
|
||||
if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil {
|
||||
return fmt.Errorf("counting components: %w", err)
|
||||
}
|
||||
|
||||
// If we have no components, we need to load them from pricelists
|
||||
if totalComponents == 0 {
|
||||
slog.Info("no components found in local database, loading from latest pricelist")
|
||||
// This would typically be called from the sync service or setup process
|
||||
// For now, we'll just return nil to indicate no action needed
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we have components but no prices, load from latest estimate pricelist.
|
||||
var latestPricelist LocalPricelist
|
||||
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&latestPricelist).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
slog.Warn("no pricelists found in local database")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("finding latest pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Update prices from the latest pricelist
|
||||
updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating component prices from pricelist: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("loaded component prices from latest pricelist",
|
||||
"pricelist_id", latestPricelist.ID,
|
||||
"updated_components", updated)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
ServerModel: cfg.ServerModel,
|
||||
SupportCode: cfg.SupportCode,
|
||||
Article: cfg.Article,
|
||||
PricelistID: cfg.PricelistID,
|
||||
OnlyInStock: cfg.OnlyInStock,
|
||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||
@@ -72,6 +75,9 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
Notes: local.Notes,
|
||||
IsTemplate: local.IsTemplate,
|
||||
ServerCount: local.ServerCount,
|
||||
ServerModel: local.ServerModel,
|
||||
SupportCode: local.SupportCode,
|
||||
Article: local.Article,
|
||||
PricelistID: local.PricelistID,
|
||||
OnlyInStock: local.OnlyInStock,
|
||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||
@@ -85,6 +91,9 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
userID := local.OriginalUserID
|
||||
cfg.UserID = &userID
|
||||
}
|
||||
if local.CurrentVersion != nil {
|
||||
cfg.CurrentVersionNo = local.CurrentVersion.VersionNo
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
@@ -100,6 +109,8 @@ func ProjectToLocal(project *models.Project) *LocalProject {
|
||||
local := &LocalProject{
|
||||
UUID: project.UUID,
|
||||
OwnerUsername: project.OwnerUsername,
|
||||
Code: project.Code,
|
||||
Variant: project.Variant,
|
||||
Name: project.Name,
|
||||
TrackerURL: project.TrackerURL,
|
||||
IsActive: project.IsActive,
|
||||
@@ -119,6 +130,8 @@ func LocalToProject(local *LocalProject) *models.Project {
|
||||
project := &models.Project{
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OwnerUsername,
|
||||
Code: local.Code,
|
||||
Variant: local.Variant,
|
||||
Name: local.Name,
|
||||
TrackerURL: local.TrackerURL,
|
||||
IsActive: local.IsActive,
|
||||
@@ -169,6 +182,7 @@ func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *Lo
|
||||
return &LocalPricelistItem{
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
LotCategory: item.LotCategory,
|
||||
Price: item.Price,
|
||||
AvailableQty: item.AvailableQty,
|
||||
Partnumbers: partnumbers,
|
||||
@@ -183,6 +197,7 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
|
||||
ID: local.ID,
|
||||
PricelistID: serverPricelistID,
|
||||
LotName: local.LotName,
|
||||
LotCategory: local.LotCategory,
|
||||
Price: local.Price,
|
||||
AvailableQty: local.AvailableQty,
|
||||
Partnumbers: partnumbers,
|
||||
@@ -213,17 +228,14 @@ func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
|
||||
LotDescription: lotDesc,
|
||||
Category: category,
|
||||
Model: meta.Model,
|
||||
CurrentPrice: meta.CurrentPrice,
|
||||
SyncedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// LocalToComponent converts LocalComponent to models.LotMetadata
|
||||
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
||||
return &models.LotMetadata{
|
||||
LotName: local.LotName,
|
||||
Model: local.Model,
|
||||
CurrentPrice: local.CurrentPrice,
|
||||
LotName: local.LotName,
|
||||
Model: local.Model,
|
||||
Lot: &models.Lot{
|
||||
LotName: local.LotName,
|
||||
LotDescription: local.LotDescription,
|
||||
|
||||
34
internal/localdb/converters_test.go
Normal file
34
internal/localdb/converters_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
|
||||
item := &models.PricelistItem{
|
||||
LotName: "CPU_A",
|
||||
LotCategory: "CPU",
|
||||
Price: 10,
|
||||
}
|
||||
|
||||
local := PricelistItemToLocal(item, 123)
|
||||
if local.LotCategory != "CPU" {
|
||||
t.Fatalf("expected LotCategory=CPU, got %q", local.LotCategory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalToPricelistItem_PreservesLotCategory(t *testing.T) {
|
||||
local := &LocalPricelistItem{
|
||||
LotName: "CPU_A",
|
||||
LotCategory: "CPU",
|
||||
Price: 10,
|
||||
}
|
||||
|
||||
item := LocalToPricelistItem(local, 456)
|
||||
if item.LotCategory != "CPU" {
|
||||
t.Fatalf("expected LotCategory=CPU, got %q", item.LotCategory)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||
@@ -125,3 +127,129 @@ func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) {
|
||||
t.Fatalf("expected 2 pricelists, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "versions_dedup.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
cfg := &LocalConfiguration{
|
||||
UUID: "dedup-cfg",
|
||||
Name: "Dedup",
|
||||
Items: LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg); err != nil {
|
||||
t.Fatalf("save seed config: %v", err)
|
||||
}
|
||||
|
||||
baseV1Data, err := BuildConfigurationSnapshot(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("build v1 snapshot: %v", err)
|
||||
}
|
||||
baseV1 := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 1,
|
||||
Data: baseV1Data,
|
||||
AppVersion: "test",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := local.DB().Create(&baseV1).Error; err != nil {
|
||||
t.Fatalf("insert base v1: %v", err)
|
||||
}
|
||||
if err := local.DB().Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", baseV1.ID).Error; err != nil {
|
||||
t.Fatalf("set current_version_id to v1: %v", err)
|
||||
}
|
||||
|
||||
v2 := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 2,
|
||||
Data: baseV1.Data,
|
||||
AppVersion: "test",
|
||||
CreatedAt: time.Now().Add(1 * time.Second),
|
||||
}
|
||||
if err := local.DB().Create(&v2).Error; err != nil {
|
||||
t.Fatalf("insert duplicate v2: %v", err)
|
||||
}
|
||||
|
||||
modified := *cfg
|
||||
modified.Items = LocalConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}}
|
||||
total := modified.Items.Total()
|
||||
modified.TotalPrice = &total
|
||||
modified.UpdatedAt = time.Now()
|
||||
v3Data, err := BuildConfigurationSnapshot(&modified)
|
||||
if err != nil {
|
||||
t.Fatalf("build v3 snapshot: %v", err)
|
||||
}
|
||||
|
||||
v3 := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 3,
|
||||
Data: v3Data,
|
||||
AppVersion: "test",
|
||||
CreatedAt: time.Now().Add(2 * time.Second),
|
||||
}
|
||||
if err := local.DB().Create(&v3).Error; err != nil {
|
||||
t.Fatalf("insert v3: %v", err)
|
||||
}
|
||||
|
||||
v4 := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 4,
|
||||
Data: v3Data,
|
||||
AppVersion: "test",
|
||||
CreatedAt: time.Now().Add(3 * time.Second),
|
||||
}
|
||||
if err := local.DB().Create(&v4).Error; err != nil {
|
||||
t.Fatalf("insert duplicate v4: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", v4.ID).Error; err != nil {
|
||||
t.Fatalf("point current_version_id to duplicate v4: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Where("id = ?", "2026_02_19_configuration_versions_dedup_spec_price").
|
||||
Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||
t.Fatalf("delete dedup migration record: %v", err)
|
||||
}
|
||||
|
||||
if err := runLocalMigrations(local.DB()); err != nil {
|
||||
t.Fatalf("rerun local migrations: %v", err)
|
||||
}
|
||||
|
||||
var versions []LocalConfigurationVersion
|
||||
if err := local.DB().Where("configuration_uuid = ?", cfg.UUID).
|
||||
Order("version_no ASC").
|
||||
Find(&versions).Error; err != nil {
|
||||
t.Fatalf("load versions after dedup: %v", err)
|
||||
}
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions after dedup, got %d", len(versions))
|
||||
}
|
||||
if versions[0].VersionNo != 1 || versions[1].VersionNo != 3 {
|
||||
t.Fatalf("expected kept version numbers [1,3], got [%d,%d]", versions[0].VersionNo, versions[1].VersionNo)
|
||||
}
|
||||
|
||||
var after LocalConfiguration
|
||||
if err := local.DB().Where("uuid = ?", cfg.UUID).First(&after).Error; err != nil {
|
||||
t.Fatalf("load config after dedup: %v", err)
|
||||
}
|
||||
if after.CurrentVersionID == nil || *after.CurrentVersionID != v3.ID {
|
||||
t.Fatalf("expected current_version_id to point to kept latest version v3")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
"github.com/glebarez/sqlite"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
uuidpkg "github.com/google/uuid"
|
||||
@@ -41,6 +42,49 @@ type LocalDB struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// ResetData clears local data tables while keeping connection settings.
|
||||
// It does not drop schema or connection_settings.
|
||||
func ResetData(dbPath string) error {
|
||||
if strings.TrimSpace(dbPath) == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stat local db: %w", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening sqlite database: %w", err)
|
||||
}
|
||||
|
||||
// Order does not matter because we use DELETEs without FK constraints in SQLite.
|
||||
tables := []string{
|
||||
"local_projects",
|
||||
"local_configurations",
|
||||
"local_configuration_versions",
|
||||
"local_pricelists",
|
||||
"local_pricelist_items",
|
||||
"local_components",
|
||||
"local_remote_migrations_applied",
|
||||
"local_sync_guard_state",
|
||||
"pending_changes",
|
||||
"app_settings",
|
||||
}
|
||||
for _, table := range tables {
|
||||
if err := db.Exec("DELETE FROM " + table).Error; err != nil {
|
||||
return fmt.Errorf("clear %s: %w", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("local database data reset", "path", dbPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// New creates a new LocalDB instance
|
||||
func New(dbPath string) (*LocalDB, error) {
|
||||
// Ensure directory exists
|
||||
@@ -49,6 +93,14 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
return nil, fmt.Errorf("creating data directory: %w", err)
|
||||
}
|
||||
|
||||
if cfgPath, err := appstate.ResolveConfigPathNearDB("", dbPath); err == nil {
|
||||
if _, err := appstate.EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||
return nil, fmt.Errorf("backup local data: %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("resolve config path: %w", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
@@ -56,10 +108,31 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureLocalProjectsTable(db); err != nil {
|
||||
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
||||
}
|
||||
|
||||
// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
|
||||
if db.Migrator().HasTable(&LocalProject{}) {
|
||||
if !db.Migrator().HasColumn(&LocalProject{}, "uuid") {
|
||||
if err := db.Exec(`ALTER TABLE local_projects ADD COLUMN uuid TEXT`).Error; err != nil {
|
||||
return nil, fmt.Errorf("adding local_projects.uuid: %w", err)
|
||||
}
|
||||
}
|
||||
var ids []uint
|
||||
if err := db.Raw(`SELECT id FROM local_projects WHERE uuid IS NULL OR uuid = ''`).Scan(&ids).Error; err != nil {
|
||||
return nil, fmt.Errorf("finding local_projects without uuid: %w", err)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := db.Exec(`UPDATE local_projects SET uuid = ? WHERE id = ?`, uuidpkg.New().String(), id).Error; err != nil {
|
||||
return nil, fmt.Errorf("backfilling local_projects.uuid: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-migrate all local tables
|
||||
if err := db.AutoMigrate(
|
||||
&ConnectionSettings{},
|
||||
&LocalProject{},
|
||||
&LocalConfiguration{},
|
||||
&LocalConfigurationVersion{},
|
||||
&LocalPricelist{},
|
||||
@@ -84,6 +157,38 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureLocalProjectsTable(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable(&LocalProject{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
server_id INTEGER NULL,
|
||||
owner_username TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
variant TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NULL,
|
||||
tracker_url TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
synced_at DATETIME NULL,
|
||||
sync_status TEXT DEFAULT 'local'
|
||||
)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
|
||||
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
|
||||
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
|
||||
_ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasSettings returns true if connection settings exist
|
||||
func (l *LocalDB) HasSettings() bool {
|
||||
var count int64
|
||||
@@ -258,7 +363,8 @@ func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, err
|
||||
project = &LocalProject{
|
||||
UUID: uuidpkg.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
Code: "Без проекта",
|
||||
Name: ptrString("Без проекта"),
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
@@ -286,7 +392,8 @@ func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
|
||||
canonical = LocalProject{
|
||||
UUID: uuidpkg.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
Code: "Без проекта",
|
||||
Name: ptrString("Без проекта"),
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
@@ -367,6 +474,10 @@ WHERE (
|
||||
return tx.RowsAffected, tx.Error
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
|
||||
// If missing, it assigns system project "Без проекта" for configuration owner.
|
||||
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
||||
@@ -425,18 +536,36 @@ func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, of
|
||||
query := l.db.Model(&LocalConfiguration{})
|
||||
switch status {
|
||||
case "active":
|
||||
query = query.Where("is_active = ?", true)
|
||||
query = query.Where("local_configurations.is_active = ?", true)
|
||||
case "archived":
|
||||
query = query.Where("is_active = ?", false)
|
||||
query = query.Where("local_configurations.is_active = ?", false)
|
||||
case "all", "":
|
||||
// no-op
|
||||
default:
|
||||
query = query.Where("is_active = ?", true)
|
||||
query = query.Where("local_configurations.is_active = ?", true)
|
||||
}
|
||||
|
||||
search = strings.TrimSpace(search)
|
||||
if search != "" {
|
||||
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(search)+"%")
|
||||
needle := "%" + strings.ToLower(search) + "%"
|
||||
hasProjectsTable := l.db.Migrator().HasTable(&LocalProject{})
|
||||
hasServerModel := l.db.Migrator().HasColumn(&LocalConfiguration{}, "server_model")
|
||||
|
||||
conditions := []string{"LOWER(local_configurations.name) LIKE ?"}
|
||||
args := []interface{}{needle}
|
||||
|
||||
if hasProjectsTable {
|
||||
query = query.Joins("LEFT JOIN local_projects lp ON lp.uuid = local_configurations.project_uuid")
|
||||
conditions = append(conditions, "LOWER(COALESCE(lp.name, '')) LIKE ?")
|
||||
args = append(args, needle)
|
||||
}
|
||||
|
||||
if hasServerModel {
|
||||
conditions = append(conditions, "LOWER(COALESCE(local_configurations.server_model, '')) LIKE ?")
|
||||
args = append(args, needle)
|
||||
}
|
||||
|
||||
query = query.Where(strings.Join(conditions, " OR "), args...)
|
||||
}
|
||||
|
||||
var total int64
|
||||
@@ -445,7 +574,7 @@ func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, of
|
||||
}
|
||||
|
||||
var configs []LocalConfiguration
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
|
||||
if err := query.Order("local_configurations.created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return configs, total, nil
|
||||
@@ -563,7 +692,11 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
if err := l.db.
|
||||
Where("source = ?", "estimate").
|
||||
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -572,7 +705,11 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
|
||||
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Where("source = ?", source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
if err := l.db.
|
||||
Where("source = ?", source).
|
||||
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -645,6 +782,17 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
|
||||
return count
|
||||
}
|
||||
|
||||
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
|
||||
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
|
||||
var count int64
|
||||
if err := l.db.Model(&LocalPricelistItem{}).
|
||||
Where("pricelist_id = ? AND (lot_category IS NULL OR TRIM(lot_category) = '')", pricelistID).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// SaveLocalPricelistItems saves pricelist items to local SQLite
|
||||
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
||||
if len(items) == 0 {
|
||||
@@ -665,6 +813,30 @@ func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceLocalPricelistItems atomically replaces all items for a pricelist.
|
||||
func (l *LocalDB) ReplaceLocalPricelistItems(pricelistID uint, items []LocalPricelistItem) error {
|
||||
return l.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&LocalPricelistItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
batchSize := 500
|
||||
for i := 0; i < len(items); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
if err := tx.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetLocalPricelistItems returns items for a local pricelist
|
||||
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
|
||||
var items []LocalPricelistItem
|
||||
@@ -684,6 +856,36 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
|
||||
return item.Price, nil
|
||||
}
|
||||
|
||||
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||
result := make(map[string]string, len(lotNames))
|
||||
if serverPricelistID == 0 || len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
localPL, err := l.GetLocalPricelistByServerID(serverPricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type row struct {
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
LotCategory string `gorm:"column:lot_category"`
|
||||
}
|
||||
var rows []row
|
||||
if err := l.db.Model(&LocalPricelistItem{}).
|
||||
Select("lot_name, lot_category").
|
||||
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
result[r.LotName] = r.LotCategory
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// MarkPricelistAsUsed marks a pricelist as used by a configuration
|
||||
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
||||
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
||||
@@ -847,6 +1049,145 @@ func (l *LocalDB) GetPendingCount() int64 {
|
||||
return l.CountPendingChanges()
|
||||
}
|
||||
|
||||
// RepairPendingChanges attempts to fix errored pending changes by validating and correcting data.
|
||||
// Returns the number of changes repaired and a list of errors that couldn't be fixed.
|
||||
func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
|
||||
var erroredChanges []PendingChange
|
||||
if err := l.db.Where("last_error != ?", "").Find(&erroredChanges).Error; err != nil {
|
||||
return 0, nil, fmt.Errorf("fetching errored changes: %w", err)
|
||||
}
|
||||
|
||||
if len(erroredChanges) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
repaired := 0
|
||||
var remainingErrors []string
|
||||
|
||||
for _, change := range erroredChanges {
|
||||
var repairErr error
|
||||
switch change.EntityType {
|
||||
case "project":
|
||||
repairErr = l.repairProjectChange(&change)
|
||||
case "configuration":
|
||||
repairErr = l.repairConfigurationChange(&change)
|
||||
default:
|
||||
repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
|
||||
}
|
||||
|
||||
if repairErr != nil {
|
||||
remainingErrors = append(remainingErrors, fmt.Sprintf("%s %s %s: %v",
|
||||
change.Operation, change.EntityType, change.EntityUUID[:8], repairErr))
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear error and reset attempts
|
||||
if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{
|
||||
"last_error": "",
|
||||
"attempts": 0,
|
||||
}).Error; err != nil {
|
||||
remainingErrors = append(remainingErrors, fmt.Sprintf("clearing error for %s: %v", change.EntityUUID[:8], err))
|
||||
continue
|
||||
}
|
||||
|
||||
repaired++
|
||||
}
|
||||
|
||||
return repaired, remainingErrors, nil
|
||||
}
|
||||
|
||||
// repairProjectChange validates and fixes project data.
|
||||
// Note: This only validates local data. Server-side conflicts (like duplicate code+variant)
|
||||
// are handled by sync service layer with deduplication logic.
|
||||
func (l *LocalDB) repairProjectChange(change *PendingChange) error {
|
||||
project, err := l.GetProjectByUUID(change.EntityUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("project not found locally: %w", err)
|
||||
}
|
||||
|
||||
modified := false
|
||||
|
||||
// Fix Code: must be non-empty
|
||||
if strings.TrimSpace(project.Code) == "" {
|
||||
if project.Name != nil && strings.TrimSpace(*project.Name) != "" {
|
||||
project.Code = strings.TrimSpace(*project.Name)
|
||||
} else {
|
||||
project.Code = project.UUID[:8]
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
|
||||
// Fix Name: use Code if empty
|
||||
if project.Name == nil || strings.TrimSpace(*project.Name) == "" {
|
||||
name := project.Code
|
||||
project.Name = &name
|
||||
modified = true
|
||||
}
|
||||
|
||||
// Fix OwnerUsername: must be non-empty
|
||||
if strings.TrimSpace(project.OwnerUsername) == "" {
|
||||
project.OwnerUsername = l.GetDBUser()
|
||||
if project.OwnerUsername == "" {
|
||||
return fmt.Errorf("cannot determine owner username")
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
|
||||
// Check for local duplicates with same (code, variant)
|
||||
var duplicate LocalProject
|
||||
err = l.db.Where("code = ? AND variant = ? AND uuid != ?", project.Code, project.Variant, project.UUID).
|
||||
First(&duplicate).Error
|
||||
if err == nil {
|
||||
// Found local duplicate - deduplicate by appending UUID suffix to variant
|
||||
if project.Variant == "" {
|
||||
project.Variant = project.UUID[:8]
|
||||
} else {
|
||||
project.Variant = project.Variant + "-" + project.UUID[:8]
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
|
||||
if modified {
|
||||
if err := l.SaveProject(project); err != nil {
|
||||
return fmt.Errorf("saving repaired project: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// repairConfigurationChange validates and fixes configuration data
|
||||
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
|
||||
config, err := l.GetConfigurationByUUID(change.EntityUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration not found locally: %w", err)
|
||||
}
|
||||
|
||||
modified := false
|
||||
|
||||
// Check if referenced project exists
|
||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||
_, err := l.GetProjectByUUID(*config.ProjectUUID)
|
||||
if err != nil {
|
||||
// Project doesn't exist locally - use default system project
|
||||
systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
|
||||
if sysErr != nil {
|
||||
return fmt.Errorf("getting system project: %w", sysErr)
|
||||
}
|
||||
config.ProjectUUID = &systemProject.UUID
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if modified {
|
||||
if err := l.SaveConfiguration(config); err != nil {
|
||||
return fmt.Errorf("saving repaired configuration: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
|
||||
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
|
||||
var migration LocalRemoteMigrationApplied
|
||||
|
||||
@@ -51,8 +51,8 @@ func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("get system project: %v", err)
|
||||
}
|
||||
if project.Name != "Без проекта" {
|
||||
t.Fatalf("expected system project name, got %q", project.Name)
|
||||
if project.Name == nil || *project.Name != "Без проекта" {
|
||||
t.Fatalf("expected system project name, got %v", project.Name)
|
||||
}
|
||||
if !project.IsSystem {
|
||||
t.Fatalf("expected system project flag")
|
||||
|
||||
@@ -58,6 +58,56 @@ var localMigrations = []localMigration{
|
||||
name: "Backfill source for local pricelists and create source indexes",
|
||||
run: backfillLocalPricelistSource,
|
||||
},
|
||||
{
|
||||
id: "2026_02_09_drop_component_unused_fields",
|
||||
name: "Remove current_price and synced_at from local_components (unused fields)",
|
||||
run: dropComponentUnusedFields,
|
||||
},
|
||||
{
|
||||
id: "2026_02_09_add_warehouse_competitor_pricelists",
|
||||
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
|
||||
run: addWarehouseCompetitorPriceLists,
|
||||
},
|
||||
{
|
||||
id: "2026_02_11_local_pricelist_item_category",
|
||||
name: "Add lot_category to local_pricelist_items and create indexes",
|
||||
run: addLocalPricelistItemCategoryAndIndexes,
|
||||
},
|
||||
{
|
||||
id: "2026_02_11_local_config_article",
|
||||
name: "Add article to local_configurations",
|
||||
run: addLocalConfigurationArticle,
|
||||
},
|
||||
{
|
||||
id: "2026_02_11_local_config_server_model",
|
||||
name: "Add server_model to local_configurations",
|
||||
run: addLocalConfigurationServerModel,
|
||||
},
|
||||
{
|
||||
id: "2026_02_11_local_config_support_code",
|
||||
name: "Add support_code to local_configurations",
|
||||
run: addLocalConfigurationSupportCode,
|
||||
},
|
||||
{
|
||||
id: "2026_02_13_local_project_code",
|
||||
name: "Add project code to local_projects and backfill",
|
||||
run: addLocalProjectCode,
|
||||
},
|
||||
{
|
||||
id: "2026_02_13_local_project_variant",
|
||||
name: "Add project variant to local_projects and backfill",
|
||||
run: addLocalProjectVariant,
|
||||
},
|
||||
{
|
||||
id: "2026_02_13_local_project_name_nullable",
|
||||
name: "Allow NULL project names in local_projects",
|
||||
run: allowLocalProjectNameNull,
|
||||
},
|
||||
{
|
||||
id: "2026_02_19_configuration_versions_dedup_spec_price",
|
||||
name: "Deduplicate configuration revisions by spec+price",
|
||||
run: deduplicateConfigurationVersionsBySpecAndPrice,
|
||||
},
|
||||
}
|
||||
|
||||
func runLocalMigrations(db *gorm.DB) error {
|
||||
@@ -194,7 +244,8 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
||||
project = LocalProject{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: "Без проекта",
|
||||
Code: "Без проекта",
|
||||
Name: ptrString("Без проекта"),
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
@@ -208,6 +259,139 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func addLocalProjectCode(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN code TEXT`).Error; err != nil {
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
|
||||
!strings.Contains(strings.ToLower(err.Error()), "exists") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Drop unique index if it already exists to allow de-duplication updates.
|
||||
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy code from current project name.
|
||||
if err := tx.Exec(`
|
||||
UPDATE local_projects
|
||||
SET code = TRIM(COALESCE(name, ''))`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure any remaining blanks have a unique fallback.
|
||||
if err := tx.Exec(`
|
||||
UPDATE local_projects
|
||||
SET code = 'P-' || uuid
|
||||
WHERE code IS NULL OR TRIM(code) = ''`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// De-duplicate codes: OPS-1948-2, OPS-1948-3...
|
||||
if err := tx.Exec(`
|
||||
WITH ranked AS (
|
||||
SELECT id, code,
|
||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
|
||||
FROM local_projects
|
||||
)
|
||||
UPDATE local_projects
|
||||
SET code = code || '-' || (SELECT rn FROM ranked WHERE ranked.id = local_projects.id)
|
||||
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create unique index for project codes (ignore if exists).
|
||||
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code ON local_projects(code)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addLocalProjectVariant(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN variant TEXT NOT NULL DEFAULT ''`).Error; err != nil {
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
|
||||
!strings.Contains(strings.ToLower(err.Error()), "exists") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Drop legacy code index if present.
|
||||
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reset code from name and clear variant.
|
||||
if err := tx.Exec(`
|
||||
UPDATE local_projects
|
||||
SET code = TRIM(COALESCE(name, '')),
|
||||
variant = ''`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// De-duplicate by assigning variant numbers: 2,3...
|
||||
if err := tx.Exec(`
|
||||
WITH ranked AS (
|
||||
SELECT id, code,
|
||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
|
||||
FROM local_projects
|
||||
)
|
||||
UPDATE local_projects
|
||||
SET variant = CASE
|
||||
WHEN (SELECT rn FROM ranked WHERE ranked.id = local_projects.id) = 1 THEN ''
|
||||
ELSE '-' || CAST((SELECT rn FROM ranked WHERE ranked.id = local_projects.id) AS TEXT)
|
||||
END`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func allowLocalProjectNameNull(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`ALTER TABLE local_projects RENAME TO local_projects_old`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE TABLE local_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
server_id INTEGER NULL,
|
||||
owner_username TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
variant TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NULL,
|
||||
tracker_url TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
synced_at DATETIME NULL,
|
||||
sync_status TEXT DEFAULT 'local'
|
||||
)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
|
||||
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
|
||||
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
|
||||
_ = tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO local_projects (id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status)
|
||||
SELECT id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status
|
||||
FROM local_projects_old`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = tx.Exec(`DROP TABLE local_projects_old`).Error
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillConfigurationPricelists(tx *gorm.DB) error {
|
||||
var latest LocalPricelist
|
||||
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
|
||||
@@ -249,6 +433,93 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
||||
return candidate
|
||||
}
|
||||
|
||||
func deduplicateConfigurationVersionsBySpecAndPrice(tx *gorm.DB) error {
|
||||
var configs []LocalConfiguration
|
||||
if err := tx.Select("uuid", "current_version_id").Find(&configs).Error; err != nil {
|
||||
return fmt.Errorf("load configurations for revision deduplication: %w", err)
|
||||
}
|
||||
|
||||
var removedTotal int
|
||||
for i := range configs {
|
||||
cfg := configs[i]
|
||||
|
||||
var versions []LocalConfigurationVersion
|
||||
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
||||
Order("version_no ASC, created_at ASC").
|
||||
Find(&versions).Error; err != nil {
|
||||
return fmt.Errorf("load versions for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
if len(versions) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
deleteIDs := make([]string, 0)
|
||||
deleteSet := make(map[string]struct{})
|
||||
kept := make([]LocalConfigurationVersion, 0, len(versions))
|
||||
var prevKey string
|
||||
hasPrev := false
|
||||
|
||||
for _, version := range versions {
|
||||
snapshotCfg, err := DecodeConfigurationSnapshot(version.Data)
|
||||
if err != nil {
|
||||
// Keep malformed snapshots untouched and reset chain to avoid accidental removals.
|
||||
kept = append(kept, version)
|
||||
hasPrev = false
|
||||
continue
|
||||
}
|
||||
|
||||
key, err := BuildConfigurationSpecPriceFingerprint(snapshotCfg)
|
||||
if err != nil {
|
||||
kept = append(kept, version)
|
||||
hasPrev = false
|
||||
continue
|
||||
}
|
||||
|
||||
if !hasPrev || key != prevKey {
|
||||
kept = append(kept, version)
|
||||
prevKey = key
|
||||
hasPrev = true
|
||||
continue
|
||||
}
|
||||
|
||||
deleteIDs = append(deleteIDs, version.ID)
|
||||
deleteSet[version.ID] = struct{}{}
|
||||
}
|
||||
|
||||
if len(deleteIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := tx.Where("id IN ?", deleteIDs).Delete(&LocalConfigurationVersion{}).Error; err != nil {
|
||||
return fmt.Errorf("delete duplicate versions for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
removedTotal += len(deleteIDs)
|
||||
|
||||
latestKeptID := kept[len(kept)-1].ID
|
||||
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", latestKeptID).Error; err != nil {
|
||||
return fmt.Errorf("set missing current_version_id for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if _, deleted := deleteSet[*cfg.CurrentVersionID]; deleted {
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", latestKeptID).Error; err != nil {
|
||||
return fmt.Errorf("repair current_version_id for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if removedTotal > 0 {
|
||||
slog.Info("deduplicated configuration revisions", "removed_versions", removedTotal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
||||
type indexRow struct {
|
||||
Name string `gorm:"column:name"`
|
||||
@@ -316,3 +587,222 @@ func backfillLocalPricelistSource(tx *gorm.DB) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropComponentUnusedFields(tx *gorm.DB) error {
|
||||
// Check if columns exist
|
||||
type columnInfo struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
var columns []columnInfo
|
||||
if err := tx.Raw(`
|
||||
SELECT name FROM pragma_table_info('local_components')
|
||||
WHERE name IN ('current_price', 'synced_at')
|
||||
`).Scan(&columns).Error; err != nil {
|
||||
return fmt.Errorf("check columns existence: %w", err)
|
||||
}
|
||||
|
||||
if len(columns) == 0 {
|
||||
slog.Info("unused fields already removed from local_components")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SQLite: recreate table without current_price and synced_at
|
||||
if err := tx.Exec(`
|
||||
CREATE TABLE local_components_new (
|
||||
lot_name TEXT PRIMARY KEY,
|
||||
lot_description TEXT,
|
||||
category TEXT,
|
||||
model TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("create new local_components table: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO local_components_new (lot_name, lot_description, category, model)
|
||||
SELECT lot_name, lot_description, category, model
|
||||
FROM local_components
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("copy data to new table: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`DROP TABLE local_components`).Error; err != nil {
|
||||
return fmt.Errorf("drop old table: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`ALTER TABLE local_components_new RENAME TO local_components`).Error; err != nil {
|
||||
return fmt.Errorf("rename new table: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("dropped current_price and synced_at columns from local_components")
|
||||
return nil
|
||||
}
|
||||
|
||||
func addWarehouseCompetitorPriceLists(tx *gorm.DB) error {
|
||||
// Check if columns exist
|
||||
type columnInfo struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
var columns []columnInfo
|
||||
if err := tx.Raw(`
|
||||
SELECT name FROM pragma_table_info('local_configurations')
|
||||
WHERE name IN ('warehouse_pricelist_id', 'competitor_pricelist_id')
|
||||
`).Scan(&columns).Error; err != nil {
|
||||
return fmt.Errorf("check columns existence: %w", err)
|
||||
}
|
||||
|
||||
if len(columns) == 2 {
|
||||
slog.Info("warehouse and competitor pricelist columns already exist")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add columns if they don't exist
|
||||
if err := tx.Exec(`
|
||||
ALTER TABLE local_configurations
|
||||
ADD COLUMN warehouse_pricelist_id INTEGER
|
||||
`).Error; err != nil {
|
||||
// Column might already exist, ignore
|
||||
if !strings.Contains(err.Error(), "duplicate column") {
|
||||
return fmt.Errorf("add warehouse_pricelist_id column: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
ALTER TABLE local_configurations
|
||||
ADD COLUMN competitor_pricelist_id INTEGER
|
||||
`).Error; err != nil {
|
||||
// Column might already exist, ignore
|
||||
if !strings.Contains(err.Error(), "duplicate column") {
|
||||
return fmt.Errorf("add competitor_pricelist_id column: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create indexes
|
||||
if err := tx.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_local_configurations_warehouse_pricelist
|
||||
ON local_configurations(warehouse_pricelist_id)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("create warehouse pricelist index: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_local_configurations_competitor_pricelist
|
||||
ON local_configurations(competitor_pricelist_id)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("create competitor pricelist index: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("added warehouse and competitor pricelist fields to local_configurations")
|
||||
return nil
|
||||
}
|
||||
|
||||
func addLocalPricelistItemCategoryAndIndexes(tx *gorm.DB) error {
|
||||
type columnInfo struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
var columns []columnInfo
|
||||
if err := tx.Raw(`
|
||||
SELECT name FROM pragma_table_info('local_pricelist_items')
|
||||
WHERE name IN ('lot_category')
|
||||
`).Scan(&columns).Error; err != nil {
|
||||
return fmt.Errorf("check local_pricelist_items(lot_category) existence: %w", err)
|
||||
}
|
||||
|
||||
if len(columns) == 0 {
|
||||
if err := tx.Exec(`
|
||||
ALTER TABLE local_pricelist_items
|
||||
ADD COLUMN lot_category TEXT
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("add local_pricelist_items.lot_category: %w", err)
|
||||
}
|
||||
slog.Info("added lot_category to local_pricelist_items")
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot
|
||||
ON local_pricelist_items(pricelist_id, lot_name)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("ensure idx_local_pricelist_items_pricelist_lot: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_lot_category
|
||||
ON local_pricelist_items(lot_category)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("ensure idx_local_pricelist_items_lot_category: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addLocalConfigurationArticle(tx *gorm.DB) error {
|
||||
type columnInfo struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
var columns []columnInfo
|
||||
if err := tx.Raw(`
|
||||
SELECT name FROM pragma_table_info('local_configurations')
|
||||
WHERE name IN ('article')
|
||||
`).Scan(&columns).Error; err != nil {
|
||||
return fmt.Errorf("check local_configurations(article) existence: %w", err)
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
if err := tx.Exec(`
|
||||
ALTER TABLE local_configurations
|
||||
ADD COLUMN article TEXT
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("add local_configurations.article: %w", err)
|
||||
}
|
||||
slog.Info("added article to local_configurations")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addLocalConfigurationServerModel(tx *gorm.DB) error {
|
||||
type columnInfo struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
var columns []columnInfo
|
||||
if err := tx.Raw(`
|
||||
SELECT name FROM pragma_table_info('local_configurations')
|
||||
WHERE name IN ('server_model')
|
||||
`).Scan(&columns).Error; err != nil {
|
||||
return fmt.Errorf("check local_configurations(server_model) existence: %w", err)
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
if err := tx.Exec(`
|
||||
ALTER TABLE local_configurations
|
||||
ADD COLUMN server_model TEXT
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("add local_configurations.server_model: %w", err)
|
||||
}
|
||||
slog.Info("added server_model to local_configurations")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addLocalConfigurationSupportCode(tx *gorm.DB) error {
|
||||
type columnInfo struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
var columns []columnInfo
|
||||
if err := tx.Raw(`
|
||||
SELECT name FROM pragma_table_info('local_configurations')
|
||||
WHERE name IN ('support_code')
|
||||
`).Scan(&columns).Error; err != nil {
|
||||
return fmt.Errorf("check local_configurations(support_code) existence: %w", err)
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
if err := tx.Exec(`
|
||||
ALTER TABLE local_configurations
|
||||
ADD COLUMN support_code TEXT
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("add local_configurations.support_code: %w", err)
|
||||
}
|
||||
slog.Info("added support_code to local_configurations")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -96,8 +96,13 @@ 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"`
|
||||
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
||||
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
|
||||
Article string `gorm:"size:80" json:"article,omitempty"`
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -118,7 +123,9 @@ type LocalProject struct {
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id,omitempty"`
|
||||
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Code string `gorm:"not null;index:idx_local_projects_code_variant,priority:1" json:"code"`
|
||||
Variant string `gorm:"default:'';index:idx_local_projects_code_variant,priority:2" json:"variant"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
TrackerURL string `json:"tracker_url"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
@@ -170,6 +177,7 @@ type LocalPricelistItem struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
||||
LotName string `gorm:"not null" json:"lot_name"`
|
||||
LotCategory string `gorm:"column:lot_category" json:"lot_category,omitempty"`
|
||||
Price float64 `gorm:"not null" json:"price"`
|
||||
AvailableQty *float64 `json:"available_qty,omitempty"`
|
||||
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`
|
||||
@@ -179,14 +187,13 @@ func (LocalPricelistItem) TableName() string {
|
||||
return "local_pricelist_items"
|
||||
}
|
||||
|
||||
// LocalComponent stores cached components for offline search
|
||||
// LocalComponent stores cached components for offline search (metadata only)
|
||||
// All pricing is now sourced from local_pricelist_items based on configuration pricelist selection
|
||||
type LocalComponent struct {
|
||||
LotName string `gorm:"primaryKey" json:"lot_name"`
|
||||
LotDescription string `json:"lot_description"`
|
||||
Category string `json:"category"`
|
||||
Model string `json:"model"`
|
||||
CurrentPrice *float64 `json:"current_price"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
LotName string `gorm:"primaryKey" json:"lot_name"`
|
||||
LotDescription string `json:"lot_description"`
|
||||
Category string `json:"category"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
func (LocalComponent) TableName() string {
|
||||
|
||||
128
internal/localdb/pricelist_latest_test.go
Normal file
128
internal/localdb/pricelist_latest_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetLatestLocalPricelistBySource_SkipsPricelistWithoutItems(t *testing.T) {
|
||||
local, err := New(filepath.Join(t.TempDir(), "latest_without_items.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
base := time.Now().Add(-time.Minute)
|
||||
withItems := &LocalPricelist{
|
||||
ServerID: 1001,
|
||||
Source: "estimate",
|
||||
Version: "E-1",
|
||||
Name: "with-items",
|
||||
CreatedAt: base,
|
||||
SyncedAt: base,
|
||||
}
|
||||
if err := local.SaveLocalPricelist(withItems); err != nil {
|
||||
t.Fatalf("save pricelist with items: %v", err)
|
||||
}
|
||||
storedWithItems, err := local.GetLocalPricelistByServerID(withItems.ServerID)
|
||||
if err != nil {
|
||||
t.Fatalf("load pricelist with items: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||
{
|
||||
PricelistID: storedWithItems.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 100,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save pricelist items: %v", err)
|
||||
}
|
||||
|
||||
withoutItems := &LocalPricelist{
|
||||
ServerID: 1002,
|
||||
Source: "estimate",
|
||||
Version: "E-2",
|
||||
Name: "without-items",
|
||||
CreatedAt: base.Add(2 * time.Second),
|
||||
SyncedAt: base.Add(2 * time.Second),
|
||||
}
|
||||
if err := local.SaveLocalPricelist(withoutItems); err != nil {
|
||||
t.Fatalf("save pricelist without items: %v", err)
|
||||
}
|
||||
|
||||
got, err := local.GetLatestLocalPricelistBySource("estimate")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
|
||||
}
|
||||
if got.ServerID != withItems.ServerID {
|
||||
t.Fatalf("expected server_id=%d, got %d", withItems.ServerID, got.ServerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestLocalPricelistBySource_TieBreaksByID(t *testing.T) {
|
||||
local, err := New(filepath.Join(t.TempDir(), "latest_tie_break.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
base := time.Now().Add(-time.Minute)
|
||||
first := &LocalPricelist{
|
||||
ServerID: 2001,
|
||||
Source: "warehouse",
|
||||
Version: "S-1",
|
||||
Name: "first",
|
||||
CreatedAt: base,
|
||||
SyncedAt: base,
|
||||
}
|
||||
if err := local.SaveLocalPricelist(first); err != nil {
|
||||
t.Fatalf("save first pricelist: %v", err)
|
||||
}
|
||||
storedFirst, err := local.GetLocalPricelistByServerID(first.ServerID)
|
||||
if err != nil {
|
||||
t.Fatalf("load first pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||
{
|
||||
PricelistID: storedFirst.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 101,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save first items: %v", err)
|
||||
}
|
||||
|
||||
second := &LocalPricelist{
|
||||
ServerID: 2002,
|
||||
Source: "warehouse",
|
||||
Version: "S-2",
|
||||
Name: "second",
|
||||
CreatedAt: base,
|
||||
SyncedAt: base,
|
||||
}
|
||||
if err := local.SaveLocalPricelist(second); err != nil {
|
||||
t.Fatalf("save second pricelist: %v", err)
|
||||
}
|
||||
storedSecond, err := local.GetLocalPricelistByServerID(second.ServerID)
|
||||
if err != nil {
|
||||
t.Fatalf("load second pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||
{
|
||||
PricelistID: storedSecond.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 102,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save second items: %v", err)
|
||||
}
|
||||
|
||||
got, err := local.GetLatestLocalPricelistBySource("warehouse")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
|
||||
}
|
||||
if got.ServerID != second.ServerID {
|
||||
t.Fatalf("expected server_id=%d, got %d", second.ServerID, got.ServerID)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package localdb
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,9 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||
"notes": localCfg.Notes,
|
||||
"is_template": localCfg.IsTemplate,
|
||||
"server_count": localCfg.ServerCount,
|
||||
"server_model": localCfg.ServerModel,
|
||||
"support_code": localCfg.SupportCode,
|
||||
"article": localCfg.Article,
|
||||
"pricelist_id": localCfg.PricelistID,
|
||||
"only_in_stock": localCfg.OnlyInStock,
|
||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||
@@ -52,6 +56,9 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
ServerModel string `json:"server_model"`
|
||||
SupportCode string `json:"support_code"`
|
||||
Article string `json:"article"`
|
||||
PricelistID *uint `json:"pricelist_id"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||
@@ -78,6 +85,9 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
Notes: snapshot.Notes,
|
||||
IsTemplate: snapshot.IsTemplate,
|
||||
ServerCount: snapshot.ServerCount,
|
||||
ServerModel: snapshot.ServerModel,
|
||||
SupportCode: snapshot.SupportCode,
|
||||
Article: snapshot.Article,
|
||||
PricelistID: snapshot.PricelistID,
|
||||
OnlyInStock: snapshot.OnlyInStock,
|
||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||
@@ -85,3 +95,51 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
OriginalUsername: snapshot.OriginalUsername,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type configurationSpecPriceFingerprint struct {
|
||||
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
||||
ServerCount int `json:"server_count"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
CustomPrice *float64 `json:"custom_price,omitempty"`
|
||||
}
|
||||
|
||||
type configurationSpecPriceFingerprintItem struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
}
|
||||
|
||||
// BuildConfigurationSpecPriceFingerprint returns a stable JSON key based on
|
||||
// spec + price fields only, used for revision deduplication.
|
||||
func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (string, error) {
|
||||
items := make([]configurationSpecPriceFingerprintItem, 0, len(localCfg.Items))
|
||||
for _, item := range localCfg.Items {
|
||||
items = append(items, configurationSpecPriceFingerprintItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].LotName != items[j].LotName {
|
||||
return items[i].LotName < items[j].LotName
|
||||
}
|
||||
if items[i].Quantity != items[j].Quantity {
|
||||
return items[i].Quantity < items[j].Quantity
|
||||
}
|
||||
return items[i].UnitPrice < items[j].UnitPrice
|
||||
})
|
||||
|
||||
payload := configurationSpecPriceFingerprint{
|
||||
Items: items,
|
||||
ServerCount: localCfg.ServerCount,
|
||||
TotalPrice: localCfg.TotalPrice,
|
||||
CustomPrice: localCfg.CustomPrice,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal spec+price fingerprint: %w", err)
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
@@ -1,22 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||
if origin != "" {
|
||||
if isLoopbackOrigin(origin) {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
} else if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func isLoopbackOrigin(origin string) bool {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
host := strings.TrimSpace(u.Hostname())
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ type Configuration struct {
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
||||
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
|
||||
Article string `gorm:"size:80" json:"article,omitempty"`
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||
@@ -60,6 +63,7 @@ type Configuration struct {
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`
|
||||
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ type PricelistItem struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
|
||||
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
|
||||
LotCategory string `gorm:"column:lot_category;size:50" json:"lot_category,omitempty"`
|
||||
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
||||
PriceMethod string `gorm:"size:20" json:"price_method"`
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ type Project struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Code string `gorm:"size:100;not null;index:idx_qt_projects_code_variant,priority:1" json:"code"`
|
||||
Variant string `gorm:"size:100;not null;default:'';index:idx_qt_projects_code_variant,priority:2" json:"variant"`
|
||||
Name *string `gorm:"size:200" json:"name,omitempty"`
|
||||
TrackerURL string `gorm:"size:500" json:"tracker_url"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
|
||||
@@ -3,10 +3,13 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -38,7 +41,7 @@ func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
||||
}
|
||||
|
||||
@@ -65,7 +68,7 @@ func (r *PricelistRepository) ListActiveBySource(source string, offset, limit in
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
||||
}
|
||||
|
||||
@@ -146,7 +149,11 @@ func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
|
||||
// GetLatestActiveBySource returns the most recent active pricelist by source.
|
||||
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
||||
var pricelist models.Pricelist
|
||||
if err := r.db.Where("is_active = ? AND source = ?", true, source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
if err := r.db.
|
||||
Where("is_active = ? AND source = ?", true, source).
|
||||
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -236,16 +243,97 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
||||
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
|
||||
items[i].LotDescription = lot.LotDescription
|
||||
}
|
||||
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||
parts := strings.SplitN(items[i].LotName, "_", 2)
|
||||
if len(parts) >= 1 {
|
||||
items[i].Category = parts[0]
|
||||
}
|
||||
items[i].Category = strings.TrimSpace(items[i].LotCategory)
|
||||
}
|
||||
|
||||
// Stock/partnumber enrichment is optional for pricelist item listing.
|
||||
// Return base pricelist rows (lot_name/price/category) even when DB user lacks
|
||||
// access to stock mapping tables (e.g. lot_partnumbers, stock_log).
|
||||
if err := r.enrichItemsWithStock(items); err != nil {
|
||||
slog.Warn("pricelist items stock enrichment skipped", "pricelist_id", pricelistID, "error", err)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (r *PricelistRepository) enrichItemsWithStock(items []models.PricelistItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
resolver, err := lotmatch.NewLotResolverFromDB(r.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type stockRow struct {
|
||||
Partnumber string `gorm:"column:partnumber"`
|
||||
Qty *float64 `gorm:"column:qty"`
|
||||
}
|
||||
rows := make([]stockRow, 0)
|
||||
if err := r.db.Raw(`
|
||||
SELECT s.partnumber, s.qty
|
||||
FROM stock_log s
|
||||
INNER JOIN (
|
||||
SELECT partnumber, MAX(date) AS max_date
|
||||
FROM stock_log
|
||||
GROUP BY partnumber
|
||||
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
|
||||
WHERE s.qty IS NOT NULL
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lotTotals := make(map[string]float64, len(items))
|
||||
lotPartnumbers := make(map[string][]string, len(items))
|
||||
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
|
||||
|
||||
for i := range rows {
|
||||
row := rows[i]
|
||||
if strings.TrimSpace(row.Partnumber) == "" {
|
||||
continue
|
||||
}
|
||||
lotName, _, resolveErr := resolver.Resolve(row.Partnumber)
|
||||
if resolveErr != nil || strings.TrimSpace(lotName) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if row.Qty != nil {
|
||||
lotTotals[lotName] += *row.Qty
|
||||
}
|
||||
|
||||
pn := strings.TrimSpace(row.Partnumber)
|
||||
if pn == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenPartnumbers[lotName]; !ok {
|
||||
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
|
||||
}
|
||||
key := strings.ToLower(pn)
|
||||
if _, exists := seenPartnumbers[lotName][key]; exists {
|
||||
continue
|
||||
}
|
||||
seenPartnumbers[lotName][key] = struct{}{}
|
||||
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
lotName := items[i].LotName
|
||||
if qty, ok := lotTotals[lotName]; ok {
|
||||
qtyCopy := qty
|
||||
items[i].AvailableQty = &qtyCopy
|
||||
}
|
||||
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
|
||||
sort.Slice(partnumbers, func(a, b int) bool {
|
||||
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
|
||||
})
|
||||
items[i].Partnumbers = partnumbers
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLotNames returns distinct lot names from pricelist items.
|
||||
func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
|
||||
var lotNames []string
|
||||
|
||||
@@ -126,6 +126,101 @@ func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestActiveBySource_SkipsPricelistsWithoutItems(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
db := repo.db
|
||||
ts := time.Now().Add(-time.Minute)
|
||||
source := "test-estimate-skip-empty"
|
||||
|
||||
emptyLatest := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "E-empty",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts.Add(2 * time.Second),
|
||||
}
|
||||
if err := db.Create(&emptyLatest).Error; err != nil {
|
||||
t.Fatalf("create empty pricelist: %v", err)
|
||||
}
|
||||
|
||||
withItems := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "E-with-items",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts,
|
||||
}
|
||||
if err := db.Create(&withItems).Error; err != nil {
|
||||
t.Fatalf("create pricelist with items: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: withItems.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 100,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create pricelist item: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestActiveBySource: %v", err)
|
||||
}
|
||||
if got.ID != withItems.ID {
|
||||
t.Fatalf("expected pricelist with items id=%d, got id=%d", withItems.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestActiveBySource_TieBreaksByID(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
db := repo.db
|
||||
ts := time.Now().Add(-time.Minute)
|
||||
source := "test-warehouse-tie-break"
|
||||
|
||||
first := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "S-1",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts,
|
||||
}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatalf("create first pricelist: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: first.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 101,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create first item: %v", err)
|
||||
}
|
||||
|
||||
second := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "S-2",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts,
|
||||
}
|
||||
if err := db.Create(&second).Error; err != nil {
|
||||
t.Fatalf("create second pricelist: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: second.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 102,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create second item: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestActiveBySource: %v", err)
|
||||
}
|
||||
if got.ID != second.ID {
|
||||
t.Fatalf("expected later inserted pricelist id=%d, got id=%d", second.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"owner_username",
|
||||
"code",
|
||||
"variant",
|
||||
"name",
|
||||
"tracker_url",
|
||||
"is_active",
|
||||
|
||||
@@ -83,10 +83,6 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
||||
search := "%" + filter.Search + "%"
|
||||
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
|
||||
}
|
||||
if filter.HasPrice {
|
||||
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
@@ -96,8 +92,6 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
||||
sortDir = "DESC"
|
||||
}
|
||||
switch filter.SortField {
|
||||
case "current_price":
|
||||
query = query.Order("current_price " + sortDir)
|
||||
case "lot_name":
|
||||
query = query.Order("lot_name " + sortDir)
|
||||
default:
|
||||
@@ -112,9 +106,8 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
||||
result := make([]models.LotMetadata, len(components))
|
||||
for i, comp := range components {
|
||||
result[i] = models.LotMetadata{
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
CurrentPrice: comp.CurrentPrice,
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
Lot: &models.Lot{
|
||||
LotName: comp.LotName,
|
||||
LotDescription: comp.LotDescription,
|
||||
@@ -138,9 +131,8 @@ func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error)
|
||||
}
|
||||
|
||||
return &models.LotMetadata{
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
CurrentPrice: comp.CurrentPrice,
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
Lot: &models.Lot{
|
||||
LotName: comp.LotName,
|
||||
LotDescription: comp.LotDescription,
|
||||
|
||||
@@ -53,7 +53,6 @@ type ComponentView struct {
|
||||
Category string `json:"category"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Model string `json:"model"`
|
||||
CurrentPrice *float64 `json:"current_price"`
|
||||
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
||||
PopularityScore float64 `json:"popularity_score"`
|
||||
Specs models.Specs `json:"specs,omitempty"`
|
||||
@@ -92,7 +91,6 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
||||
view := ComponentView{
|
||||
LotName: c.LotName,
|
||||
Model: c.Model,
|
||||
CurrentPrice: c.CurrentPrice,
|
||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||
PopularityScore: c.PopularityScore,
|
||||
Specs: c.Specs,
|
||||
@@ -134,7 +132,6 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
||||
view := &ComponentView{
|
||||
LotName: c.LotName,
|
||||
Model: c.Model,
|
||||
CurrentPrice: c.CurrentPrice,
|
||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||
PopularityScore: c.PopularityScore,
|
||||
Specs: c.Specs,
|
||||
|
||||
@@ -52,10 +52,20 @@ type CreateConfigRequest struct {
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
ServerModel string `json:"server_model,omitempty"`
|
||||
SupportCode string `json:"support_code,omitempty"`
|
||||
Article string `json:"article,omitempty"`
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
}
|
||||
|
||||
type ArticlePreviewRequest struct {
|
||||
Items models.ConfigItems `json:"items"`
|
||||
ServerModel string `json:"server_model"`
|
||||
SupportCode string `json:"support_code,omitempty"`
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
@@ -84,6 +94,9 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
Article: req.Article,
|
||||
PricelistID: pricelistID,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
}
|
||||
@@ -146,6 +159,9 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
config.ServerModel = req.ServerModel
|
||||
config.SupportCode = req.SupportCode
|
||||
config.Article = req.Article
|
||||
config.PricelistID = pricelistID
|
||||
config.OnlyInStock = req.OnlyInStock
|
||||
|
||||
|
||||
@@ -4,33 +4,32 @@ import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
type ExportService struct {
|
||||
config config.ExportConfig
|
||||
config config.ExportConfig
|
||||
categoryRepo *repository.CategoryRepository
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository) *ExportService {
|
||||
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository, local *localdb.LocalDB) *ExportService {
|
||||
return &ExportService{
|
||||
config: cfg,
|
||||
categoryRepo: categoryRepo,
|
||||
localDB: local,
|
||||
}
|
||||
}
|
||||
|
||||
type ExportData struct {
|
||||
Name string
|
||||
Items []ExportItem
|
||||
Total float64
|
||||
Notes string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ExportItem represents a single component in an export block.
|
||||
type ExportItem struct {
|
||||
LotName string
|
||||
Description string
|
||||
@@ -40,14 +39,45 @@ type ExportItem struct {
|
||||
TotalPrice float64
|
||||
}
|
||||
|
||||
func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := csv.NewWriter(&buf)
|
||||
// ConfigExportBlock represents one configuration (server) in the export.
|
||||
type ConfigExportBlock struct {
|
||||
Article string
|
||||
ServerCount int
|
||||
UnitPrice float64 // sum of component prices for one server
|
||||
Items []ExportItem
|
||||
}
|
||||
|
||||
// ProjectExportData holds all configuration blocks for a project-level export.
|
||||
type ProjectExportData struct {
|
||||
Configs []ConfigExportBlock
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ToCSV writes project export data in the new structured CSV format.
|
||||
//
|
||||
// Format:
|
||||
//
|
||||
// Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)
|
||||
// 10;;DL380-ARTICLE;;;10;10470;104 700
|
||||
// ;;MB_INTEL_...;;1;;2074,5;
|
||||
// ...
|
||||
// (empty row)
|
||||
// 20;;DL380-ARTICLE-2;;;2;10470;20 940
|
||||
// ...
|
||||
func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
|
||||
// Write UTF-8 BOM for Excel compatibility
|
||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||
return fmt.Errorf("failed to write BOM: %w", err)
|
||||
}
|
||||
|
||||
csvWriter := csv.NewWriter(w)
|
||||
csvWriter.Comma = ';'
|
||||
defer csvWriter.Flush()
|
||||
|
||||
// Header
|
||||
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||
if err := w.Write(headers); err != nil {
|
||||
return nil, err
|
||||
headers := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
||||
if err := csvWriter.Write(headers); err != nil {
|
||||
return fmt.Errorf("failed to write header: %w", err)
|
||||
}
|
||||
|
||||
// Get category hierarchy for sorting
|
||||
@@ -61,87 +91,235 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort items by category display order
|
||||
sortedItems := make([]ExportItem, len(data.Items))
|
||||
copy(sortedItems, data.Items)
|
||||
for i, block := range data.Configs {
|
||||
lineNo := (i + 1) * 10
|
||||
|
||||
// Sort using category display order (items without category go to the end)
|
||||
for i := 0; i < len(sortedItems)-1; i++ {
|
||||
for j := i + 1; j < len(sortedItems); j++ {
|
||||
orderI, hasI := categoryOrder[sortedItems[i].Category]
|
||||
orderJ, hasJ := categoryOrder[sortedItems[j].Category]
|
||||
serverCount := block.ServerCount
|
||||
if serverCount < 1 {
|
||||
serverCount = 1
|
||||
}
|
||||
|
||||
// Items without category go to the end
|
||||
if !hasI && hasJ {
|
||||
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
|
||||
} else if hasI && hasJ {
|
||||
// Both have categories, sort by display order
|
||||
if orderI > orderJ {
|
||||
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
|
||||
totalPrice := block.UnitPrice * float64(serverCount)
|
||||
|
||||
// Server summary row
|
||||
serverRow := []string{
|
||||
fmt.Sprintf("%d", lineNo), // Line
|
||||
"", // Type
|
||||
block.Article, // p/n
|
||||
"", // Description
|
||||
"", // Qty (1 pcs.)
|
||||
fmt.Sprintf("%d", serverCount), // Qty (total)
|
||||
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
|
||||
formatPriceWithSpace(totalPrice), // Price (total)
|
||||
}
|
||||
if err := csvWriter.Write(serverRow); err != nil {
|
||||
return fmt.Errorf("failed to write server row: %w", err)
|
||||
}
|
||||
|
||||
// Sort items by category display order
|
||||
sortedItems := make([]ExportItem, len(block.Items))
|
||||
copy(sortedItems, block.Items)
|
||||
sortItemsByCategory(sortedItems, categoryOrder)
|
||||
|
||||
// Component rows
|
||||
for _, item := range sortedItems {
|
||||
componentRow := []string{
|
||||
"", // Line
|
||||
item.Category, // Type
|
||||
item.LotName, // p/n
|
||||
"", // Description
|
||||
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
|
||||
"", // Qty (total)
|
||||
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
|
||||
"", // Price (total)
|
||||
}
|
||||
if err := csvWriter.Write(componentRow); err != nil {
|
||||
return fmt.Errorf("failed to write component row: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty separator row between blocks (skip after last)
|
||||
if i < len(data.Configs)-1 {
|
||||
if err := csvWriter.Write([]string{"", "", "", "", "", "", "", ""}); err != nil {
|
||||
return fmt.Errorf("failed to write separator row: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
csvWriter.Flush()
|
||||
if err := csvWriter.Error(); err != nil {
|
||||
return fmt.Errorf("csv writer error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes.
|
||||
func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := s.ToCSV(&buf, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// ConfigToExportData converts a single configuration into ProjectExportData.
|
||||
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
|
||||
block := s.buildExportBlock(cfg)
|
||||
return &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{block},
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
||||
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
||||
blocks := make([]ConfigExportBlock, 0, len(configs))
|
||||
for i := range configs {
|
||||
blocks = append(blocks, s.buildExportBlock(&configs[i]))
|
||||
}
|
||||
return &ProjectExportData{
|
||||
Configs: blocks,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock {
|
||||
// Batch-fetch categories from local data (pricelist items → local_components fallback)
|
||||
lotNames := make([]string, len(cfg.Items))
|
||||
for i, item := range cfg.Items {
|
||||
lotNames[i] = item.LotName
|
||||
}
|
||||
categories := s.resolveCategories(cfg.PricelistID, lotNames)
|
||||
|
||||
items := make([]ExportItem, len(cfg.Items))
|
||||
var unitTotal float64
|
||||
|
||||
for i, item := range cfg.Items {
|
||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||
items[i] = ExportItem{
|
||||
LotName: item.LotName,
|
||||
Category: categories[item.LotName],
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: itemTotal,
|
||||
}
|
||||
unitTotal += itemTotal
|
||||
}
|
||||
|
||||
serverCount := cfg.ServerCount
|
||||
if serverCount < 1 {
|
||||
serverCount = 1
|
||||
}
|
||||
|
||||
return ConfigExportBlock{
|
||||
Article: cfg.Article,
|
||||
ServerCount: serverCount,
|
||||
UnitPrice: unitTotal,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCategories returns lot_name → category map.
|
||||
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
||||
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
||||
if len(lotNames) == 0 || s.localDB == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
categories := make(map[string]string, len(lotNames))
|
||||
|
||||
// Primary: pricelist items
|
||||
if pricelistID != nil && *pricelistID > 0 {
|
||||
if cats, err := s.localDB.GetLocalLotCategoriesByServerPricelistID(*pricelistID, lotNames); err == nil {
|
||||
for lot, cat := range cats {
|
||||
if strings.TrimSpace(cat) != "" {
|
||||
categories[lot] = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Items
|
||||
for _, item := range sortedItems {
|
||||
row := []string{
|
||||
item.LotName,
|
||||
item.Description,
|
||||
item.Category,
|
||||
fmt.Sprintf("%d", item.Quantity),
|
||||
fmt.Sprintf("%.2f", item.UnitPrice),
|
||||
fmt.Sprintf("%.2f", item.TotalPrice),
|
||||
}
|
||||
if err := w.Write(row); err != nil {
|
||||
return nil, err
|
||||
// Fallback: local_components for any still missing
|
||||
var missing []string
|
||||
for _, lot := range lotNames {
|
||||
if categories[lot] == "" {
|
||||
missing = append(missing, lot)
|
||||
}
|
||||
}
|
||||
|
||||
// Total row
|
||||
if err := w.Write([]string{"", "", "", "", "ИТОГО:", fmt.Sprintf("%.2f", data.Total)}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return buf.Bytes(), w.Error()
|
||||
}
|
||||
|
||||
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
|
||||
items := make([]ExportItem, len(config.Items))
|
||||
var total float64
|
||||
|
||||
for i, item := range config.Items {
|
||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||
|
||||
// Получаем информацию о компоненте для заполнения категории
|
||||
componentView, err := componentService.GetByLotName(item.LotName)
|
||||
if err != nil {
|
||||
// Если не удалось получить информацию о компоненте, используем только основные данные
|
||||
items[i] = ExportItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: itemTotal,
|
||||
}
|
||||
} else {
|
||||
items[i] = ExportItem{
|
||||
LotName: item.LotName,
|
||||
Description: componentView.Description,
|
||||
Category: componentView.Category,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: itemTotal,
|
||||
if len(missing) > 0 {
|
||||
if fallback, err := s.localDB.GetLocalComponentCategoriesByLotNames(missing); err == nil {
|
||||
for lot, cat := range fallback {
|
||||
if strings.TrimSpace(cat) != "" {
|
||||
categories[lot] = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
total += itemTotal
|
||||
}
|
||||
|
||||
return &ExportData{
|
||||
Name: config.Name,
|
||||
Items: items,
|
||||
Total: total,
|
||||
Notes: config.Notes,
|
||||
CreatedAt: config.CreatedAt,
|
||||
return categories
|
||||
}
|
||||
|
||||
// sortItemsByCategory sorts items by category display order (items without category go to the end).
|
||||
func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
|
||||
for i := 0; i < len(items)-1; i++ {
|
||||
for j := i + 1; j < len(items); j++ {
|
||||
orderI, hasI := categoryOrder[items[i].Category]
|
||||
orderJ, hasJ := categoryOrder[items[j].Category]
|
||||
|
||||
if !hasI && hasJ {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
} else if hasI && hasJ && orderI > orderJ {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").
|
||||
// Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown.
|
||||
func formatPriceComma(value float64) string {
|
||||
if value == math.Trunc(value) {
|
||||
return fmt.Sprintf("%.0f", value)
|
||||
}
|
||||
s := fmt.Sprintf("%.2f", value)
|
||||
s = strings.ReplaceAll(s, ".", ",")
|
||||
// Trim trailing zero: "2074,50" -> "2074,5"
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimRight(s, ",")
|
||||
return s
|
||||
}
|
||||
|
||||
// formatPriceInt formats price as integer (rounded), no decimal.
|
||||
func formatPriceInt(value float64) string {
|
||||
return fmt.Sprintf("%.0f", math.Round(value))
|
||||
}
|
||||
|
||||
// formatPriceWithSpace formats a price as an integer with space as thousands separator (e.g., "104 700").
|
||||
func formatPriceWithSpace(value float64) string {
|
||||
intVal := int64(math.Round(value))
|
||||
if intVal < 0 {
|
||||
return "-" + formatIntWithSpace(-intVal)
|
||||
}
|
||||
return formatIntWithSpace(intVal)
|
||||
}
|
||||
|
||||
func formatIntWithSpace(n int64) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
remainder := len(s) % 3
|
||||
if remainder > 0 {
|
||||
result.WriteString(s[:remainder])
|
||||
}
|
||||
for i := remainder; i < len(s); i += 3 {
|
||||
if result.Len() > 0 {
|
||||
result.WriteByte(' ')
|
||||
}
|
||||
result.WriteString(s[i : i+3])
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
406
internal/services/export_test.go
Normal file
406
internal/services/export_test.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
)
|
||||
|
||||
func newTestProjectData(items []ExportItem, article string, serverCount int) *ProjectExportData {
|
||||
var unitTotal float64
|
||||
for _, item := range items {
|
||||
unitTotal += item.UnitPrice * float64(item.Quantity)
|
||||
}
|
||||
if serverCount < 1 {
|
||||
serverCount = 1
|
||||
}
|
||||
return &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{
|
||||
{
|
||||
Article: article,
|
||||
ServerCount: serverCount,
|
||||
UnitPrice: unitTotal,
|
||||
Items: items,
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_UTF8BOM(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Category: "CAT",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
}, "TEST-ARTICLE", 1)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
if len(csvBytes) < 3 {
|
||||
t.Fatalf("CSV too short to contain BOM")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := csvBytes[:3]
|
||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Category: "CAT",
|
||||
Quantity: 2,
|
||||
UnitPrice: 100.50,
|
||||
TotalPrice: 201.00,
|
||||
},
|
||||
}, "TEST-ARTICLE", 1)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Read header
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 8 {
|
||||
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||
}
|
||||
|
||||
expectedHeader := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
||||
for i, col := range expectedHeader {
|
||||
if i < len(header) && header[i] != col {
|
||||
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Read server row
|
||||
serverRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read server row: %v", err)
|
||||
}
|
||||
if serverRow[0] != "10" {
|
||||
t.Errorf("Expected line number 10, got %s", serverRow[0])
|
||||
}
|
||||
if serverRow[2] != "TEST-ARTICLE" {
|
||||
t.Errorf("Expected article TEST-ARTICLE, got %s", serverRow[2])
|
||||
}
|
||||
|
||||
// Read component row
|
||||
itemRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read item row: %v", err)
|
||||
}
|
||||
if itemRow[2] != "LOT-001" {
|
||||
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[2])
|
||||
}
|
||||
if itemRow[4] != "2" {
|
||||
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[4])
|
||||
}
|
||||
if itemRow[6] != "100,5" {
|
||||
t.Errorf("Unit price mismatch: expected 100,5, got %s", itemRow[6])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_ServerRow(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
{LotName: "LOT-002", Category: "CAT", Quantity: 2, UnitPrice: 50.0, TotalPrice: 100.0},
|
||||
}, "DL380-ART", 10)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Skip header
|
||||
reader.Read()
|
||||
|
||||
// Read server row
|
||||
serverRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read server row: %v", err)
|
||||
}
|
||||
|
||||
if serverRow[0] != "10" {
|
||||
t.Errorf("Expected line 10, got %s", serverRow[0])
|
||||
}
|
||||
if serverRow[2] != "DL380-ART" {
|
||||
t.Errorf("Expected article DL380-ART, got %s", serverRow[2])
|
||||
}
|
||||
if serverRow[5] != "10" {
|
||||
t.Errorf("Expected server count 10, got %s", serverRow[5])
|
||||
}
|
||||
// UnitPrice = 100 + 100 = 200
|
||||
if serverRow[6] != "200" {
|
||||
t.Errorf("Expected unit price 200, got %s", serverRow[6])
|
||||
}
|
||||
// TotalPrice = 200 * 10 = 2000
|
||||
if serverRow[7] != "2 000" {
|
||||
t.Errorf("Expected total price '2 000', got %q", serverRow[7])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_CategorySorting(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT-A", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
{LotName: "LOT-002", Category: "CAT-C", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
{LotName: "LOT-003", Category: "CAT-B", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
}, "ART", 1)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Skip header and server row
|
||||
reader.Read()
|
||||
reader.Read()
|
||||
|
||||
// Without category repo, items maintain original order
|
||||
row1, _ := reader.Read()
|
||||
if row1[2] != "LOT-001" {
|
||||
t.Errorf("Expected LOT-001 first, got %s", row1[2])
|
||||
}
|
||||
|
||||
row2, _ := reader.Read()
|
||||
if row2[2] != "LOT-002" {
|
||||
t.Errorf("Expected LOT-002 second, got %s", row2[2])
|
||||
}
|
||||
|
||||
row3, _ := reader.Read()
|
||||
if row3[2] != "LOT-003" {
|
||||
t.Errorf("Expected LOT-003 third, got %s", row3[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_EmptyData(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 8 {
|
||||
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||
}
|
||||
|
||||
// No more rows expected
|
||||
_, err = reader.Read()
|
||||
if err != io.EOF {
|
||||
t.Errorf("Expected EOF after header, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
}, "ART", 1)
|
||||
|
||||
csvBytes, err := svc.ToCSVBytes(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ToCSVBytes failed: %v", err)
|
||||
}
|
||||
|
||||
if len(csvBytes) < 3 {
|
||||
t.Fatalf("CSV bytes too short")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := csvBytes[:3]
|
||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_WriterError(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
}, "ART", 1)
|
||||
|
||||
failingWriter := &failingWriter{}
|
||||
|
||||
if err := svc.ToCSV(failingWriter, data); err == nil {
|
||||
t.Errorf("Expected error from failing writer, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_MultipleBlocks(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{
|
||||
{
|
||||
Article: "ART-1",
|
||||
ServerCount: 2,
|
||||
UnitPrice: 500.0,
|
||||
Items: []ExportItem{
|
||||
{LotName: "LOT-A", Category: "CPU", Quantity: 1, UnitPrice: 500.0, TotalPrice: 500.0},
|
||||
},
|
||||
},
|
||||
{
|
||||
Article: "ART-2",
|
||||
ServerCount: 3,
|
||||
UnitPrice: 1000.0,
|
||||
Items: []ExportItem{
|
||||
{LotName: "LOT-B", Category: "MEM", Quantity: 2, UnitPrice: 500.0, TotalPrice: 1000.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
reader.FieldsPerRecord = -1 // allow variable fields
|
||||
|
||||
// Header
|
||||
reader.Read()
|
||||
|
||||
// Block 1: server row
|
||||
srv1, _ := reader.Read()
|
||||
if srv1[0] != "10" {
|
||||
t.Errorf("Block 1 line: expected 10, got %s", srv1[0])
|
||||
}
|
||||
if srv1[7] != "1 000" {
|
||||
t.Errorf("Block 1 total: expected '1 000', got %q", srv1[7])
|
||||
}
|
||||
|
||||
// Block 1: component row
|
||||
comp1, _ := reader.Read()
|
||||
if comp1[2] != "LOT-A" {
|
||||
t.Errorf("Block 1 component: expected LOT-A, got %s", comp1[2])
|
||||
}
|
||||
|
||||
// Separator row
|
||||
sep, _ := reader.Read()
|
||||
allEmpty := true
|
||||
for _, v := range sep {
|
||||
if v != "" {
|
||||
allEmpty = false
|
||||
}
|
||||
}
|
||||
if !allEmpty {
|
||||
t.Errorf("Expected empty separator row, got %v", sep)
|
||||
}
|
||||
|
||||
// Block 2: server row
|
||||
srv2, _ := reader.Read()
|
||||
if srv2[0] != "20" {
|
||||
t.Errorf("Block 2 line: expected 20, got %s", srv2[0])
|
||||
}
|
||||
if srv2[7] != "3 000" {
|
||||
t.Errorf("Block 2 total: expected '3 000', got %q", srv2[7])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPriceWithSpace(t *testing.T) {
|
||||
tests := []struct {
|
||||
input float64
|
||||
expected string
|
||||
}{
|
||||
{0, "0"},
|
||||
{100, "100"},
|
||||
{1000, "1 000"},
|
||||
{10470, "10 470"},
|
||||
{104700, "104 700"},
|
||||
{1000000, "1 000 000"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatPriceWithSpace(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatPriceWithSpace(%v): expected %q, got %q", tt.input, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPriceComma(t *testing.T) {
|
||||
tests := []struct {
|
||||
input float64
|
||||
expected string
|
||||
}{
|
||||
{100.0, "100"},
|
||||
{2074.5, "2074,5"},
|
||||
{100.50, "100,5"},
|
||||
{99.99, "99,99"},
|
||||
{0, "0"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatPriceComma(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatPriceComma(%v): expected %q, got %q", tt.input, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// failingWriter always returns an error
|
||||
type failingWriter struct{}
|
||||
|
||||
func (fw *failingWriter) Write(p []byte) (int, error) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/article"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
@@ -64,6 +65,18 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.ServerModel) != "" {
|
||||
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
ServerPricelist: pricelistID,
|
||||
})
|
||||
if articleErr != nil {
|
||||
return nil, articleErr
|
||||
}
|
||||
req.Article = articleResult.Article
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
@@ -80,6 +93,9 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
Article: req.Article,
|
||||
PricelistID: pricelistID,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
@@ -142,6 +158,18 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.ServerModel) != "" {
|
||||
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
ServerPricelist: pricelistID,
|
||||
})
|
||||
if articleErr != nil {
|
||||
return nil, articleErr
|
||||
}
|
||||
req.Article = articleResult.Article
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
@@ -163,6 +191,9 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
localCfg.Notes = req.Notes
|
||||
localCfg.IsTemplate = req.IsTemplate
|
||||
localCfg.ServerCount = req.ServerCount
|
||||
localCfg.ServerModel = req.ServerModel
|
||||
localCfg.SupportCode = req.SupportCode
|
||||
localCfg.Article = req.Article
|
||||
localCfg.PricelistID = pricelistID
|
||||
localCfg.OnlyInStock = req.OnlyInStock
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
@@ -176,6 +207,19 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// BuildArticlePreview generates server article based on current items and server_model/support_code.
|
||||
func (s *LocalConfigurationService) BuildArticlePreview(req *ArticlePreviewRequest) (article.BuildResult, error) {
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return article.BuildResult{}, err
|
||||
}
|
||||
return article.Build(s.localDB, req.Items, article.BuildOptions{
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
ServerPricelist: pricelistID,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete deletes a configuration from local SQLite and queues it for sync
|
||||
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
@@ -269,6 +313,9 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
ServerModel: original.ServerModel,
|
||||
SupportCode: original.SupportCode,
|
||||
Article: original.Article,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
@@ -347,7 +394,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 +409,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to current component price from local cache
|
||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||
if err != nil || component.CurrentPrice == nil {
|
||||
// Keep original item if component not found or no price available
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
// Update item with current price from local cache
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *component.CurrentPrice,
|
||||
}
|
||||
// Keep original item if price not found in pricelist
|
||||
updatedItems[i] = item
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
@@ -407,14 +442,14 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
|
||||
// GetByUUIDNoAuth returns configuration without ownership check
|
||||
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
var localCfg localdb.LocalConfiguration
|
||||
if err := s.localDB.DB().Preload("CurrentVersion").Where("uuid = ?", uuid).First(&localCfg).Error; err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
if !localCfg.IsActive {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
return localdb.LocalToConfiguration(localCfg), nil
|
||||
return localdb.LocalToConfiguration(&localCfg), nil
|
||||
}
|
||||
|
||||
// UpdateNoAuth updates configuration without ownership check
|
||||
@@ -426,9 +461,21 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
|
||||
projectUUID := localCfg.ProjectUUID
|
||||
if req.ProjectUUID != nil {
|
||||
requestedProjectUUID := strings.TrimSpace(*req.ProjectUUID)
|
||||
currentProjectUUID := ""
|
||||
if localCfg.ProjectUUID != nil {
|
||||
currentProjectUUID = strings.TrimSpace(*localCfg.ProjectUUID)
|
||||
}
|
||||
|
||||
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Allow save for legacy/orphaned configs when request keeps the same project UUID.
|
||||
// This can happen for imported configs whose project is not present in local cache.
|
||||
if errors.Is(err, ErrProjectNotFound) && requestedProjectUUID != "" && requestedProjectUUID == currentProjectUUID {
|
||||
projectUUID = localCfg.ProjectUUID
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
@@ -436,6 +483,18 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.ServerModel) != "" {
|
||||
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
ServerPricelist: pricelistID,
|
||||
})
|
||||
if articleErr != nil {
|
||||
return nil, articleErr
|
||||
}
|
||||
req.Article = articleResult.Article
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
@@ -456,6 +515,9 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
localCfg.Notes = req.Notes
|
||||
localCfg.IsTemplate = req.IsTemplate
|
||||
localCfg.ServerCount = req.ServerCount
|
||||
localCfg.ServerModel = req.ServerModel
|
||||
localCfg.SupportCode = req.SupportCode
|
||||
localCfg.Article = req.Article
|
||||
localCfg.PricelistID = pricelistID
|
||||
localCfg.OnlyInStock = req.OnlyInStock
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
@@ -521,10 +583,30 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
||||
return s.CloneNoAuthToProjectFromVersion(configUUID, newName, ownerUsername, projectUUID, 0)
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID string, newName string, ownerUsername string, projectUUID *string, fromVersion int) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUIDNoAuth(configUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If fromVersion specified, use snapshot from that version
|
||||
if fromVersion > 0 {
|
||||
version, vErr := s.GetVersion(configUUID, fromVersion)
|
||||
if vErr != nil {
|
||||
return nil, vErr
|
||||
}
|
||||
snapshot, decErr := s.decodeConfigurationSnapshot(version.Data)
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("decode version snapshot for clone: %w", decErr)
|
||||
}
|
||||
snapshotCfg := localdb.LocalToConfiguration(snapshot)
|
||||
original = snapshotCfg
|
||||
original.UUID = configUUID // preserve original UUID for project resolution
|
||||
}
|
||||
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
@@ -672,7 +754,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 +769,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to current component price from local cache
|
||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||
if err != nil || component.CurrentPrice == nil {
|
||||
// Keep original item if component not found or no price available
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
// Update item with current price from local cache
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *component.CurrentPrice,
|
||||
}
|
||||
// Keep original item if price not found in pricelist
|
||||
updatedItems[i] = item
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
@@ -730,6 +800,58 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// UpdateServerCount updates server count and recalculates total price without creating a new version.
|
||||
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
|
||||
if serverCount < 1 {
|
||||
return nil, fmt.Errorf("server count must be at least 1")
|
||||
}
|
||||
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(configUUID)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
localCfg.ServerCount = serverCount
|
||||
total := localCfg.Items.Total()
|
||||
if serverCount > 1 {
|
||||
total *= float64(serverCount)
|
||||
}
|
||||
localCfg.TotalPrice = &total
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
var cfg *models.Configuration
|
||||
err = s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(localCfg).Error; err != nil {
|
||||
return fmt.Errorf("save local configuration: %w", err)
|
||||
}
|
||||
|
||||
// Use existing current version for the pending change
|
||||
var version localdb.LocalConfigurationVersion
|
||||
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
||||
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err != nil {
|
||||
return fmt.Errorf("load current version: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||
Order("version_no DESC").First(&version).Error; err != nil {
|
||||
return fmt.Errorf("load latest version: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg = localdb.LocalToConfiguration(localCfg)
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", &version, ""); err != nil {
|
||||
return fmt.Errorf("enqueue server-count pending change: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
||||
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
||||
return s.syncService.ImportConfigurationsToLocal()
|
||||
@@ -858,6 +980,7 @@ func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalCon
|
||||
return fmt.Errorf("set current version id: %w", err)
|
||||
}
|
||||
localCfg.CurrentVersionID = &version.ID
|
||||
localCfg.CurrentVersion = version
|
||||
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
||||
return fmt.Errorf("enqueue create pending change: %w", err)
|
||||
@@ -884,6 +1007,26 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
||||
return fmt.Errorf("lock configuration row: %w", err)
|
||||
}
|
||||
|
||||
if operation == "update" {
|
||||
currentVersion, err := s.loadCurrentVersionTx(tx, &locked)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load current version before save: %w", err)
|
||||
}
|
||||
|
||||
// Legacy/orphaned rows may have empty or stale current_version_id.
|
||||
// In that case we treat update as content-changing and append a fresh version.
|
||||
if currentVersion != nil {
|
||||
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compare revision content: %w", err)
|
||||
}
|
||||
if sameRevisionContent {
|
||||
cfg = localdb.LocalToConfiguration(&locked)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Save(localCfg).Error; err != nil {
|
||||
return fmt.Errorf("save local configuration: %w", err)
|
||||
}
|
||||
@@ -899,6 +1042,7 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
||||
return fmt.Errorf("update current version id: %w", err)
|
||||
}
|
||||
localCfg.CurrentVersionID = &version.ID
|
||||
localCfg.CurrentVersion = version
|
||||
|
||||
cfg = localdb.LocalToConfiguration(localCfg)
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
||||
@@ -917,6 +1061,44 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
|
||||
var version localdb.LocalConfigurationVersion
|
||||
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
||||
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err == nil {
|
||||
return &version, nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||
Order("version_no DESC").
|
||||
First(&version).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &version, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) hasSameRevisionContent(localCfg *localdb.LocalConfiguration, currentVersion *localdb.LocalConfigurationVersion) (bool, error) {
|
||||
currentSnapshotCfg, err := s.decodeConfigurationSnapshot(currentVersion.Data)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode current version snapshot: %w", err)
|
||||
}
|
||||
|
||||
currentFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(currentSnapshotCfg)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("build current snapshot fingerprint: %w", err)
|
||||
}
|
||||
nextFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("build next snapshot fingerprint: %w", err)
|
||||
}
|
||||
return currentFingerprint == nextFingerprint, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) appendVersionTx(
|
||||
tx *gorm.DB,
|
||||
localCfg *localdb.LocalConfiguration,
|
||||
|
||||
@@ -27,8 +27,12 @@ func TestSaveCreatesNewVersionAndUpdatesCurrentPointer(t *testing.T) {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.RenameNoAuth(created.UUID, "v2"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "v1",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("update config: %v", err)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
@@ -60,8 +64,12 @@ func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.RenameNoAuth(created.UUID, "changed"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "base",
|
||||
Items: models.ConfigItems{{LotName: "RAM_A", Quantity: 3, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("update config: %v", err)
|
||||
}
|
||||
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
|
||||
t.Fatalf("rollback to v1: %v", err)
|
||||
@@ -79,6 +87,56 @@ func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "dedupe",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
_, err = service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "dedupe",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first update config: %v", err)
|
||||
}
|
||||
|
||||
_, err = service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "dedupe",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second update config: %v", err)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions (create + first update), got %d", len(versions))
|
||||
}
|
||||
if versions[1].VersionNo != 2 {
|
||||
t.Fatalf("expected latest version_no=2, got %d", versions[1].VersionNo)
|
||||
}
|
||||
|
||||
var pendingCount int64
|
||||
if err := local.DB().
|
||||
Table("pending_changes").
|
||||
Where("entity_type = ? AND entity_uuid = ?", "configuration", created.UUID).
|
||||
Count(&pendingCount).Error; err != nil {
|
||||
t.Fatalf("count pending changes: %v", err)
|
||||
}
|
||||
if pendingCount != 2 {
|
||||
t.Fatalf("expected 2 pending changes (create + first update), got %d", pendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
@@ -97,8 +155,12 @@ func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
||||
}
|
||||
v1Before := versionsBefore[0]
|
||||
|
||||
if _, err := service.RenameNoAuth(created.UUID, "after-rename"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "initial",
|
||||
Items: models.ConfigItems{{LotName: "SSD_A", Quantity: 2, UnitPrice: 300}},
|
||||
ServerCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("update config: %v", err)
|
||||
}
|
||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
@@ -144,7 +206,7 @@ func TestConcurrentSaveNoDuplicateVersionNumbers(t *testing.T) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
if err := renameWithRetry(service, created.UUID, fmt.Sprintf("name-%d", i)); err != nil {
|
||||
if err := updateWithRetry(service, created.UUID, i+2); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
@@ -191,7 +253,8 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
||||
project := &localdb.LocalProject{
|
||||
UUID: "project-keep",
|
||||
OwnerUsername: "tester",
|
||||
Name: "Keep Project",
|
||||
Code: "TEST-KEEP",
|
||||
Name: ptrString("Keep Project"),
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
@@ -227,6 +290,103 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNoAuthAllowsOrphanProjectWhenUUIDUnchanged(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
project := &localdb.LocalProject{
|
||||
UUID: "project-orphan",
|
||||
OwnerUsername: "tester",
|
||||
Code: "TEST-ORPHAN",
|
||||
Name: ptrString("Orphan Project"),
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "synced",
|
||||
}
|
||||
if err := local.SaveProject(project); err != nil {
|
||||
t.Fatalf("save project: %v", err)
|
||||
}
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "cfg",
|
||||
ProjectUUID: &project.UUID,
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
// Simulate missing project in local cache while config still references its UUID.
|
||||
if err := local.DB().Where("uuid = ?", project.UUID).Delete(&localdb.LocalProject{}).Error; err != nil {
|
||||
t.Fatalf("delete project: %v", err)
|
||||
}
|
||||
|
||||
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "cfg-updated",
|
||||
ProjectUUID: &project.UUID,
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update config with orphan project_uuid: %v", err)
|
||||
}
|
||||
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNoAuthRecoversWhenCurrentVersionMissing(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "cfg",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
// Simulate corrupted/legacy versioning state:
|
||||
// local configuration exists, but all version rows are gone and pointer is stale.
|
||||
if err := local.DB().Where("configuration_uuid = ?", created.UUID).
|
||||
Delete(&localdb.LocalConfigurationVersion{}).Error; err != nil {
|
||||
t.Fatalf("delete versions: %v", err)
|
||||
}
|
||||
staleID := "missing-version-id"
|
||||
if err := local.DB().Model(&localdb.LocalConfiguration{}).
|
||||
Where("uuid = ?", created.UUID).
|
||||
Update("current_version_id", staleID).Error; err != nil {
|
||||
t.Fatalf("set stale current_version_id: %v", err)
|
||||
}
|
||||
|
||||
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "cfg-updated",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update config with missing current version: %v", err)
|
||||
}
|
||||
|
||||
if updated.Name != "cfg-updated" {
|
||||
t.Fatalf("expected updated name, got %q", updated.Name)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 1 {
|
||||
t.Fatalf("expected 1 recreated version, got %d", len(versions))
|
||||
}
|
||||
if versions[0].VersionNo != 1 {
|
||||
t.Fatalf("expected recreated version_no=1, got %d", versions[0].VersionNo)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
||||
t.Helper()
|
||||
|
||||
@@ -259,10 +419,14 @@ func loadVersions(t *testing.T, local *localdb.LocalDB, configurationUUID string
|
||||
return versions
|
||||
}
|
||||
|
||||
func renameWithRetry(service *LocalConfigurationService, uuid string, name string) error {
|
||||
func updateWithRetry(service *LocalConfigurationService, uuid string, quantity int) error {
|
||||
var lastErr error
|
||||
for i := 0; i < 6; i++ {
|
||||
_, err := service.RenameNoAuth(uuid, name)
|
||||
_, err := service.UpdateNoAuth(uuid, &CreateConfigRequest{
|
||||
Name: "base",
|
||||
Items: models.ConfigItems{{LotName: "NIC_A", Quantity: quantity, UnitPrice: 150}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -273,7 +437,7 @@ func renameWithRetry(service *LocalConfigurationService, uuid string, name strin
|
||||
}
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("rename retries exhausted: %w", lastErr)
|
||||
return fmt.Errorf("update retries exhausted: %w", lastErr)
|
||||
}
|
||||
|
||||
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
||||
@@ -287,8 +451,12 @@ func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if _, err := service.RenameNoAuth(created.UUID, "second"); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "initial",
|
||||
Items: models.ConfigItems{{LotName: "GPU_A", Quantity: 2, UnitPrice: 2000}},
|
||||
ServerCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
|
||||
@@ -16,8 +16,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||
)
|
||||
|
||||
type ProjectService struct {
|
||||
@@ -29,12 +31,16 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
TrackerURL string `json:"tracker_url"`
|
||||
}
|
||||
|
||||
type UpdateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Variant *string `json:"variant,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
TrackerURL *string `json:"tracker_url,omitempty"`
|
||||
}
|
||||
|
||||
@@ -45,17 +51,30 @@ type ProjectConfigurationsResult struct {
|
||||
}
|
||||
|
||||
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("project name is required")
|
||||
var namePtr *string
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name != "" {
|
||||
namePtr = &name
|
||||
}
|
||||
}
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("project code is required")
|
||||
}
|
||||
variant := strings.TrimSpace(req.Variant)
|
||||
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
localProject := &localdb.LocalProject{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: name,
|
||||
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
|
||||
Code: code,
|
||||
Variant: variant,
|
||||
Name: namePtr,
|
||||
TrackerURL: normalizeProjectTrackerURL(code, req.TrackerURL),
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
CreatedAt: now,
|
||||
@@ -76,20 +95,33 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if localProject.OwnerUsername != ownerUsername {
|
||||
return nil, ErrProjectForbidden
|
||||
|
||||
if req.Code != nil {
|
||||
code := strings.TrimSpace(*req.Code)
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("project code is required")
|
||||
}
|
||||
localProject.Code = code
|
||||
}
|
||||
if req.Variant != nil {
|
||||
localProject.Variant = strings.TrimSpace(*req.Variant)
|
||||
}
|
||||
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("project name is required")
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
localProject.Name = nil
|
||||
} else {
|
||||
localProject.Name = &name
|
||||
}
|
||||
}
|
||||
|
||||
localProject.Name = name
|
||||
if req.TrackerURL != nil {
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, *req.TrackerURL)
|
||||
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "")
|
||||
}
|
||||
localProject.UpdatedAt = time.Now()
|
||||
localProject.SyncStatus = "pending"
|
||||
@@ -102,6 +134,38 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
|
||||
normalizedCode := normalizeProjectCode(code)
|
||||
normalizedVariant := normalizeProjectVariant(variant)
|
||||
if normalizedCode == "" {
|
||||
return fmt.Errorf("project code is required")
|
||||
}
|
||||
|
||||
projects, err := s.localDB.GetAllProjects(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range projects {
|
||||
project := projects[i]
|
||||
if excludeUUID != "" && project.UUID == excludeUUID {
|
||||
continue
|
||||
}
|
||||
if normalizeProjectCode(project.Code) == normalizedCode &&
|
||||
normalizeProjectVariant(project.Variant) == normalizedVariant {
|
||||
return ErrProjectCodeExists
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeProjectCode(code string) string {
|
||||
return strings.ToLower(strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
func normalizeProjectVariant(variant string) string {
|
||||
return strings.ToLower(strings.TrimSpace(variant))
|
||||
}
|
||||
|
||||
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
||||
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||
}
|
||||
@@ -110,15 +174,23 @@ func (s *ProjectService) Reactivate(projectUUID, ownerUsername string) error {
|
||||
return s.setProjectActive(projectUUID, ownerUsername, true)
|
||||
}
|
||||
|
||||
func (s *ProjectService) DeleteVariant(projectUUID, ownerUsername string) error {
|
||||
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||
if err != nil {
|
||||
return ErrProjectNotFound
|
||||
}
|
||||
if strings.TrimSpace(localProject.Variant) == "" {
|
||||
return ErrCannotDeleteMainVariant
|
||||
}
|
||||
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||
}
|
||||
|
||||
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
|
||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
var project localdb.LocalProject
|
||||
if err := tx.Where("uuid = ?", projectUUID).First(&project).Error; err != nil {
|
||||
return ErrProjectNotFound
|
||||
}
|
||||
if project.OwnerUsername != ownerUsername {
|
||||
return ErrProjectForbidden
|
||||
}
|
||||
if project.IsActive == isActive {
|
||||
return nil
|
||||
}
|
||||
@@ -203,8 +275,8 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
|
||||
}, nil
|
||||
}
|
||||
|
||||
localConfigs, err := s.localDB.GetConfigurations()
|
||||
if err != nil {
|
||||
var localConfigs []localdb.LocalConfiguration
|
||||
if err := s.localDB.DB().Preload("CurrentVersion").Order("created_at DESC").Find(&localConfigs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -189,33 +189,54 @@ func listActiveClientMigrations(db *gorm.DB) ([]clientLocalMigration, error) {
|
||||
}
|
||||
|
||||
func ensureClientMigrationRegistryTable(db *gorm.DB) error {
|
||||
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 err
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
return 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
|
||||
|
||||
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 {
|
||||
|
||||
@@ -200,6 +200,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
||||
}
|
||||
|
||||
existing.OwnerUsername = project.OwnerUsername
|
||||
existing.Code = project.Code
|
||||
existing.Name = project.Name
|
||||
existing.TrackerURL = project.TrackerURL
|
||||
existing.IsActive = project.IsActive
|
||||
@@ -346,16 +347,18 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
}
|
||||
|
||||
synced := 0
|
||||
var latestEstimateLocalID uint
|
||||
var latestEstimateCreatedAt time.Time
|
||||
for _, pl := range serverPricelists {
|
||||
// Check if pricelist already exists locally
|
||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||
if existing != nil {
|
||||
// Track latest estimate pricelist by created_at for component refresh.
|
||||
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
||||
latestEstimateCreatedAt = pl.CreatedAt
|
||||
latestEstimateLocalID = existing.ID
|
||||
// Backfill items for legacy/partial local caches where only pricelist metadata exists.
|
||||
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
|
||||
itemCount, err := s.SyncPricelistItems(existing.ID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err)
|
||||
} else {
|
||||
slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -385,10 +388,6 @@ 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++
|
||||
}
|
||||
|
||||
@@ -399,15 +398,8 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Info("deleted stale local pricelists", "deleted", removed)
|
||||
}
|
||||
|
||||
// Update component prices from latest estimate pricelist only.
|
||||
if latestEstimateLocalID > 0 {
|
||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to update component prices from pricelist", "error", err)
|
||||
} else {
|
||||
slog.Info("updated component prices from latest pricelist", "updated", updated)
|
||||
}
|
||||
}
|
||||
// Backfill lot_category for used pricelists (older local caches may miss the column values).
|
||||
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
||||
|
||||
// Update last sync time
|
||||
s.localDB.SetLastSyncTime(time.Now())
|
||||
@@ -417,6 +409,83 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
return synced, nil
|
||||
}
|
||||
|
||||
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
|
||||
if s.localDB == nil || pricelistRepo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
activeSet := make(map[uint]struct{}, len(activeServerPricelistIDs))
|
||||
for _, id := range activeServerPricelistIDs {
|
||||
activeSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
type row struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
}
|
||||
var usedRows []row
|
||||
if err := s.localDB.DB().Raw(`
|
||||
SELECT DISTINCT pricelist_id AS id
|
||||
FROM local_configurations
|
||||
WHERE is_active = 1 AND pricelist_id IS NOT NULL
|
||||
UNION
|
||||
SELECT DISTINCT warehouse_pricelist_id AS id
|
||||
FROM local_configurations
|
||||
WHERE is_active = 1 AND warehouse_pricelist_id IS NOT NULL
|
||||
UNION
|
||||
SELECT DISTINCT competitor_pricelist_id AS id
|
||||
FROM local_configurations
|
||||
WHERE is_active = 1 AND competitor_pricelist_id IS NOT NULL
|
||||
`).Scan(&usedRows).Error; err != nil {
|
||||
slog.Warn("pricelist category backfill: failed to list used pricelists", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range usedRows {
|
||||
serverID := r.ID
|
||||
if serverID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := activeSet[serverID]; !ok {
|
||||
// Not present on server (or not active) - cannot backfill from remote.
|
||||
continue
|
||||
}
|
||||
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(serverID)
|
||||
if err != nil || localPL == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.localDB.CountLocalPricelistItems(localPL.ID) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
missing, err := s.localDB.CountLocalPricelistItemsWithEmptyCategory(localPL.ID)
|
||||
if err != nil {
|
||||
slog.Warn("pricelist category backfill: failed to check local items", "server_id", serverID, "error", err)
|
||||
continue
|
||||
}
|
||||
if missing == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
serverItems, _, err := pricelistRepo.GetItems(serverID, 0, 10000, "")
|
||||
if err != nil {
|
||||
slog.Warn("pricelist category backfill: failed to load server items", "server_id", serverID, "error", err)
|
||||
continue
|
||||
}
|
||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||
for i := range serverItems {
|
||||
localItems[i] = *localdb.PricelistItemToLocal(&serverItems[i], localPL.ID)
|
||||
}
|
||||
|
||||
if err := s.localDB.ReplaceLocalPricelistItems(localPL.ID, localItems); err != nil {
|
||||
slog.Warn("pricelist category backfill: failed to replace local items", "server_id", serverID, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("pricelist category backfill: refreshed local items", "server_id", serverID, "items", len(localItems))
|
||||
}
|
||||
}
|
||||
|
||||
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
|
||||
// Only users with write rights are expected to be able to update this table.
|
||||
func (s *Service) RecordSyncHeartbeat() {
|
||||
@@ -553,24 +622,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
|
||||
@@ -606,15 +685,7 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
// Convert and save locally
|
||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||
for i, item := range serverItems {
|
||||
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
|
||||
partnumbers = append(partnumbers, item.Partnumbers...)
|
||||
localItems[i] = localdb.LocalPricelistItem{
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
AvailableQty: item.AvailableQty,
|
||||
Partnumbers: partnumbers,
|
||||
}
|
||||
localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
|
||||
}
|
||||
|
||||
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||
@@ -787,11 +858,36 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||
project := payload.Snapshot
|
||||
project.UUID = payload.ProjectUUID
|
||||
|
||||
if err := projectRepo.UpsertByUUID(&project); err != nil {
|
||||
return fmt.Errorf("upsert project on server: %w", err)
|
||||
if strings.TrimSpace(project.Code) == "" {
|
||||
project.Code = strings.TrimSpace(derefString(project.Name))
|
||||
if project.Code == "" {
|
||||
project.Code = project.UUID
|
||||
}
|
||||
}
|
||||
|
||||
// Try upsert by UUID first
|
||||
err = projectRepo.UpsertByUUID(&project)
|
||||
if err != nil {
|
||||
// Check if it's a duplicate (code, variant) constraint violation
|
||||
// In this case, find existing project with same (code, variant) and link to it
|
||||
var existing models.Project
|
||||
lookupErr := mariaDB.Where("code = ? AND variant = ?", project.Code, project.Variant).First(&existing).Error
|
||||
if lookupErr == nil {
|
||||
// Found duplicate - link local project to existing server project
|
||||
slog.Info("project duplicate found, linking to existing",
|
||||
"local_uuid", project.UUID,
|
||||
"server_uuid", existing.UUID,
|
||||
"server_id", existing.ID,
|
||||
"code", project.Code,
|
||||
"variant", project.Variant)
|
||||
project.ID = existing.ID
|
||||
} else {
|
||||
// Not a duplicate issue, return original error
|
||||
return fmt.Errorf("upsert project on server: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update local project with server ID
|
||||
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||
if localErr == nil {
|
||||
if project.ID > 0 {
|
||||
@@ -807,6 +903,17 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
|
||||
var payload ProjectChangePayload
|
||||
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
|
||||
@@ -1077,7 +1184,8 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
||||
systemProject = &models.Project{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
Code: "Без проекта",
|
||||
Name: ptrString("Без проекта"),
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
}
|
||||
@@ -1241,6 +1349,21 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
|
||||
}
|
||||
}
|
||||
|
||||
if currentVersionNo == 0 {
|
||||
if err := s.repairMissingConfigurationVersion(localCfg); err != nil {
|
||||
return models.Configuration{}, "", 0, fmt.Errorf("repair missing configuration version: %w", err)
|
||||
}
|
||||
var latest localdb.LocalConfigurationVersion
|
||||
err = s.localDB.DB().
|
||||
Where("configuration_uuid = ?", configurationUUID).
|
||||
Order("version_no DESC").
|
||||
First(&latest).Error
|
||||
if err == nil {
|
||||
currentVersionNo = latest.VersionNo
|
||||
currentVersionID = latest.ID
|
||||
}
|
||||
}
|
||||
|
||||
if currentVersionNo == 0 {
|
||||
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
|
||||
}
|
||||
@@ -1248,6 +1371,64 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
|
||||
return cfg, currentVersionID, currentVersionNo, nil
|
||||
}
|
||||
|
||||
func (s *Service) repairMissingConfigurationVersion(localCfg *localdb.LocalConfiguration) error {
|
||||
if localCfg == nil {
|
||||
return fmt.Errorf("local configuration is nil")
|
||||
}
|
||||
|
||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
var cfg localdb.LocalConfiguration
|
||||
if err := tx.Where("uuid = ?", localCfg.UUID).First(&cfg).Error; err != nil {
|
||||
return fmt.Errorf("load local configuration: %w", err)
|
||||
}
|
||||
|
||||
// If versions exist, just make sure current_version_id is set.
|
||||
var latest localdb.LocalConfigurationVersion
|
||||
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
||||
Order("version_no DESC").
|
||||
First(&latest).Error; err == nil {
|
||||
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
||||
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", latest.ID).Error; err != nil {
|
||||
return fmt.Errorf("set current version id: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("load latest version: %w", err)
|
||||
}
|
||||
|
||||
snapshot, err := localdb.BuildConfigurationSnapshot(&cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build configuration snapshot: %w", err)
|
||||
}
|
||||
|
||||
note := "Auto-repaired missing local version"
|
||||
version := localdb.LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 1,
|
||||
Data: snapshot,
|
||||
ChangeNote: ¬e,
|
||||
AppVersion: appmeta.Version(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
return fmt.Errorf("create initial version: %w", err)
|
||||
}
|
||||
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", version.ID).Error; err != nil {
|
||||
return fmt.Errorf("set current version id: %w", err)
|
||||
}
|
||||
|
||||
slog.Warn("repaired missing local configuration version", "uuid", cfg.UUID, "version_no", version.VersionNo)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// NOTE: prepared for future conflict resolution:
|
||||
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
|
||||
// against remote version and branch into custom strategies. For now use last-write-wins.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package sync_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
)
|
||||
|
||||
func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
|
||||
if err := serverDB.AutoMigrate(
|
||||
&models.Pricelist{},
|
||||
&models.PricelistItem{},
|
||||
&models.Lot{},
|
||||
&models.LotPartnumber{},
|
||||
&models.StockLog{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate server tables: %v", err)
|
||||
}
|
||||
|
||||
serverPL := models.Pricelist{
|
||||
Source: "estimate",
|
||||
Version: "2026-02-11-001",
|
||||
Notification: "server",
|
||||
CreatedBy: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
if err := serverDB.Create(&serverPL).Error; err != nil {
|
||||
t.Fatalf("create server pricelist: %v", err)
|
||||
}
|
||||
if err := serverDB.Create(&models.PricelistItem{
|
||||
PricelistID: serverPL.ID,
|
||||
LotName: "CPU_A",
|
||||
LotCategory: "CPU",
|
||||
Price: 10,
|
||||
PriceMethod: "",
|
||||
MetaPrices: "",
|
||||
ManualPrice: nil,
|
||||
AvailableQty: nil,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create server pricelist item: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: serverPL.ID,
|
||||
Source: serverPL.Source,
|
||||
Version: serverPL.Version,
|
||||
Name: serverPL.Notification,
|
||||
CreatedAt: serverPL.CreatedAt,
|
||||
SyncedAt: time.Now(),
|
||||
IsUsed: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(serverPL.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{
|
||||
PricelistID: localPL.ID,
|
||||
LotName: "CPU_A",
|
||||
LotCategory: "",
|
||||
Price: 10,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed local pricelist items: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
|
||||
UUID: "cfg-1",
|
||||
OriginalUsername: "tester",
|
||||
Name: "cfg",
|
||||
Items: localdb.LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 10}},
|
||||
IsActive: true,
|
||||
PricelistID: &serverPL.ID,
|
||||
SyncStatus: "synced",
|
||||
CreatedAt: time.Now().Add(-30 * time.Minute),
|
||||
UpdatedAt: time.Now().Add(-30 * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed local configuration with pricelist ref: %v", err)
|
||||
}
|
||||
|
||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
if _, err := svc.SyncPricelists(); err != nil {
|
||||
t.Fatalf("sync pricelists: %v", err)
|
||||
}
|
||||
|
||||
items, err := local.GetLocalPricelistItems(localPL.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load local items: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 local item, got %d", len(items))
|
||||
}
|
||||
if items[0].LotCategory != "CPU" {
|
||||
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
|
||||
projectService := services.NewProjectService(local)
|
||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||
|
||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project A"})
|
||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
@@ -74,11 +74,11 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
|
||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
|
||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project v1"})
|
||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil {
|
||||
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil {
|
||||
t.Fatalf("update project: %v", err)
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
|
||||
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
|
||||
t.Fatalf("project not pushed to server: %v", err)
|
||||
}
|
||||
if serverProject.Name != "Project v2" {
|
||||
t.Fatalf("expected latest project name, got %q", serverProject.Name)
|
||||
if serverProject.Name == nil || *serverProject.Name != "Project v2" {
|
||||
t.Fatalf("expected latest project name, got %v", serverProject.Name)
|
||||
}
|
||||
|
||||
var serverCfg models.Configuration
|
||||
@@ -324,6 +324,8 @@ CREATE TABLE qt_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
owner_username TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
variant TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
tracker_url TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -333,6 +335,9 @@ CREATE TABLE qt_projects (
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create qt_projects: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);`).Error; err != nil {
|
||||
t.Fatalf("create qt_projects index: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE qt_configurations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -348,6 +353,9 @@ CREATE TABLE qt_configurations (
|
||||
notes TEXT NULL,
|
||||
is_template INTEGER NOT NULL DEFAULT 0,
|
||||
server_count INTEGER NOT NULL DEFAULT 1,
|
||||
server_model TEXT NULL,
|
||||
support_code TEXT NULL,
|
||||
article TEXT NULL,
|
||||
pricelist_id INTEGER NULL,
|
||||
warehouse_pricelist_id INTEGER NULL,
|
||||
competitor_pricelist_id INTEGER NULL,
|
||||
@@ -361,6 +369,10 @@ CREATE TABLE qt_configurations (
|
||||
return db
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
|
||||
t.Helper()
|
||||
if currentVersionID == nil || *currentVersionID == "" {
|
||||
|
||||
93
markdown
Normal file
93
markdown
Normal file
@@ -0,0 +1,93 @@
|
||||
<p><strong>ТЕХНИЧЕСКОЕ ЗАДАНИЕ</strong></p>
|
||||
<p>1. Требования к продукции</p>
|
||||
<p>Поставляемое оборудование должно быть новым, оригинальным, не бывшим
|
||||
в употреблении, не восстановленным. Гарантийный срок — не менее 12
|
||||
месяцев с момента поставки. Все компоненты, включая процессоры, память,
|
||||
накопители и контроллеры, должны быть совместимы и предварительно
|
||||
протестированы на совместимость и производительность в рамках единой
|
||||
системы.</p>
|
||||
<p>2. Количественные и качественные характеристики</p>
|
||||
<p>2.1. Базовые требования к серверной платформе:</p>
|
||||
<p>Модель (артикул): Сервер в конфигурации с шасси, поддерживающим 12
|
||||
отсека 2.5" для накопителей NVMe U.2/U.3.</p>
|
||||
<p>Форм-фактор: 2U для установки в стойку.</p>
|
||||
<p>Кол-во процессорных сокетов: 1.</p>
|
||||
<p>2.2. Требования к процессорам (CPU):</p>
|
||||
<p>Количество: 1 шт.</p>
|
||||
<p>Модель/семейство: 256t x AMD EPYC 9755 2.7 GHz 128c-Core Processor
|
||||
Объём кэша L3: 512MB Техпроцесс: 4 нм, архитектура процессора: Zen-5
|
||||
(Turin).</p>
|
||||
<p>Минимальная базовая тактовая частота: 2.7 ГГц.</p>
|
||||
<p>Максимальная частота работы процессора (Turboboost): 4.1 GHz</p>
|
||||
<p>Для обеспечения полной производительности всех 12 накопителей NVMe и
|
||||
сетевых адаптеров, процессор и системная платформа в целом должны
|
||||
обеспечивать достаточно линий PCIe 5.0.</p>
|
||||
<p>2.3. Требования к оперативной памяти (RAM):</p>
|
||||
<p>Тип памяти: DDR5 с коррекцией ошибок (ECC) RDIMM 6000Mhz.</p>
|
||||
<p>Минимальный объем оперативной памяти: 2048 ГБ.</p>
|
||||
<p>Конфигурация: Модули памяти должны быть установлены в оптимальной
|
||||
конфигурации для обеспечения полной пропускной способности всех линий
|
||||
PCIe 5.0 от NVMe-накопителей. Платформа должна поддерживать установку не
|
||||
менее 16 модулей DDR5 ECC REG 6000Mhz для последующего
|
||||
масштабирования.</p>
|
||||
<p>Поддерживаемая частота: Не менее 6000 МТ/с.</p>
|
||||
<p>Одобренные модули оперативной памяти - Samsung/Micron/Hynix, DDR5,
|
||||
64GB, RDIMM, ECC</p>
|
||||
<p>2.4 Требования к системе хранения данных:</p>
|
||||
<p>Конфигурация шасси: Обязательна поставка в конфигурации с 12 отсеками
|
||||
2.5" под горячую замену, поддерживающими интерфейс NVMe через PCIe 5.0
|
||||
x4 и 2 отсеками 2.5"/М.2 под горячую замену, поддерживающими SATA.</p>
|
||||
<p>Дополнительно (для ОС): Поддержка установки 2x M.2 NVMe накопителей в
|
||||
dedicated-слотах на материнской плате под операционную систему отдельно
|
||||
от основного хранилища данных.</p>
|
||||
<p>2.5. Требования к сетевым интерфейсам (NIC):</p>
|
||||
<p>Слоты расширения сети: Наличие не менее 1 слотов OCP 3.0 SFF для
|
||||
установки специализированных сетевых адаптеров.</p>
|
||||
<p>Дополнительные сетевые адаптеры (обязательная поставка): Один сетевой
|
||||
адаптер OCP 3.0 с 2 портами 25 Гбит/с Ethernet Intel 810 или Mellanox
|
||||
CX-6.</p>
|
||||
<p>Встроенные порты управления: порт 1 Гбит/с Ethernet (RJ-45) для
|
||||
модуля управления iBMC.</p>
|
||||
<p>2.6. Требования к интерфейсам расширения (PCIe):</p>
|
||||
<p>Количество слотов PCIe: Конфигурация с 12 дисками NVMe использует
|
||||
большую часть линий PCIe. Тем не менее, не менее слотов PCIe 5.0 должны
|
||||
оставаться свободными для будущего расширения (например, установки
|
||||
дополнительных сетевых карт или FPGA/GPU для специфических задач).</p>
|
||||
<p>Шинная архитектура: Поставщик должен предоставить схему распределения
|
||||
линий PCIe 5.0 между процессором контроллером RAID и слотами расширения,
|
||||
подтверждающую отсутствие узких мест (bottlenecks).</p>
|
||||
<p>2.7. Требования к системе управления:</p>
|
||||
<p>Внеполосный (out-of-band) модуль управления: Наличие выделенного чипа
|
||||
iBMC.</p>
|
||||
<p>Интеллектуальные функции: Критически важна поддержка детального
|
||||
мониторинга состояния NVMe-накопителей (SMART, температура, износ,
|
||||
прогнозирование сбоев) через интерфейс iBMC и поддержка технологии
|
||||
горячей замены NVMe-накопителей.</p>
|
||||
<p>Наличие безагентного КВМ (HTML5)</p>
|
||||
<p>Желательна поддержка shared LAN (через NCSI OCPv3 разъем) с
|
||||
тегированием VLAN, настройка по умолчанию DHCP IPv4</p>
|
||||
<p>Управление параметрами работы сервера (режим работы вентиляторов,
|
||||
потребление энергии, итд).</p>
|
||||
<p>Наличие 2х видов логирования:</p>
|
||||
<p>Все аппаратные события, включая ECC ошибки по памяти, ошибки PCIe,
|
||||
SATA (IPMI/Hardware Event Log)</p>
|
||||
<p>Все сессии аутентификации и изменения системных параметров
|
||||
(Audit/Security Log)</p>
|
||||
<p>Наличие функционала обновления прошивок сервера (BIOS, BMC, CPLD
|
||||
(опционально)) с сохранением и без сохранения настроек.</p>
|
||||
<p>2.8. Требования к системе питания и охлаждения:</p>
|
||||
<p>Блоки питания (PSU):</p>
|
||||
<p>Количество: 2 шт. (резервирование 1+1) с возможностью горячей
|
||||
замены.</p>
|
||||
<p>Номинальная мощность каждого: Минимум 1200 Вт с сертификацией 80 Plus
|
||||
Platinum/Titanium. Мощность должна быть достаточной для одновременной
|
||||
пиковой нагрузки от процессора, 12 NVMe-дисков и прочих компонентов.</p>
|
||||
<p>Система охлаждения: не менее N+1 резервирование вентиляторов.</p>
|
||||
<p>3. Упаковка и маркировка:</p>
|
||||
<p>Оборудование должно быть упаковано так, чтобы предотвратить
|
||||
повреждение при транспортировке.</p>
|
||||
<p>4. Требования к комплектации:</p>
|
||||
<p>Рельсовый комплект - без инструментов с горизонтальной загрузкой</p>
|
||||
<p>Оборудование - C19-C20 или С13-С14 кабели питания 1-2 m в зависимости
|
||||
от БП, 19 " комплект для монтажа в стойку, комплект винтов, все отсеки с
|
||||
корзинами.</p>
|
||||
41
memory.md
Normal file
41
memory.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Changes summary (2026-02-11)
|
||||
|
||||
Implemented strict `lot_category` flow using `pricelist_items.lot_category` only (no parsing from `lot_name`), plus local caching and backfill:
|
||||
|
||||
1. Local DB schema + migrations
|
||||
- Added `lot_category` column to `local_pricelist_items` via `LocalPricelistItem` model.
|
||||
- Added local migration `2026_02_11_local_pricelist_item_category` to add the column if missing and create indexes:
|
||||
- `idx_local_pricelist_items_pricelist_lot (pricelist_id, lot_name)`
|
||||
- `idx_local_pricelist_items_lot_category (lot_category)`
|
||||
|
||||
2. Server model/repository
|
||||
- Added `LotCategory` field to `models.PricelistItem`.
|
||||
- `PricelistRepository.GetItems` now sets `Category` from `LotCategory` (no parsing from `lot_name`).
|
||||
|
||||
3. Sync + local DB helpers
|
||||
- `SyncPricelistItems` now saves `lot_category` into local cache via `PricelistItemToLocal`.
|
||||
- Added `LocalDB.CountLocalPricelistItemsWithEmptyCategory` and `LocalDB.ReplaceLocalPricelistItems`.
|
||||
- Added `LocalDB.GetLocalLotCategoriesByServerPricelistID` for strict category lookup.
|
||||
- Added `SyncPricelists` backfill step: for used active pricelists with empty categories, force refresh items from server.
|
||||
|
||||
4. API handler
|
||||
- `GET /api/pricelists/:id/items` returns `category` from `local_pricelist_items.lot_category` (no parsing from `lot_name`).
|
||||
|
||||
5. Article category foundation
|
||||
- New package `internal/article`:
|
||||
- `ResolveLotCategoriesStrict` pulls categories from local pricelist items and errors on missing category.
|
||||
- `GroupForLotCategory` maps only allowed codes (CPU/MEM/GPU/M2/SSD/HDD/EDSFF/HHHL/NIC/HCA/DPU/PSU/PS) to article groups; excludes `SFP`.
|
||||
- Error type `MissingCategoryForLotError` with base `ErrMissingCategoryForLot`.
|
||||
|
||||
6. Tests
|
||||
- Added unit tests for converters and article category resolver.
|
||||
- Added handler test to ensure `/api/pricelists/:id/items` returns `lot_category`.
|
||||
- Added sync test for category backfill on used pricelist items.
|
||||
- `go test ./...` passed.
|
||||
|
||||
Additional fixes (2026-02-11):
|
||||
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
|
||||
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).
|
||||
|
||||
UI rule (2026-02-19):
|
||||
- In all breadcrumbs, truncate long specification/configuration names to 16 characters using ellipsis.
|
||||
2
migrations/022_add_article_to_configurations.sql
Normal file
2
migrations/022_add_article_to_configurations.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count;
|
||||
2
migrations/023_add_server_model_to_configurations.sql
Normal file
2
migrations/023_add_server_model_to_configurations.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count;
|
||||
2
migrations/024_add_support_code_to_configurations.sql
Normal file
2
migrations/024_add_support_code_to_configurations.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model;
|
||||
38
migrations/025_add_project_code.sql
Normal file
38
migrations/025_add_project_code.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Add project code and enforce uniqueness
|
||||
|
||||
ALTER TABLE qt_projects
|
||||
ADD COLUMN code VARCHAR(100) NULL AFTER owner_username;
|
||||
|
||||
-- Copy code from current project name (truncate to fit)
|
||||
UPDATE qt_projects
|
||||
SET code = LEFT(TRIM(COALESCE(name, '')), 100);
|
||||
|
||||
-- Fallback for any remaining blanks
|
||||
UPDATE qt_projects
|
||||
SET code = uuid
|
||||
WHERE code IS NULL OR TRIM(code) = '';
|
||||
|
||||
-- Drop unique index if it already exists to allow de-duplication updates
|
||||
DROP INDEX IF EXISTS idx_qt_projects_code ON qt_projects;
|
||||
|
||||
-- De-duplicate codes: OPS-1948-2, OPS-1948-3... (MariaDB without CTE)
|
||||
UPDATE qt_projects p
|
||||
JOIN (
|
||||
SELECT p1.id,
|
||||
p1.code AS base_code,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM qt_projects p2
|
||||
WHERE p2.code = p1.code AND p2.id <= p1.id
|
||||
) AS rn
|
||||
FROM qt_projects p1
|
||||
) r ON r.id = p.id
|
||||
SET p.code = CASE
|
||||
WHEN r.rn = 1 THEN r.base_code
|
||||
ELSE CONCAT(LEFT(r.base_code, 90), '-', r.rn)
|
||||
END;
|
||||
|
||||
ALTER TABLE qt_projects
|
||||
MODIFY COLUMN code VARCHAR(100) NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX idx_qt_projects_code ON qt_projects(code);
|
||||
28
migrations/026_add_project_variant.sql
Normal file
28
migrations/026_add_project_variant.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Add project variant and reset codes from project names
|
||||
|
||||
ALTER TABLE qt_projects
|
||||
ADD COLUMN variant VARCHAR(100) NOT NULL DEFAULT '' AFTER code;
|
||||
|
||||
-- Drop legacy unique index on code to allow duplicate codes
|
||||
DROP INDEX IF EXISTS idx_qt_projects_code ON qt_projects;
|
||||
DROP INDEX IF EXISTS idx_qt_projects_code_variant ON qt_projects;
|
||||
|
||||
-- Reset code from name and clear variant
|
||||
UPDATE qt_projects
|
||||
SET code = LEFT(TRIM(COALESCE(name, '')), 100),
|
||||
variant = '';
|
||||
|
||||
-- De-duplicate by assigning variant numbers: -2, -3...
|
||||
UPDATE qt_projects p
|
||||
JOIN (
|
||||
SELECT p1.id,
|
||||
p1.code,
|
||||
(SELECT COUNT(*)
|
||||
FROM qt_projects p2
|
||||
WHERE p2.code = p1.code AND p2.id <= p1.id) AS rn
|
||||
FROM qt_projects p1
|
||||
) r ON r.id = p.id
|
||||
SET p.code = r.code,
|
||||
p.variant = CASE WHEN r.rn = 1 THEN '' ELSE CONCAT('-', r.rn) END;
|
||||
|
||||
CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);
|
||||
4
migrations/027_project_name_nullable.sql
Normal file
4
migrations/027_project_name_nullable.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Allow NULL project names
|
||||
|
||||
ALTER TABLE qt_projects
|
||||
MODIFY COLUMN name VARCHAR(200) NULL;
|
||||
72
releases/memory/v1.2.1.md
Normal file
72
releases/memory/v1.2.1.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# v1.2.1 Release Notes
|
||||
|
||||
**Date:** 2026-02-09
|
||||
**Changes since v1.2.0:** 2 commits
|
||||
|
||||
## Summary
|
||||
Fixed configurator component substitution by updating to work with new pricelist-based pricing model. Addresses regression from v1.2.0 refactor that removed `CurrentPrice` field from components.
|
||||
|
||||
## Commits
|
||||
|
||||
### 1. Refactor: Remove CurrentPrice from local_components (5984a57)
|
||||
**Type:** Refactor
|
||||
**Files Changed:** 11 files, +167 insertions, -194 deletions
|
||||
|
||||
#### Overview
|
||||
Transitioned from component-based pricing to pricelist-based pricing model:
|
||||
- Removed `CurrentPrice` and `SyncedAt` from LocalComponent (metadata-only now)
|
||||
- Added `WarehousePricelistID` and `CompetitorPricelistID` to LocalConfiguration
|
||||
- Removed 2 unused methods: UpdateComponentPricesFromPricelist, EnsureComponentPricesFromPricelists
|
||||
|
||||
#### Key Changes
|
||||
- **Data Model:**
|
||||
- LocalComponent: now stores only metadata (LotName, LotDescription, Category, Model)
|
||||
- LocalConfiguration: added warehouse and competitor pricelist references
|
||||
|
||||
- **Migrations:**
|
||||
- drop_component_unused_fields - removes CurrentPrice, SyncedAt columns
|
||||
- add_warehouse_competitor_pricelists - adds new pricelist fields
|
||||
|
||||
- **Quote Calculation:**
|
||||
- Updated to use pricelist_items instead of component.CurrentPrice
|
||||
- Added PricelistID field to QuoteRequest
|
||||
- Maintains offline-first behavior
|
||||
|
||||
- **API:**
|
||||
- Removed CurrentPrice from ComponentView
|
||||
- Components API no longer returns pricing
|
||||
|
||||
### 2. Fix: Load component prices via API (acf7c8a)
|
||||
**Type:** Bug Fix
|
||||
**Files Changed:** 1 file (web/templates/index.html), +66 insertions, -12 deletions
|
||||
|
||||
#### Problem
|
||||
After v1.2.0 refactor, the configurator's autocomplete was filtering out all components because it checked for the removed `current_price` field on component objects.
|
||||
|
||||
#### Solution
|
||||
Implemented on-demand price loading via API:
|
||||
- Added `ensurePricesLoaded()` function to fetch prices from `/api/quote/price-levels`
|
||||
- Added `componentPricesCache` to cache loaded prices in memory
|
||||
- Updated all 3 autocomplete modes (single, multi, section) to load prices when input is focused
|
||||
- Changed price validation from `c.current_price` to `hasComponentPrice(lot_name)`
|
||||
- Updated cart item creation to use cached API prices
|
||||
|
||||
#### Impact
|
||||
- Components without prices are still filtered out (as required)
|
||||
- Price checks now use API data instead of removed database field
|
||||
- Frontend loads prices on-demand for better performance
|
||||
|
||||
## Testing Notes
|
||||
- ✅ Configurator component substitution now works
|
||||
- ✅ Prices load correctly from pricelist
|
||||
- ✅ Offline mode still supported (prices cached after initial load)
|
||||
- ✅ Multi-pricelist support functional (estimate/warehouse/competitor)
|
||||
|
||||
## Known Issues
|
||||
None
|
||||
|
||||
## Migration Path
|
||||
No database migration needed from v1.2.0 - migrations were applied in v1.2.0 release.
|
||||
|
||||
## Breaking Changes
|
||||
None for end users. Internal: `ComponentView` no longer includes `CurrentPrice` in API responses.
|
||||
59
releases/memory/v1.2.2.md
Normal file
59
releases/memory/v1.2.2.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Release v1.2.2 (2026-02-09)
|
||||
|
||||
## Summary
|
||||
|
||||
Fixed CSV export filename inconsistency where project names weren't being resolved correctly. Standardized export format across both manual exports and project configuration exports to use `YYYY-MM-DD (project_name) config_name BOM.csv`.
|
||||
|
||||
## Commits
|
||||
|
||||
- `8f596ce` fix: standardize CSV export filename format to use project name
|
||||
|
||||
## Changes
|
||||
|
||||
### CSV Export Filename Standardization
|
||||
|
||||
**Problem:**
|
||||
- ExportCSV and ExportConfigCSV had inconsistent filename formats
|
||||
- Project names sometimes fell back to config names when not explicitly provided
|
||||
- Export timestamps didn't reflect actual price update time
|
||||
|
||||
**Solution:**
|
||||
- Unified format: `YYYY-MM-DD (project_name) config_name BOM.csv`
|
||||
- Both export paths now use PriceUpdatedAt if available, otherwise CreatedAt
|
||||
- Project name resolved from ProjectUUID via ProjectService for both paths
|
||||
- Frontend passes project_uuid context when exporting
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
Backend:
|
||||
- Added `ProjectUUID` field to `ExportRequest` struct in handlers/export.go
|
||||
- Updated ExportCSV to look up project name from ProjectUUID using ProjectService
|
||||
- Ensured ExportConfigCSV gets project name from config's ProjectUUID
|
||||
- Both use CreatedAt (for ExportCSV) or PriceUpdatedAt/CreatedAt (for ExportConfigCSV)
|
||||
|
||||
Frontend:
|
||||
- Added `projectUUID` and `projectName` state variables in index.html
|
||||
- Load and store projectUUID when configuration is loaded
|
||||
- Pass `project_uuid` in JSON body for both export requests
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `internal/handlers/export.go` - Project name resolution and ExportRequest update
|
||||
- `internal/handlers/export_test.go` - Updated mock initialization with projectService param
|
||||
- `cmd/qfs/main.go` - Pass projectService to ExportHandler constructor
|
||||
- `web/templates/index.html` - Add projectUUID tracking and export payload updates
|
||||
|
||||
## Testing Notes
|
||||
|
||||
✅ All existing tests updated and passing
|
||||
✅ Code builds without errors
|
||||
✅ Export filename now includes correct project name
|
||||
✅ Works for both form-based and project-based exports
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None - API response format unchanged, only filename generation updated.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None identified.
|
||||
95
releases/memory/v1.2.3.md
Normal file
95
releases/memory/v1.2.3.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Release v1.2.3 (2026-02-10)
|
||||
|
||||
## Summary
|
||||
|
||||
Unified synchronization functionality with event-driven UI updates. Resolved user confusion about duplicate sync buttons by implementing a single sync source with automatic page refreshes.
|
||||
|
||||
## Changes
|
||||
|
||||
### Main Feature: Sync Event System
|
||||
|
||||
- **Added `sync-completed` event** in base.html's `syncAction()` function
|
||||
- Dispatched after successful `/api/sync/all` or `/api/sync/push`
|
||||
- Includes endpoint and response data in event detail
|
||||
- Enables pages to react automatically to sync completion
|
||||
|
||||
### Configs Page (`configs.html`)
|
||||
|
||||
- **Removed "Импорт с сервера" button** - duplicate functionality no longer needed
|
||||
- **Updated layout** - changed from 2-column grid to single button layout
|
||||
- **Removed `importConfigsFromServer()` function** - functionality now handled by navbar sync
|
||||
- **Added sync-completed event listener**:
|
||||
- Automatically reloads configurations list after sync
|
||||
- Resets pagination to first page
|
||||
- New configurations appear immediately without manual refresh
|
||||
|
||||
### Projects Page (`projects.html`)
|
||||
|
||||
- **Wrapped initialization in DOMContentLoaded**:
|
||||
- Moved `loadProjects()` and all event listeners inside handler
|
||||
- Ensures DOM is fully loaded before accessing elements
|
||||
- **Added sync-completed event listener**:
|
||||
- Automatically reloads projects list after sync
|
||||
- New projects appear immediately without manual refresh
|
||||
|
||||
### Pricelists Page (`pricelists.html`)
|
||||
|
||||
- **Added sync-completed event listener** to existing DOMContentLoaded:
|
||||
- Automatically reloads pricelists when sync completes
|
||||
- Maintains existing permissions and modal functionality
|
||||
|
||||
## Benefits
|
||||
|
||||
### User Experience
|
||||
- ✅ Single "Синхронизация" button in navbar - no confusion about sync sources
|
||||
- ✅ Automatic list updates after sync - no need for manual F5 refresh
|
||||
- ✅ Consistent behavior across all pages (configs, projects, pricelists)
|
||||
- ✅ Better feedback: toast notification + automatic UI refresh
|
||||
|
||||
### Architecture
|
||||
- ✅ Event-driven loose coupling between navbar and pages
|
||||
- ✅ Easy to extend to other pages (just add event listener)
|
||||
- ✅ No backend changes needed
|
||||
- ✅ Production-ready
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
- **`/api/configs/import` endpoint** still works but UI button removed
|
||||
- Users should use navbar "Синхронизация" button instead
|
||||
- Backend API remains unchanged for backward compatibility
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `web/templates/base.html` - Added sync-completed event dispatch
|
||||
2. `web/templates/configs.html` - Event listener + removed duplicate UI
|
||||
3. `web/templates/projects.html` - DOMContentLoaded wrapper + event listener
|
||||
4. `web/templates/pricelists.html` - Event listener for auto-refresh
|
||||
|
||||
**Stats:** 4 files changed, 59 insertions(+), 65 deletions(-)
|
||||
|
||||
## Commits
|
||||
|
||||
- `99fd80b` - feat: unify sync functionality with event-driven UI updates
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] Configs page: New configurations appear after navbar sync
|
||||
- [x] Projects page: New projects appear after navbar sync
|
||||
- [x] Pricelists page: Pricelists refresh after navbar sync
|
||||
- [x] Both `/api/sync/all` and `/api/sync/push` trigger updates
|
||||
- [x] Toast notifications still show correctly
|
||||
- [x] Sync status indicator updates
|
||||
- [x] Error handling (423, network errors) still works
|
||||
- [x] Mode switching (Active/Archive) works correctly
|
||||
- [x] Backward compatibility maintained
|
||||
|
||||
## Known Issues
|
||||
|
||||
None - implementation is production-ready
|
||||
|
||||
## Migration Notes
|
||||
|
||||
No migration needed. Changes are frontend-only and backward compatible:
|
||||
- Old `/api/configs/import` endpoint still functional
|
||||
- No database schema changes
|
||||
- No configuration changes needed
|
||||
68
releases/memory/v1.3.0.md
Normal file
68
releases/memory/v1.3.0.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Release v1.3.0 (2026-02-11)
|
||||
|
||||
## Summary
|
||||
|
||||
Introduced article generation with pricelist categories, added local configuration storage, and expanded sync/export capabilities. Simplified article generator compression and loosened project update constraints.
|
||||
|
||||
## Changes
|
||||
|
||||
### Main Features: Articles + Pricelist Categories
|
||||
|
||||
- **Article generation pipeline**
|
||||
- New generator and tests under `internal/article/`
|
||||
- Category support with test coverage
|
||||
- **Pricelist category integration**
|
||||
- Handler and repository updates
|
||||
- Sync backfill test for category propagation
|
||||
|
||||
### Local Configuration Storage
|
||||
|
||||
- **Local DB support**
|
||||
- New localdb models, converters, snapshots, and migrations
|
||||
- Local configuration service for cached configurations
|
||||
|
||||
### Export & UI
|
||||
|
||||
- **Export handler updates** for article data output
|
||||
- **Configs and index templates** adjusted for new article-related fields
|
||||
|
||||
### Behavior Changes
|
||||
|
||||
- **Cross-user project updates allowed**
|
||||
- Removed restriction in project service
|
||||
- **Article compression refinement**
|
||||
- Generator logic simplified to reduce complexity
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None identified. Existing APIs remain intact.
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `internal/article/*` - Article generator + categories + tests
|
||||
2. `internal/localdb/*` - Local DB models, migrations, snapshots
|
||||
3. `internal/handlers/export.go` - Export updates
|
||||
4. `internal/handlers/pricelist.go` - Category handling
|
||||
5. `internal/services/sync/service.go` - Category backfill logic
|
||||
6. `web/templates/configs.html` - Article field updates
|
||||
7. `web/templates/index.html` - Article field updates
|
||||
|
||||
**Stats:** 33 files changed, 2059 insertions(+), 329 deletions(-)
|
||||
|
||||
## Commits
|
||||
|
||||
- `5edffe8` - Add article generation and pricelist categories
|
||||
- `e355903` - Allow cross-user project updates
|
||||
- `e58fd35` - Refine article compression and simplify generator
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Tests not run (not requested)
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- New migrations:
|
||||
- `022_add_article_to_configurations.sql`
|
||||
- `023_add_server_model_to_configurations.sql`
|
||||
- `024_add_support_code_to_configurations.sql`
|
||||
- Ensure migrations are applied before running v1.3.0
|
||||
66
releases/memory/v1.3.2.md
Normal file
66
releases/memory/v1.3.2.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Release v1.3.2 (2026-02-19)
|
||||
|
||||
## Summary
|
||||
|
||||
Release focuses on stability and data integrity for local configurations. Added configuration revision history, stronger recovery for broken local sync/version states, improved sync self-healing, and clearer API error logging.
|
||||
|
||||
## Changes
|
||||
|
||||
### Configuration Revisions
|
||||
|
||||
- Added full local configuration revision flow with storage and UI support.
|
||||
- Introduced revisions page/template and backend plumbing for browsing revisions.
|
||||
- Prevented duplicate revisions when content did not actually change.
|
||||
|
||||
### Local Data Integrity and Recovery
|
||||
|
||||
- Added migration and snapshot support for local configuration version data.
|
||||
- Hardened updates for legacy/orphaned configuration rows:
|
||||
- allow update when project UUID is unchanged even if referenced project is missing locally;
|
||||
- recover gracefully when `current_version_id` is stale or version rows are missing.
|
||||
- Added regression tests for orphan-project and missing-current-version scenarios.
|
||||
|
||||
### Sync Reliability
|
||||
|
||||
- Added smart self-healing path for sync errors.
|
||||
- Fixed duplicate-project sync edge cases.
|
||||
|
||||
### API and Logging
|
||||
|
||||
- Improved HTTP error mapping for configuration updates (`404/403` instead of generic `500` in known cases).
|
||||
- Enhanced request logger to capture error responses (status, response body snippet, gin errors) for failed requests.
|
||||
|
||||
### UI and Export
|
||||
|
||||
- Updated project detail and index templates for revisions and related UX improvements.
|
||||
- Updated export pipeline and tests to align with revisions/project behavior changes.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None identified.
|
||||
|
||||
## Files Changed
|
||||
|
||||
- 24 files changed, 2394 insertions(+), 482 deletions(-)
|
||||
- Main touched areas:
|
||||
- `internal/services/local_configuration.go`
|
||||
- `internal/services/local_configuration_versioning_test.go`
|
||||
- `internal/localdb/{localdb.go,migrations.go,snapshots.go,local_migrations_test.go}`
|
||||
- `internal/services/export.go`
|
||||
- `cmd/qfs/main.go`
|
||||
- `web/templates/{config_revisions.html,project_detail.html,index.html,base.html}`
|
||||
|
||||
## Commits Included (`v1.3.1..v1.3.2`)
|
||||
|
||||
- `b153afb` - Add smart self-healing for sync errors
|
||||
- `8508ee2` - Fix sync errors for duplicate projects and add modal scrolling
|
||||
- `2e973b6` - Add configuration revisions system and project variant deletion
|
||||
- `71f73e2` - chore: save current changes
|
||||
- `cbaeafa` - Deduplicate configuration revisions and update revisions UI
|
||||
- `075fc70` - Harden local config updates and error logging
|
||||
|
||||
## Testing
|
||||
|
||||
- [x] Targeted tests for local configuration update/version recovery:
|
||||
- `go test ./internal/services -run 'TestUpdateNoAuth(AllowsOrphanProjectWhenUUIDUnchanged|RecoversWhenCurrentVersionMissing|KeepsProjectWhenProjectUUIDOmitted)$'`
|
||||
- [ ] Full regression suite not run in this release step.
|
||||
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
|
||||
@@ -45,10 +45,10 @@
|
||||
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
||||
|
||||
<!-- Sync Info Modal -->
|
||||
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
|
||||
<div class="p-6 border-b border-gray-200 flex-shrink-0">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
||||
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -56,8 +56,10 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Section 1: DB Connection -->
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 mb-2">Подключение к БД</h4>
|
||||
@@ -123,9 +125,31 @@
|
||||
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
|
||||
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<!-- Section 5: Self-Healing (shown only if errors exist) -->
|
||||
<div id="modal-repair-section" class="hidden">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 class="font-medium text-blue-900 mb-2">Автоматическое исправление</h4>
|
||||
<p class="text-sm text-blue-700 mb-3">
|
||||
Система может исправить данные и очистить ошибки синхронизации:
|
||||
</p>
|
||||
<ul class="text-sm text-blue-700 mb-3 ml-4 list-disc space-y-1">
|
||||
<li>Проверит и исправит названия проектов</li>
|
||||
<li>Восстановит битые ссылки на проекты</li>
|
||||
<li>Очистит ошибки и даст pending changes еще шанс</li>
|
||||
</ul>
|
||||
<button id="repair-button" onclick="repairPendingChanges()"
|
||||
class="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
ИСПРАВИТЬ
|
||||
</button>
|
||||
<div id="repair-result" class="mt-2 text-sm hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-gray-200 flex-shrink-0">
|
||||
<div class="flex justify-end">
|
||||
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
||||
Закрыть
|
||||
</button>
|
||||
@@ -235,7 +259,8 @@
|
||||
// Section 4: Errors
|
||||
const errorsSection = document.getElementById('modal-errors-section');
|
||||
const errorsList = document.getElementById('modal-errors-list');
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
const hasErrors = data.errors && data.errors.length > 0;
|
||||
if (hasErrors) {
|
||||
errorsSection.classList.remove('hidden');
|
||||
errorsList.innerHTML = data.errors.map(error => {
|
||||
const time = new Date(error.timestamp).toLocaleString('ru-RU');
|
||||
@@ -246,12 +271,65 @@
|
||||
} else {
|
||||
errorsSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Section 5: Repair (show only if errors exist)
|
||||
const repairSection = document.getElementById('modal-repair-section');
|
||||
const repairResult = document.getElementById('repair-result');
|
||||
if (hasErrors) {
|
||||
repairSection.classList.remove('hidden');
|
||||
repairResult.classList.add('hidden');
|
||||
repairResult.innerHTML = '';
|
||||
} else {
|
||||
repairSection.classList.add('hidden');
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to load sync info:', e);
|
||||
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
||||
}
|
||||
}
|
||||
|
||||
// Repair pending changes
|
||||
async function repairPendingChanges() {
|
||||
const button = document.getElementById('repair-button');
|
||||
const resultDiv = document.getElementById('repair-result');
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Исправление...';
|
||||
resultDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/sync/repair', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
resultDiv.classList.remove('hidden');
|
||||
if (data.repaired > 0) {
|
||||
resultDiv.className = 'mt-2 text-sm text-green-700 bg-green-50 rounded px-3 py-2';
|
||||
resultDiv.textContent = `✓ Исправлено: ${data.repaired}`;
|
||||
// Reload sync info after repair
|
||||
setTimeout(() => loadSyncInfo(), 1000);
|
||||
} else {
|
||||
resultDiv.className = 'mt-2 text-sm text-yellow-700 bg-yellow-50 rounded px-3 py-2';
|
||||
resultDiv.textContent = 'Нечего исправлять или проблемы остались';
|
||||
if (data.remaining_errors && data.remaining_errors.length > 0) {
|
||||
resultDiv.innerHTML += '<div class="mt-1 text-xs">' + data.remaining_errors.join('<br>') + '</div>';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resultDiv.classList.remove('hidden');
|
||||
resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2';
|
||||
resultDiv.textContent = 'Ошибка: ' + (data.error || 'неизвестная ошибка');
|
||||
}
|
||||
} catch (e) {
|
||||
resultDiv.classList.remove('hidden');
|
||||
resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2';
|
||||
resultDiv.textContent = 'Ошибка запроса: ' + e.message;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = 'ИСПРАВИТЬ';
|
||||
}
|
||||
}
|
||||
|
||||
// Event delegation for sync dropdown and actions
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadDBUser();
|
||||
@@ -285,6 +363,14 @@
|
||||
showToast(successMessage, 'success');
|
||||
// Update last sync time - removed since dropdown is gone
|
||||
// loadLastSyncTime();
|
||||
|
||||
// Dispatch custom event for pages to react to sync completion
|
||||
window.dispatchEvent(new CustomEvent('sync-completed', {
|
||||
detail: {
|
||||
endpoint: endpoint,
|
||||
data: data
|
||||
}
|
||||
}));
|
||||
} else if (resp.status === 423) {
|
||||
const reason = data.reason_text || data.error || 'Синхронизация заблокирована.';
|
||||
showToast(reason, 'error');
|
||||
|
||||
238
web/templates/config_revisions.html
Normal file
238
web/templates/config_revisions.html
Normal file
@@ -0,0 +1,238 @@
|
||||
{{define "title"}}Ревизии - QuoteForge{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="text-2xl font-bold flex items-center gap-2" id="revisions-breadcrumbs">
|
||||
<a id="breadcrumb-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||
<span id="breadcrumb-code">—</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<a id="breadcrumb-variant-link" href="/projects" class="text-blue-700 hover:underline">
|
||||
<span id="breadcrumb-variant">main</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<a id="breadcrumb-config-link" href="/configurator" class="text-blue-700 hover:underline">
|
||||
<span id="breadcrumb-config">—</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<span class="text-gray-600">Ревизии</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="revisions-list">
|
||||
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const configUUID = '{{.ConfigUUID}}';
|
||||
let configData = null;
|
||||
let projectData = null;
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '—';
|
||||
return '$\u00A0' + num.toLocaleString('ru-RU', {minimumFractionDigits: 0, maximumFractionDigits: 2});
|
||||
}
|
||||
|
||||
function parseVersionSnapshot(version) {
|
||||
try {
|
||||
const raw = typeof version.data === 'string' ? version.data : '';
|
||||
if (!raw) return { article: '—', price: null, serverCount: 1 };
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
article: parsed.article || '—',
|
||||
price: typeof parsed.total_price === 'number' ? parsed.total_price : null,
|
||||
serverCount: Number.isFinite(Number(parsed.server_count)) && Number(parsed.server_count) > 0
|
||||
? Number(parsed.server_count)
|
||||
: 1
|
||||
};
|
||||
} catch (_) {
|
||||
return { article: '—', price: null, serverCount: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
function truncateBreadcrumbSpecName(name) {
|
||||
const maxLength = 16;
|
||||
if (!name || name.length <= maxLength) return name;
|
||||
return name.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
|
||||
async function loadConfigInfo() {
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID);
|
||||
if (!resp.ok) {
|
||||
document.getElementById('revisions-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Конфигурация не найдена</div>';
|
||||
return false;
|
||||
}
|
||||
configData = await resp.json();
|
||||
|
||||
const fullConfigName = configData.name || 'Конфигурация';
|
||||
const configBreadcrumbEl = document.getElementById('breadcrumb-config');
|
||||
configBreadcrumbEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||
configBreadcrumbEl.title = fullConfigName;
|
||||
document.getElementById('breadcrumb-config-link').href = '/configurator?uuid=' + configUUID;
|
||||
|
||||
if (configData.project_uuid) {
|
||||
const projResp = await fetch('/api/projects/' + configData.project_uuid);
|
||||
if (projResp.ok) {
|
||||
projectData = await projResp.json();
|
||||
document.getElementById('breadcrumb-code').textContent = projectData.code || '—';
|
||||
const variant = (projectData.variant || '').trim();
|
||||
document.getElementById('breadcrumb-variant').textContent = variant === '' ? 'main' : variant;
|
||||
document.getElementById('breadcrumb-variant-link').href = '/projects/' + projectData.uuid;
|
||||
|
||||
// Find main variant for code link
|
||||
const allResp = await fetch('/api/projects/all');
|
||||
if (allResp.ok) {
|
||||
const allProjects = await allResp.json();
|
||||
const main = (Array.isArray(allProjects) ? allProjects : []).find(
|
||||
p => p.code === projectData.code && (p.variant || '').trim() === ''
|
||||
);
|
||||
if (main) {
|
||||
document.getElementById('breadcrumb-code-link').href = '/projects/' + main.uuid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
document.getElementById('revisions-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/versions?limit=200');
|
||||
if (!resp.ok) {
|
||||
document.getElementById('revisions-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки ревизий</div>';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const versions = data.versions || [];
|
||||
renderVersions(versions);
|
||||
} catch (e) {
|
||||
document.getElementById('revisions-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderVersions(versions) {
|
||||
if (versions.length === 0) {
|
||||
document.getElementById('revisions-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет ревизий</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||||
html += '<thead class="bg-gray-50"><tr>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Серверов</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '</tr></thead><tbody class="divide-y">';
|
||||
|
||||
versions.forEach((v, idx) => {
|
||||
const date = new Date(v.created_at).toLocaleString('ru-RU');
|
||||
const author = v.created_by || '—';
|
||||
const snapshot = parseVersionSnapshot(v);
|
||||
const isCurrent = idx === 0;
|
||||
|
||||
html += '<tr class="hover:bg-gray-50' + (isCurrent ? ' bg-blue-50' : '') + '">';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium">';
|
||||
html += 'v' + v.version_no;
|
||||
if (isCurrent) html += ' <span class="text-xs text-blue-600 font-normal">(текущая)</span>';
|
||||
html += '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(date) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(snapshot.article) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + formatMoney(snapshot.price) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(String(snapshot.serverCount)) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||
|
||||
// Open in configurator (readonly view)
|
||||
html += '<a href="/configurator?uuid=' + configUUID + '&version=' + v.version_no + '" class="text-blue-600 hover:text-blue-800 inline-block" title="Открыть">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg></a>';
|
||||
|
||||
// Clone from this version
|
||||
html += '<button onclick="cloneFromVersion(' + v.version_no + ')" class="text-green-600 hover:text-green-800" title="Скопировать из этой ревизии">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
|
||||
|
||||
// Rollback (not for current version)
|
||||
if (!isCurrent) {
|
||||
html += '<button onclick="rollbackToVersion(' + v.version_no + ')" class="text-orange-600 hover:text-orange-800" title="Восстановить эту ревизию">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path></svg></button>';
|
||||
}
|
||||
|
||||
html += '</td></tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
document.getElementById('revisions-list').innerHTML = html;
|
||||
}
|
||||
|
||||
async function cloneFromVersion(versionNo) {
|
||||
const name = prompt('Название копии:', (configData ? configData.name : '') + ' (v' + versionNo + ')');
|
||||
if (!name) return;
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/clone', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name, from_version: versionNo})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
alert(data.error || 'Не удалось скопировать');
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
showToast('Копия создана', 'success');
|
||||
if (created && created.uuid) {
|
||||
window.location.href = '/configurator?uuid=' + created.uuid;
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackToVersion(versionNo) {
|
||||
if (!confirm('Восстановить конфигурацию до ревизии v' + versionNo + '?')) return;
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/rollback', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({target_version: versionNo})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
alert(data.error || 'Не удалось восстановить');
|
||||
return;
|
||||
}
|
||||
showToast('Ревизия восстановлена', 'success');
|
||||
loadVersions();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const ok = await loadConfigInfo();
|
||||
if (!ok) return;
|
||||
await loadVersions();
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
@@ -4,13 +4,10 @@
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
||||
|
||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
<div id="action-buttons" class="mt-4">
|
||||
<button onclick="openCreateModal()" class="w-full sm:w-auto py-3 px-6 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
+ Создать новую конфигурацию
|
||||
</button>
|
||||
<button id="import-configs-btn" onclick="importConfigsFromServer()" class="py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium">
|
||||
Импорт с сервера
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
|
||||
@@ -57,15 +54,15 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Номер Opportunity</label>
|
||||
<input type="text" id="opportunity-number" placeholder="Например: OPP-2024-001"
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
|
||||
<input type="text" id="opportunity-number" placeholder="Например: Сервер для проекта X"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||
<input id="create-project-input"
|
||||
list="create-project-options"
|
||||
placeholder="Начните вводить название проекта"
|
||||
placeholder="Например: OPS-123 (Lenovo)"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<datalist id="create-project-options"></datalist>
|
||||
<div class="mt-2 flex justify-between items-center gap-3">
|
||||
@@ -150,7 +147,7 @@
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||
<input id="move-project-input"
|
||||
list="move-project-options"
|
||||
placeholder="Начните вводить название проекта"
|
||||
placeholder="Например: OPS-123 (Lenovo)"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<datalist id="move-project-options"></datalist>
|
||||
<div class="mt-2 flex justify-between items-center gap-3">
|
||||
@@ -177,7 +174,17 @@
|
||||
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
|
||||
<p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
|
||||
<div class="mb-4">
|
||||
<label for="create-project-on-move-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
|
||||
<input id="create-project-on-move-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="create-project-on-move-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
|
||||
<input id="create-project-on-move-variant" type="text" placeholder="Например: Lenovo"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
|
||||
@@ -194,10 +201,12 @@ let configStatusMode = 'active';
|
||||
let configsSearch = '';
|
||||
let projectsCache = [];
|
||||
let projectNameByUUID = {};
|
||||
let projectCodeByUUID = {};
|
||||
let projectVariantByUUID = {};
|
||||
let pendingMoveConfigUUID = '';
|
||||
let pendingMoveProjectName = '';
|
||||
let pendingMoveProjectCode = '';
|
||||
let pendingCreateConfigName = '';
|
||||
let pendingCreateProjectName = '';
|
||||
let pendingCreateProjectCode = '';
|
||||
|
||||
function renderConfigs(configs) {
|
||||
const emptyText = configStatusMode === 'archived'
|
||||
@@ -252,10 +261,23 @@ function renderConfigs(configs) {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>';
|
||||
}
|
||||
}
|
||||
const article = c.article ? escapeHtml(c.article) : '';
|
||||
const serverModel = c.server_model ? escapeHtml(c.server_model) : '';
|
||||
const subtitle = article || serverModel;
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">';
|
||||
html += '<div>' + escapeHtml(c.name) + '</div>';
|
||||
if (subtitle) {
|
||||
html += '<div class="text-xs text-gray-500">' + subtitle + '</div>';
|
||||
}
|
||||
html += '</td>';
|
||||
} else {
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium">';
|
||||
html += '<a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a>';
|
||||
if (subtitle) {
|
||||
html += '<div class="text-xs text-gray-500">' + subtitle + '</div>';
|
||||
}
|
||||
html += '</td>';
|
||||
}
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
|
||||
@@ -297,6 +319,30 @@ function renderConfigs(configs) {
|
||||
document.getElementById('configs-list').innerHTML = html;
|
||||
}
|
||||
|
||||
function projectDisplayKey(project) {
|
||||
const code = (project.code || '').trim();
|
||||
const variant = (project.variant || '').trim();
|
||||
if (!code) return '';
|
||||
return variant ? (code + ' (' + variant + ')') : code;
|
||||
}
|
||||
|
||||
function findProjectByInput(input) {
|
||||
const trimmed = (input || '').trim().toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const directMatch = projectsCache.find(p => projectDisplayKey(p).toLowerCase() === trimmed);
|
||||
if (directMatch) return directMatch;
|
||||
|
||||
const codeMatches = projectsCache.filter(p => (p.code || '').toLowerCase() === trimmed);
|
||||
if (codeMatches.length === 1) {
|
||||
return codeMatches[0];
|
||||
}
|
||||
if (codeMatches.length > 1) {
|
||||
alert('У проекта несколько вариантов. Укажите вариант в формате "CODE (variant)".');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
@@ -434,17 +480,21 @@ async function createConfig() {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectName = document.getElementById('create-project-input').value.trim();
|
||||
const projectCode = document.getElementById('create-project-input').value.trim();
|
||||
let projectUUID = '';
|
||||
|
||||
if (projectName) {
|
||||
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
|
||||
if (existingProject) {
|
||||
projectUUID = existingProject.uuid;
|
||||
if (projectCode) {
|
||||
const matchedProject = findProjectByInput(projectCode);
|
||||
if (matchedProject) {
|
||||
if (!matchedProject.is_active) {
|
||||
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
|
||||
return;
|
||||
}
|
||||
projectUUID = matchedProject.uuid;
|
||||
} else {
|
||||
pendingCreateConfigName = name;
|
||||
pendingCreateProjectName = projectName;
|
||||
openCreateProjectOnCreateModal(projectName);
|
||||
pendingCreateProjectCode = projectCode;
|
||||
openCreateProjectOnCreateModal(projectCode);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -492,12 +542,14 @@ function openMoveProjectModal(uuid, configName, currentProjectUUID) {
|
||||
projectsCache.forEach(project => {
|
||||
if (!project.is_active) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = project.name;
|
||||
option.value = projectDisplayKey(project);
|
||||
option.label = project.name || '';
|
||||
options.appendChild(option);
|
||||
});
|
||||
|
||||
if (currentProjectUUID && projectNameByUUID[currentProjectUUID]) {
|
||||
input.value = projectNameByUUID[currentProjectUUID];
|
||||
if (currentProjectUUID && projectCodeByUUID[currentProjectUUID]) {
|
||||
const variant = projectVariantByUUID[currentProjectUUID] || '';
|
||||
input.value = variant ? (projectCodeByUUID[currentProjectUUID] + ' (' + variant + ')') : projectCodeByUUID[currentProjectUUID];
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
@@ -513,19 +565,23 @@ function closeMoveProjectModal() {
|
||||
|
||||
async function confirmMoveProject() {
|
||||
const uuid = document.getElementById('move-project-uuid').value;
|
||||
const projectName = document.getElementById('move-project-input').value.trim();
|
||||
const projectCode = document.getElementById('move-project-input').value.trim();
|
||||
|
||||
if (!uuid) return;
|
||||
let projectUUID = '';
|
||||
|
||||
if (projectName) {
|
||||
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
|
||||
if (existingProject) {
|
||||
projectUUID = existingProject.uuid;
|
||||
if (projectCode) {
|
||||
const matchedProject = findProjectByInput(projectCode);
|
||||
if (matchedProject) {
|
||||
if (!matchedProject.is_active) {
|
||||
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
|
||||
return;
|
||||
}
|
||||
projectUUID = matchedProject.uuid;
|
||||
} else {
|
||||
pendingMoveConfigUUID = uuid;
|
||||
pendingMoveProjectName = projectName;
|
||||
openCreateProjectOnMoveModal(projectName);
|
||||
pendingMoveProjectCode = projectCode;
|
||||
openCreateProjectOnMoveModal(projectCode);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -542,7 +598,9 @@ function clearCreateProjectInput() {
|
||||
}
|
||||
|
||||
function openCreateProjectOnMoveModal(projectName) {
|
||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-code').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-name').value = projectName;
|
||||
document.getElementById('create-project-on-move-variant').value = '';
|
||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
|
||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||
@@ -550,7 +608,9 @@ function openCreateProjectOnMoveModal(projectName) {
|
||||
}
|
||||
|
||||
function openCreateProjectOnCreateModal(projectName) {
|
||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-code').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-name').value = projectName;
|
||||
document.getElementById('create-project-on-move-variant').value = '';
|
||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?';
|
||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать';
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||
@@ -561,22 +621,32 @@ function closeCreateProjectOnMoveModal() {
|
||||
document.getElementById('create-project-on-move-modal').classList.add('hidden');
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('flex');
|
||||
pendingMoveConfigUUID = '';
|
||||
pendingMoveProjectName = '';
|
||||
pendingMoveProjectCode = '';
|
||||
pendingCreateConfigName = '';
|
||||
pendingCreateProjectName = '';
|
||||
pendingCreateProjectCode = '';
|
||||
document.getElementById('create-project-on-move-name').value = '';
|
||||
document.getElementById('create-project-on-move-variant').value = '';
|
||||
}
|
||||
|
||||
async function confirmCreateProjectOnMove() {
|
||||
if (pendingCreateConfigName && pendingCreateProjectName) {
|
||||
const projectNameInput = document.getElementById('create-project-on-move-name');
|
||||
const projectVariantInput = document.getElementById('create-project-on-move-variant');
|
||||
const projectName = (projectNameInput.value || '').trim();
|
||||
const projectVariant = (projectVariantInput.value || '').trim();
|
||||
if (pendingCreateConfigName && pendingCreateProjectCode) {
|
||||
const configName = pendingCreateConfigName;
|
||||
const projectName = pendingCreateProjectName;
|
||||
const projectCode = pendingCreateProjectCode;
|
||||
try {
|
||||
const createResp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ name: projectName })
|
||||
body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
if (createResp.status === 409) {
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
const err = await createResp.json();
|
||||
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
|
||||
return;
|
||||
@@ -584,14 +654,14 @@ async function confirmCreateProjectOnMove() {
|
||||
|
||||
const newProject = await createResp.json();
|
||||
pendingCreateConfigName = '';
|
||||
pendingCreateProjectName = '';
|
||||
pendingCreateProjectCode = '';
|
||||
await loadProjectsForConfigUI();
|
||||
const created = await createConfigWithProject(configName, newProject.uuid);
|
||||
if (created) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
} else {
|
||||
closeCreateProjectOnMoveModal();
|
||||
document.getElementById('create-project-input').value = projectName;
|
||||
document.getElementById('create-project-input').value = projectCode;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка создания проекта');
|
||||
@@ -600,8 +670,8 @@ async function confirmCreateProjectOnMove() {
|
||||
}
|
||||
|
||||
const configUUID = pendingMoveConfigUUID;
|
||||
const projectName = pendingMoveProjectName;
|
||||
if (!configUUID || !projectName) {
|
||||
const projectCode = pendingMoveProjectCode;
|
||||
if (!configUUID || !projectCode) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
return;
|
||||
}
|
||||
@@ -610,9 +680,13 @@ async function confirmCreateProjectOnMove() {
|
||||
const createResp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ name: projectName })
|
||||
body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
if (createResp.status === 409) {
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
const err = await createResp.json();
|
||||
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
|
||||
return;
|
||||
@@ -620,9 +694,9 @@ async function confirmCreateProjectOnMove() {
|
||||
|
||||
const newProject = await createResp.json();
|
||||
pendingMoveConfigUUID = '';
|
||||
pendingMoveProjectName = '';
|
||||
pendingMoveProjectCode = '';
|
||||
await loadProjectsForConfigUI();
|
||||
document.getElementById('move-project-input').value = projectName;
|
||||
document.getElementById('move-project-input').value = projectCode;
|
||||
const moved = await moveConfigToProject(configUUID, newProject.uuid);
|
||||
if (moved) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
@@ -785,44 +859,19 @@ async function loadConfigs() {
|
||||
}
|
||||
}
|
||||
|
||||
async function importConfigsFromServer() {
|
||||
const button = document.getElementById('import-configs-btn');
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Импорт...';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/import', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
alert('Ошибка импорта: ' + (data.error || 'неизвестная ошибка'));
|
||||
return;
|
||||
}
|
||||
|
||||
alert(
|
||||
'Импорт завершен:\n' +
|
||||
'- Новых: ' + (data.imported || 0) + '\n' +
|
||||
'- Обновлено: ' + (data.updated || 0) + '\n' +
|
||||
'- Пропущено (локальные изменения): ' + (data.skipped || 0)
|
||||
);
|
||||
|
||||
currentPage = 1;
|
||||
await loadConfigs();
|
||||
} catch (e) {
|
||||
alert('Ошибка импорта с сервера');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
applyStatusModeUI();
|
||||
loadProjectsForConfigUI().then(loadConfigs);
|
||||
|
||||
// Load latest pricelist version for badge
|
||||
loadLatestPricelistVersion();
|
||||
|
||||
// Listen for sync completion events from navbar
|
||||
window.addEventListener('sync-completed', function(e) {
|
||||
// Reset pagination and reload configurations list
|
||||
currentPage = 1;
|
||||
loadConfigs();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('configs-search').addEventListener('input', function(e) {
|
||||
@@ -834,14 +883,25 @@ document.getElementById('configs-search').addEventListener('input', function(e)
|
||||
async function loadProjectsForConfigUI() {
|
||||
projectsCache = [];
|
||||
projectNameByUUID = {};
|
||||
projectCodeByUUID = {};
|
||||
projectVariantByUUID = {};
|
||||
try {
|
||||
const resp = await fetch('/api/projects?status=all');
|
||||
// Use /api/projects/all to get all projects without pagination
|
||||
const resp = await fetch('/api/projects/all');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
projectsCache = (data.projects || []);
|
||||
// data is now a simple array of {uuid, name} objects
|
||||
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||
|
||||
projectsCache.forEach(project => {
|
||||
projectNameByUUID[project.uuid] = project.name;
|
||||
// For compatibility with rest of code, populate projectsCache but mainly use projectNameByUUID
|
||||
projectsCache = allProjects;
|
||||
|
||||
allProjects.forEach(project => {
|
||||
const variant = (project.variant || '').trim();
|
||||
const baseName = project.name || '';
|
||||
projectNameByUUID[project.uuid] = variant ? (baseName + ' (' + variant + ')') : baseName;
|
||||
projectCodeByUUID[project.uuid] = project.code || '';
|
||||
projectVariantByUUID[project.uuid] = project.variant || '';
|
||||
});
|
||||
|
||||
const createOptions = document.getElementById('create-project-options');
|
||||
@@ -850,7 +910,8 @@ async function loadProjectsForConfigUI() {
|
||||
projectsCache.forEach(project => {
|
||||
if (!project.is_active) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = project.name;
|
||||
option.value = projectDisplayKey(project);
|
||||
option.label = project.name || '';
|
||||
createOptions.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,14 +5,27 @@
|
||||
<!-- Header with config name and back button -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/configs" class="text-gray-500 hover:text-gray-700">
|
||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold">
|
||||
<span id="config-name">Конфигуратор</span>
|
||||
</h1>
|
||||
<div class="text-2xl font-bold flex items-center gap-2" id="config-breadcrumbs">
|
||||
<a id="breadcrumb-project-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||
<span id="breadcrumb-project-code">—</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<a id="breadcrumb-project-variant-link" href="/projects" class="text-blue-700 hover:underline">
|
||||
<span id="breadcrumb-project-variant">main</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<a id="breadcrumb-config-name-link" href="#" class="text-blue-700 hover:underline">
|
||||
<span id="breadcrumb-config-name">Конфигуратор</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<span id="breadcrumb-config-version">v1</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
||||
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
@@ -98,6 +111,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div id="cart-summary-content" class="p-4">
|
||||
<div id="article-display" class="text-sm text-gray-700 mb-3 font-mono"></div>
|
||||
<div id="cart-items" class="space-y-2 mb-4"></div>
|
||||
<div class="border-t pt-3 flex justify-between items-center">
|
||||
<div class="text-lg font-bold">
|
||||
@@ -326,17 +340,104 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
// State
|
||||
let configUUID = '{{.ConfigUUID}}';
|
||||
let configName = '';
|
||||
let currentVersionNo = 1;
|
||||
let projectUUID = '';
|
||||
let projectName = '';
|
||||
let projectCode = '';
|
||||
let projectVariant = '';
|
||||
let projectIndexLoaded = false;
|
||||
let projectByUUID = {};
|
||||
let projectMainByCode = {};
|
||||
|
||||
async function loadProjectIndex() {
|
||||
if (projectIndexLoaded) return;
|
||||
try {
|
||||
const resp = await fetch('/api/projects/all');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||
projectByUUID = {};
|
||||
projectMainByCode = {};
|
||||
allProjects.forEach(p => {
|
||||
projectByUUID[p.uuid] = p;
|
||||
const code = (p.code || '').trim();
|
||||
const variant = (p.variant || '').trim();
|
||||
if (code && (variant === '' || variant === 'main')) {
|
||||
if (!projectMainByCode[code]) {
|
||||
projectMainByCode[code] = p.uuid;
|
||||
}
|
||||
}
|
||||
});
|
||||
projectIndexLoaded = true;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function updateConfigBreadcrumbs() {
|
||||
const codeEl = document.getElementById('breadcrumb-project-code');
|
||||
const variantEl = document.getElementById('breadcrumb-project-variant');
|
||||
const configEl = document.getElementById('breadcrumb-config-name');
|
||||
const versionEl = document.getElementById('breadcrumb-config-version');
|
||||
const projectCodeLinkEl = document.getElementById('breadcrumb-project-code-link');
|
||||
const projectVariantLinkEl = document.getElementById('breadcrumb-project-variant-link');
|
||||
|
||||
let code = 'Без проекта';
|
||||
let variant = 'main';
|
||||
if (projectUUID && projectByUUID[projectUUID]) {
|
||||
code = (projectByUUID[projectUUID].code || '').trim() || 'Без проекта';
|
||||
const rawVariant = (projectByUUID[projectUUID].variant || '').trim();
|
||||
variant = rawVariant === '' ? 'main' : rawVariant;
|
||||
if (projectCodeLinkEl) {
|
||||
const mainUUID = projectMainByCode[code];
|
||||
projectCodeLinkEl.href = mainUUID ? ('/projects/' + mainUUID) : ('/projects/' + projectUUID);
|
||||
}
|
||||
if (projectVariantLinkEl) {
|
||||
projectVariantLinkEl.href = '/projects/' + projectUUID;
|
||||
}
|
||||
} else {
|
||||
if (projectCodeLinkEl) projectCodeLinkEl.href = '/projects';
|
||||
if (projectVariantLinkEl) projectVariantLinkEl.href = '/projects';
|
||||
}
|
||||
codeEl.textContent = code;
|
||||
variantEl.textContent = variant;
|
||||
const fullConfigName = configName || 'Конфигурация';
|
||||
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||
configEl.title = fullConfigName;
|
||||
versionEl.textContent = 'v' + (currentVersionNo || 1);
|
||||
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||
if (configNameLinkEl && configUUID) {
|
||||
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||
}
|
||||
}
|
||||
|
||||
function truncateBreadcrumbSpecName(name) {
|
||||
const maxLength = 16;
|
||||
if (!name || name.length <= maxLength) return name;
|
||||
return name.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
let currentTab = 'base';
|
||||
let allComponents = [];
|
||||
let cart = [];
|
||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||
let hasUnsavedChanges = false;
|
||||
let exitSaveStarted = false;
|
||||
let serverCount = 1; // Server count for the configuration
|
||||
let serverModelForQuote = '';
|
||||
let supportCode = '';
|
||||
let currentArticle = '';
|
||||
let articlePreviewTimeout = null;
|
||||
let selectedPricelistIds = {
|
||||
estimate: null,
|
||||
warehouse: null,
|
||||
competitor: null
|
||||
};
|
||||
let resolvedAutoPricelistIds = {
|
||||
estimate: null,
|
||||
warehouse: null,
|
||||
competitor: null
|
||||
};
|
||||
let disablePriceRefresh = false;
|
||||
let onlyInStock = false;
|
||||
let activePricelistsBySource = {
|
||||
@@ -351,6 +452,8 @@ let priceLevelsRefreshTimer = null;
|
||||
let warehouseStockLotsByPricelist = new Map();
|
||||
let warehouseStockLoadSeq = 0;
|
||||
let warehouseStockLoadsByPricelist = new Map();
|
||||
let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via API
|
||||
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
|
||||
|
||||
// Autocomplete state
|
||||
let autocompleteInput = null;
|
||||
@@ -400,6 +503,22 @@ function formatDelta(abs, pct) {
|
||||
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
||||
}
|
||||
|
||||
function getEffectivePricelistID(source) {
|
||||
const explicit = selectedPricelistIds[source];
|
||||
if (Number.isFinite(explicit) && explicit > 0) {
|
||||
return Number(explicit);
|
||||
}
|
||||
const resolvedAuto = resolvedAutoPricelistIds[source];
|
||||
if (Number.isFinite(resolvedAuto) && resolvedAuto > 0) {
|
||||
return Number(resolvedAuto);
|
||||
}
|
||||
const fallback = activePricelistsBySource[source]?.[0]?.id;
|
||||
if (Number.isFinite(fallback) && fallback > 0) {
|
||||
return Number(fallback);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshPriceLevels(options = {}) {
|
||||
const force = options.force === true;
|
||||
const noCache = options.noCache === true;
|
||||
@@ -445,12 +564,10 @@ async function refreshPriceLevels(options = {}) {
|
||||
if (data.resolved_pricelist_ids) {
|
||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||||
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
|
||||
selectedPricelistIds[source] = data.resolved_pricelist_ids[source];
|
||||
resolvedAutoPricelistIds[source] = Number(data.resolved_pricelist_ids[source]);
|
||||
}
|
||||
});
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
persistLocalPriceSettings();
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to refresh price levels', e);
|
||||
@@ -483,11 +600,7 @@ function schedulePriceLevelsRefresh(options = {}) {
|
||||
}
|
||||
|
||||
function currentWarehousePricelistID() {
|
||||
const id = selectedPricelistIds.warehouse;
|
||||
if (Number.isFinite(id) && id > 0) return Number(id);
|
||||
const fallback = activePricelistsBySource.warehouse?.[0]?.id;
|
||||
if (Number.isFinite(fallback) && fallback > 0) return Number(fallback);
|
||||
return null;
|
||||
return getEffectivePricelistID('warehouse');
|
||||
}
|
||||
|
||||
async function loadWarehouseInStockLots() {
|
||||
@@ -607,7 +720,10 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
|
||||
const config = await resp.json();
|
||||
configName = config.name;
|
||||
document.getElementById('config-name').textContent = config.name;
|
||||
currentVersionNo = config.current_version_no || 1;
|
||||
projectUUID = config.project_uuid || '';
|
||||
await loadProjectIndex();
|
||||
updateConfigBreadcrumbs();
|
||||
document.getElementById('save-buttons').classList.remove('hidden');
|
||||
|
||||
// Set server count from config
|
||||
@@ -629,6 +745,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||
}));
|
||||
}
|
||||
serverModelForQuote = config.server_model || '';
|
||||
supportCode = config.support_code || '';
|
||||
currentArticle = config.article || '';
|
||||
|
||||
// Restore custom price if saved
|
||||
if (config.custom_price) {
|
||||
@@ -719,9 +838,7 @@ async function loadActivePricelists(force = false) {
|
||||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
||||
return;
|
||||
}
|
||||
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
|
||||
? Number(activePricelistsBySource[source][0].id)
|
||||
: null;
|
||||
selectedPricelistIds[source] = null;
|
||||
} catch (e) {
|
||||
activePricelistsBySource[source] = [];
|
||||
selectedPricelistIds[source] = null;
|
||||
@@ -857,6 +974,15 @@ function applyPriceSettings() {
|
||||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
||||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
||||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
||||
if (selectedPricelistIds.estimate) {
|
||||
resolvedAutoPricelistIds.estimate = null;
|
||||
}
|
||||
if (selectedPricelistIds.warehouse) {
|
||||
resolvedAutoPricelistIds.warehouse = null;
|
||||
}
|
||||
if (selectedPricelistIds.competitor) {
|
||||
resolvedAutoPricelistIds.competitor = null;
|
||||
}
|
||||
disablePriceRefresh = disableVal;
|
||||
onlyInStock = inStockVal;
|
||||
|
||||
@@ -943,7 +1069,32 @@ function renderTab() {
|
||||
}
|
||||
|
||||
function renderSingleSelectTab(categories) {
|
||||
let html = `
|
||||
let html = '';
|
||||
if (currentTab === 'base') {
|
||||
html += `
|
||||
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label>
|
||||
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
|
||||
</div>
|
||||
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||
<input type="text"
|
||||
id="server-model-input"
|
||||
value="${escapeHtml(serverModelForQuote)}"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="updateServerModelForQuote(this.value)">
|
||||
<select id="support-code-select"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="updateSupportCode(this.value)">
|
||||
<option value="">—</option>
|
||||
<option value="1yW" ${supportCode === '1yW' ? 'selected' : ''}>1yW</option>
|
||||
<option value="1yB" ${supportCode === '1yB' ? 'selected' : ''}>1yB</option>
|
||||
<option value="1yS" ${supportCode === '1yS' ? 'selected' : ''}>1yS</option>
|
||||
<option value="1yP" ${supportCode === '1yP' ? 'selected' : ''}>1yP</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += `
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
@@ -1201,12 +1352,54 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
document.getElementById('tab-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// Load prices for components in a category/tab via API
|
||||
async function ensurePricesLoaded(components) {
|
||||
if (!components || components.length === 0) return;
|
||||
|
||||
// Filter out components that already have prices cached
|
||||
const toLoad = components.filter(c => !(c.lot_name in componentPricesCache));
|
||||
if (toLoad.length === 0) return;
|
||||
|
||||
try {
|
||||
// Use quote/price-levels API to get prices for these components
|
||||
const resp = await fetch('/api/quote/price-levels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
items: toLoad.map(c => ({ lot_name: c.lot_name, quantity: 1 })),
|
||||
pricelist_ids: Object.fromEntries(
|
||||
Object.entries(selectedPricelistIds)
|
||||
.filter(([, id]) => typeof id === 'number' && id > 0)
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (data.items) {
|
||||
data.items.forEach(item => {
|
||||
// Cache the estimate price (or 0 if not found)
|
||||
componentPricesCache[item.lot_name] = item.estimate_price || 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load component prices', e);
|
||||
}
|
||||
}
|
||||
|
||||
function hasComponentPrice(lotName) {
|
||||
return lotName in componentPricesCache && componentPricesCache[lotName] > 0;
|
||||
}
|
||||
|
||||
// Autocomplete for single select (Base tab)
|
||||
function showAutocomplete(category, input) {
|
||||
async function showAutocomplete(category, input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = category;
|
||||
autocompleteMode = 'single';
|
||||
autocompleteIndex = -1;
|
||||
const components = getComponentsForCategory(category);
|
||||
await ensurePricesLoaded(components);
|
||||
filterAutocomplete(category, input.value);
|
||||
}
|
||||
|
||||
@@ -1215,7 +1408,7 @@ function filterAutocomplete(category, search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
|
||||
autocompleteFiltered = components.filter(c => {
|
||||
if (!c.current_price) return false;
|
||||
if (!hasComponentPrice(c.lot_name)) return false;
|
||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
return text.includes(searchLower);
|
||||
@@ -1298,12 +1491,13 @@ function selectAutocompleteItem(index) {
|
||||
|
||||
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
|
||||
const qty = parseInt(qtyInput?.value) || 1;
|
||||
const price = componentPricesCache[comp.lot_name] || 0;
|
||||
|
||||
cart.push({
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: comp.current_price,
|
||||
estimate_price: comp.current_price,
|
||||
unit_price: price,
|
||||
estimate_price: price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
delta_wh_estimate_abs: null,
|
||||
@@ -1333,11 +1527,13 @@ function hideAutocomplete() {
|
||||
}
|
||||
|
||||
// Autocomplete for multi select tabs
|
||||
function showAutocompleteMulti(input) {
|
||||
async function showAutocompleteMulti(input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = null;
|
||||
autocompleteMode = 'multi';
|
||||
autocompleteIndex = -1;
|
||||
const components = getComponentsForTab(currentTab);
|
||||
await ensurePricesLoaded(components);
|
||||
filterAutocompleteMulti(input.value);
|
||||
}
|
||||
|
||||
@@ -1349,7 +1545,7 @@ function filterAutocompleteMulti(search) {
|
||||
const addedLots = new Set(cart.map(i => i.lot_name));
|
||||
|
||||
autocompleteFiltered = components.filter(c => {
|
||||
if (!c.current_price) return false;
|
||||
if (!hasComponentPrice(c.lot_name)) return false;
|
||||
if (addedLots.has(c.lot_name)) return false;
|
||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
@@ -1390,12 +1586,13 @@ function selectAutocompleteItemMulti(index) {
|
||||
|
||||
const qtyInput = document.getElementById('new-qty');
|
||||
const qty = parseInt(qtyInput?.value) || 1;
|
||||
const price = componentPricesCache[comp.lot_name] || 0;
|
||||
|
||||
cart.push({
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: comp.current_price,
|
||||
estimate_price: comp.current_price,
|
||||
unit_price: price,
|
||||
estimate_price: price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
delta_wh_estimate_abs: null,
|
||||
@@ -1417,11 +1614,16 @@ function selectAutocompleteItemMulti(index) {
|
||||
}
|
||||
|
||||
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
||||
function showAutocompleteSection(sectionId, input) {
|
||||
async function showAutocompleteSection(sectionId, input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = sectionId; // Store section ID
|
||||
autocompleteMode = 'section';
|
||||
autocompleteIndex = -1;
|
||||
|
||||
// Load prices for tab components
|
||||
const components = getComponentsForTab(currentTab);
|
||||
await ensurePricesLoaded(components);
|
||||
|
||||
filterAutocompleteSection(sectionId, input.value, input);
|
||||
}
|
||||
|
||||
@@ -1448,7 +1650,7 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
|
||||
const addedLots = new Set(cart.map(i => i.lot_name));
|
||||
|
||||
autocompleteFiltered = sectionComponents.filter(c => {
|
||||
if (!c.current_price) return false;
|
||||
if (!hasComponentPrice(c.lot_name)) return false;
|
||||
if (addedLots.has(c.lot_name)) return false;
|
||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
@@ -1489,12 +1691,13 @@ function selectAutocompleteItemSection(index, sectionId) {
|
||||
|
||||
const qtyInput = document.getElementById('new-qty-' + sectionId);
|
||||
const qty = parseInt(qtyInput?.value) || 1;
|
||||
const price = componentPricesCache[comp.lot_name] || 0;
|
||||
|
||||
cart.push({
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: comp.current_price,
|
||||
estimate_price: comp.current_price,
|
||||
unit_price: price,
|
||||
estimate_price: price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
delta_wh_estimate_abs: null,
|
||||
@@ -1579,6 +1782,8 @@ function updateCartUI() {
|
||||
calculateCustomPrice();
|
||||
renderSalePriceTable();
|
||||
|
||||
scheduleArticlePreview();
|
||||
|
||||
if (cart.length === 0) {
|
||||
document.getElementById('cart-items').innerHTML =
|
||||
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
|
||||
@@ -1654,13 +1859,192 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function updateServerModelForQuote(value) {
|
||||
serverModelForQuote = value || '';
|
||||
scheduleArticlePreview();
|
||||
}
|
||||
|
||||
function updateSupportCode(value) {
|
||||
supportCode = value || '';
|
||||
scheduleArticlePreview();
|
||||
}
|
||||
|
||||
function scheduleArticlePreview() {
|
||||
if (articlePreviewTimeout) {
|
||||
clearTimeout(articlePreviewTimeout);
|
||||
}
|
||||
articlePreviewTimeout = setTimeout(() => {
|
||||
previewArticle();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function previewArticle() {
|
||||
const el = document.getElementById('article-display');
|
||||
if (!el) return;
|
||||
|
||||
const model = serverModelForQuote.trim();
|
||||
const estimatePricelistID = getEffectivePricelistID('estimate');
|
||||
if (!model || !estimatePricelistID || cart.length === 0) {
|
||||
currentArticle = '';
|
||||
el.textContent = 'Артикул: —';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/preview-article', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
server_model: serverModelForQuote,
|
||||
support_code: supportCode,
|
||||
pricelist_id: estimatePricelistID,
|
||||
items: cart.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price || 0
|
||||
}))
|
||||
})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
currentArticle = '';
|
||||
el.textContent = 'Артикул: —';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
currentArticle = data.article || '';
|
||||
el.textContent = currentArticle ? ('Артикул: ' + currentArticle) : 'Артикул: —';
|
||||
} catch(e) {
|
||||
currentArticle = '';
|
||||
el.textContent = 'Артикул: —';
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentArticle() {
|
||||
return currentArticle || '';
|
||||
}
|
||||
|
||||
function getAutosaveStorageKey() {
|
||||
return `qf_config_autosave_${configUUID || 'default'}`;
|
||||
}
|
||||
|
||||
function buildSavePayload() {
|
||||
const customPriceInput = document.getElementById('custom-price-input');
|
||||
const customPriceValue = parseFloat(customPriceInput.value);
|
||||
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
||||
|
||||
return {
|
||||
name: configName,
|
||||
items: cart,
|
||||
custom_price: customPrice,
|
||||
notes: '',
|
||||
server_count: serverCount,
|
||||
server_model: serverModelForQuote,
|
||||
support_code: supportCode,
|
||||
article: getCurrentArticle(),
|
||||
pricelist_id: selectedPricelistIds.estimate,
|
||||
only_in_stock: onlyInStock
|
||||
};
|
||||
}
|
||||
|
||||
function persistAutosaveDraft() {
|
||||
if (!configUUID) return;
|
||||
try {
|
||||
sessionStorage.setItem(getAutosaveStorageKey(), JSON.stringify({
|
||||
payload: buildSavePayload(),
|
||||
saved_at: Date.now()
|
||||
}));
|
||||
} catch (_) {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
function clearAutosaveDraft() {
|
||||
try {
|
||||
sessionStorage.removeItem(getAutosaveStorageKey());
|
||||
} catch (_) {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
function restoreAutosaveDraftIfAny() {
|
||||
if (!configUUID) return;
|
||||
let raw = null;
|
||||
try {
|
||||
raw = sessionStorage.getItem(getAutosaveStorageKey());
|
||||
} catch (_) {
|
||||
raw = null;
|
||||
}
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const payload = parsed && parsed.payload ? parsed.payload : null;
|
||||
if (!payload) return;
|
||||
|
||||
if (Array.isArray(payload.items)) {
|
||||
cart = payload.items.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
estimate_price: item.unit_price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
description: item.description || '',
|
||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||
}));
|
||||
}
|
||||
if (typeof payload.server_count === 'number' && payload.server_count > 0) {
|
||||
serverCount = payload.server_count;
|
||||
const serverCountInput = document.getElementById('server-count');
|
||||
if (serverCountInput) serverCountInput.value = serverCount;
|
||||
const totalServerCount = document.getElementById('total-server-count');
|
||||
if (totalServerCount) totalServerCount.textContent = serverCount;
|
||||
}
|
||||
serverModelForQuote = payload.server_model || serverModelForQuote;
|
||||
supportCode = payload.support_code || supportCode;
|
||||
currentArticle = payload.article || currentArticle;
|
||||
selectedPricelistIds.estimate = payload.pricelist_id || selectedPricelistIds.estimate;
|
||||
onlyInStock = Boolean(payload.only_in_stock);
|
||||
|
||||
const customPriceInput = document.getElementById('custom-price-input');
|
||||
if (customPriceInput) {
|
||||
if (typeof payload.custom_price === 'number' && payload.custom_price > 0) {
|
||||
customPriceInput.value = payload.custom_price.toFixed(2);
|
||||
} else {
|
||||
customPriceInput.value = '';
|
||||
}
|
||||
}
|
||||
hasUnsavedChanges = true;
|
||||
} catch (_) {
|
||||
// ignore invalid draft
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfigOnExit() {
|
||||
if (!configUUID || !hasUnsavedChanges || exitSaveStarted) return;
|
||||
exitSaveStarted = true;
|
||||
try {
|
||||
fetch('/api/configs/' + configUUID, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(buildSavePayload()),
|
||||
keepalive: true
|
||||
});
|
||||
} catch (_) {
|
||||
// best effort save on page exit
|
||||
}
|
||||
}
|
||||
|
||||
function triggerAutoSave() {
|
||||
// Debounce autosave - wait 1 second after last change
|
||||
// Autosave keeps local draft only; server revision is created on Save/Exit.
|
||||
hasUnsavedChanges = true;
|
||||
if (autoSaveTimeout) {
|
||||
clearTimeout(autoSaveTimeout);
|
||||
}
|
||||
autoSaveTimeout = setTimeout(() => {
|
||||
saveConfig(false); // false = don't show notification
|
||||
persistAutosaveDraft();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
@@ -1674,29 +2058,13 @@ async function saveConfig(showNotification = true) {
|
||||
|
||||
await refreshPriceLevels({ force: true, noCache: true });
|
||||
|
||||
// Get custom price if set
|
||||
const customPriceInput = document.getElementById('custom-price-input');
|
||||
const customPriceValue = parseFloat(customPriceInput.value);
|
||||
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
||||
|
||||
// Get server count
|
||||
const serverCountValue = serverCount;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: configName,
|
||||
items: cart,
|
||||
custom_price: customPrice,
|
||||
notes: '',
|
||||
server_count: serverCountValue,
|
||||
pricelist_id: selectedPricelistIds.estimate,
|
||||
only_in_stock: onlyInStock
|
||||
})
|
||||
body: JSON.stringify(buildSavePayload())
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -1706,6 +2074,16 @@ async function saveConfig(showNotification = true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await resp.json();
|
||||
if (saved && saved.current_version_no) {
|
||||
currentVersionNo = saved.current_version_no;
|
||||
const versionEl = document.getElementById('breadcrumb-config-version');
|
||||
if (versionEl) versionEl.textContent = 'v' + currentVersionNo;
|
||||
}
|
||||
hasUnsavedChanges = false;
|
||||
clearAutosaveDraft();
|
||||
exitSaveStarted = false;
|
||||
|
||||
if (showNotification) {
|
||||
showToast('Сохранено', 'success');
|
||||
}
|
||||
@@ -1716,6 +2094,14 @@ async function saveConfig(showNotification = true) {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to extract filename from Content-Disposition header
|
||||
function getFilenameFromResponse(resp) {
|
||||
const contentDisposition = resp.headers.get('content-disposition');
|
||||
if (!contentDisposition) return null;
|
||||
const matches = contentDisposition.match(/filename="?([^"]+)"?/);
|
||||
return matches && matches[1] ? matches[1] : null;
|
||||
}
|
||||
|
||||
async function exportCSV() {
|
||||
if (cart.length === 0) return;
|
||||
|
||||
@@ -1730,17 +2116,19 @@ async function exportCSV() {
|
||||
...item,
|
||||
unit_price: getDisplayPrice(item),
|
||||
}));
|
||||
const article = getCurrentArticle();
|
||||
const resp = await fetch('/api/export/csv', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({items: exportItems, name: configName})
|
||||
body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID, article: article, server_count: serverCount, pricelist_id: selectedPricelistIds.estimate || null})
|
||||
});
|
||||
|
||||
const blob = await resp.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (configName || 'config') + '.csv';
|
||||
const articleForName = article || 'BOM';
|
||||
a.download = getFilenameFromResponse(resp) || ((configName || 'config') + ' ' + articleForName + '.csv');
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch(e) {
|
||||
@@ -1986,14 +2374,14 @@ async function exportCSVWithCustomPrice() {
|
||||
const resp = await fetch('/api/export/csv', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({items: adjustedCart, name: configName})
|
||||
body: JSON.stringify({items: adjustedCart, name: configName, project_uuid: projectUUID})
|
||||
});
|
||||
|
||||
const blob = await resp.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (configName || 'config') + '.csv';
|
||||
a.download = getFilenameFromResponse(resp) || (configName || 'config') + '.csv';
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch(e) {
|
||||
@@ -2043,13 +2431,19 @@ async function refreshPrices() {
|
||||
updatePriceUpdateDate(config.price_updated_at);
|
||||
}
|
||||
if (config.pricelist_id) {
|
||||
selectedPricelistIds.estimate = config.pricelist_id;
|
||||
if (selectedPricelistIds.estimate) {
|
||||
selectedPricelistIds.estimate = config.pricelist_id;
|
||||
} else {
|
||||
resolvedAutoPricelistIds.estimate = Number(config.pricelist_id);
|
||||
}
|
||||
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
||||
await loadActivePricelists();
|
||||
}
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
persistLocalPriceSettings();
|
||||
if (selectedPricelistIds.estimate) {
|
||||
persistLocalPriceSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render UI
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/pricelists" class="text-gray-500 hover:text-gray-700">
|
||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>
|
||||
|
||||
@@ -235,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}}
|
||||
|
||||
@@ -4,22 +4,51 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Назад к проектам">
|
||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold" id="project-title">Проект</h1>
|
||||
<div class="text-2xl font-bold flex items-center gap-2">
|
||||
<a id="project-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||
<span id="project-code">—</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<div class="relative">
|
||||
<button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-base font-medium px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200">
|
||||
<span id="project-variant-label">main</span>
|
||||
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="project-variant-menu" class="absolute left-0 mt-2 min-w-[10rem] rounded-lg border border-gray-200 bg-white shadow-lg hidden z-10">
|
||||
<div id="project-variant-list" class="py-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 grid grid-cols-1 sm:grid-cols-6 gap-3">
|
||||
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
||||
+ Новый вариант
|
||||
</button>
|
||||
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
+ Создать новую квоту
|
||||
</button>
|
||||
<button onclick="openImportModal()" class="py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
|
||||
<button onclick="openImportModal()" class="py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
|
||||
Импорт квоты
|
||||
</button>
|
||||
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
|
||||
Параметры
|
||||
</button>
|
||||
<button onclick="exportProject()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
|
||||
Экспорт CSV
|
||||
</button>
|
||||
<button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden">
|
||||
Удалить вариант
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
||||
@@ -58,38 +87,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rename-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Переименовать квоту</h2>
|
||||
<div id="new-variant-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Новый вариант</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Новое название</label>
|
||||
<input type="text" id="rename-input"
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||
<div id="new-variant-code" class="px-3 py-2 bg-gray-50 border rounded text-sm text-gray-700">—</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-variant-name" class="block text-sm font-medium text-gray-700 mb-1">Название (необязательно)</label>
|
||||
<input id="new-variant-name" type="text" placeholder="Например: Lenovo"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<input type="hidden" id="rename-uuid">
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-variant-value" class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
|
||||
<input id="new-variant-value" type="text" placeholder="Например: Lenovo"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<div class="text-xs text-gray-500 mt-1">Оставьте пустым для main нельзя — нужно уникальное значение.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeRenameModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="renameConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<button onclick="closeNewVariantModal()" class="px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200">Отмена</button>
|
||||
<button onclick="createNewVariant()" class="px-4 py-2 text-white bg-purple-600 rounded hover:bg-purple-700">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="clone-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div id="config-action-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Копировать квоту</h2>
|
||||
<h2 class="text-xl font-semibold mb-4">Действия с квотой</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название копии</label>
|
||||
<input type="text" id="clone-input"
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
||||
<input type="text" id="config-action-name"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<input type="hidden" id="clone-uuid">
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" id="config-action-copy" class="rounded border-gray-300">
|
||||
Создать копию
|
||||
</label>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||
<input id="config-action-project-input"
|
||||
list="config-action-project-options"
|
||||
placeholder="Начните вводить проект"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<datalist id="config-action-project-options"></datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
|
||||
<input id="config-action-variant-input"
|
||||
list="config-action-variant-options"
|
||||
placeholder="Выберите вариант"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<datalist id="config-action-variant-options"></datalist>
|
||||
</div>
|
||||
<input type="hidden" id="config-action-uuid">
|
||||
<input type="hidden" id="config-action-current-name">
|
||||
<input type="hidden" id="config-action-current-project">
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeCloneModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="cloneConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Копировать</button>
|
||||
<button onclick="closeConfigActionModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="saveConfigAction()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,11 +173,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="project-settings-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Параметры проекта</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||
<input type="text" id="project-settings-code"
|
||||
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>
|
||||
<input type="text" id="project-settings-variant"
|
||||
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>
|
||||
<input type="text" id="project-settings-name"
|
||||
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>
|
||||
<input type="text" id="project-settings-tracker-url" placeholder="https://tracker.example.com/PROJ-123"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<div class="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы скрыть ссылку.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeProjectSettingsModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="saveProjectSettings()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const projectUUID = '{{.ProjectUUID}}';
|
||||
let configStatusMode = 'active';
|
||||
let project = null;
|
||||
let allConfigs = [];
|
||||
let projectVariants = [];
|
||||
let projectsCatalog = [];
|
||||
let variantMenuInitialized = false;
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
@@ -131,6 +227,95 @@ function resolveProjectTrackerURL(projectData) {
|
||||
return explicitURL;
|
||||
}
|
||||
|
||||
function formatProjectTitle(projectData) {
|
||||
if (!projectData) return 'Проект';
|
||||
const code = (projectData.code || '').trim();
|
||||
const name = (projectData.name || '').trim();
|
||||
const variant = (projectData.variant || '').trim();
|
||||
if (!code) return name || 'Проект';
|
||||
if (variant) {
|
||||
return code + ': (' + variant + ') ' + (name || '');
|
||||
}
|
||||
return code + ': ' + (name || '');
|
||||
}
|
||||
|
||||
function normalizeVariantLabel(variant) {
|
||||
const trimmed = (variant || '').trim();
|
||||
return trimmed === '' ? 'main' : trimmed;
|
||||
}
|
||||
|
||||
async function loadVariantsForCode(code) {
|
||||
try {
|
||||
const resp = await fetch('/api/projects/all');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||
projectsCatalog = allProjects.filter(p => p && p.uuid && p.is_active !== false);
|
||||
if (!code) {
|
||||
projectVariants = [];
|
||||
return;
|
||||
}
|
||||
projectVariants = allProjects
|
||||
.filter(p => (p.code || '').trim() === code && p.is_active !== false)
|
||||
.map(p => ({uuid: p.uuid, variant: (p.variant || '').trim()}));
|
||||
projectVariants.sort((a, b) => normalizeVariantLabel(a.variant).localeCompare(normalizeVariantLabel(b.variant)));
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantSelect() {
|
||||
const list = document.getElementById('project-variant-list');
|
||||
const menu = document.getElementById('project-variant-menu');
|
||||
const button = document.getElementById('project-variant-button');
|
||||
const label = document.getElementById('project-variant-label');
|
||||
const codeLink = document.getElementById('project-code-link');
|
||||
if (!list || !menu || !button || !label) return;
|
||||
list.innerHTML = '';
|
||||
const variants = projectVariants.length ? projectVariants : [{uuid: projectUUID, variant: (project && project.variant) || ''}];
|
||||
let mainUUID = '';
|
||||
variants.forEach(item => {
|
||||
const variantLabel = normalizeVariantLabel(item.variant);
|
||||
if (variantLabel === 'main' && !mainUUID) {
|
||||
mainUUID = item.uuid;
|
||||
}
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-50';
|
||||
if (item.uuid === projectUUID) {
|
||||
option.className += ' font-semibold text-gray-900';
|
||||
label.textContent = variantLabel;
|
||||
}
|
||||
option.textContent = variantLabel;
|
||||
option.onclick = function() {
|
||||
menu.classList.add('hidden');
|
||||
if (item.uuid && item.uuid !== projectUUID) {
|
||||
window.location.href = '/projects/' + item.uuid;
|
||||
}
|
||||
};
|
||||
list.appendChild(option);
|
||||
});
|
||||
|
||||
if (codeLink) {
|
||||
const targetMain = mainUUID || projectUUID;
|
||||
codeLink.href = '/projects/' + targetMain;
|
||||
}
|
||||
|
||||
if (!variantMenuInitialized) {
|
||||
button.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle('hidden');
|
||||
};
|
||||
document.addEventListener('click', function() {
|
||||
menu.classList.add('hidden');
|
||||
});
|
||||
menu.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
variantMenuInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function setConfigStatusMode(mode) {
|
||||
if (mode !== 'active' && mode !== 'archived') return;
|
||||
configStatusMode = mode;
|
||||
@@ -171,6 +356,7 @@ function renderConfigs(configs) {
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||
html += '<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Ревизия</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '</tr></thead><tbody class="divide-y">';
|
||||
|
||||
@@ -191,17 +377,23 @@ function renderConfigs(configs) {
|
||||
}
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||
} else {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>';
|
||||
}
|
||||
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
const versionNo = c.current_version_no || 1;
|
||||
html += '<td class="px-4 py-3 text-sm text-center text-gray-500">v' + versionNo + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg></button>';
|
||||
} else {
|
||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
|
||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg></button>';
|
||||
html += '<a href="/configs/' + c.uuid + '/revisions" class="text-purple-600 hover:text-purple-800 inline-block" title="Ревизии">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg></a>';
|
||||
html += '<button onclick="openConfigActionModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\', \'' + (c.project_uuid || projectUUID) + '\')" class="text-indigo-600 hover:text-indigo-800" title="Переименовать / копировать / переместить">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16M4 17h16"></path></svg></button>';
|
||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
|
||||
}
|
||||
@@ -213,7 +405,8 @@ function renderConfigs(configs) {
|
||||
html += '<tr>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="4">Итого по проекту</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900" data-footer-total="1">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
html += '<td class="px-4 py-3"></td>';
|
||||
html += '<td class="px-4 py-3"></td>';
|
||||
html += '</tr>';
|
||||
html += '</tfoot>';
|
||||
@@ -228,7 +421,9 @@ async function loadProject() {
|
||||
return false;
|
||||
}
|
||||
project = await resp.json();
|
||||
document.getElementById('project-title').textContent = project.name;
|
||||
document.getElementById('project-code').textContent = project.code || '—';
|
||||
await loadVariantsForCode(project.code || '');
|
||||
renderVariantSelect();
|
||||
const trackerLink = document.getElementById('tracker-link');
|
||||
if (trackerLink) {
|
||||
if (project && project.is_system) {
|
||||
@@ -271,6 +466,56 @@ function openCreateModal() {
|
||||
document.getElementById('create-name').focus();
|
||||
}
|
||||
|
||||
function openNewVariantModal() {
|
||||
if (!project) return;
|
||||
document.getElementById('new-variant-code').textContent = (project.code || '').trim() || '—';
|
||||
document.getElementById('new-variant-name').value = project.name || '';
|
||||
document.getElementById('new-variant-value').value = '';
|
||||
document.getElementById('new-variant-modal').classList.remove('hidden');
|
||||
document.getElementById('new-variant-modal').classList.add('flex');
|
||||
document.getElementById('new-variant-value').focus();
|
||||
}
|
||||
|
||||
function closeNewVariantModal() {
|
||||
document.getElementById('new-variant-modal').classList.add('hidden');
|
||||
document.getElementById('new-variant-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function createNewVariant() {
|
||||
if (!project) return;
|
||||
const code = (project.code || '').trim();
|
||||
const variant = (document.getElementById('new-variant-value').value || '').trim();
|
||||
const nameRaw = (document.getElementById('new-variant-name').value || '').trim();
|
||||
if (!code || !variant) {
|
||||
showToast('Укажите вариант', 'error');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
code: code,
|
||||
variant: variant,
|
||||
name: nameRaw ? nameRaw : null
|
||||
};
|
||||
const resp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
showToast(data.error || 'Ошибка создания варианта', 'error');
|
||||
return;
|
||||
}
|
||||
const created = await resp.json().catch(() => null);
|
||||
closeNewVariantModal();
|
||||
showToast('Вариант создан', 'success');
|
||||
if (created && created.uuid) {
|
||||
window.location.href = '/projects/' + created.uuid;
|
||||
return;
|
||||
}
|
||||
loadProject();
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
document.getElementById('create-modal').classList.add('hidden');
|
||||
document.getElementById('create-modal').classList.remove('flex');
|
||||
@@ -318,68 +563,227 @@ async function reactivateConfig(uuid) {
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
function openRenameModal(uuid, currentName) {
|
||||
document.getElementById('rename-uuid').value = uuid;
|
||||
document.getElementById('rename-input').value = currentName;
|
||||
document.getElementById('rename-modal').classList.remove('hidden');
|
||||
document.getElementById('rename-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeRenameModal() {
|
||||
document.getElementById('rename-modal').classList.add('hidden');
|
||||
document.getElementById('rename-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function renameConfig() {
|
||||
const uuid = document.getElementById('rename-uuid').value;
|
||||
const name = document.getElementById('rename-input').value.trim();
|
||||
if (!name) {
|
||||
alert('Введите название');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/configs/' + uuid + '/rename', {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name})
|
||||
function projectCodeEntries() {
|
||||
const byCode = new Map();
|
||||
projectsCatalog.forEach(p => {
|
||||
const code = (p.code || '').trim();
|
||||
if (!code || byCode.has(code)) return;
|
||||
byCode.set(code, {
|
||||
code: code,
|
||||
name: (p.name || '').trim()
|
||||
});
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось переименовать');
|
||||
return;
|
||||
return Array.from(byCode.values()).sort((a, b) => a.code.localeCompare(b.code, 'ru'));
|
||||
}
|
||||
|
||||
function formatProjectAutocompleteValue(entry) {
|
||||
if (!entry) return '';
|
||||
return entry.name ? (entry.code + ' - ' + entry.name) : entry.code;
|
||||
}
|
||||
|
||||
function resolveProjectCodeFromInput(rawInput) {
|
||||
const input = (rawInput || '').trim();
|
||||
if (!input) return '';
|
||||
const entries = projectCodeEntries();
|
||||
|
||||
const exactCode = entries.find(e => e.code.toLowerCase() === input.toLowerCase());
|
||||
if (exactCode) return exactCode.code;
|
||||
|
||||
const exactDisplayMatches = entries.filter(e => formatProjectAutocompleteValue(e).toLowerCase() === input.toLowerCase());
|
||||
if (exactDisplayMatches.length === 1) return exactDisplayMatches[0].code;
|
||||
|
||||
const byUniqueName = entries.filter(e => (e.name || '').toLowerCase() === input.toLowerCase());
|
||||
if (byUniqueName.length === 1) return byUniqueName[0].code;
|
||||
|
||||
if (input.includes(' - ')) {
|
||||
const codeCandidate = input.split(' - ')[0].trim();
|
||||
const byCandidate = entries.find(e => e.code.toLowerCase() === codeCandidate.toLowerCase());
|
||||
if (byCandidate) return byCandidate.code;
|
||||
}
|
||||
closeRenameModal();
|
||||
loadConfigs();
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function openCloneModal(uuid, currentName) {
|
||||
document.getElementById('clone-uuid').value = uuid;
|
||||
document.getElementById('clone-input').value = currentName + ' (копия)';
|
||||
document.getElementById('clone-modal').classList.remove('hidden');
|
||||
document.getElementById('clone-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeCloneModal() {
|
||||
document.getElementById('clone-modal').classList.add('hidden');
|
||||
document.getElementById('clone-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function cloneConfig() {
|
||||
const uuid = document.getElementById('clone-uuid').value;
|
||||
const name = document.getElementById('clone-input').value.trim();
|
||||
if (!name) {
|
||||
alert('Введите название');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs/' + uuid + '/clone', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name})
|
||||
function populateProjectAutocomplete() {
|
||||
const options = document.getElementById('config-action-project-options');
|
||||
options.innerHTML = '';
|
||||
projectCodeEntries().forEach(entry => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = formatProjectAutocompleteValue(entry);
|
||||
opt.label = entry.code;
|
||||
options.appendChild(opt);
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось скопировать');
|
||||
}
|
||||
|
||||
function variantsForProjectCode(projectCode) {
|
||||
const code = (projectCode || '').trim();
|
||||
if (!code) return [];
|
||||
return projectsCatalog
|
||||
.filter(p => (p.code || '').trim() === code)
|
||||
.map(p => ({uuid: p.uuid, variant: normalizeVariantLabel(p.variant || '')}))
|
||||
.sort((a, b) => a.variant.localeCompare(b.variant, 'ru'));
|
||||
}
|
||||
|
||||
function populateVariantAutocomplete(projectCode, selectedVariantLabel) {
|
||||
const options = document.getElementById('config-action-variant-options');
|
||||
const input = document.getElementById('config-action-variant-input');
|
||||
const variants = variantsForProjectCode(projectCode);
|
||||
options.innerHTML = '';
|
||||
variants.forEach(v => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v.variant;
|
||||
options.appendChild(opt);
|
||||
});
|
||||
if (selectedVariantLabel) {
|
||||
input.value = selectedVariantLabel;
|
||||
} else if (variants.length === 1) {
|
||||
input.value = variants[0].variant;
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTargetProjectUUIDFromInputs() {
|
||||
const projectCode = resolveProjectCodeFromInput(document.getElementById('config-action-project-input').value);
|
||||
if (!projectCode) {
|
||||
return {error: 'Выберите проект из подсказок'};
|
||||
}
|
||||
const variantLabel = normalizeVariantLabel(document.getElementById('config-action-variant-input').value || 'main');
|
||||
const target = projectsCatalog.find(p =>
|
||||
(p.code || '').trim() === projectCode &&
|
||||
normalizeVariantLabel(p.variant || '') === variantLabel
|
||||
);
|
||||
if (!target) {
|
||||
return {error: 'Выберите вариант из подсказок'};
|
||||
}
|
||||
return {uuid: target.uuid};
|
||||
}
|
||||
|
||||
function syncActionModalMode() {
|
||||
const copyCheckbox = document.getElementById('config-action-copy');
|
||||
if (copyCheckbox.checked) {
|
||||
// no-op: copy always uses latest revision
|
||||
} else {
|
||||
// no-op: copy always uses latest revision
|
||||
}
|
||||
}
|
||||
|
||||
async function openConfigActionModal(uuid, currentName, currentProjectUUID) {
|
||||
document.getElementById('config-action-uuid').value = uuid;
|
||||
document.getElementById('config-action-current-name').value = currentName;
|
||||
document.getElementById('config-action-current-project').value = currentProjectUUID || projectUUID;
|
||||
document.getElementById('config-action-name').value = currentName;
|
||||
document.getElementById('config-action-copy').checked = false;
|
||||
populateProjectAutocomplete();
|
||||
const currentProject = projectsCatalog.find(p => p.uuid === (currentProjectUUID || projectUUID));
|
||||
if (currentProject) {
|
||||
const entry = {
|
||||
code: (currentProject.code || '').trim(),
|
||||
name: (currentProject.name || '').trim()
|
||||
};
|
||||
document.getElementById('config-action-project-input').value = formatProjectAutocompleteValue(entry);
|
||||
populateVariantAutocomplete(entry.code, normalizeVariantLabel(currentProject.variant || ''));
|
||||
} else {
|
||||
document.getElementById('config-action-project-input').value = '';
|
||||
populateVariantAutocomplete('', '');
|
||||
}
|
||||
syncActionModalMode();
|
||||
document.getElementById('config-action-modal').classList.remove('hidden');
|
||||
document.getElementById('config-action-modal').classList.add('flex');
|
||||
const nameInput = document.getElementById('config-action-name');
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}
|
||||
|
||||
function closeConfigActionModal() {
|
||||
document.getElementById('config-action-modal').classList.add('hidden');
|
||||
document.getElementById('config-action-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function saveConfigAction() {
|
||||
const notify = (message, type) => {
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(message, type || 'success');
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
const uuid = document.getElementById('config-action-uuid').value;
|
||||
const currentName = document.getElementById('config-action-current-name').value;
|
||||
const currentProjectUUID = document.getElementById('config-action-current-project').value || projectUUID;
|
||||
const name = document.getElementById('config-action-name').value.trim();
|
||||
const copy = document.getElementById('config-action-copy').checked;
|
||||
const targetProject = resolveTargetProjectUUIDFromInputs();
|
||||
if (targetProject.error) {
|
||||
notify(targetProject.error, 'error');
|
||||
return;
|
||||
}
|
||||
closeCloneModal();
|
||||
loadConfigs();
|
||||
const targetProjectUUID = targetProject.uuid || currentProjectUUID;
|
||||
|
||||
if (!name) {
|
||||
notify('Введите название', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (copy) {
|
||||
const cloneResp = await fetch('/api/projects/' + targetProjectUUID + '/configs/' + uuid + '/clone', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name})
|
||||
});
|
||||
if (!cloneResp.ok) {
|
||||
notify('Не удалось скопировать квоту', 'error');
|
||||
return;
|
||||
}
|
||||
closeConfigActionModal();
|
||||
await loadConfigs();
|
||||
notify('Копия создана', 'success');
|
||||
if (targetProjectUUID && targetProjectUUID !== projectUUID) {
|
||||
window.location.href = '/projects/' + targetProjectUUID;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
if (name !== currentName) {
|
||||
const renameResp = await fetch('/api/configs/' + uuid + '/rename', {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name})
|
||||
});
|
||||
if (!renameResp.ok) {
|
||||
notify('Не удалось переименовать квоту', 'error');
|
||||
return;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (targetProjectUUID !== currentProjectUUID) {
|
||||
const moveResp = await fetch('/api/configs/' + uuid + '/project', {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({project_uuid: targetProjectUUID})
|
||||
});
|
||||
if (!moveResp.ok) {
|
||||
notify('Не удалось перенести квоту', 'error');
|
||||
return;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
closeConfigActionModal();
|
||||
return;
|
||||
}
|
||||
|
||||
closeConfigActionModal();
|
||||
await loadConfigs();
|
||||
notify('Изменения сохранены', 'success');
|
||||
if (targetProjectUUID && targetProjectUUID !== projectUUID) {
|
||||
window.location.href = '/projects/' + targetProjectUUID;
|
||||
}
|
||||
}
|
||||
|
||||
function openImportModal() {
|
||||
@@ -397,6 +801,65 @@ function closeImportModal() {
|
||||
document.getElementById('import-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function openProjectSettingsModal() {
|
||||
if (!project) return;
|
||||
if (project.is_system) {
|
||||
alert('Системный проект нельзя редактировать');
|
||||
return;
|
||||
}
|
||||
document.getElementById('project-settings-code').value = project.code || '';
|
||||
document.getElementById('project-settings-variant').value = project.variant || '';
|
||||
document.getElementById('project-settings-name').value = project.name || '';
|
||||
document.getElementById('project-settings-tracker-url').value = (project.tracker_url || '').trim();
|
||||
document.getElementById('project-settings-modal').classList.remove('hidden');
|
||||
document.getElementById('project-settings-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeProjectSettingsModal() {
|
||||
document.getElementById('project-settings-modal').classList.add('hidden');
|
||||
document.getElementById('project-settings-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function saveProjectSettings() {
|
||||
if (!project) return;
|
||||
const code = document.getElementById('project-settings-code').value.trim();
|
||||
const variant = document.getElementById('project-settings-variant').value.trim();
|
||||
const name = document.getElementById('project-settings-name').value.trim();
|
||||
const trackerURL = document.getElementById('project-settings-tracker-url').value.trim();
|
||||
if (!code) {
|
||||
alert('Введите код проекта');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/projects/' + projectUUID, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({code: code, variant: variant, name: name, tracker_url: trackerURL})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 409) {
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
alert('Не удалось сохранить параметры проекта');
|
||||
return;
|
||||
}
|
||||
project = await resp.json();
|
||||
document.getElementById('project-code').textContent = project.code || '—';
|
||||
await loadVariantsForCode(project.code || '');
|
||||
renderVariantSelect();
|
||||
const trackerLink = document.getElementById('tracker-link');
|
||||
if (trackerLink) {
|
||||
const trackerURLResolved = resolveProjectTrackerURL(project);
|
||||
if (trackerURLResolved) {
|
||||
trackerLink.href = trackerURLResolved;
|
||||
trackerLink.classList.remove('hidden');
|
||||
} else {
|
||||
trackerLink.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
closeProjectSettingsModal();
|
||||
}
|
||||
|
||||
async function loadImportOptions() {
|
||||
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
|
||||
if (!resp.ok) return;
|
||||
@@ -476,23 +939,120 @@ function wildcardMatch(value, pattern) {
|
||||
return regex.test(value);
|
||||
}
|
||||
|
||||
async function deleteVariant() {
|
||||
if (!project) return;
|
||||
const variantLabel = normalizeVariantLabel(project.variant);
|
||||
if (!confirm('Удалить вариант «' + variantLabel + '»? Все квоты будут архивированы.')) return;
|
||||
const resp = await fetch('/api/projects/' + projectUUID, {method: 'DELETE'});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
alert(data.error || 'Не удалось удалить вариант');
|
||||
return;
|
||||
}
|
||||
// Redirect to main variant or projects list
|
||||
const mainVariant = projectVariants.find(v => normalizeVariantLabel(v.variant) === 'main');
|
||||
if (mainVariant && mainVariant.uuid !== projectUUID) {
|
||||
window.location.href = '/projects/' + mainVariant.uuid;
|
||||
} else {
|
||||
window.location.href = '/projects';
|
||||
}
|
||||
}
|
||||
|
||||
function updateDeleteVariantButton() {
|
||||
const btn = document.getElementById('delete-variant-btn');
|
||||
if (!btn || !project) return;
|
||||
if ((project.variant || '').trim() !== '') {
|
||||
btn.classList.remove('hidden');
|
||||
} else {
|
||||
btn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
||||
document.getElementById('rename-modal').addEventListener('click', function(e) { if (e.target === this) closeRenameModal(); });
|
||||
document.getElementById('clone-modal').addEventListener('click', function(e) { if (e.target === this) closeCloneModal(); });
|
||||
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
|
||||
document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); });
|
||||
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
|
||||
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
|
||||
document.getElementById('config-action-project-input').addEventListener('input', function(e) {
|
||||
const code = resolveProjectCodeFromInput(e.target.value);
|
||||
populateVariantAutocomplete(code, '');
|
||||
});
|
||||
document.getElementById('config-action-copy').addEventListener('change', function(e) {
|
||||
const currentName = document.getElementById('config-action-current-name').value;
|
||||
const nameInput = document.getElementById('config-action-name');
|
||||
if (e.target.checked && nameInput.value.trim() === currentName.trim()) {
|
||||
nameInput.value = currentName + ' (копия)';
|
||||
}
|
||||
syncActionModalMode();
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeCreateModal();
|
||||
closeRenameModal();
|
||||
closeCloneModal();
|
||||
closeConfigActionModal();
|
||||
closeImportModal();
|
||||
closeProjectSettingsModal();
|
||||
}
|
||||
});
|
||||
|
||||
async function updateConfigServerCount(input) {
|
||||
const uuid = input.dataset.uuid;
|
||||
const prevValue = parseInt(input.dataset.prev) || 1;
|
||||
const newValue = parseInt(input.value);
|
||||
if (!newValue || newValue < 1) {
|
||||
input.value = prevValue;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + uuid + '/server-count', {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({server_count: newValue})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
input.value = prevValue;
|
||||
showToast('Не удалось обновить количество', 'error');
|
||||
return;
|
||||
}
|
||||
const updated = await resp.json();
|
||||
input.dataset.prev = newValue;
|
||||
// Update row total price
|
||||
const totalCell = document.querySelector('[data-total-uuid="' + uuid + '"]');
|
||||
if (totalCell && updated.total_price != null) {
|
||||
totalCell.textContent = '$' + updated.total_price.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||
}
|
||||
// Update the config in allConfigs and recalculate footer total
|
||||
for (let i = 0; i < allConfigs.length; i++) {
|
||||
if (allConfigs[i].uuid === uuid) {
|
||||
allConfigs[i].total_price = updated.total_price;
|
||||
allConfigs[i].server_count = newValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
updateFooterTotal();
|
||||
} catch (e) {
|
||||
input.value = prevValue;
|
||||
showToast('Ошибка сети', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateFooterTotal() {
|
||||
let totalSum = 0;
|
||||
allConfigs.forEach(c => { totalSum += (c.total_price || 0); });
|
||||
const footer = document.querySelector('tfoot td[data-footer-total]');
|
||||
if (footer) {
|
||||
footer.textContent = '$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||
}
|
||||
}
|
||||
|
||||
function exportProject() {
|
||||
window.location.href = '/api/projects/' + projectUUID + '/export';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
applyStatusModeUI();
|
||||
const ok = await loadProject();
|
||||
if (!ok) return;
|
||||
updateDeleteVariantButton();
|
||||
await loadConfigs();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
|
||||
<div class="max-w-md">
|
||||
<input id="projects-search" type="text" placeholder="Поиск проекта по названию"
|
||||
<input id="projects-search" type="text" placeholder="Поиск проекта по названию или коду"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
@@ -31,11 +31,21 @@
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Новый проект</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="create-project-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
|
||||
<input id="create-project-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="create-project-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
|
||||
<input id="create-project-variant" type="text" placeholder="Например: Lenovo"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
|
||||
<input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123"
|
||||
@@ -59,6 +69,8 @@ let sortField = 'created_at';
|
||||
let sortDir = 'desc';
|
||||
let createProjectTrackerManuallyEdited = false;
|
||||
let createProjectLastAutoTrackerURL = '';
|
||||
let variantsByCode = {};
|
||||
let variantsLoaded = false;
|
||||
|
||||
const trackerBaseURL = 'https://tracker.yandex.ru/';
|
||||
|
||||
@@ -85,6 +97,55 @@ function formatDateTime(value) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeVariant(variant) {
|
||||
const trimmed = (variant || '').trim();
|
||||
return trimmed === '' ? 'main' : trimmed;
|
||||
}
|
||||
|
||||
function renderVariantChips(code, fallbackVariant, fallbackUUID) {
|
||||
const variants = variantsByCode[code || ''] || [];
|
||||
if (!variants.length) {
|
||||
const single = normalizeVariant(fallbackVariant);
|
||||
const href = fallbackUUID ? ('/projects/' + fallbackUUID) : '/projects';
|
||||
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(single) + '</a>';
|
||||
}
|
||||
return variants.map(v => {
|
||||
const href = v.uuid ? ('/projects/' + v.uuid) : '/projects';
|
||||
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(v.label) + '</a>';
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
async function loadVariantsIndex() {
|
||||
if (variantsLoaded) return;
|
||||
try {
|
||||
const resp = await fetch('/api/projects/all');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||
variantsByCode = {};
|
||||
allProjects.forEach(p => {
|
||||
const code = (p.code || '').trim();
|
||||
const variant = normalizeVariant(p.variant);
|
||||
if (!variantsByCode[code]) {
|
||||
variantsByCode[code] = [];
|
||||
}
|
||||
if (!variantsByCode[code].some(v => v.label === variant)) {
|
||||
variantsByCode[code].push({label: variant, uuid: p.uuid});
|
||||
}
|
||||
});
|
||||
Object.keys(variantsByCode).forEach(code => {
|
||||
variantsByCode[code].sort((a, b) => {
|
||||
if (a.label === 'main') return -1;
|
||||
if (b.label === 'main') return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
});
|
||||
variantsLoaded = true;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSort(field) {
|
||||
if (sortField === field) {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
@@ -132,10 +193,33 @@ async function loadProjects() {
|
||||
}
|
||||
const data = await resp.json();
|
||||
rows = data.projects || [];
|
||||
if (Array.isArray(rows) && rows.length) {
|
||||
const byCode = {};
|
||||
rows.forEach(p => {
|
||||
const codeKey = (p.code || '').trim();
|
||||
if (!codeKey) {
|
||||
const fallbackKey = p.uuid || Math.random().toString(36);
|
||||
byCode[fallbackKey] = p;
|
||||
return;
|
||||
}
|
||||
const variant = (p.variant || '').trim();
|
||||
if (!byCode[codeKey]) {
|
||||
byCode[codeKey] = p;
|
||||
return;
|
||||
}
|
||||
const current = byCode[codeKey];
|
||||
const currentVariant = (current.variant || '').trim();
|
||||
if (currentVariant !== '' && variant === '') {
|
||||
byCode[codeKey] = p;
|
||||
}
|
||||
});
|
||||
rows = Object.values(byCode);
|
||||
}
|
||||
total = data.total || 0;
|
||||
totalPages = data.total_pages || 0;
|
||||
page = data.page || currentPage;
|
||||
currentPage = page;
|
||||
await loadVariantsIndex();
|
||||
} catch (e) {
|
||||
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
|
||||
return;
|
||||
@@ -144,27 +228,22 @@ async function loadProjects() {
|
||||
let html = '<div class="overflow-x-auto"><table class="w-full">';
|
||||
html += '<thead class="bg-gray-50">';
|
||||
html += '<tr>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Код</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
|
||||
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название проекта';
|
||||
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название';
|
||||
if (sortField === 'name') {
|
||||
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
||||
}
|
||||
html += '</button></th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
|
||||
html += '<button type="button" onclick="toggleSort(\'created_at\')" class="inline-flex items-center gap-1 hover:text-gray-700">Создан';
|
||||
if (sortField === 'created_at') {
|
||||
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
||||
}
|
||||
html += '</button></th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Кол-во квот</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Создан @ автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Изменен @ кто</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Варианты</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '</tr>';
|
||||
html += '<tr>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
@@ -175,21 +254,28 @@ async function loadProjects() {
|
||||
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
|
||||
}
|
||||
|
||||
rows.forEach(p => {
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(formatDateTime(p.created_at)) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>';
|
||||
rows.forEach(p => {
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
const displayName = p.name || '';
|
||||
const createdBy = p.owner_username || '—';
|
||||
const updatedBy = '—';
|
||||
const createdLabel = formatDateTime(p.created_at) + ' @ ' + createdBy;
|
||||
const updatedLabel = formatDateTime(p.updated_at) + ' @ ' + updatedBy;
|
||||
const variantChips = renderVariantChips(p.code, p.variant, p.uuid);
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.code || '—') + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(displayName) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(createdLabel) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(updatedLabel) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm">' + variantChips + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
|
||||
|
||||
if (p.is_active) {
|
||||
html += '<button onclick="copyProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-green-700 hover:text-green-900" title="Копировать">';
|
||||
const safeName = escapeHtml(displayName).replace(/'/g, "\\'");
|
||||
html += '<button onclick="copyProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-green-700 hover:text-green-900" title="Копировать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>';
|
||||
html += '</button>';
|
||||
|
||||
html += '<button onclick="renameProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-blue-700 hover:text-blue-900" title="Переименовать">';
|
||||
html += '<button onclick="renameProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-blue-700 hover:text-blue-900" title="Переименовать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>';
|
||||
html += '</button>';
|
||||
|
||||
@@ -251,15 +337,19 @@ function buildTrackerURLFromProjectCode(projectCode) {
|
||||
}
|
||||
|
||||
function openCreateProjectModal() {
|
||||
const nameInput = document.getElementById('create-project-name');
|
||||
const codeInput = document.getElementById('create-project-code');
|
||||
const variantInput = document.getElementById('create-project-variant');
|
||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||
nameInput.value = '';
|
||||
codeInput.value = '';
|
||||
variantInput.value = '';
|
||||
trackerInput.value = '';
|
||||
createProjectTrackerManuallyEdited = false;
|
||||
createProjectLastAutoTrackerURL = '';
|
||||
document.getElementById('create-project-modal').classList.remove('hidden');
|
||||
document.getElementById('create-project-modal').classList.add('flex');
|
||||
codeInput.focus();
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function closeCreateProjectModal() {
|
||||
@@ -278,10 +368,14 @@ function updateCreateProjectTrackerURL() {
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
const nameInput = document.getElementById('create-project-name');
|
||||
const codeInput = document.getElementById('create-project-code');
|
||||
const variantInput = document.getElementById('create-project-variant');
|
||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||
const name = (codeInput.value || '').trim();
|
||||
if (!name) {
|
||||
const name = (nameInput.value || '').trim();
|
||||
const code = (codeInput.value || '').trim();
|
||||
const variant = (variantInput.value || '').trim();
|
||||
if (!code) {
|
||||
alert('Введите код проекта');
|
||||
return;
|
||||
}
|
||||
@@ -290,10 +384,16 @@ async function createProject() {
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
code: code,
|
||||
variant: variant,
|
||||
tracker_url: (trackerInput.value || '').trim()
|
||||
})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 409) {
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
alert('Не удалось создать проект');
|
||||
return;
|
||||
}
|
||||
@@ -310,6 +410,10 @@ async function renameProject(projectUUID, currentName) {
|
||||
body: JSON.stringify({name: name.trim()})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 409) {
|
||||
alert('Проект с таким названием уже существует');
|
||||
return;
|
||||
}
|
||||
alert('Не удалось переименовать проект');
|
||||
return;
|
||||
}
|
||||
@@ -353,13 +457,20 @@ async function addConfigToProject(projectUUID) {
|
||||
async function copyProject(projectUUID, projectName) {
|
||||
const newName = prompt('Название копии проекта', projectName + ' (копия)');
|
||||
if (!newName || !newName.trim()) return;
|
||||
const newCode = prompt('Код проекта', '');
|
||||
if (!newCode || !newCode.trim()) return;
|
||||
const newVariant = prompt('Вариант (необязательно)', '');
|
||||
|
||||
const createResp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: newName.trim()})
|
||||
body: JSON.stringify({name: newName.trim(), code: newCode.trim(), variant: (newVariant || '').trim()})
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
if (createResp.status === 409) {
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
alert('Не удалось создать копию проекта');
|
||||
return;
|
||||
}
|
||||
@@ -385,40 +496,55 @@ 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-name').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createProject();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('create-project-tracker-url').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-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreateProjectModal();
|
||||
}
|
||||
document.getElementById('create-project-code').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createProject();
|
||||
}
|
||||
});
|
||||
|
||||
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