Compare commits
30 Commits
cbaeafa9c8
...
feature/ve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8663a87d28 | ||
| 2e0faf4aec | |||
| 4b0879779a | |||
| 2b175a3d1e | |||
| 5732c75b85 | |||
| eb7c3739ce | |||
|
|
6e0335af7c | ||
|
|
a42a80beb8 | ||
|
|
586114c79c | ||
|
|
e9230c0e58 | ||
|
|
aa65fc8156 | ||
|
|
b22e961656 | ||
|
|
af83818564 | ||
|
|
8a138327a3 | ||
| d0400b18a3 | |||
| d3f1a838eb | |||
| c6086ac03a | |||
| a127ebea82 | |||
| 347599e06b | |||
| 4a44d48366 | |||
| 23882637b5 | |||
| 5e56f386cc | |||
| e5b6902c9e | |||
|
|
3c46cd7bf0 | ||
|
|
7f8491d197 | ||
|
|
3fd7a2231a | ||
|
|
c295b60dd8 | ||
| cc9b846c31 | |||
| 87cb12906d | |||
| 075fc709dd |
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "bible"]
|
||||||
|
path = bible
|
||||||
|
url = https://git.mchus.pro/mchus/bible.git
|
||||||
11
AGENTS.md
Normal file
11
AGENTS.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# QuoteForge — Instructions for Codex
|
||||||
|
|
||||||
|
## Shared Engineering Rules
|
||||||
|
Read `bible/` — shared rules for all projects (CSV, logging, DB, tables, background tasks, code style).
|
||||||
|
Start with `bible/rules/patterns/` for specific contracts.
|
||||||
|
|
||||||
|
## Project Architecture
|
||||||
|
Read `bible-local/` — QuoteForge specific architecture.
|
||||||
|
Read order: `bible-local/README.md` → relevant files for the task.
|
||||||
|
|
||||||
|
Every architectural decision specific to this project must be recorded in `bible-local/`.
|
||||||
93
CLAUDE.md
93
CLAUDE.md
@@ -1,88 +1,17 @@
|
|||||||
# QuoteForge - Claude Code Instructions
|
# QuoteForge — Instructions for Claude
|
||||||
|
|
||||||
## Overview
|
## Shared Engineering Rules
|
||||||
Корпоративный конфигуратор серверов с offline-first архитектурой.
|
Read `bible/` — shared rules for all projects (CSV, logging, DB, tables, background tasks, code style).
|
||||||
Приложение работает через локальную SQLite базу, синхронизация с MariaDB выполняется фоново.
|
Start with `bible/rules/patterns/` for specific contracts.
|
||||||
|
|
||||||
## Product Scope
|
## Project Architecture
|
||||||
- Конфигуратор компонентов и расчёт КП
|
Read `bible-local/` — QuoteForge specific architecture.
|
||||||
- Проекты и конфигурации
|
Read order: `bible-local/README.md` → relevant files for the task.
|
||||||
- Read-only просмотр прайслистов из локального кэша
|
|
||||||
- Sync (pull компонентов/прайслистов, push локальных изменений)
|
|
||||||
|
|
||||||
Из области исключены:
|
Every architectural decision specific to this project must be recorded in `bible-local/`.
|
||||||
- admin pricing UI/API
|
|
||||||
- stock import
|
|
||||||
- alerts
|
|
||||||
- cron/importer утилиты
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
- Local-first: чтение и запись происходят в SQLite
|
|
||||||
- MariaDB используется как сервер синхронизации
|
|
||||||
- Background worker: периодический sync push+pull
|
|
||||||
- Система ревизий конфигураций: immutable snapshots при каждом сохранении (local_configuration_versions)
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- CSV-экспорт: имя файла должно содержать **код проекта** (`project.Code`), а не название (`project.Name`). Формат: `YYYY-MM-DD (КодПроекта) ИмяКонфигурации Артикул.csv`.
|
|
||||||
- UI: во всех breadcrumbs длинные названия спецификаций/конфигураций сокращать до 16 символов с многоточием.
|
|
||||||
|
|
||||||
## Key SQLite Data
|
|
||||||
- `connection_settings`
|
|
||||||
- `local_components`
|
|
||||||
- `local_pricelists`, `local_pricelist_items`
|
|
||||||
- `local_configurations`
|
|
||||||
- `local_configuration_versions` — immutable snapshots (ревизии) при каждом сохранении
|
|
||||||
- `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 + versions/rollback via `/api/configs/*` |
|
|
||||||
| Projects | CRUD + nested configs + `DELETE /api/projects/:uuid` (delete variant) 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`
|
|
||||||
- `/configs/:uuid/revisions`
|
|
||||||
- `/projects`
|
|
||||||
- `/projects/:uuid`
|
|
||||||
- `/pricelists`
|
|
||||||
- `/pricelists/:id`
|
|
||||||
- `/setup`
|
|
||||||
|
|
||||||
## Release Notes & Change Log
|
|
||||||
Release notes are maintained in `releases/memory/` directory organized by version tags (e.g., `v1.2.1.md`).
|
|
||||||
Before working on the codebase, review the most recent release notes to understand recent changes.
|
|
||||||
- Check `releases/memory/` for detailed changelog between tags
|
|
||||||
- Each release file documents commits, breaking changes, and migration notes
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
go build ./cmd/qfs && go vet ./... # verify
|
||||||
go run ./cmd/qfs
|
go run ./cmd/qfs # run
|
||||||
make run
|
make build-release # release build
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
471
README.md
471
README.md
@@ -1,477 +1,66 @@
|
|||||||
# QuoteForge
|
# QuoteForge
|
||||||
|
|
||||||
**Server Configuration & Quotation Tool**
|
**Корпоративный конфигуратор серверов и расчёт КП**
|
||||||
|
|
||||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП).
|
Offline-first архитектура: пользовательские операции через локальную SQLite, MariaDB только для синхронизации.
|
||||||
Приложение работает в strict local-first режиме: пользовательские операции выполняются через локальную SQLite, MariaDB используется для синхронизации и серверного администрирования прайслистов.
|
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## Возможности
|
---
|
||||||
|
|
||||||
### Для пользователей
|
## Документация
|
||||||
- 📱 **Mobile-first интерфейс** — удобная работа с телефона и планшета
|
|
||||||
- 🖥️ **Конфигуратор серверов** — пошаговый выбор компонентов с проверкой совместимости
|
|
||||||
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
|
|
||||||
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
|
|
||||||
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
|
|
||||||
- 🔌 **Полная офлайн-работа** — можно продолжать работу без сети и синхронизировать позже
|
|
||||||
- 🛡️ **Защищенная синхронизация** — sync блокируется preflight-проверкой, если локальная схема не готова
|
|
||||||
|
|
||||||
### Для ценовых администраторов
|
Полная архитектурная документация хранится в **[bible/](bible/README.md)**:
|
||||||
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
|
|
||||||
- 🎯 **Система алертов** — уведомления о популярных компонентах с устаревшими ценами
|
|
||||||
- 📉 **Аналитика использования** — какие компоненты востребованы в КП
|
|
||||||
- ⚙️ **Гибкие настройки** — периоды расчёта, методы, ручные переопределения
|
|
||||||
|
|
||||||
### Индикация актуальности цен
|
| Файл | Тема |
|
||||||
| Цвет | Статус | Условие |
|
|------|------|
|
||||||
|------|--------|---------|
|
| [bible/01-overview.md](bible/01-overview.md) | Продукт, возможности, технологии, структура репо |
|
||||||
| 🟢 Зелёный | Свежая | < 30 дней, ≥ 3 источника |
|
| [bible/02-architecture.md](bible/02-architecture.md) | Local-first, sync, ценообразование, версионность |
|
||||||
| 🟡 Жёлтый | Нормальная | 30-60 дней |
|
| [bible/03-database.md](bible/03-database.md) | SQLite и MariaDB схемы, права, миграции |
|
||||||
| 🟠 Оранжевый | Устаревающая | 60-90 дней |
|
| [bible/04-api.md](bible/04-api.md) | Все API endpoints и web-маршруты |
|
||||||
| 🔴 Красный | Устаревшая | > 90 дней или нет данных |
|
| [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
|
```bash
|
||||||
|
# Применить миграции
|
||||||
go run ./cmd/qfs -migrate
|
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
|
|
||||||
-- Справочные таблицы (только чтение)
|
|
||||||
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, 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;
|
|
||||||
|
|
||||||
-- Проверка выданных прав
|
|
||||||
SHOW GRANTS FOR '<DB_USER>'@'%';
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Таблицы и их назначение
|
|
||||||
|
|
||||||
| Таблица | Назначение | Права | Примечание |
|
|
||||||
|---------|-----------|-------|-----------|
|
|
||||||
| `lot` | Справочник компонентов | SELECT | Существующая таблица |
|
|
||||||
| `qt_lot_metadata` | Расширенные данные компонентов | SELECT | Метаданные компонентов |
|
|
||||||
| `qt_categories` | Категории компонентов | SELECT | Справочник |
|
|
||||||
| `qt_pricelists` | Прайслисты | SELECT | Управляется сервером |
|
|
||||||
| `qt_pricelist_items` | Позиции прайслистов | SELECT | Управляется сервером |
|
|
||||||
| `qt_configurations` | Сохранённые конфигурации | SELECT, INSERT, UPDATE | Основная таблица работы |
|
|
||||||
| `qt_projects` | Проекты | SELECT, INSERT, UPDATE | Для группировки конфигураций |
|
|
||||||
| `qt_client_local_migrations` | Справочник миграций БД | SELECT | Только чтение (управляется админом) |
|
|
||||||
| `qt_client_schema_state` | Состояние локальной схемы | SELECT, INSERT, UPDATE | Отслеживание примененных миграций |
|
|
||||||
| `qt_pricelist_sync_status` | Статус синхронизации | SELECT, INSERT, UPDATE | Отслеживание активности синхронизации |
|
|
||||||
|
|
||||||
#### При создании нового пользователя
|
|
||||||
|
|
||||||
Если нужно создать нового пользователя с нуля:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 1) Создать пользователя
|
|
||||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
|
||||||
|
|
||||||
-- 2) Выдать все необходимые права
|
|
||||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
|
||||||
|
|
||||||
-- 3) Применить изменения
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
|
|
||||||
-- 4) Проверить права
|
|
||||||
SHOW GRANTS FOR 'quote_user'@'%';
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Важные замечания
|
|
||||||
|
|
||||||
- **Таблицы синхронизации** должны быть созданы администратором БД один раз. Приложение не требует прав CREATE TABLE.
|
|
||||||
- **Прайслисты** (`qt_pricelists`, `qt_pricelist_items`) — справочные таблицы, управляются сервером, пользователь имеет только SELECT.
|
|
||||||
- **Конфигурации и проекты** — таблицы, в которые пишет само приложение (INSERT, UPDATE при сохранении изменений).
|
|
||||||
- **Таблицы миграций** нужны для синхронизации: приложение читает список миграций и отчитывается о применённых.
|
|
||||||
- Если видите ошибку `Access denied for user ...@'<ip>'`, проверьте наличие конфликтующих записей пользователя с разными хостами (user@localhost vs user@'%').
|
|
||||||
|
|
||||||
### 4. Импорт метаданных компонентов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go run ./cmd/importer
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Запуск
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
go run ./cmd/qfs
|
go run ./cmd/qfs
|
||||||
|
# или
|
||||||
# Production (with Makefile - recommended)
|
make run
|
||||||
make build-release # Builds with version info
|
|
||||||
./bin/qfs -version # Check version
|
|
||||||
|
|
||||||
# Production (manual)
|
|
||||||
VERSION=$(git describe --tags --always --dirty)
|
|
||||||
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
|
|
||||||
./bin/qfs -version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Makefile команды:**
|
Приложение: http://localhost:8080 → откроется `/setup` для настройки подключения к MariaDB.
|
||||||
```bash
|
|
||||||
make build-release # Оптимизированная сборка с версией
|
|
||||||
make build-all # Сборка для всех платформ (Linux, macOS, Windows)
|
|
||||||
make build-windows # Только для Windows
|
|
||||||
make run # Запуск dev сервера
|
|
||||||
make test # Запуск тестов
|
|
||||||
make install-hooks # Установить git hooks (блокировка коммита с секретами)
|
|
||||||
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-методом:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/configs/:uuid/rollback
|
# Сборка
|
||||||
{
|
make build-release
|
||||||
"target_version": 3,
|
|
||||||
"note": "optional"
|
# Проверка
|
||||||
}
|
go build ./cmd/qfs && go vet ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
Результат:
|
---
|
||||||
- создаётся новая версия `vN` с `data` из целевой версии
|
|
||||||
- `change_note = "rollback to v{target_version}"` (+ note, если передан)
|
|
||||||
- `current_version_id` переключается на новую версию
|
|
||||||
- конфигурация уходит в `sync_status = pending`
|
|
||||||
|
|
||||||
### Локальный config.yaml
|
|
||||||
|
|
||||||
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
|
|
||||||
Если файла нет, он создаётся автоматически. Если формат устарел, он автоматически мигрируется в runtime-формат (`server` + `logging`).
|
|
||||||
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Сборка образа
|
|
||||||
docker build -t quoteforge .
|
|
||||||
|
|
||||||
# Запуск с docker-compose
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
quoteforge/
|
|
||||||
├── cmd/
|
|
||||||
│ ├── server/main.go # Main HTTP server
|
|
||||||
│ └── importer/main.go # Import metadata from lot table
|
|
||||||
├── internal/
|
|
||||||
│ ├── config/ # Конфигурация
|
|
||||||
│ ├── models/ # GORM модели
|
|
||||||
│ ├── handlers/ # HTTP handlers
|
|
||||||
│ ├── services/ # Бизнес-логика
|
|
||||||
│ ├── middleware/ # Auth, CORS, etc.
|
|
||||||
│ └── repository/ # Работа с БД
|
|
||||||
├── web/
|
|
||||||
│ ├── templates/ # HTML шаблоны
|
|
||||||
│ └── static/ # CSS, JS, изображения
|
|
||||||
├── migrations/ # SQL миграции
|
|
||||||
├── config.example.yaml # Пример конфигурации
|
|
||||||
├── releases/
|
|
||||||
│ └── memory/ # Changelog между тегами (v1.2.1.md, v1.2.2.md, ...)
|
|
||||||
└── go.mod
|
|
||||||
```
|
|
||||||
|
|
||||||
## Releases & Changelog
|
## Releases & Changelog
|
||||||
|
|
||||||
Change log между версиями хранится в `releases/memory/` каталоге в файлах вида `v{major}.{minor}.{patch}.md`.
|
Changelog между версиями: `releases/memory/v{major}.{minor}.{patch}.md`
|
||||||
|
|
||||||
Каждый файл содержит:
|
---
|
||||||
- Список коммитов между версиями
|
|
||||||
- Описание изменений и их влияния
|
|
||||||
- Breaking changes и заметки о миграции
|
|
||||||
|
|
||||||
**Перед работой над кодом проверьте последний файл в этой папке, чтобы понять текущее состояние проекта.**
|
|
||||||
|
|
||||||
## Роли пользователей
|
|
||||||
|
|
||||||
| Роль | Описание |
|
|
||||||
|------|----------|
|
|
||||||
| `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 |
|
|
||||||
| `QFS_BACKUP_DIR` | Каталог для ротационных бэкапов локальных данных | `<db dir>/backups` |
|
|
||||||
| `QFS_BACKUP_DISABLE` | Отключить автоматические бэкапы (`1/true/yes`) | — |
|
|
||||||
|
|
||||||
## Интеграция с существующей БД
|
|
||||||
|
|
||||||
QuoteForge интегрируется с существующей базой RFQ_LOG:
|
|
||||||
|
|
||||||
- `lot` — справочник компонентов (только чтение)
|
|
||||||
- `lot_log` — история цен от поставщиков (только чтение)
|
|
||||||
- `supplier` — справочник поставщиков (только чтение)
|
|
||||||
|
|
||||||
Новые таблицы QuoteForge имеют префикс `qt_`:
|
|
||||||
|
|
||||||
- `qt_users` — пользователи приложения
|
|
||||||
- `qt_lot_metadata` — расширенные данные компонентов
|
|
||||||
- `qt_configurations` — сохранённые конфигурации
|
|
||||||
- `qt_pricing_alerts` — алерты для администраторов
|
|
||||||
|
|
||||||
## Поддержка
|
## Поддержка
|
||||||
|
|
||||||
По вопросам работы приложения обращайтесь:
|
|
||||||
- Email: mike@mchus.pro
|
- Email: mike@mchus.pro
|
||||||
- Internal: @mchus
|
- Internal: @mchus
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
Данное программное обеспечение является собственностью компании и предназначено исключительно для внутреннего использования. Распространение, копирование или модификация без письменного разрешения запрещены.
|
Собственность компании, только для внутреннего использования. См. [LICENSE](LICENSE).
|
||||||
|
|
||||||
См. файл [LICENSE](LICENSE) для подробностей.
|
|
||||||
|
|||||||
1
bible
Submodule
1
bible
Submodule
Submodule bible added at 472c8a6918
119
bible-local/01-overview.md
Normal file
119
bible-local/01-overview.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# 01 — Product Overview
|
||||||
|
|
||||||
|
## What is QuoteForge
|
||||||
|
|
||||||
|
A corporate server configuration and quotation tool.
|
||||||
|
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only for synchronization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
- Mobile-first interface — works comfortably on phones and tablets
|
||||||
|
- Server configurator — step-by-step component selection
|
||||||
|
- Automatic price calculation — based on pricelists from local cache
|
||||||
|
- CSV export — ready-to-use specifications for clients
|
||||||
|
- Configuration history — versioned snapshots with rollback support
|
||||||
|
- Full offline operation — continue working without network, sync later
|
||||||
|
- Guarded synchronization — sync is blocked by preflight check if local schema is not ready
|
||||||
|
|
||||||
|
### User Roles
|
||||||
|
|
||||||
|
| Role | Permissions |
|
||||||
|
|------|-------------|
|
||||||
|
| `viewer` | View, create quotes, export |
|
||||||
|
| `editor` | + save configurations |
|
||||||
|
| `pricing_admin` | + manage prices and alerts |
|
||||||
|
| `admin` | Full access, user management |
|
||||||
|
|
||||||
|
### Price Freshness Indicators
|
||||||
|
|
||||||
|
| Color | Status | Condition |
|
||||||
|
|-------|--------|-----------|
|
||||||
|
| Green | Fresh | < 30 days, ≥ 3 sources |
|
||||||
|
| Yellow | Normal | 30–60 days |
|
||||||
|
| Orange | Aging | 60–90 days |
|
||||||
|
| Red | Stale | > 90 days or no data |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Stack |
|
||||||
|
|-------|-------|
|
||||||
|
| Backend | Go 1.22+, Gin, GORM |
|
||||||
|
| Frontend | HTML, Tailwind CSS, htmx |
|
||||||
|
| Local DB | SQLite (`qfs.db`) |
|
||||||
|
| Server DB | MariaDB 11+ (sync + server admin) |
|
||||||
|
| Export | encoding/csv, excelize (XLSX) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Scope
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
- Component configurator and quotation calculation
|
||||||
|
- Projects and configurations
|
||||||
|
- Read-only pricelist viewing from local cache
|
||||||
|
- Sync (pull components/pricelists, push local changes)
|
||||||
|
|
||||||
|
**Out of scope (removed intentionally — do not restore):**
|
||||||
|
- Admin pricing UI/API
|
||||||
|
- Stock import
|
||||||
|
- Alerts
|
||||||
|
- Cron/importer utilities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
quoteforge/
|
||||||
|
├── cmd/
|
||||||
|
│ ├── qfs/main.go # HTTP server entry point
|
||||||
|
│ ├── migrate/ # Migration tool
|
||||||
|
│ └── migrate_ops_projects/ # OPS project migrator
|
||||||
|
├── internal/
|
||||||
|
│ ├── appmeta/ # App version metadata
|
||||||
|
│ ├── appstate/ # State management, backup
|
||||||
|
│ ├── article/ # Article generation
|
||||||
|
│ ├── config/ # Config parsing
|
||||||
|
│ ├── db/ # DB initialization
|
||||||
|
│ ├── handlers/ # HTTP handlers
|
||||||
|
│ ├── localdb/ # SQLite layer
|
||||||
|
│ ├── lotmatch/ # Lot matching logic
|
||||||
|
│ ├── middleware/ # Auth, CORS, etc.
|
||||||
|
│ ├── models/ # GORM models
|
||||||
|
│ ├── repository/ # Repository layer
|
||||||
|
│ └── services/ # Business logic
|
||||||
|
├── web/
|
||||||
|
│ ├── templates/ # HTML templates + partials
|
||||||
|
│ └── static/ # CSS, JS, assets
|
||||||
|
├── migrations/ # SQL migration files (30+)
|
||||||
|
├── bible/ # Architectural documentation (this section)
|
||||||
|
├── releases/memory/ # Per-version changelogs
|
||||||
|
├── config.example.yaml # Config template (the only one in repo)
|
||||||
|
└── go.mod
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration with Existing DB
|
||||||
|
|
||||||
|
QuoteForge integrates with the existing `RFQ_LOG` database:
|
||||||
|
|
||||||
|
**Read-only:**
|
||||||
|
- `lot` — component catalog
|
||||||
|
- `qt_lot_metadata` — extended component data
|
||||||
|
- `qt_categories` — categories
|
||||||
|
- `qt_pricelists`, `qt_pricelist_items` — pricelists
|
||||||
|
|
||||||
|
**Read + Write:**
|
||||||
|
- `qt_configurations` — configurations
|
||||||
|
- `qt_projects` — projects
|
||||||
|
|
||||||
|
**Sync service tables:**
|
||||||
|
- `qt_client_local_migrations` — migration catalog (SELECT only)
|
||||||
|
- `qt_client_schema_state` — applied migrations state
|
||||||
|
- `qt_pricelist_sync_status` — pricelist sync status
|
||||||
220
bible-local/02-architecture.md
Normal file
220
bible-local/02-architecture.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# 02 — Architecture
|
||||||
|
|
||||||
|
## Local-First Principle
|
||||||
|
|
||||||
|
**SQLite** is the single source of truth for the user.
|
||||||
|
**MariaDB** is a sync server only — it never blocks local operations.
|
||||||
|
|
||||||
|
```
|
||||||
|
User
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SQLite (qfs.db) ← all CRUD operations go here
|
||||||
|
│
|
||||||
|
│ background sync (every 5 min)
|
||||||
|
▼
|
||||||
|
MariaDB (RFQ_LOG) ← pull/push only
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- All CRUD operations go through SQLite only
|
||||||
|
- If MariaDB is unavailable → local work continues without restrictions
|
||||||
|
- Changes are queued in `pending_changes` and pushed on next sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Synchronization
|
||||||
|
|
||||||
|
### Data Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
[ SERVER / MariaDB ]
|
||||||
|
┌───────────────────────────┐
|
||||||
|
│ qt_projects │
|
||||||
|
│ qt_configurations │
|
||||||
|
│ qt_pricelists │
|
||||||
|
│ qt_pricelist_items │
|
||||||
|
│ qt_pricelist_sync_status │
|
||||||
|
└─────────────┬─────────────┘
|
||||||
|
│
|
||||||
|
pull (projects/configs/pricelists)
|
||||||
|
│
|
||||||
|
┌────────────────────┴────────────────────┐
|
||||||
|
│ │
|
||||||
|
[ CLIENT A / SQLite ] [ CLIENT B / SQLite ]
|
||||||
|
local_projects local_projects
|
||||||
|
local_configurations local_configurations
|
||||||
|
local_pricelists local_pricelists
|
||||||
|
local_pricelist_items local_pricelist_items
|
||||||
|
pending_changes pending_changes
|
||||||
|
│ │
|
||||||
|
└────── push (projects/configs only) ─────┘
|
||||||
|
│
|
||||||
|
[ SERVER / MariaDB ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sync Direction by Entity
|
||||||
|
|
||||||
|
| Entity | Direction |
|
||||||
|
|--------|-----------|
|
||||||
|
| Configurations | Client ↔ Server ↔ Other Clients |
|
||||||
|
| Projects | Client ↔ Server ↔ Other Clients |
|
||||||
|
| Pricelists | Server → Clients only (no push) |
|
||||||
|
| Components | Server → Clients only |
|
||||||
|
|
||||||
|
Local pricelists not present on the server and not referenced by active configurations are deleted automatically on sync.
|
||||||
|
|
||||||
|
### Soft Deletes (Archive Pattern)
|
||||||
|
|
||||||
|
Configurations and projects are **never hard-deleted**. Deletion is archive via `is_active = false`.
|
||||||
|
|
||||||
|
- `DELETE /api/configs/:uuid` → sets `is_active = false` (archived); can be restored via `reactivate`
|
||||||
|
- `DELETE /api/projects/:uuid` → archives a project **variant** only (`variant` field must be non-empty); main projects cannot be deleted via this endpoint
|
||||||
|
|
||||||
|
## Sync Readiness Guard
|
||||||
|
|
||||||
|
Before every push/pull, a preflight check runs:
|
||||||
|
1. Is the server (MariaDB) reachable?
|
||||||
|
2. Can centralized local DB migrations be applied?
|
||||||
|
3. Does the application version satisfy `min_app_version` of pending migrations?
|
||||||
|
|
||||||
|
**If the check fails:**
|
||||||
|
- Local CRUD continues without restriction
|
||||||
|
- Sync API returns `423 Locked` with `reason_code` and `reason_text`
|
||||||
|
- UI shows a red indicator with the block reason
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pricing
|
||||||
|
|
||||||
|
### Principle
|
||||||
|
|
||||||
|
**Prices come only from `local_pricelist_items`.**
|
||||||
|
Components (`local_components`) are metadata-only — they contain no pricing information.
|
||||||
|
|
||||||
|
### Lookup Pattern
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Look up a price for a line item
|
||||||
|
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
||||||
|
if found && price > 0 {
|
||||||
|
// use price
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside lookupPriceByPricelistID:
|
||||||
|
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
|
||||||
|
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Level Pricelists
|
||||||
|
|
||||||
|
A configuration can reference up to three pricelists simultaneously:
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `pricelist_id` | Primary (estimate) |
|
||||||
|
| `warehouse_pricelist_id` | Warehouse pricing |
|
||||||
|
| `competitor_pricelist_id` | Competitor pricing |
|
||||||
|
|
||||||
|
Pricelist sources: `estimate` | `warehouse` | `competitor`
|
||||||
|
|
||||||
|
### "Auto" Pricelist Selection
|
||||||
|
|
||||||
|
Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`):
|
||||||
|
|
||||||
|
- **Explicit mode:** concrete `pricelist_id` is set by user in settings.
|
||||||
|
- **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist.
|
||||||
|
|
||||||
|
`auto` must stay `auto` after price-level refresh and after manual "refresh prices":
|
||||||
|
- resolved IDs are runtime-only and must not overwrite user's mode;
|
||||||
|
- switching to explicit selection must clear runtime auto resolution for that source.
|
||||||
|
|
||||||
|
### Latest Pricelist Resolution Rules
|
||||||
|
|
||||||
|
For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with:
|
||||||
|
|
||||||
|
1. only pricelists that have at least one item (`EXISTS ...pricelist_items`);
|
||||||
|
2. deterministic sort: `created_at DESC, id DESC`.
|
||||||
|
|
||||||
|
This prevents selecting empty/incomplete snapshots and removes nondeterministic ties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Versioning
|
||||||
|
|
||||||
|
### Principle
|
||||||
|
|
||||||
|
Append-only for **spec+price** changes: immutable snapshots are stored in `local_configuration_versions`.
|
||||||
|
|
||||||
|
```
|
||||||
|
local_configurations
|
||||||
|
└── current_version_id ──► local_configuration_versions (v3) ← active
|
||||||
|
local_configuration_versions (v2)
|
||||||
|
local_configuration_versions (v1)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `version_no = max + 1` when configuration **spec+price** changes
|
||||||
|
- Old versions are never modified or deleted in normal flow
|
||||||
|
- Rollback does **not** rewind history — it creates a **new** version from the snapshot
|
||||||
|
- Operational updates (`line_no` reorder, server count, project move, rename)
|
||||||
|
are synced via `pending_changes` but do **not** create a new revision snapshot
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/configs/:uuid/rollback
|
||||||
|
{
|
||||||
|
"target_version": 3,
|
||||||
|
"note": "optional comment"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- A new version `vN` is created with `data` from the target version
|
||||||
|
- `change_note = "rollback to v{target_version}"` (+ note if provided)
|
||||||
|
- `current_version_id` is switched to the new version
|
||||||
|
- Configuration moves to `sync_status = pending`
|
||||||
|
|
||||||
|
### Sync Status Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
local → pending → synced
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Specification Ordering (`Line`)
|
||||||
|
|
||||||
|
- Each project configuration has persistent `line_no` (`10,20,30...`) in both SQLite and MariaDB.
|
||||||
|
- Project list ordering is deterministic:
|
||||||
|
`line_no ASC`, then `created_at DESC`, then `id DESC`.
|
||||||
|
- Drag-and-drop reorder in project UI updates `line_no` for active project configurations.
|
||||||
|
- Reorder writes are queued as configuration `update` events in `pending_changes`
|
||||||
|
without creating new configuration versions.
|
||||||
|
- Backward compatibility: if remote MariaDB schema does not yet include `line_no`,
|
||||||
|
sync falls back to create/update without `line_no` instead of failing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync Payload for Versioning
|
||||||
|
|
||||||
|
Events in `pending_changes` for configurations contain:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `configuration_uuid` | Identifier |
|
||||||
|
| `operation` | `create` / `update` / `rollback` |
|
||||||
|
| `current_version_id` | Active version ID |
|
||||||
|
| `current_version_no` | Version number |
|
||||||
|
| `snapshot` | Current configuration state |
|
||||||
|
| `idempotency_key` | For idempotent push |
|
||||||
|
| `conflict_policy` | `last_write_wins` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background Processes
|
||||||
|
|
||||||
|
| Process | Interval | What it does |
|
||||||
|
|---------|----------|--------------|
|
||||||
|
| Sync worker | 5 min | push pending + pull all |
|
||||||
|
| Backup scheduler | configurable (`backup.time`) | creates ZIP archives |
|
||||||
204
bible-local/03-database.md
Normal file
204
bible-local/03-database.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# 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` |
|
||||||
|
|
||||||
|
#### Partnumber Books (PN → LOT mapping, pull-only from PriceForge)
|
||||||
|
|
||||||
|
| Table | Purpose | Key Fields |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| `local_partnumber_books` | Version snapshots of PN→LOT mappings | `id`, `server_id` (unique), `version`, `created_at`, `is_active` |
|
||||||
|
| `local_partnumber_book_items` | PN→LOT mapping rows | `id`, `book_id` (FK), `partnumber`, `lot_name`, `is_primary_pn` |
|
||||||
|
|
||||||
|
Active book: `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
|
||||||
|
|
||||||
|
`is_primary_pn=1` means this PN's quantity in the vendor BOM determines qty(LOT). If only non-primary PNs are present for a LOT, qty defaults to 1.
|
||||||
|
|
||||||
|
#### Configurations and Projects
|
||||||
|
|
||||||
|
| Table | Purpose | Key Fields |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `vendor_spec` (JSON: PN/qty/description + canonical `lot_mappings[]`), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
|
||||||
|
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
|
||||||
|
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
|
||||||
|
|
||||||
|
#### Sync
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `pending_changes` | Queue of changes to push to MariaDB |
|
||||||
|
| `local_schema_migrations` | Applied migrations (idempotency guard) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Key SQLite Indexes
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Pricelists
|
||||||
|
INDEX local_pricelist_items(pricelist_id)
|
||||||
|
UNIQUE INDEX local_pricelists(server_id)
|
||||||
|
INDEX local_pricelists(source, created_at) -- used for "latest by source" queries
|
||||||
|
-- latest-by-source runtime query also applies deterministic tie-break by id DESC
|
||||||
|
|
||||||
|
-- Configurations
|
||||||
|
INDEX local_configurations(pricelist_id)
|
||||||
|
INDEX local_configurations(warehouse_pricelist_id)
|
||||||
|
INDEX local_configurations(competitor_pricelist_id)
|
||||||
|
INDEX local_configurations(project_uuid, line_no) -- project ordering (Line column)
|
||||||
|
UNIQUE INDEX local_configurations(uuid)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `items` JSON Structure in Configurations
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"lot_name": "CPU_AMD_9654",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit_price": 123456.78,
|
||||||
|
"section": "Processors"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prices are stored inside the `items` JSON field and refreshed from the pricelist on configuration refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MariaDB (server-side, sync-only)
|
||||||
|
|
||||||
|
Database: `RFQ_LOG`
|
||||||
|
|
||||||
|
### Tables and Permissions
|
||||||
|
|
||||||
|
| Table | Purpose | Permissions |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `lot` | Component catalog | SELECT |
|
||||||
|
| `qt_lot_metadata` | Extended component data | SELECT |
|
||||||
|
| `qt_categories` | Component categories | SELECT |
|
||||||
|
| `qt_pricelists` | Pricelists | SELECT |
|
||||||
|
| `qt_pricelist_items` | Pricelist line items | SELECT |
|
||||||
|
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
|
||||||
|
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
|
||||||
|
| `qt_configurations` | Saved configurations (includes `line_no`) | 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 |
|
||||||
|
| `qt_partnumber_books` | Partnumber book snapshots (written by PriceForge) | SELECT |
|
||||||
|
| `qt_partnumber_book_items` | PN→LOT mapping rows (written by PriceForge) | SELECT |
|
||||||
|
| `qt_vendor_partnumber_seen` | Vendor PN tracking for unresolved/ignored BOM rows (`is_ignored`) | 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>'@'%';
|
||||||
|
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
|
||||||
|
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen 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'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'quote_user'@'%';
|
||||||
|
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen 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) — три уровня, выполняются при каждом старте
|
||||||
|
|
||||||
|
**1. GORM AutoMigrate** (`internal/localdb/localdb.go`) — первый и основной уровень.
|
||||||
|
Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**.
|
||||||
|
→ Для добавления новой таблицы или колонки достаточно добавить модель/поле и включить модель в AutoMigrate.
|
||||||
|
|
||||||
|
**2. `runLocalMigrations`** (`internal/localdb/migrations.go`) — второй уровень, для операций которые AutoMigrate не умеет: backfill данных, пересоздание таблиц, создание индексов.
|
||||||
|
Каждая функция выполняется один раз — идемпотентность через запись `id` в `local_schema_migrations`.
|
||||||
|
|
||||||
|
**3. Централизованные (server-side)** — третий уровень, при проверке готовности к синку.
|
||||||
|
SQL-тексты хранятся в `qt_client_local_migrations` (MariaDB, пишет только PriceForge). Клиент читает, применяет к локальной SQLite, записывает в `local_remote_migrations_applied` + `qt_client_schema_state`.
|
||||||
|
|
||||||
|
### MariaDB Migrations (server-side)
|
||||||
|
|
||||||
|
- Stored in `migrations/` (SQL files)
|
||||||
|
- Applied via `-migrate` flag
|
||||||
|
- `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"
|
||||||
|
```
|
||||||
163
bible-local/04-api.md
Normal file
163
bible-local/04-api.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# 04 — API and Web Routes
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | `/setup` | Initial setup page |
|
||||||
|
| POST | `/setup` | Save connection settings |
|
||||||
|
| POST | `/setup/test` | Test MariaDB connection |
|
||||||
|
| GET | `/setup/status` | Setup status |
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | `/api/components` | List components (metadata only) |
|
||||||
|
| GET | `/api/components/:lot_name` | Component by lot_name |
|
||||||
|
| GET | `/api/categories` | List categories |
|
||||||
|
|
||||||
|
### Quote
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| POST | `/api/quote/validate` | Validate line items |
|
||||||
|
| POST | `/api/quote/calculate` | Calculate quote (prices from pricelist) |
|
||||||
|
| POST | `/api/quote/price-levels` | Prices by level (estimate/warehouse/competitor) |
|
||||||
|
|
||||||
|
### Pricelists (read-only)
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | `/api/pricelists` | List pricelists (`source`, `active_only`, pagination) |
|
||||||
|
| GET | `/api/pricelists/latest` | Latest pricelist by source |
|
||||||
|
| GET | `/api/pricelists/:id` | Pricelist by ID |
|
||||||
|
| GET | `/api/pricelists/:id/items` | Pricelist line items |
|
||||||
|
| GET | `/api/pricelists/:id/lots` | Lot names in pricelist |
|
||||||
|
|
||||||
|
`GET /api/pricelists?active_only=true` returns only pricelists that have synced items (`item_count > 0`).
|
||||||
|
|
||||||
|
### Configurations
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | `/api/configs` | List configurations |
|
||||||
|
| POST | `/api/configs` | Create configuration |
|
||||||
|
| GET | `/api/configs/:uuid` | Get configuration |
|
||||||
|
| PUT | `/api/configs/:uuid` | Update configuration |
|
||||||
|
| DELETE | `/api/configs/:uuid` | Archive configuration |
|
||||||
|
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
|
||||||
|
| POST | `/api/configs/:uuid/clone` | Clone configuration |
|
||||||
|
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
|
||||||
|
| POST | `/api/configs/:uuid/rename` | Rename configuration |
|
||||||
|
| POST | `/api/configs/preview-article` | Preview generated article for a configuration |
|
||||||
|
| POST | `/api/configs/:uuid/rollback` | Roll back to a version |
|
||||||
|
| GET | `/api/configs/:uuid/versions` | List versions |
|
||||||
|
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
|
||||||
|
|
||||||
|
`line` field in configuration payloads is backed by persistent `line_no` in DB.
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | `/api/projects` | List projects |
|
||||||
|
| POST | `/api/projects` | Create project |
|
||||||
|
| GET | `/api/projects/:uuid` | Get project |
|
||||||
|
| PUT | `/api/projects/:uuid` | Update project |
|
||||||
|
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
|
||||||
|
| GET | `/api/projects/:uuid/configs` | Project configurations |
|
||||||
|
| PATCH | `/api/projects/:uuid/configs/reorder` | Reorder active project configurations (`ordered_uuids`) and persist `line_no` |
|
||||||
|
|
||||||
|
`GET /api/projects/:uuid/configs` ordering:
|
||||||
|
`line ASC`, then `created_at DESC`, then `id DESC`.
|
||||||
|
|
||||||
|
### Sync
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose | Flow |
|
||||||
|
|--------|----------|---------|------|
|
||||||
|
| GET | `/api/sync/status` | Overall sync status | read-only |
|
||||||
|
| GET | `/api/sync/readiness` | Preflight status (ready/blocked/unknown) | read-only |
|
||||||
|
| GET | `/api/sync/info` | Data for sync modal | read-only |
|
||||||
|
| GET | `/api/sync/users-status` | Users status | read-only |
|
||||||
|
| GET | `/api/sync/pending` | List pending changes | read-only |
|
||||||
|
| GET | `/api/sync/pending/count` | Count of pending changes | read-only |
|
||||||
|
| POST | `/api/sync/push` | Push pending → MariaDB | SQLite → MariaDB |
|
||||||
|
| POST | `/api/sync/components` | Pull components | MariaDB → SQLite |
|
||||||
|
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
|
||||||
|
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
|
||||||
|
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
|
||||||
|
| POST | `/api/sync/partnumber-seen` | Report unresolved/ignored vendor PNs for server-side tracking | QuoteForge → MariaDB |
|
||||||
|
|
||||||
|
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
|
||||||
|
|
||||||
|
### Vendor Spec (BOM)
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored vendor BOM |
|
||||||
|
| PUT | `/api/configs/:uuid/vendor-spec` | Replace vendor BOM (full update) |
|
||||||
|
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (read-only) |
|
||||||
|
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `GET` / `PUT /api/configs/:uuid/vendor-spec` exchange normalized BOM rows (`vendor_spec`), not raw pasted Excel layout.
|
||||||
|
- BOM row contract stores canonical LOT mapping list as seen in BOM UI:
|
||||||
|
- `lot_mappings[]`
|
||||||
|
- each mapping contains `lot_name` + `quantity_per_pn`
|
||||||
|
- `POST /api/configs/:uuid/vendor-spec/apply` rebuilds cart items from explicit BOM mappings:
|
||||||
|
- all LOTs from `lot_mappings[]`
|
||||||
|
|
||||||
|
### Partnumber Books (read-only)
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | `/api/partnumber-books` | List local book snapshots |
|
||||||
|
| GET | `/api/partnumber-books/:id` | Items for a book by `server_id` |
|
||||||
|
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
|
||||||
|
|
||||||
|
See [09-vendor-spec.md](09-vendor-spec.md) for schema and pull logic.
|
||||||
|
See [09-vendor-spec.md](09-vendor-spec.md) for `vendor_spec` JSON schema and BOM UI mapping contract.
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
| `/partnumber-books` | Partnumber books (active book summary + snapshot history) |
|
||||||
|
| `/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-local/05-config.md
Normal file
129
bible-local/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-local/06-backup.md
Normal file
221
bible-local/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-local/07-dev.md
Normal file
136
bible-local/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 to the binary's stdout/stderr). No `console.log` or any other logging in browser-side JS — the browser console is never used for diagnostics.
|
||||||
|
- **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)
|
||||||
364
bible-local/09-vendor-spec.md
Normal file
364
bible-local/09-vendor-spec.md
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# 09 — Vendor Spec (BOM Import)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a configuration. It maps vendor part numbers (PN) to internal LOT names using an active partnumber book (snapshot pulled from PriceForge), then aggregates quantities to populate the Estimate (cart).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
| Data | Storage | Sync direction |
|
||||||
|
|------|---------|---------------|
|
||||||
|
| `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT, JSON-encoded) | Two-way via `pending_changes` |
|
||||||
|
| Partnumber book snapshots | `local_partnumber_books` + `local_partnumber_book_items` | Pull-only from PriceForge |
|
||||||
|
|
||||||
|
`vendor_spec` is a JSON array of `VendorSpecItem` objects stored inside the configuration row. It syncs to MariaDB `qt_configurations.vendor_spec` via the existing pending_changes mechanism.
|
||||||
|
|
||||||
|
### `vendor_spec` JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"sort_order": 10,
|
||||||
|
"vendor_partnumber": "ABC-123",
|
||||||
|
"quantity": 2,
|
||||||
|
"description": "...",
|
||||||
|
"unit_price": 4500.00,
|
||||||
|
"total_price": 9000.00,
|
||||||
|
"lot_mappings": [
|
||||||
|
{ "lot_name": "LOT_A", "quantity_per_pn": 1 },
|
||||||
|
{ "lot_name": "LOT_B", "quantity_per_pn": 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`lot_mappings[]` is the canonical persisted LOT mapping list for a BOM row.
|
||||||
|
Each mapping entry stores:
|
||||||
|
|
||||||
|
- `lot_name`
|
||||||
|
- `quantity_per_pn` (how many units of this LOT are included in **one vendor PN**)
|
||||||
|
|
||||||
|
### PN → LOT Mapping Contract (single LOT, multiplier, bundle)
|
||||||
|
|
||||||
|
QuoteForge expects the server to return/store BOM rows (`vendor_spec`) using a single canonical mapping list:
|
||||||
|
|
||||||
|
- `lot_mappings[]` contains **all** LOT mappings for the PN row (single LOT and bundle cases alike)
|
||||||
|
- the list stores exactly what the user sees in BOM (LOT + "LOT в 1 PN")
|
||||||
|
- the DB contract does **not** split mappings into "base LOT" vs "bundle LOTs"
|
||||||
|
|
||||||
|
#### Final quantity contribution to Estimate
|
||||||
|
|
||||||
|
For one BOM row with vendor PN quantity `pn_qty`:
|
||||||
|
|
||||||
|
- each mapping contribution:
|
||||||
|
- `lot_qty = pn_qty * lot_mappings[i].quantity_per_pn`
|
||||||
|
|
||||||
|
#### Example: one PN maps to multiple LOTs
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vendor_partnumber": "SYS-821GE-TNHR",
|
||||||
|
"quantity": 3,
|
||||||
|
"lot_mappings": [
|
||||||
|
{ "lot_name": "CHASSIS_X13_8GPU", "quantity_per_pn": 1 },
|
||||||
|
{ "lot_name": "PS_3000W_Titanium", "quantity_per_pn": 2 },
|
||||||
|
{ "lot_name": "RAILKIT_X13", "quantity_per_pn": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This row contributes to Estimate:
|
||||||
|
|
||||||
|
- `CHASSIS_X13_8GPU` → `3 * 1 = 3`
|
||||||
|
- `PS_3000W_Titanium` → `3 * 2 = 6`
|
||||||
|
- `RAILKIT_X13` → `3 * 1 = 3`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Partnumber Books (Snapshots)
|
||||||
|
|
||||||
|
Partnumber books are immutable versioned snapshots of the global PN→LOT mapping table, analogous to pricelists. PriceForge creates new snapshots; QuoteForge only pulls and reads them.
|
||||||
|
|
||||||
|
### SQLite (local mirror)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE local_partnumber_books (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER UNIQUE NOT NULL, -- id from qt_partnumber_books
|
||||||
|
version TEXT NOT NULL, -- format YYYY-MM-DD-NNN
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE local_partnumber_book_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
book_id INTEGER NOT NULL, -- FK → local_partnumber_books.id
|
||||||
|
partnumber TEXT NOT NULL,
|
||||||
|
lot_name TEXT NOT NULL,
|
||||||
|
is_primary_pn INTEGER NOT NULL DEFAULT 0,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_local_book_pn ON local_partnumber_book_items(book_id, partnumber);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Active book query:** `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
|
||||||
|
|
||||||
|
**Schema creation:** GORM AutoMigrate (not `runLocalMigrations`).
|
||||||
|
|
||||||
|
### MariaDB (managed exclusively by PriceForge)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE qt_partnumber_books (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
version VARCHAR(50) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE qt_partnumber_book_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
book_id INT NOT NULL,
|
||||||
|
partnumber VARCHAR(255) NOT NULL,
|
||||||
|
lot_name VARCHAR(255) NOT NULL,
|
||||||
|
is_primary_pn TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
description VARCHAR(10000) NULL,
|
||||||
|
INDEX idx_book_pn (book_id, partnumber),
|
||||||
|
FOREIGN KEY (book_id) REFERENCES qt_partnumber_books(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE qt_configurations ADD COLUMN vendor_spec JSON NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
QuoteForge has `SELECT` permission only on `qt_partnumber_books` and `qt_partnumber_book_items`. All writes are managed by PriceForge.
|
||||||
|
|
||||||
|
**Grant (add to existing user setup):**
|
||||||
|
```sql
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
|
||||||
|
```
|
||||||
|
|
||||||
|
### `is_primary_pn` semantics
|
||||||
|
|
||||||
|
- `1` = primary PN for this `lot_name`. Its quantity in the vendor BOM determines `qty(LOT)`.
|
||||||
|
- `0` = non-primary (trigger) PN. If no primary PN for the LOT is found in BOM, contributes `qty=1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolution Algorithm (3-step)
|
||||||
|
|
||||||
|
For each `vendor_partnumber` in the BOM, QuoteForge builds/updates UI-visible LOT mappings:
|
||||||
|
|
||||||
|
1. **Active book lookup** — query `local_partnumber_book_items WHERE book_id = activeBook.id AND partnumber = ?`.
|
||||||
|
2. **Populate BOM UI** — if a match exists, BOM row shows a LOT value (user can still edit it).
|
||||||
|
3. **Unresolved** — red row + inline LOT input with strict autocomplete.
|
||||||
|
|
||||||
|
Persistence note: the application stores the final user-visible mappings in `lot_mappings[]` (not separate "resolved/manual" persisted fields).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Qty Aggregation Logic
|
||||||
|
|
||||||
|
After resolution, qty per LOT is computed as:
|
||||||
|
|
||||||
|
```
|
||||||
|
qty(lot) = SUM(quantity of rows where is_primary_pn=1 AND resolved_lot=lot)
|
||||||
|
if at least one primary PN for this lot was found in BOM
|
||||||
|
= 1
|
||||||
|
if only non-primary PNs for this lot were found
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples (book: LOT_A → x1[primary], x2, x3):
|
||||||
|
- BOM: x2×1, x3×2 → 1×LOT_A (no primary PN)
|
||||||
|
- BOM: x1×2, x2×1 → 2×LOT_A (primary qty=2)
|
||||||
|
- BOM: x1×1, x2×3 → 1×LOT_A (primary qty=1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI: Three Top-Level Tabs
|
||||||
|
|
||||||
|
The configurator (`/configurator`) has three tabs:
|
||||||
|
|
||||||
|
1. **Estimate** — existing cart/component configurator (unchanged).
|
||||||
|
2. **BOM** — paste/import vendor BOM, manual column mapping, LOT matching, bundle decomposition (`1 PN -> multiple LOTs`), "Пересчитать эстимейт", "Очистить".
|
||||||
|
3. **Ценообразование** — pricing summary table + custom price input.
|
||||||
|
|
||||||
|
BOM data is shared between tabs 2 and 3.
|
||||||
|
|
||||||
|
### BOM Import UI (raw table, manual column mapping)
|
||||||
|
|
||||||
|
After paste (`Ctrl+V`) QuoteForge renders an editable raw table (not auto-detected parsing).
|
||||||
|
|
||||||
|
- The pasted rows are shown **as-is** (including header rows, if present).
|
||||||
|
- The user selects a type for each column manually:
|
||||||
|
- `P/N`
|
||||||
|
- `Кол-во`
|
||||||
|
- `Цена`
|
||||||
|
- `Описание`
|
||||||
|
- `Не использовать`
|
||||||
|
- Required mapping:
|
||||||
|
- exactly one `P/N`
|
||||||
|
- exactly one `Кол-во`
|
||||||
|
- Optional mapping:
|
||||||
|
- `Цена` (0..1)
|
||||||
|
- `Описание` (0..1)
|
||||||
|
- Rows can be:
|
||||||
|
- ignored (UI-only, excluded from `vendor_spec`)
|
||||||
|
- deleted
|
||||||
|
- Raw cells are editable inline after paste.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- There is **no auto column detection**.
|
||||||
|
- There is **no auto header-row skip**.
|
||||||
|
- Raw import layout itself is not stored on server; only normalized `vendor_spec` is stored.
|
||||||
|
|
||||||
|
### LOT matching in BOM table
|
||||||
|
|
||||||
|
The BOM table adds service columns on the right:
|
||||||
|
|
||||||
|
- `LOT`
|
||||||
|
- `LOT в 1 PN`
|
||||||
|
- actions (`+`, ignore, delete)
|
||||||
|
|
||||||
|
`LOT` behavior:
|
||||||
|
- The first LOT row shown in the BOM UI is the primary LOT mapping for the PN row.
|
||||||
|
- Additional LOT rows are added via the `+` action.
|
||||||
|
- inline LOT input is strict:
|
||||||
|
- autocomplete source = full local components list (`/api/components?per_page=5000`)
|
||||||
|
- free text that does not match an existing LOT is rejected
|
||||||
|
|
||||||
|
`LOT в 1 PN` behavior:
|
||||||
|
- quantity multiplier for each visible LOT row in BOM (`quantity_per_pn` in persisted `lot_mappings[]`)
|
||||||
|
- default = `1`
|
||||||
|
- editable inline
|
||||||
|
|
||||||
|
### Bundle mode (`1 PN -> multiple LOTs`)
|
||||||
|
|
||||||
|
The `+` action in BOM rows adds an extra LOT mapping row for the same vendor PN row.
|
||||||
|
|
||||||
|
- All visible LOT rows (first + added rows) are persisted uniformly in `lot_mappings[]`
|
||||||
|
- Each mapping row has:
|
||||||
|
- LOT
|
||||||
|
- qty (`LOT in 1 PN` = `quantity_per_pn`)
|
||||||
|
|
||||||
|
### BOM restore on config open
|
||||||
|
|
||||||
|
On config open, QuoteForge loads `vendor_spec` from server and reconstructs the editable BOM table in normalized form:
|
||||||
|
|
||||||
|
- columns restored as: `Qty | P/N | Description | Price`
|
||||||
|
- column mapping restored as:
|
||||||
|
- `qty`, `pn`, `description`, `price`
|
||||||
|
- LOT / `LOT в 1 PN` rows are restored from `vendor_spec.lot_mappings[]`
|
||||||
|
|
||||||
|
This restores the BOM editing state, but not the original raw Excel layout (extra columns, ignored rows, original headers).
|
||||||
|
|
||||||
|
### Pricing Tab: column order
|
||||||
|
|
||||||
|
```
|
||||||
|
LOT | PN вендора | Описание | Кол-во | Estimate | Цена вендора | Склад | Конк.
|
||||||
|
```
|
||||||
|
|
||||||
|
**If BOM is empty** — the pricing tab still renders, using cart items as rows (PN вендора = "—", Цена вендора = "—").
|
||||||
|
|
||||||
|
**Description source priority:** BOM row description → LOT description from `local_components`.
|
||||||
|
|
||||||
|
### Pricing Tab: BOM + Estimate merge behavior
|
||||||
|
|
||||||
|
When BOM exists, the pricing tab renders:
|
||||||
|
|
||||||
|
- BOM-based rows (including rows resolved via manual LOT and bundle mappings)
|
||||||
|
- plus **Estimate-only LOTs** (rows currently in cart but not covered by BOM mappings)
|
||||||
|
|
||||||
|
Estimate-only rows are shown as separate rows with:
|
||||||
|
- `PN вендора = "—"`
|
||||||
|
- vendor price = `—`
|
||||||
|
- description from local components
|
||||||
|
|
||||||
|
### Pricing Tab: "Своя цена" input
|
||||||
|
|
||||||
|
- Manual entry → proportionally redistributes custom price into "Цена вендора" cells (proportional to each row's Estimate share). Last row absorbs rounding remainder.
|
||||||
|
- "Проставить цены BOM" button → restores per-row original BOM prices directly (no proportional redistribution). Sets "Своя цена" to their sum.
|
||||||
|
- Both paths show "Скидка от Estimate: X%" info.
|
||||||
|
- "Экспорт CSV" button → downloads `pricing_<uuid>.csv` with UTF-8 BOM, same column order as table, plus Итого row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | URL | Description |
|
||||||
|
|--------|-----|-------------|
|
||||||
|
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored BOM |
|
||||||
|
| PUT | `/api/configs/:uuid/vendor-spec` | Replace BOM (full update) |
|
||||||
|
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (no cart mutation) |
|
||||||
|
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
|
||||||
|
| GET | `/api/partnumber-books` | List local book snapshots |
|
||||||
|
| GET | `/api/partnumber-books/:id` | Items for a book by server_id |
|
||||||
|
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
|
||||||
|
| POST | `/api/sync/partnumber-seen` | Push unresolved PNs to `qt_vendor_partnumber_seen` on MariaDB |
|
||||||
|
|
||||||
|
## Unresolved PN Tracking (`qt_vendor_partnumber_seen`)
|
||||||
|
|
||||||
|
After each `resolveBOM()` call, QuoteForge pushes PN rows to `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored):
|
||||||
|
|
||||||
|
- unresolved BOM rows (`ignored = false`)
|
||||||
|
- raw BOM rows explicitly marked as ignored in UI (`ignored = true`) — these rows are **not** saved to `vendor_spec`, but are reported for server-side tracking
|
||||||
|
|
||||||
|
The handler calls `sync.PushPartnumberSeen()` which upserts into `qt_vendor_partnumber_seen`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
||||||
|
VALUES ('manual', '', ?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_seen_at = VALUES(last_seen_at),
|
||||||
|
is_ignored = VALUES(is_ignored),
|
||||||
|
description = COALESCE(NULLIF(VALUES(description), ''), description)
|
||||||
|
```
|
||||||
|
|
||||||
|
Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge uses this table for populating the partnumber book.
|
||||||
|
|
||||||
|
## BOM Persistence
|
||||||
|
|
||||||
|
- `vendor_spec` is saved to server via `PUT /api/configs/:uuid/vendor-spec`.
|
||||||
|
- `GET` / `PUT` `vendor_spec` must preserve row-level mapping fields used by the UI:
|
||||||
|
- `lot_mappings[]`
|
||||||
|
- each item: `lot_name`, `quantity_per_pn`
|
||||||
|
- `description` is persisted in each BOM row and is used by the Pricing tab when available.
|
||||||
|
- Ignored raw rows are **not** persisted into `vendor_spec`.
|
||||||
|
- The PUT handler explicitly marshals `VendorSpec` to JSON string before passing to GORM `Update` (GORM does not reliably call `driver.Valuer` for custom types in `Update(column, value)`).
|
||||||
|
- BOM is autosaved (debounced) after BOM-changing actions, including:
|
||||||
|
- `resolveBOM()`
|
||||||
|
- LOT row qty (`LOT в 1 PN`) changes
|
||||||
|
- LOT row add/remove (`+` / delete in bundle context)
|
||||||
|
- "Сохранить BOM" button triggers explicit save.
|
||||||
|
|
||||||
|
## Pricing Tab: Estimate Price Source
|
||||||
|
|
||||||
|
`renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with LOTs collected from:
|
||||||
|
|
||||||
|
- `lot_mappings[]` from BOM rows
|
||||||
|
- current Estimate/cart LOTs not covered by BOM mappings (to show estimate-only rows)
|
||||||
|
|
||||||
|
This ensures Estimate prices appear for:
|
||||||
|
|
||||||
|
- manually matched LOTs in the BOM tab
|
||||||
|
- bundle LOTs
|
||||||
|
- LOTs already present in Estimate but not mapped from BOM
|
||||||
|
|
||||||
|
### Apply to Estimate (`Пересчитать эстимейт`)
|
||||||
|
|
||||||
|
When applying BOM to Estimate, QuoteForge builds cart rows from explicit UI mappings stored in `lot_mappings[]`.
|
||||||
|
|
||||||
|
For a BOM row with PN qty = `Q`:
|
||||||
|
|
||||||
|
- each mapped LOT contributes `Q * quantity_per_pn`
|
||||||
|
|
||||||
|
Rows without any valid LOT mapping are skipped.
|
||||||
|
|
||||||
|
## Web Route
|
||||||
|
|
||||||
|
| Route | Page |
|
||||||
|
|-------|------|
|
||||||
|
| `/partnumber-books` | Partnumber books — active book summary (unique LOTs, total PN, primary PN count), searchable items table, collapsible snapshot history |
|
||||||
55
bible-local/README.md
Normal file
55
bible-local/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`
|
||||||
136
cmd/qfs/main.go
136
cmd/qfs/main.go
@@ -46,8 +46,11 @@ var Version = "dev"
|
|||||||
|
|
||||||
const backgroundSyncInterval = 5 * time.Minute
|
const backgroundSyncInterval = 5 * time.Minute
|
||||||
const onDemandPullCooldown = 30 * time.Second
|
const onDemandPullCooldown = 30 * time.Second
|
||||||
|
const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
showStartupConsoleWarning()
|
||||||
|
|
||||||
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
|
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)")
|
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)")
|
resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
|
||||||
@@ -303,6 +306,13 @@ 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 {
|
func shouldResetLocalDB(flagValue bool) bool {
|
||||||
if flagValue {
|
if flagValue {
|
||||||
return true
|
return true
|
||||||
@@ -326,8 +336,6 @@ func derefString(value *string) string {
|
|||||||
return *value
|
return *value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func setConfigDefaults(cfg *config.Config) {
|
func setConfigDefaults(cfg *config.Config) {
|
||||||
if cfg.Server.Host == "" {
|
if cfg.Server.Host == "" {
|
||||||
cfg.Server.Host = "127.0.0.1"
|
cfg.Server.Host = "127.0.0.1"
|
||||||
@@ -547,11 +555,11 @@ func runSetupMode(local *localdb.LocalDB) {
|
|||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(gin.Recovery())
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
staticPath := filepath.Join("web", "static")
|
if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||||
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
|
||||||
router.Static("/static", staticPath)
|
|
||||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
|
||||||
router.StaticFS("/static", http.FS(staticFS))
|
router.StaticFS("/static", http.FS(staticFS))
|
||||||
|
} else {
|
||||||
|
slog.Error("failed to load embedded static assets", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup routes only
|
// Setup routes only
|
||||||
@@ -812,6 +820,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||||
exportHandler := handlers.NewExportHandler(exportService, configService, projectService)
|
exportHandler := handlers.NewExportHandler(exportService, configService, projectService)
|
||||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||||
|
vendorSpecHandler := handlers.NewVendorSpecHandler(local)
|
||||||
|
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
|
||||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||||
@@ -837,11 +847,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
router.Use(middleware.OfflineDetector(connMgr, local))
|
router.Use(middleware.OfflineDetector(connMgr, local))
|
||||||
|
|
||||||
// Static files (use filepath.Join for Windows compatibility)
|
// Static files (use filepath.Join for Windows compatibility)
|
||||||
staticPath := filepath.Join("web", "static")
|
if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||||
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
|
||||||
router.Static("/static", staticPath)
|
|
||||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
|
||||||
router.StaticFS("/static", http.FS(staticFS))
|
router.StaticFS("/static", http.FS(staticFS))
|
||||||
|
} else {
|
||||||
|
return nil, nil, fmt.Errorf("load embedded static assets: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
@@ -933,6 +942,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
router.GET("/configs/:uuid/revisions", webHandler.ConfigRevisions)
|
router.GET("/configs/:uuid/revisions", webHandler.ConfigRevisions)
|
||||||
router.GET("/pricelists", webHandler.Pricelists)
|
router.GET("/pricelists", webHandler.Pricelists)
|
||||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||||
|
router.GET("/partnumber-books", webHandler.PartnumberBooks)
|
||||||
|
|
||||||
// htmx partials
|
// htmx partials
|
||||||
partials := router.Group("/partials")
|
partials := router.Group("/partials")
|
||||||
@@ -982,6 +992,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
pricelists.GET("/:id/lots", pricelistHandler.GetLotNames)
|
pricelists.GET("/:id/lots", pricelistHandler.GetLotNames)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partnumber books (read-only)
|
||||||
|
pnBooks := api.Group("/partnumber-books")
|
||||||
|
{
|
||||||
|
pnBooks.GET("", partnumberBooksHandler.List)
|
||||||
|
pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
|
||||||
|
}
|
||||||
|
|
||||||
// Configurations (public - RBAC disabled)
|
// Configurations (public - RBAC disabled)
|
||||||
configs := api.Group("/configs")
|
configs := api.Group("/configs")
|
||||||
{
|
{
|
||||||
@@ -1079,7 +1096,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
config, err := configService.UpdateNoAuth(uuid, &req)
|
config, err := configService.UpdateNoAuth(uuid, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1293,6 +1319,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
||||||
|
|
||||||
|
// Vendor spec (BOM) endpoints
|
||||||
|
configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec)
|
||||||
|
configs.PUT("/:uuid/vendor-spec", vendorSpecHandler.PutVendorSpec)
|
||||||
|
configs.POST("/:uuid/vendor-spec/resolve", vendorSpecHandler.ResolveVendorSpec)
|
||||||
|
configs.POST("/:uuid/vendor-spec/apply", vendorSpecHandler.ApplyVendorSpec)
|
||||||
|
|
||||||
configs.PATCH("/:uuid/server-count", func(c *gin.Context) {
|
configs.PATCH("/:uuid/server-count", func(c *gin.Context) {
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -1644,6 +1676,43 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
OrderedUUIDs []string `json:"ordered_uuids"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.OrderedUUIDs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ordered_uuids is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, err := configService.ReorderProjectConfigurationsNoAuth(c.Param("uuid"), req.OrderedUUIDs)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
total := 0.0
|
||||||
|
for i := range configs {
|
||||||
|
if configs[i].TotalPrice != nil {
|
||||||
|
total += *configs[i].TotalPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"project_uuid": c.Param("uuid"),
|
||||||
|
"configurations": configs,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
projects.POST("/:uuid/configs", func(c *gin.Context) {
|
projects.POST("/:uuid/configs", func(c *gin.Context) {
|
||||||
var req services.CreateConfigRequest
|
var req services.CreateConfigRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -1691,6 +1760,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
|
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
|
||||||
syncAPI.POST("/components", syncHandler.SyncComponents)
|
syncAPI.POST("/components", syncHandler.SyncComponents)
|
||||||
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
||||||
|
syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks)
|
||||||
|
syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen)
|
||||||
syncAPI.POST("/all", syncHandler.SyncAll)
|
syncAPI.POST("/all", syncHandler.SyncAll)
|
||||||
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
||||||
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
||||||
@@ -1747,11 +1818,37 @@ func requestLogger() gin.HandlerFunc {
|
|||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
query := c.Request.URL.RawQuery
|
query := c.Request.URL.RawQuery
|
||||||
|
|
||||||
|
blw := &captureResponseWriter{
|
||||||
|
ResponseWriter: c.Writer,
|
||||||
|
body: bytes.NewBuffer(nil),
|
||||||
|
}
|
||||||
|
c.Writer = blw
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
status := c.Writer.Status()
|
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",
|
slog.Info("request",
|
||||||
"method", c.Request.Method,
|
"method", c.Request.Method,
|
||||||
"path", path,
|
"path", path,
|
||||||
@@ -1762,3 +1859,22 @@ func requestLogger() gin.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type captureResponseWriter struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
body *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *captureResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
if len(b) > 0 {
|
||||||
|
_, _ = w.body.Write(b)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *captureResponseWriter) WriteString(s string) (int, error) {
|
||||||
|
if s != "" {
|
||||||
|
_, _ = w.body.WriteString(s)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.WriteString(s)
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func TestConfigurationVersioningAPI(t *testing.T) {
|
|||||||
if err := json.Unmarshal(rbRec.Body.Bytes(), &rbResp); err != nil {
|
if err := json.Unmarshal(rbRec.Body.Bytes(), &rbResp); err != nil {
|
||||||
t.Fatalf("unmarshal rollback response: %v", err)
|
t.Fatalf("unmarshal rollback response: %v", err)
|
||||||
}
|
}
|
||||||
if rbResp.Message == "" || rbResp.CurrentVersion.VersionNo != 3 {
|
if rbResp.Message == "" || rbResp.CurrentVersion.VersionNo != 2 {
|
||||||
t.Fatalf("unexpected rollback response: %+v", rbResp)
|
t.Fatalf("unexpected rollback response: %+v", rbResp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
|||||||
if !ok || group != GroupGPU {
|
if !ok || group != GroupGPU {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(strings.ToUpper(it.LotName), "MB_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
model := parseGPUModel(it.LotName)
|
model := parseGPUModel(it.LotName)
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = "UNK"
|
model = "UNK"
|
||||||
@@ -332,7 +335,7 @@ func parseGPUModel(lotName string) string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch p {
|
switch p {
|
||||||
case "NV", "NVIDIA", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
||||||
continue
|
continue
|
||||||
default:
|
default:
|
||||||
if strings.Contains(p, "GB") {
|
if strings.Contains(p, "GB") {
|
||||||
|
|||||||
90
internal/handlers/partnumber_books.go
Normal file
90
internal/handlers/partnumber_books.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PartnumberBooksHandler provides read-only access to local partnumber book snapshots.
|
||||||
|
type PartnumberBooksHandler struct {
|
||||||
|
localDB *localdb.LocalDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPartnumberBooksHandler(localDB *localdb.LocalDB) *PartnumberBooksHandler {
|
||||||
|
return &PartnumberBooksHandler{localDB: localDB}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all local partnumber book snapshots.
|
||||||
|
// GET /api/partnumber-books
|
||||||
|
func (h *PartnumberBooksHandler) List(c *gin.Context) {
|
||||||
|
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||||
|
books, err := bookRepo.ListBooks()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type bookSummary struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ServerID int `json:"server_id"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
ItemCount int64 `json:"item_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries := make([]bookSummary, 0, len(books))
|
||||||
|
for _, b := range books {
|
||||||
|
summaries = append(summaries, bookSummary{
|
||||||
|
ID: b.ID,
|
||||||
|
ServerID: b.ServerID,
|
||||||
|
Version: b.Version,
|
||||||
|
CreatedAt: b.CreatedAt.Format("2006-01-02"),
|
||||||
|
IsActive: b.IsActive,
|
||||||
|
ItemCount: bookRepo.CountBookItems(b.ID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"books": summaries,
|
||||||
|
"total": len(summaries),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItems returns items for a partnumber book by server ID.
|
||||||
|
// GET /api/partnumber-books/:id
|
||||||
|
func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||||
|
|
||||||
|
// Find local book by server_id
|
||||||
|
var book localdb.LocalPartnumberBook
|
||||||
|
if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := bookRepo.GetBookItems(book.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"book_id": book.ServerID,
|
||||||
|
"version": book.Version,
|
||||||
|
"is_active": book.IsActive,
|
||||||
|
"items": items,
|
||||||
|
"total": len(items),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -46,8 +46,30 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
localPLs = filtered
|
localPLs = filtered
|
||||||
}
|
}
|
||||||
if activeOnly {
|
type pricelistWithCount struct {
|
||||||
// Local cache stores only active snapshots for normal operations.
|
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) })
|
sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) })
|
||||||
total := len(localPLs)
|
total := len(localPLs)
|
||||||
@@ -62,10 +84,14 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
|||||||
pageSlice := localPLs[start:end]
|
pageSlice := localPLs[start:end]
|
||||||
summaries := make([]map[string]interface{}, 0, len(pageSlice))
|
summaries := make([]map[string]interface{}, 0, len(pageSlice))
|
||||||
for _, lpl := range pageSlice {
|
for _, lpl := range pageSlice {
|
||||||
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
|
itemCount := int64(0)
|
||||||
usageCount := 0
|
usageCount := 0
|
||||||
if lpl.IsUsed {
|
for _, row := range withCounts {
|
||||||
usageCount = 1
|
if row.pricelist.ID == lpl.ID {
|
||||||
|
itemCount = row.itemCount
|
||||||
|
usageCount = row.usageCount
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
summaries = append(summaries, map[string]interface{}{
|
summaries = append(summaries, map[string]interface{}{
|
||||||
"id": lpl.ServerID,
|
"id": lpl.ServerID,
|
||||||
|
|||||||
@@ -82,3 +82,80 @@ func TestPricelistGetItems_ReturnsLotCategoryFromLocalPricelistItems(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,7 +26,7 @@ type SetupHandler struct {
|
|||||||
restartSig chan 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{
|
funcMap := template.FuncMap{
|
||||||
"sub": func(a, b int) int { return a - b },
|
"sub": func(a, b int) int { return a - b },
|
||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
@@ -37,14 +35,9 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te
|
|||||||
templates := make(map[string]*template.Template)
|
templates := make(map[string]*template.Template)
|
||||||
|
|
||||||
// Load setup template (standalone, no base needed)
|
// Load setup template (standalone, no base needed)
|
||||||
setupPath := filepath.Join(templatesPath, "setup.html")
|
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
var err error
|
var err error
|
||||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
|
||||||
} else {
|
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parsing setup template: %w", err)
|
return nil, fmt.Errorf("parsing setup template: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
stdsync "sync"
|
stdsync "sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,16 +30,9 @@ type SyncHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewSyncHandler creates a new sync handler
|
// 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
|
// Load sync_status partial template
|
||||||
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
tmpl, err := template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
||||||
var tmpl *template.Template
|
|
||||||
var err error
|
|
||||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
|
||||||
tmpl, err = template.ParseFiles(partialPath)
|
|
||||||
} else {
|
|
||||||
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -243,6 +234,33 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
|||||||
h.syncService.RecordSyncHeartbeat()
|
h.syncService.RecordSyncHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite.
|
||||||
|
// POST /api/sync/partnumber-books
|
||||||
|
func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
|
||||||
|
if !h.ensureSyncReadiness(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
pulled, err := h.syncService.PullPartnumberBooks()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("partnumber books pull failed", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, SyncResultResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Partnumber books synced successfully",
|
||||||
|
Synced: pulled,
|
||||||
|
Duration: time.Since(startTime).String(),
|
||||||
|
})
|
||||||
|
h.syncService.RecordSyncHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
// SyncAllResponse represents result of full sync
|
// SyncAllResponse represents result of full sync
|
||||||
type SyncAllResponse struct {
|
type SyncAllResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -652,3 +670,37 @@ func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadine
|
|||||||
h.readinessMu.Unlock()
|
h.readinessMu.Unlock()
|
||||||
return readiness
|
return readiness
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReportPartnumberSeen pushes unresolved vendor partnumbers to qt_vendor_partnumber_seen on MariaDB.
|
||||||
|
// POST /api/sync/partnumber-seen
|
||||||
|
func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Items []struct {
|
||||||
|
Partnumber string `json:"partnumber"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Ignored bool `json:"ignored"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]sync.SeenPartnumber, 0, len(body.Items))
|
||||||
|
for _, it := range body.Items {
|
||||||
|
if it.Partnumber != "" {
|
||||||
|
items = append(items, sync.SeenPartnumber{
|
||||||
|
Partnumber: it.Partnumber,
|
||||||
|
Description: it.Description,
|
||||||
|
Ignored: it.Ignored,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.syncService.PushPartnumberSeen(items); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"reported": len(items)})
|
||||||
|
}
|
||||||
|
|||||||
209
internal/handlers/vendor_spec.go
Normal file
209
internal/handlers/vendor_spec.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
|
||||||
|
type VendorSpecHandler struct {
|
||||||
|
localDB *localdb.LocalDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
||||||
|
return &VendorSpecHandler{localDB: localDB}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupConfig finds an active configuration by UUID using the standard localDB method.
|
||||||
|
func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfiguration, error) {
|
||||||
|
cfg, err := h.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !cfg.IsActive {
|
||||||
|
return nil, errors.New("not active")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVendorSpec returns the vendor spec (BOM) for a configuration.
|
||||||
|
// GET /api/configs/:uuid/vendor-spec
|
||||||
|
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
|
||||||
|
cfg, err := h.lookupConfig(c.Param("uuid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := cfg.VendorSpec
|
||||||
|
if spec == nil {
|
||||||
|
spec = localdb.VendorSpec{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutVendorSpec saves (replaces) the vendor spec for a configuration.
|
||||||
|
// PUT /api/configs/:uuid/vendor-spec
|
||||||
|
func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||||
|
cfg, err := h.lookupConfig(c.Param("uuid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range body.VendorSpec {
|
||||||
|
if body.VendorSpec[i].SortOrder == 0 {
|
||||||
|
body.VendorSpec[i].SortOrder = (i + 1) * 10
|
||||||
|
}
|
||||||
|
// Persist canonical LOT mapping only.
|
||||||
|
body.VendorSpec[i].LotMappings = normalizeLotMappings(body.VendorSpec[i].LotMappings)
|
||||||
|
body.VendorSpec[i].ResolvedLotName = ""
|
||||||
|
body.VendorSpec[i].ResolutionSource = ""
|
||||||
|
body.VendorSpec[i].ManualLotSuggestion = ""
|
||||||
|
body.VendorSpec[i].LotQtyPerPN = 0
|
||||||
|
body.VendorSpec[i].LotAllocations = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := localdb.VendorSpec(body.VendorSpec)
|
||||||
|
specJSON, err := json.Marshal(spec)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
merged := make(map[string]int, len(in))
|
||||||
|
order := make([]string, 0, len(in))
|
||||||
|
for _, m := range in {
|
||||||
|
lot := strings.TrimSpace(m.LotName)
|
||||||
|
if lot == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty := m.QuantityPerPN
|
||||||
|
if qty < 1 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
if _, exists := merged[lot]; !exists {
|
||||||
|
order = append(order, lot)
|
||||||
|
}
|
||||||
|
merged[lot] += qty
|
||||||
|
}
|
||||||
|
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
|
||||||
|
for _, lot := range order {
|
||||||
|
out = append(out, localdb.VendorSpecLotMapping{
|
||||||
|
LotName: lot,
|
||||||
|
QuantityPerPN: merged[lot],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
|
||||||
|
// POST /api/configs/:uuid/vendor-spec/resolve
|
||||||
|
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
||||||
|
if _, err := h.lookupConfig(c.Param("uuid")); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||||
|
resolver := services.NewVendorSpecResolver(bookRepo)
|
||||||
|
|
||||||
|
resolved, err := resolver.Resolve(body.VendorSpec)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
book, _ := bookRepo.GetActiveBook()
|
||||||
|
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"resolved": resolved,
|
||||||
|
"aggregated": aggregated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyVendorSpec applies the resolved BOM to the cart (Estimate items).
|
||||||
|
// POST /api/configs/:uuid/vendor-spec/apply
|
||||||
|
func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
|
||||||
|
cfg, err := h.lookupConfig(c.Param("uuid"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Items []struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newItems := make(localdb.LocalConfigItems, 0, len(body.Items))
|
||||||
|
for _, it := range body.Items {
|
||||||
|
newItems = append(newItems, localdb.LocalConfigItem{
|
||||||
|
LotName: it.LotName,
|
||||||
|
Quantity: it.Quantity,
|
||||||
|
UnitPrice: it.UnitPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsJSON, err := json.Marshal(newItems)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": newItems})
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
@@ -17,7 +15,7 @@ type WebHandler struct {
|
|||||||
componentService *services.ComponentService
|
componentService *services.ComponentService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) {
|
func NewWebHandler(_ string, componentService *services.ComponentService) (*WebHandler, error) {
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"sub": func(a, b int) int { return a - b },
|
"sub": func(a, b int) int { return a - b },
|
||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
@@ -60,27 +58,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
templates := make(map[string]*template.Template)
|
templates := make(map[string]*template.Template)
|
||||||
basePath := filepath.Join(templatesPath, "base.html")
|
|
||||||
useDisk := false
|
|
||||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
|
||||||
useDisk = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load each page template with base
|
// Load each page template with base
|
||||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
|
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html", "partnumber_books.html"}
|
||||||
for _, page := range simplePages {
|
for _, page := range simplePages {
|
||||||
pagePath := filepath.Join(templatesPath, page)
|
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
var err error
|
var err error
|
||||||
if useDisk {
|
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
|
||||||
} else {
|
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||||
qfassets.TemplatesFS,
|
qfassets.TemplatesFS,
|
||||||
"web/templates/base.html",
|
"web/templates/base.html",
|
||||||
"web/templates/"+page,
|
"web/templates/"+page,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -88,20 +75,14 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Index page needs components_list.html as well
|
// Index page needs components_list.html as well
|
||||||
indexPath := filepath.Join(templatesPath, "index.html")
|
|
||||||
componentsListPath := filepath.Join(templatesPath, "components_list.html")
|
|
||||||
var indexTmpl *template.Template
|
var indexTmpl *template.Template
|
||||||
var err error
|
var err error
|
||||||
if useDisk {
|
|
||||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
|
||||||
} else {
|
|
||||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||||
qfassets.TemplatesFS,
|
qfassets.TemplatesFS,
|
||||||
"web/templates/base.html",
|
"web/templates/base.html",
|
||||||
"web/templates/index.html",
|
"web/templates/index.html",
|
||||||
"web/templates/components_list.html",
|
"web/templates/components_list.html",
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -110,17 +91,12 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
// Load partial templates (no base needed)
|
// Load partial templates (no base needed)
|
||||||
partials := []string{"components_list.html"}
|
partials := []string{"components_list.html"}
|
||||||
for _, partial := range partials {
|
for _, partial := range partials {
|
||||||
partialPath := filepath.Join(templatesPath, partial)
|
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
var err error
|
var err error
|
||||||
if useDisk {
|
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
|
||||||
} else {
|
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||||
qfassets.TemplatesFS,
|
qfassets.TemplatesFS,
|
||||||
"web/templates/"+partial,
|
"web/templates/"+partial,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -212,6 +188,10 @@ func (h *WebHandler) PricelistDetail(c *gin.Context) {
|
|||||||
h.render(c, "pricelist_detail.html", gin.H{"ActivePage": "pricelists"})
|
h.render(c, "pricelist_detail.html", gin.H{"ActivePage": "pricelists"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WebHandler) PartnumberBooks(c *gin.Context) {
|
||||||
|
h.render(c, "partnumber_books.html", gin.H{"ActivePage": "partnumber-books"})
|
||||||
|
}
|
||||||
|
|
||||||
// Partials for htmx
|
// Partials for htmx
|
||||||
|
|
||||||
func (h *WebHandler) ComponentsPartial(c *gin.Context) {
|
func (h *WebHandler) ComponentsPartial(c *gin.Context) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
Article: cfg.Article,
|
Article: cfg.Article,
|
||||||
PricelistID: cfg.PricelistID,
|
PricelistID: cfg.PricelistID,
|
||||||
OnlyInStock: cfg.OnlyInStock,
|
OnlyInStock: cfg.OnlyInStock,
|
||||||
|
Line: cfg.Line,
|
||||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||||
CreatedAt: cfg.CreatedAt,
|
CreatedAt: cfg.CreatedAt,
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
@@ -80,6 +81,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
Article: local.Article,
|
Article: local.Article,
|
||||||
PricelistID: local.PricelistID,
|
PricelistID: local.PricelistID,
|
||||||
OnlyInStock: local.OnlyInStock,
|
OnlyInStock: local.OnlyInStock,
|
||||||
|
Line: local.Line,
|
||||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||||
CreatedAt: local.CreatedAt,
|
CreatedAt: local.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,3 +253,63 @@ func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *te
|
|||||||
t.Fatalf("expected current_version_id to point to kept latest version v3")
|
t.Fatalf("expected current_version_id to point to kept latest version v3")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunLocalMigrationsBackfillsConfigurationLineNo(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "line_no_backfill.db")
|
||||||
|
|
||||||
|
local, err := New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
projectUUID := "project-line"
|
||||||
|
cfg1 := &LocalConfiguration{
|
||||||
|
UUID: "line-cfg-1",
|
||||||
|
ProjectUUID: &projectUUID,
|
||||||
|
Name: "Cfg 1",
|
||||||
|
Items: LocalConfigItems{},
|
||||||
|
SyncStatus: "pending",
|
||||||
|
OriginalUsername: "tester",
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||||
|
}
|
||||||
|
cfg2 := &LocalConfiguration{
|
||||||
|
UUID: "line-cfg-2",
|
||||||
|
ProjectUUID: &projectUUID,
|
||||||
|
Name: "Cfg 2",
|
||||||
|
Items: LocalConfigItems{},
|
||||||
|
SyncStatus: "pending",
|
||||||
|
OriginalUsername: "tester",
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := local.SaveConfiguration(cfg1); err != nil {
|
||||||
|
t.Fatalf("save cfg1: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveConfiguration(cfg2); err != nil {
|
||||||
|
t.Fatalf("save cfg2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.DB().Model(&LocalConfiguration{}).Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Update("line_no", 0).Error; err != nil {
|
||||||
|
t.Fatalf("reset line_no: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.DB().Where("id = ?", "2026_02_19_local_config_line_no").Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete migration record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runLocalMigrations(local.DB()); err != nil {
|
||||||
|
t.Fatalf("rerun local migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []LocalConfiguration
|
||||||
|
if err := local.DB().Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Order("created_at ASC").Find(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("load configurations: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 configurations, got %d", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Line != 10 || rows[1].Line != 20 {
|
||||||
|
t.Fatalf("expected line_no [10,20], got [%d,%d]", rows[0].Line, rows[1].Line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
&LocalRemoteMigrationApplied{},
|
&LocalRemoteMigrationApplied{},
|
||||||
&LocalSyncGuardState{},
|
&LocalSyncGuardState{},
|
||||||
&PendingChange{},
|
&PendingChange{},
|
||||||
|
&LocalPartnumberBook{},
|
||||||
|
&LocalPartnumberBookItem{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("migrating sqlite database: %w", err)
|
return nil, fmt.Errorf("migrating sqlite database: %w", err)
|
||||||
}
|
}
|
||||||
@@ -341,7 +343,7 @@ func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, e
|
|||||||
func (l *LocalDB) GetProjectConfigurations(projectUUID string) ([]LocalConfiguration, error) {
|
func (l *LocalDB) GetProjectConfigurations(projectUUID string) ([]LocalConfiguration, error) {
|
||||||
var configs []LocalConfiguration
|
var configs []LocalConfiguration
|
||||||
err := l.db.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
|
err := l.db.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
|
||||||
Order("created_at DESC").
|
Order(configurationLineOrderClause()).
|
||||||
Find(&configs).Error
|
Find(&configs).Error
|
||||||
return configs, err
|
return configs, err
|
||||||
}
|
}
|
||||||
@@ -514,9 +516,54 @@ func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
|||||||
|
|
||||||
// SaveConfiguration saves a configuration to local SQLite
|
// SaveConfiguration saves a configuration to local SQLite
|
||||||
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
|
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
|
||||||
|
if config != nil && config.IsActive && config.Line <= 0 {
|
||||||
|
line, err := l.NextConfigurationLine(config.ProjectUUID, config.UUID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
config.Line = line
|
||||||
|
}
|
||||||
return l.db.Save(config).Error
|
return l.db.Save(config).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *LocalDB) NextConfigurationLine(projectUUID *string, excludeUUID string) (int, error) {
|
||||||
|
return NextConfigurationLineTx(l.db, projectUUID, excludeUUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NextConfigurationLineTx(tx *gorm.DB, projectUUID *string, excludeUUID string) (int, error) {
|
||||||
|
query := tx.Model(&LocalConfiguration{}).
|
||||||
|
Where("is_active = ?", true)
|
||||||
|
|
||||||
|
trimmedExclude := strings.TrimSpace(excludeUUID)
|
||||||
|
if trimmedExclude != "" {
|
||||||
|
query = query.Where("uuid <> ?", trimmedExclude)
|
||||||
|
}
|
||||||
|
|
||||||
|
if projectUUID != nil && strings.TrimSpace(*projectUUID) != "" {
|
||||||
|
query = query.Where("project_uuid = ?", strings.TrimSpace(*projectUUID))
|
||||||
|
} else {
|
||||||
|
query = query.Where("project_uuid IS NULL OR TRIM(project_uuid) = ''")
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxLine int
|
||||||
|
if err := query.Select("COALESCE(MAX(line_no), 0)").Scan(&maxLine).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("read max line_no: %w", err)
|
||||||
|
}
|
||||||
|
if maxLine < 0 {
|
||||||
|
maxLine = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
next := ((maxLine / 10) + 1) * 10
|
||||||
|
if next < 10 {
|
||||||
|
next = 10
|
||||||
|
}
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configurationLineOrderClause() string {
|
||||||
|
return "CASE WHEN COALESCE(local_configurations.line_no, 0) <= 0 THEN 2147483647 ELSE local_configurations.line_no END ASC, local_configurations.created_at DESC, local_configurations.id DESC"
|
||||||
|
}
|
||||||
|
|
||||||
// GetConfigurations returns all local configurations
|
// GetConfigurations returns all local configurations
|
||||||
func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) {
|
func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) {
|
||||||
var configs []LocalConfiguration
|
var configs []LocalConfiguration
|
||||||
@@ -692,7 +739,11 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
|||||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||||
var pricelist LocalPricelist
|
var pricelist LocalPricelist
|
||||||
if err := l.db.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 nil, err
|
||||||
}
|
}
|
||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
@@ -701,7 +752,11 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
|||||||
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
|
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
|
||||||
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
||||||
var pricelist LocalPricelist
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ var localMigrations = []localMigration{
|
|||||||
name: "Deduplicate configuration revisions by spec+price",
|
name: "Deduplicate configuration revisions by spec+price",
|
||||||
run: deduplicateConfigurationVersionsBySpecAndPrice,
|
run: deduplicateConfigurationVersionsBySpecAndPrice,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_19_local_config_line_no",
|
||||||
|
name: "Add line_no to local_configurations and backfill ordering",
|
||||||
|
run: addLocalConfigurationLineNo,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLocalMigrations(db *gorm.DB) error {
|
func runLocalMigrations(db *gorm.DB) error {
|
||||||
@@ -806,3 +811,57 @@ func addLocalConfigurationSupportCode(tx *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addLocalConfigurationLineNo(tx *gorm.DB) error {
|
||||||
|
type columnInfo struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
var columns []columnInfo
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT name FROM pragma_table_info('local_configurations')
|
||||||
|
WHERE name IN ('line_no')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check local_configurations(line_no) existence: %w", err)
|
||||||
|
}
|
||||||
|
if len(columns) == 0 {
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_configurations
|
||||||
|
ADD COLUMN line_no INTEGER
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("add local_configurations.line_no: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("added line_no to local_configurations")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY COALESCE(NULLIF(TRIM(project_uuid), ''), '__NO_PROJECT__')
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
) AS rn
|
||||||
|
FROM local_configurations
|
||||||
|
WHERE line_no IS NULL OR line_no <= 0
|
||||||
|
)
|
||||||
|
UPDATE local_configurations
|
||||||
|
SET line_no = (
|
||||||
|
SELECT rn * 10
|
||||||
|
FROM ranked
|
||||||
|
WHERE ranked.id = local_configurations.id
|
||||||
|
)
|
||||||
|
WHERE id IN (SELECT id FROM ranked)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("backfill local_configurations.line_no: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_local_configurations_project_line_no
|
||||||
|
ON local_configurations(project_uuid, line_no)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("ensure idx_local_configurations_project_line_no: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ type LocalConfiguration struct {
|
|||||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||||
|
VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
|
||||||
|
Line int `gorm:"column:line_no;index" json:"line"`
|
||||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -242,3 +244,85 @@ type PendingChange struct {
|
|||||||
func (PendingChange) TableName() string {
|
func (PendingChange) TableName() string {
|
||||||
return "pending_changes"
|
return "pending_changes"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LocalPartnumberBook stores a version snapshot of the PN→LOT mapping book (pull-only from PriceForge)
|
||||||
|
type LocalPartnumberBook struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
ServerID int `gorm:"uniqueIndex;not null" json:"server_id"`
|
||||||
|
Version string `gorm:"not null" json:"version"`
|
||||||
|
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||||
|
IsActive bool `gorm:"not null;default:true" json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LocalPartnumberBook) TableName() string {
|
||||||
|
return "local_partnumber_books"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalPartnumberBookItem stores a single PN→LOT mapping within a book snapshot
|
||||||
|
type LocalPartnumberBookItem struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
BookID uint `gorm:"not null;index:idx_local_book_pn,priority:1" json:"book_id"`
|
||||||
|
Partnumber string `gorm:"not null;index:idx_local_book_pn,priority:2" json:"partnumber"`
|
||||||
|
LotName string `gorm:"not null" json:"lot_name"`
|
||||||
|
IsPrimaryPN bool `gorm:"not null;default:false" json:"is_primary_pn"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LocalPartnumberBookItem) TableName() string {
|
||||||
|
return "local_partnumber_book_items"
|
||||||
|
}
|
||||||
|
|
||||||
|
// VendorSpecItem represents a single row in a vendor BOM specification
|
||||||
|
type VendorSpecItem struct {
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
VendorPartnumber string `json:"vendor_partnumber"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||||
|
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||||
|
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
|
||||||
|
ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved"
|
||||||
|
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
|
||||||
|
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
|
||||||
|
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
|
||||||
|
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VendorSpecLotAllocation struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Quantity int `json:"quantity"` // quantity of LOT per 1 vendor PN
|
||||||
|
}
|
||||||
|
|
||||||
|
// VendorSpecLotMapping is the canonical persisted LOT mapping for a vendor PN row.
|
||||||
|
// It stores all mapped LOTs (base + bundle) uniformly.
|
||||||
|
type VendorSpecLotMapping struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
QuantityPerPN int `json:"quantity_per_pn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VendorSpec is a JSON-encodable slice of VendorSpecItem
|
||||||
|
type VendorSpec []VendorSpecItem
|
||||||
|
|
||||||
|
func (v VendorSpec) Value() (driver.Value, error) {
|
||||||
|
if v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return json.Marshal(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VendorSpec) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*v = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var bytes []byte
|
||||||
|
switch val := value.(type) {
|
||||||
|
case []byte:
|
||||||
|
bytes = val
|
||||||
|
case string:
|
||||||
|
bytes = []byte(val)
|
||||||
|
default:
|
||||||
|
return errors.New("type assertion failed for VendorSpec")
|
||||||
|
}
|
||||||
|
return json.Unmarshal(bytes, v)
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
|||||||
"article": localCfg.Article,
|
"article": localCfg.Article,
|
||||||
"pricelist_id": localCfg.PricelistID,
|
"pricelist_id": localCfg.PricelistID,
|
||||||
"only_in_stock": localCfg.OnlyInStock,
|
"only_in_stock": localCfg.OnlyInStock,
|
||||||
|
"line": localCfg.Line,
|
||||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||||
"created_at": localCfg.CreatedAt,
|
"created_at": localCfg.CreatedAt,
|
||||||
"updated_at": localCfg.UpdatedAt,
|
"updated_at": localCfg.UpdatedAt,
|
||||||
@@ -61,6 +62,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
Article string `json:"article"`
|
Article string `json:"article"`
|
||||||
PricelistID *uint `json:"pricelist_id"`
|
PricelistID *uint `json:"pricelist_id"`
|
||||||
OnlyInStock bool `json:"only_in_stock"`
|
OnlyInStock bool `json:"only_in_stock"`
|
||||||
|
Line int `json:"line"`
|
||||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||||
OriginalUserID uint `json:"original_user_id"`
|
OriginalUserID uint `json:"original_user_id"`
|
||||||
OriginalUsername string `json:"original_username"`
|
OriginalUsername string `json:"original_username"`
|
||||||
@@ -90,6 +92,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
Article: snapshot.Article,
|
Article: snapshot.Article,
|
||||||
PricelistID: snapshot.PricelistID,
|
PricelistID: snapshot.PricelistID,
|
||||||
OnlyInStock: snapshot.OnlyInStock,
|
OnlyInStock: snapshot.OnlyInStock,
|
||||||
|
Line: snapshot.Line,
|
||||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||||
OriginalUserID: snapshot.OriginalUserID,
|
OriginalUserID: snapshot.OriginalUserID,
|
||||||
OriginalUsername: snapshot.OriginalUsername,
|
OriginalUsername: snapshot.OriginalUsername,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type Configuration struct {
|
|||||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||||
|
Line int `gorm:"column:line_no;index" json:"line"`
|
||||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`
|
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -14,7 +16,13 @@ func NewConfigurationRepository(db *gorm.DB) *ConfigurationRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ConfigurationRepository) Create(config *models.Configuration) error {
|
func (r *ConfigurationRepository) Create(config *models.Configuration) error {
|
||||||
return r.db.Create(config).Error
|
if err := r.db.Create(config).Error; err != nil {
|
||||||
|
if isUnknownLineNoColumnError(err) {
|
||||||
|
return r.db.Omit("line_no").Create(config).Error
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
||||||
@@ -36,7 +44,21 @@ func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ConfigurationRepository) Update(config *models.Configuration) error {
|
func (r *ConfigurationRepository) Update(config *models.Configuration) error {
|
||||||
return r.db.Save(config).Error
|
if err := r.db.Save(config).Error; err != nil {
|
||||||
|
if isUnknownLineNoColumnError(err) {
|
||||||
|
return r.db.Omit("line_no").Save(config).Error
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUnknownLineNoColumnError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "unknown column 'line_no'") || strings.Contains(msg, "no column named line_no")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ConfigurationRepository) Delete(id uint) error {
|
func (r *ConfigurationRepository) Delete(id uint) error {
|
||||||
|
|||||||
66
internal/repository/partnumber_book.go
Normal file
66
internal/repository/partnumber_book.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PartnumberBookRepository provides read-only access to local partnumber book snapshots.
|
||||||
|
type PartnumberBookRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPartnumberBookRepository(db *gorm.DB) *PartnumberBookRepository {
|
||||||
|
return &PartnumberBookRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveBook returns the most recently active local partnumber book.
|
||||||
|
func (r *PartnumberBookRepository) GetActiveBook() (*localdb.LocalPartnumberBook, error) {
|
||||||
|
var book localdb.LocalPartnumberBook
|
||||||
|
err := r.db.Where("is_active = 1").Order("created_at DESC, id DESC").First(&book).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &book, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBookItems returns all items for the given local book ID.
|
||||||
|
func (r *PartnumberBookRepository) GetBookItems(bookID uint) ([]localdb.LocalPartnumberBookItem, error) {
|
||||||
|
var items []localdb.LocalPartnumberBookItem
|
||||||
|
err := r.db.Where("book_id = ?", bookID).Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLotByPartnumber looks up a partnumber in the active book and returns the matching items.
|
||||||
|
func (r *PartnumberBookRepository) FindLotByPartnumber(bookID uint, partnumber string) ([]localdb.LocalPartnumberBookItem, error) {
|
||||||
|
var items []localdb.LocalPartnumberBookItem
|
||||||
|
err := r.db.Where("book_id = ? AND partnumber = ?", bookID, partnumber).Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBooks returns all local partnumber books ordered newest first.
|
||||||
|
func (r *PartnumberBookRepository) ListBooks() ([]localdb.LocalPartnumberBook, error) {
|
||||||
|
var books []localdb.LocalPartnumberBook
|
||||||
|
err := r.db.Order("created_at DESC, id DESC").Find(&books).Error
|
||||||
|
return books, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveBook saves a new partnumber book snapshot.
|
||||||
|
func (r *PartnumberBookRepository) SaveBook(book *localdb.LocalPartnumberBook) error {
|
||||||
|
return r.db.Save(book).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveBookItems bulk-inserts items for a book snapshot.
|
||||||
|
func (r *PartnumberBookRepository) SaveBookItems(items []localdb.LocalPartnumberBookItem) error {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.db.CreateInBatches(items, 500).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountBookItems returns the number of items for a given local book ID.
|
||||||
|
func (r *PartnumberBookRepository) CountBookItems(bookID uint) int64 {
|
||||||
|
var count int64
|
||||||
|
r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("book_id = ?", bookID).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -40,7 +41,7 @@ func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pricelists []models.Pricelist
|
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)
|
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ func (r *PricelistRepository) ListActiveBySource(source string, offset, limit in
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pricelists []models.Pricelist
|
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)
|
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +149,11 @@ func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
|
|||||||
// GetLatestActiveBySource returns the most recent active pricelist by source.
|
// GetLatestActiveBySource returns the most recent active pricelist by source.
|
||||||
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
||||||
var pricelist models.Pricelist
|
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 nil, fmt.Errorf("getting latest pricelist: %w", err)
|
||||||
}
|
}
|
||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
@@ -241,8 +246,11 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
|||||||
items[i].Category = strings.TrimSpace(items[i].LotCategory)
|
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 {
|
if err := r.enrichItemsWithStock(items); err != nil {
|
||||||
return nil, 0, fmt.Errorf("enriching pricelist items with stock: %w", err)
|
slog.Warn("pricelist items stock enrichment skipped", "pricelist_id", pricelistID, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return items, total, nil
|
return items, total, nil
|
||||||
|
|||||||
@@ -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 {
|
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ type ExportItem struct {
|
|||||||
// ConfigExportBlock represents one configuration (server) in the export.
|
// ConfigExportBlock represents one configuration (server) in the export.
|
||||||
type ConfigExportBlock struct {
|
type ConfigExportBlock struct {
|
||||||
Article string
|
Article string
|
||||||
|
Line int
|
||||||
ServerCount int
|
ServerCount int
|
||||||
UnitPrice float64 // sum of component prices for one server
|
UnitPrice float64 // sum of component prices for one server
|
||||||
Items []ExportItem
|
Items []ExportItem
|
||||||
@@ -92,7 +94,10 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, block := range data.Configs {
|
for i, block := range data.Configs {
|
||||||
lineNo := (i + 1) * 10
|
lineNo := block.Line
|
||||||
|
if lineNo <= 0 {
|
||||||
|
lineNo = (i + 1) * 10
|
||||||
|
}
|
||||||
|
|
||||||
serverCount := block.ServerCount
|
serverCount := block.ServerCount
|
||||||
if serverCount < 1 {
|
if serverCount < 1 {
|
||||||
@@ -174,9 +179,30 @@ func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectEx
|
|||||||
|
|
||||||
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
||||||
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
||||||
|
sortedConfigs := make([]models.Configuration, len(configs))
|
||||||
|
copy(sortedConfigs, configs)
|
||||||
|
sort.Slice(sortedConfigs, func(i, j int) bool {
|
||||||
|
leftLine := sortedConfigs[i].Line
|
||||||
|
rightLine := sortedConfigs[j].Line
|
||||||
|
|
||||||
|
if leftLine <= 0 {
|
||||||
|
leftLine = int(^uint(0) >> 1)
|
||||||
|
}
|
||||||
|
if rightLine <= 0 {
|
||||||
|
rightLine = int(^uint(0) >> 1)
|
||||||
|
}
|
||||||
|
if leftLine != rightLine {
|
||||||
|
return leftLine < rightLine
|
||||||
|
}
|
||||||
|
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
|
||||||
|
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
|
||||||
|
}
|
||||||
|
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
|
||||||
|
})
|
||||||
|
|
||||||
blocks := make([]ConfigExportBlock, 0, len(configs))
|
blocks := make([]ConfigExportBlock, 0, len(configs))
|
||||||
for i := range configs {
|
for i := range sortedConfigs {
|
||||||
blocks = append(blocks, s.buildExportBlock(&configs[i]))
|
blocks = append(blocks, s.buildExportBlock(&sortedConfigs[i]))
|
||||||
}
|
}
|
||||||
return &ProjectExportData{
|
return &ProjectExportData{
|
||||||
Configs: blocks,
|
Configs: blocks,
|
||||||
@@ -214,6 +240,7 @@ func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExport
|
|||||||
|
|
||||||
return ConfigExportBlock{
|
return ConfigExportBlock{
|
||||||
Article: cfg.Article,
|
Article: cfg.Article,
|
||||||
|
Line: cfg.Line,
|
||||||
ServerCount: serverCount,
|
ServerCount: serverCount,
|
||||||
UnitPrice: unitTotal,
|
UnitPrice: unitTotal,
|
||||||
Items: items,
|
Items: items,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestProjectData(items []ExportItem, article string, serverCount int) *ProjectExportData {
|
func newTestProjectData(items []ExportItem, article string, serverCount int) *ProjectExportData {
|
||||||
@@ -357,6 +358,51 @@ func TestToCSV_MultipleBlocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProjectToExportData_SortsByLine(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
|
configs := []models.Configuration{
|
||||||
|
{
|
||||||
|
UUID: "cfg-1",
|
||||||
|
Line: 30,
|
||||||
|
Article: "ART-30",
|
||||||
|
ServerCount: 1,
|
||||||
|
Items: models.ConfigItems{{LotName: "LOT-30", Quantity: 1, UnitPrice: 300}},
|
||||||
|
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
UUID: "cfg-2",
|
||||||
|
Line: 10,
|
||||||
|
Article: "ART-10",
|
||||||
|
ServerCount: 1,
|
||||||
|
Items: models.ConfigItems{{LotName: "LOT-10", Quantity: 1, UnitPrice: 100}},
|
||||||
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
UUID: "cfg-3",
|
||||||
|
Line: 20,
|
||||||
|
Article: "ART-20",
|
||||||
|
ServerCount: 1,
|
||||||
|
Items: models.ConfigItems{{LotName: "LOT-20", Quantity: 1, UnitPrice: 200}},
|
||||||
|
CreatedAt: time.Now().Add(-3 * time.Hour),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data := svc.ProjectToExportData(configs)
|
||||||
|
if len(data.Configs) != 3 {
|
||||||
|
t.Fatalf("expected 3 blocks, got %d", len(data.Configs))
|
||||||
|
}
|
||||||
|
if data.Configs[0].Article != "ART-10" || data.Configs[0].Line != 10 {
|
||||||
|
t.Fatalf("first block must be line 10, got article=%s line=%d", data.Configs[0].Article, data.Configs[0].Line)
|
||||||
|
}
|
||||||
|
if data.Configs[1].Article != "ART-20" || data.Configs[1].Line != 20 {
|
||||||
|
t.Fatalf("second block must be line 20, got article=%s line=%d", data.Configs[1].Article, data.Configs[1].Line)
|
||||||
|
}
|
||||||
|
if data.Configs[2].Article != "ART-30" || data.Configs[2].Line != 30 {
|
||||||
|
t.Fatalf("third block must be line 30, got article=%s line=%d", data.Configs[2].Article, data.Configs[2].Line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormatPriceWithSpace(t *testing.T) {
|
func TestFormatPriceWithSpace(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input float64
|
input float64
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
||||||
return nil, fmt.Errorf("create configuration with version: %w", err)
|
return nil, fmt.Errorf("create configuration with version: %w", err)
|
||||||
}
|
}
|
||||||
|
cfg.Line = localCfg.Line
|
||||||
|
|
||||||
// Record usage stats
|
// Record usage stats
|
||||||
_ = s.quoteService.RecordUsage(req.Items)
|
_ = s.quoteService.RecordUsage(req.Items)
|
||||||
@@ -325,6 +326,7 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
|||||||
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
||||||
return nil, fmt.Errorf("clone configuration with version: %w", err)
|
return nil, fmt.Errorf("clone configuration with version: %w", err)
|
||||||
}
|
}
|
||||||
|
clone.Line = localCfg.Line
|
||||||
|
|
||||||
return clone, nil
|
return clone, nil
|
||||||
}
|
}
|
||||||
@@ -461,11 +463,23 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
|
|
||||||
projectUUID := localCfg.ProjectUUID
|
projectUUID := localCfg.ProjectUUID
|
||||||
if req.ProjectUUID != nil {
|
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)
|
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Allow save for legacy/orphaned configs when request keeps the same project UUID.
|
||||||
|
// This can happen for imported configs whose project is not present in local cache.
|
||||||
|
if errors.Is(err, ErrProjectNotFound) && requestedProjectUUID != "" && requestedProjectUUID == currentProjectUUID {
|
||||||
|
projectUUID = localCfg.ProjectUUID
|
||||||
|
} else {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -628,6 +642,7 @@ func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID s
|
|||||||
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
||||||
return nil, fmt.Errorf("clone configuration without auth with version: %w", err)
|
return nil, fmt.Errorf("clone configuration without auth with version: %w", err)
|
||||||
}
|
}
|
||||||
|
clone.Line = localCfg.Line
|
||||||
|
|
||||||
return clone, nil
|
return clone, nil
|
||||||
}
|
}
|
||||||
@@ -814,21 +829,13 @@ func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverC
|
|||||||
return fmt.Errorf("save local configuration: %w", err)
|
return fmt.Errorf("save local configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use existing current version for the pending change
|
version, err := s.loadVersionForPendingTx(tx, localCfg)
|
||||||
var version localdb.LocalConfigurationVersion
|
if err != nil {
|
||||||
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
return err
|
||||||
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)
|
cfg = localdb.LocalToConfiguration(localCfg)
|
||||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", &version, ""); err != nil {
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", version, ""); err != nil {
|
||||||
return fmt.Errorf("enqueue server-count pending change: %w", err)
|
return fmt.Errorf("enqueue server-count pending change: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -840,6 +847,99 @@ func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverC
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) ReorderProjectConfigurationsNoAuth(projectUUID string, orderedUUIDs []string) ([]models.Configuration, error) {
|
||||||
|
projectUUID = strings.TrimSpace(projectUUID)
|
||||||
|
if projectUUID == "" {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
if _, err := s.localDB.GetProjectByUUID(projectUUID); err != nil {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
if len(orderedUUIDs) == 0 {
|
||||||
|
return []models.Configuration{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(orderedUUIDs))
|
||||||
|
normalized := make([]string, 0, len(orderedUUIDs))
|
||||||
|
for _, raw := range orderedUUIDs {
|
||||||
|
u := strings.TrimSpace(raw)
|
||||||
|
if u == "" {
|
||||||
|
return nil, fmt.Errorf("ordered_uuids contains empty uuid")
|
||||||
|
}
|
||||||
|
if _, exists := seen[u]; exists {
|
||||||
|
return nil, fmt.Errorf("ordered_uuids contains duplicate uuid: %s", u)
|
||||||
|
}
|
||||||
|
seen[u] = struct{}{}
|
||||||
|
normalized = append(normalized, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
|
var active []localdb.LocalConfiguration
|
||||||
|
if err := tx.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
|
||||||
|
Find(&active).Error; err != nil {
|
||||||
|
return fmt.Errorf("load project active configurations: %w", err)
|
||||||
|
}
|
||||||
|
if len(active) != len(normalized) {
|
||||||
|
return fmt.Errorf("ordered_uuids count mismatch: expected %d got %d", len(active), len(normalized))
|
||||||
|
}
|
||||||
|
|
||||||
|
byUUID := make(map[string]*localdb.LocalConfiguration, len(active))
|
||||||
|
for i := range active {
|
||||||
|
cfg := active[i]
|
||||||
|
byUUID[cfg.UUID] = &cfg
|
||||||
|
}
|
||||||
|
for _, id := range normalized {
|
||||||
|
if _, ok := byUUID[id]; !ok {
|
||||||
|
return fmt.Errorf("configuration %s not found in project %s", id, projectUUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for idx, id := range normalized {
|
||||||
|
cfg := byUUID[id]
|
||||||
|
newLine := (idx + 1) * 10
|
||||||
|
if cfg.Line == newLine {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Line = newLine
|
||||||
|
cfg.UpdatedAt = now
|
||||||
|
cfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
if err := tx.Save(cfg).Error; err != nil {
|
||||||
|
return fmt.Errorf("save reordered configuration %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := s.loadVersionForPendingTx(tx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.enqueueConfigurationPendingChangeTx(tx, cfg, "update", version, ""); err != nil {
|
||||||
|
return fmt.Errorf("enqueue reorder pending change for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var localConfigs []localdb.LocalConfiguration
|
||||||
|
if err := s.localDB.DB().
|
||||||
|
Preload("CurrentVersion").
|
||||||
|
Where("project_uuid = ? AND is_active = ?", projectUUID, true).
|
||||||
|
Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC").
|
||||||
|
Find(&localConfigs).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("load reordered configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]models.Configuration, 0, len(localConfigs))
|
||||||
|
for i := range localConfigs {
|
||||||
|
result = append(result, *localdb.LocalToConfiguration(&localConfigs[i]))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
||||||
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
||||||
return s.syncService.ImportConfigurationsToLocal()
|
return s.syncService.ImportConfigurationsToLocal()
|
||||||
@@ -953,6 +1053,11 @@ func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, own
|
|||||||
|
|
||||||
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
|
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
|
||||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
|
if localCfg.IsActive {
|
||||||
|
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := tx.Create(localCfg).Error; err != nil {
|
if err := tx.Create(localCfg).Error; err != nil {
|
||||||
return fmt.Errorf("create local configuration: %w", err)
|
return fmt.Errorf("create local configuration: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1001,14 +1106,37 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return fmt.Errorf("load current version before save: %w", err)
|
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)
|
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("compare revision content: %w", err)
|
return fmt.Errorf("compare revision content: %w", err)
|
||||||
}
|
}
|
||||||
if sameRevisionContent {
|
if sameRevisionContent {
|
||||||
|
if !hasNonRevisionConfigurationChanges(&locked, localCfg) {
|
||||||
cfg = localdb.LocalToConfiguration(&locked)
|
cfg = localdb.LocalToConfiguration(&locked)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if err := tx.Save(localCfg).Error; err != nil {
|
||||||
|
return fmt.Errorf("save local configuration (no new revision): %w", err)
|
||||||
|
}
|
||||||
|
cfg = localdb.LocalToConfiguration(localCfg)
|
||||||
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, currentVersion, createdBy); err != nil {
|
||||||
|
return fmt.Errorf("enqueue %s pending change without revision: %w", operation, err)
|
||||||
|
}
|
||||||
|
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
|
||||||
|
return fmt.Errorf("recalculate local pricelist usage: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if localCfg.IsActive {
|
||||||
|
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Save(localCfg).Error; err != nil {
|
if err := tx.Save(localCfg).Error; err != nil {
|
||||||
@@ -1045,6 +1173,50 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, next *localdb.LocalConfiguration) bool {
|
||||||
|
if current == nil || next == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if current.Name != next.Name ||
|
||||||
|
current.Notes != next.Notes ||
|
||||||
|
current.IsTemplate != next.IsTemplate ||
|
||||||
|
current.ServerModel != next.ServerModel ||
|
||||||
|
current.SupportCode != next.SupportCode ||
|
||||||
|
current.Article != next.Article ||
|
||||||
|
current.OnlyInStock != next.OnlyInStock ||
|
||||||
|
current.IsActive != next.IsActive ||
|
||||||
|
current.Line != next.Line {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !equalUintPtr(current.PricelistID, next.PricelistID) ||
|
||||||
|
!equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) ||
|
||||||
|
!equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) ||
|
||||||
|
!equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStringPtr(a, b *string) bool {
|
||||||
|
if a == nil && b == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(*a) == strings.TrimSpace(*b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalUintPtr(a, b *uint) bool {
|
||||||
|
if a == nil && b == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return *a == *b
|
||||||
|
}
|
||||||
|
|
||||||
func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
|
func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
|
||||||
var version localdb.LocalConfigurationVersion
|
var version localdb.LocalConfigurationVersion
|
||||||
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
||||||
@@ -1058,11 +1230,84 @@ func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *
|
|||||||
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||||
Order("version_no DESC").
|
Order("version_no DESC").
|
||||||
First(&version).Error; err != nil {
|
First(&version).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &version, nil
|
return &version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) loadVersionForPendingTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
|
||||||
|
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
||||||
|
var current localdb.LocalConfigurationVersion
|
||||||
|
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(¤t).Error; err == nil {
|
||||||
|
return ¤t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var latest localdb.LocalConfigurationVersion
|
||||||
|
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||||
|
Order("version_no DESC").
|
||||||
|
First(&latest).Error; err != nil {
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("load version for pending change: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy/imported rows may exist without local version history.
|
||||||
|
// Bootstrap the first version so pending sync payloads can reference a version.
|
||||||
|
version, createErr := s.appendVersionTx(tx, localCfg, "bootstrap", "")
|
||||||
|
if createErr != nil {
|
||||||
|
return nil, fmt.Errorf("bootstrap version for pending change: %w", createErr)
|
||||||
|
}
|
||||||
|
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", localCfg.UUID).
|
||||||
|
Update("current_version_id", version.ID).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("set current version id for bootstrapped pending change: %w", err)
|
||||||
|
}
|
||||||
|
localCfg.CurrentVersionID = &version.ID
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
return &latest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) ensureConfigurationLineTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) error {
|
||||||
|
if localCfg == nil || !localCfg.IsActive {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
needsAssign := localCfg.Line <= 0
|
||||||
|
if !needsAssign {
|
||||||
|
query := tx.Model(&localdb.LocalConfiguration{}).
|
||||||
|
Where("is_active = ? AND line_no = ?", true, localCfg.Line)
|
||||||
|
|
||||||
|
if strings.TrimSpace(localCfg.UUID) != "" {
|
||||||
|
query = query.Where("uuid <> ?", strings.TrimSpace(localCfg.UUID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if localCfg.ProjectUUID != nil && strings.TrimSpace(*localCfg.ProjectUUID) != "" {
|
||||||
|
query = query.Where("project_uuid = ?", strings.TrimSpace(*localCfg.ProjectUUID))
|
||||||
|
} else {
|
||||||
|
query = query.Where("project_uuid IS NULL OR TRIM(project_uuid) = ''")
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflicts int64
|
||||||
|
if err := query.Count(&conflicts).Error; err != nil {
|
||||||
|
return fmt.Errorf("check line_no conflict for configuration %s: %w", localCfg.UUID, err)
|
||||||
|
}
|
||||||
|
needsAssign = conflicts > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsAssign {
|
||||||
|
line, err := localdb.NextConfigurationLineTx(tx, localCfg.ProjectUUID, localCfg.UUID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("assign line_no for configuration %s: %w", localCfg.UUID, err)
|
||||||
|
}
|
||||||
|
localCfg.Line = line
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *LocalConfigurationService) hasSameRevisionContent(localCfg *localdb.LocalConfiguration, currentVersion *localdb.LocalConfigurationVersion) (bool, error) {
|
func (s *LocalConfigurationService) hasSameRevisionContent(localCfg *localdb.LocalConfiguration, currentVersion *localdb.LocalConfigurationVersion) (bool, error) {
|
||||||
currentSnapshotCfg, err := s.decodeConfigurationSnapshot(currentVersion.Data)
|
currentSnapshotCfg, err := s.decodeConfigurationSnapshot(currentVersion.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1176,6 +1421,9 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
|||||||
current.ServerCount = rollbackData.ServerCount
|
current.ServerCount = rollbackData.ServerCount
|
||||||
current.PricelistID = rollbackData.PricelistID
|
current.PricelistID = rollbackData.PricelistID
|
||||||
current.OnlyInStock = rollbackData.OnlyInStock
|
current.OnlyInStock = rollbackData.OnlyInStock
|
||||||
|
if rollbackData.Line > 0 {
|
||||||
|
current.Line = rollbackData.Line
|
||||||
|
}
|
||||||
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
|
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
|
||||||
current.UpdatedAt = time.Now()
|
current.UpdatedAt = time.Now()
|
||||||
current.SyncStatus = "pending"
|
current.SyncStatus = "pending"
|
||||||
|
|||||||
@@ -137,6 +137,78 @@ func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) {
|
||||||
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
project := &localdb.LocalProject{
|
||||||
|
UUID: "project-reorder",
|
||||||
|
OwnerUsername: "tester",
|
||||||
|
Code: "PRJ-ORDER",
|
||||||
|
Variant: "",
|
||||||
|
Name: ptrString("Project Reorder"),
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SyncStatus: "pending",
|
||||||
|
}
|
||||||
|
if err := local.SaveProject(project); err != nil {
|
||||||
|
t.Fatalf("save project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "Cfg A",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
ProjectUUID: &project.UUID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create first config: %v", err)
|
||||||
|
}
|
||||||
|
second, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "Cfg B",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_B", Quantity: 1, UnitPrice: 200}},
|
||||||
|
ServerCount: 1,
|
||||||
|
ProjectUUID: &project.UUID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create second config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeFirst := loadVersions(t, local, first.UUID)
|
||||||
|
beforeSecond := loadVersions(t, local, second.UUID)
|
||||||
|
|
||||||
|
reordered, err := service.ReorderProjectConfigurationsNoAuth(project.UUID, []string{second.UUID, first.UUID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reorder configurations: %v", err)
|
||||||
|
}
|
||||||
|
if len(reordered) != 2 {
|
||||||
|
t.Fatalf("expected 2 reordered configs, got %d", len(reordered))
|
||||||
|
}
|
||||||
|
if reordered[0].UUID != second.UUID || reordered[0].Line != 10 {
|
||||||
|
t.Fatalf("expected second config first with line 10, got uuid=%s line=%d", reordered[0].UUID, reordered[0].Line)
|
||||||
|
}
|
||||||
|
if reordered[1].UUID != first.UUID || reordered[1].Line != 20 {
|
||||||
|
t.Fatalf("expected first config second with line 20, got uuid=%s line=%d", reordered[1].UUID, reordered[1].Line)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterFirst := loadVersions(t, local, first.UUID)
|
||||||
|
afterSecond := loadVersions(t, local, second.UUID)
|
||||||
|
if len(afterFirst) != len(beforeFirst) || len(afterSecond) != len(beforeSecond) {
|
||||||
|
t.Fatalf("reorder must not create new versions")
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingCount int64
|
||||||
|
if err := local.DB().
|
||||||
|
Table("pending_changes").
|
||||||
|
Where("entity_type = ? AND operation = ? AND entity_uuid IN ?", "configuration", "update", []string{first.UUID, second.UUID}).
|
||||||
|
Count(&pendingCount).Error; err != nil {
|
||||||
|
t.Fatalf("count reorder pending changes: %v", err)
|
||||||
|
}
|
||||||
|
if pendingCount < 2 {
|
||||||
|
t.Fatalf("expected at least 2 pending update changes for reorder, got %d", pendingCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
||||||
service, local := newLocalConfigServiceForTest(t)
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
@@ -290,6 +362,99 @@ 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 {
|
func ptrString(value string) *string {
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,8 +275,23 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query := s.localDB.DB().
|
||||||
|
Preload("CurrentVersion").
|
||||||
|
Where("project_uuid = ?", projectUUID).
|
||||||
|
Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC")
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case "active", "":
|
||||||
|
query = query.Where("is_active = ?", true)
|
||||||
|
case "archived":
|
||||||
|
query = query.Where("is_active = ?", false)
|
||||||
|
case "all":
|
||||||
|
default:
|
||||||
|
query = query.Where("is_active = ?", true)
|
||||||
|
}
|
||||||
|
|
||||||
var localConfigs []localdb.LocalConfiguration
|
var localConfigs []localdb.LocalConfiguration
|
||||||
if err := s.localDB.DB().Preload("CurrentVersion").Order("created_at DESC").Find(&localConfigs).Error; err != nil {
|
if err := query.Find(&localConfigs).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,25 +299,6 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
|
|||||||
total := 0.0
|
total := 0.0
|
||||||
for i := range localConfigs {
|
for i := range localConfigs {
|
||||||
localCfg := localConfigs[i]
|
localCfg := localConfigs[i]
|
||||||
if localCfg.ProjectUUID == nil || *localCfg.ProjectUUID != projectUUID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch status {
|
|
||||||
case "active", "":
|
|
||||||
if !localCfg.IsActive {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
case "archived":
|
|
||||||
if localCfg.IsActive {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
case "all":
|
|
||||||
default:
|
|
||||||
if !localCfg.IsActive {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := localdb.LocalToConfiguration(&localCfg)
|
cfg := localdb.LocalToConfiguration(&localCfg)
|
||||||
if cfg.TotalPrice != nil {
|
if cfg.TotalPrice != nil {
|
||||||
total += *cfg.TotalPrice
|
total += *cfg.TotalPrice
|
||||||
|
|||||||
127
internal/services/sync/partnumber_books.go
Normal file
127
internal/services/sync/partnumber_books.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PullPartnumberBooks synchronizes partnumber book snapshots from MariaDB to local SQLite.
|
||||||
|
// Append-only for headers; re-pulls items if a book header exists but has 0 items.
|
||||||
|
func (s *Service) PullPartnumberBooks() (int, error) {
|
||||||
|
slog.Info("starting partnumber book pull")
|
||||||
|
|
||||||
|
mariaDB, err := s.getDB()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("database not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
|
||||||
|
|
||||||
|
type serverBook struct {
|
||||||
|
ID int `gorm:"column:id"`
|
||||||
|
Version string `gorm:"column:version"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
IsActive bool `gorm:"column:is_active"`
|
||||||
|
}
|
||||||
|
var serverBooks []serverBook
|
||||||
|
if err := mariaDB.Raw("SELECT id, version, created_at, is_active FROM qt_partnumber_books ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("querying server partnumber books: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("partnumber books found on server", "count", len(serverBooks))
|
||||||
|
|
||||||
|
pulled := 0
|
||||||
|
for _, sb := range serverBooks {
|
||||||
|
var existing localdb.LocalPartnumberBook
|
||||||
|
err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
// Header exists — check whether items were saved
|
||||||
|
localItemCount := localBookRepo.CountBookItems(existing.ID)
|
||||||
|
if localItemCount > 0 {
|
||||||
|
slog.Debug("partnumber book already synced, skipping", "server_id", sb.ID, "version", sb.Version, "items", localItemCount)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Items missing — re-pull them
|
||||||
|
slog.Info("partnumber book header exists but has no items, re-pulling items", "server_id", sb.ID, "version", sb.Version)
|
||||||
|
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, existing.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to re-pull items for existing book", "server_id", sb.ID, "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("re-pulled items for existing book", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
|
||||||
|
pulled++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("pulling new partnumber book", "server_id", sb.ID, "version", sb.Version, "is_active", sb.IsActive)
|
||||||
|
|
||||||
|
localBook := &localdb.LocalPartnumberBook{
|
||||||
|
ServerID: sb.ID,
|
||||||
|
Version: sb.Version,
|
||||||
|
CreatedAt: sb.CreatedAt,
|
||||||
|
IsActive: sb.IsActive,
|
||||||
|
}
|
||||||
|
if err := localBookRepo.SaveBook(localBook); err != nil {
|
||||||
|
slog.Error("failed to save local partnumber book", "server_id", sb.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, localBook.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to pull items for new book", "server_id", sb.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("partnumber book saved locally", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
|
||||||
|
pulled++
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("partnumber book pull completed", "new_books_pulled", pulled, "total_on_server", len(serverBooks))
|
||||||
|
return pulled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pullBookItems fetches items for a single book from MariaDB and saves them to SQLite.
|
||||||
|
// Returns the number of items saved.
|
||||||
|
func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, serverBookID int, localBookID uint) (int, error) {
|
||||||
|
type serverItem struct {
|
||||||
|
Partnumber string `gorm:"column:partnumber"`
|
||||||
|
LotName string `gorm:"column:lot_name"`
|
||||||
|
IsPrimaryPN bool `gorm:"column:is_primary_pn"`
|
||||||
|
Description string `gorm:"column:description"`
|
||||||
|
}
|
||||||
|
// description column may not exist yet on older server schemas — query without it first,
|
||||||
|
// then retry with it to populate descriptions if available.
|
||||||
|
var serverItems []serverItem
|
||||||
|
err := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn, description FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("description column not available on server, retrying without it", "server_book_id", serverBookID, "error", err)
|
||||||
|
if err2 := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error; err2 != nil {
|
||||||
|
return 0, fmt.Errorf("querying items from server: %w", err2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Info("partnumber book items fetched from server", "server_book_id", serverBookID, "count", len(serverItems))
|
||||||
|
|
||||||
|
if len(serverItems) == 0 {
|
||||||
|
slog.Warn("server returned 0 items for book — check qt_partnumber_book_items on server", "server_book_id", serverBookID)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems))
|
||||||
|
for _, si := range serverItems {
|
||||||
|
localItems = append(localItems, localdb.LocalPartnumberBookItem{
|
||||||
|
BookID: localBookID,
|
||||||
|
Partnumber: si.Partnumber,
|
||||||
|
LotName: si.LotName,
|
||||||
|
IsPrimaryPN: si.IsPrimaryPN,
|
||||||
|
Description: si.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := repo.SaveBookItems(localItems); err != nil {
|
||||||
|
return 0, fmt.Errorf("saving items to local db: %w", err)
|
||||||
|
}
|
||||||
|
return len(localItems), nil
|
||||||
|
}
|
||||||
51
internal/services/sync/partnumber_seen.go
Normal file
51
internal/services/sync/partnumber_seen.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeenPartnumber represents an unresolved vendor partnumber to report.
|
||||||
|
type SeenPartnumber struct {
|
||||||
|
Partnumber string
|
||||||
|
Description string
|
||||||
|
Ignored bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
||||||
|
// Uses INSERT ... ON DUPLICATE KEY UPDATE so existing rows are updated (last_seen_at) without error.
|
||||||
|
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mariaDB, err := s.getDB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Partnumber == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err := mariaDB.Exec(`
|
||||||
|
INSERT INTO qt_vendor_partnumber_seen
|
||||||
|
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
||||||
|
VALUES
|
||||||
|
('manual', '', ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_seen_at = VALUES(last_seen_at),
|
||||||
|
is_ignored = VALUES(is_ignored),
|
||||||
|
description = COALESCE(NULLIF(VALUES(description), ''), description)
|
||||||
|
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to upsert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||||
|
// Continue with remaining items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("partnumber_seen pushed to server", "count", len(items))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -145,6 +145,12 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
|||||||
|
|
||||||
if existing != nil && err == nil {
|
if existing != nil && err == nil {
|
||||||
localCfg.ID = existing.ID
|
localCfg.ID = existing.ID
|
||||||
|
if localCfg.Line <= 0 && existing.Line > 0 {
|
||||||
|
localCfg.Line = existing.Line
|
||||||
|
}
|
||||||
|
// vendor_spec is local-only for BOM tab and is not stored on server.
|
||||||
|
// Preserve it during server pull updates.
|
||||||
|
localCfg.VendorSpec = existing.VendorSpec
|
||||||
result.Updated++
|
result.Updated++
|
||||||
} else {
|
} else {
|
||||||
result.Imported++
|
result.Imported++
|
||||||
@@ -351,6 +357,15 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
// Check if pricelist already exists locally
|
// Check if pricelist already exists locally
|
||||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
|
// Backfill items for legacy/partial local caches where only pricelist metadata exists.
|
||||||
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,135 @@ func TestPushPendingChangesCreateIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPushPendingChangesConfigurationPushesLine(t *testing.T) {
|
||||||
|
local := newLocalDBForSyncTest(t)
|
||||||
|
serverDB := newServerDBForSyncTest(t)
|
||||||
|
|
||||||
|
localSync := syncsvc.NewService(nil, local)
|
||||||
|
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||||
|
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
||||||
|
|
||||||
|
created, err := configService.Create("tester", &services.CreateConfigRequest{
|
||||||
|
Name: "Cfg Line Push",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_LINE", Quantity: 1, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
if created.Line != 10 {
|
||||||
|
t.Fatalf("expected local create line=10, got %d", created.Line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := pushService.PushPendingChanges(); err != nil {
|
||||||
|
t.Fatalf("push pending changes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverCfg models.Configuration
|
||||||
|
if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil {
|
||||||
|
t.Fatalf("load server config: %v", err)
|
||||||
|
}
|
||||||
|
if serverCfg.Line != 10 {
|
||||||
|
t.Fatalf("expected server line=10 after push, got %d", serverCfg.Line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportConfigurationsToLocalPullsLine(t *testing.T) {
|
||||||
|
local := newLocalDBForSyncTest(t)
|
||||||
|
serverDB := newServerDBForSyncTest(t)
|
||||||
|
|
||||||
|
cfg := models.Configuration{
|
||||||
|
UUID: "server-line-config",
|
||||||
|
OwnerUsername: "tester",
|
||||||
|
Name: "Cfg Line Pull",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
|
||||||
|
ServerCount: 1,
|
||||||
|
Line: 40,
|
||||||
|
}
|
||||||
|
total := cfg.Items.Total()
|
||||||
|
cfg.TotalPrice = &total
|
||||||
|
if err := serverDB.Create(&cfg).Error; err != nil {
|
||||||
|
t.Fatalf("seed server config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||||
|
if _, err := svc.ImportConfigurationsToLocal(); err != nil {
|
||||||
|
t.Fatalf("import configurations to local: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg, err := local.GetConfigurationByUUID(cfg.UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load local config: %v", err)
|
||||||
|
}
|
||||||
|
if localCfg.Line != 40 {
|
||||||
|
t.Fatalf("expected imported line=40, got %d", localCfg.Line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
||||||
|
local := newLocalDBForSyncTest(t)
|
||||||
|
serverDB := newServerDBForSyncTest(t)
|
||||||
|
|
||||||
|
cfg := models.Configuration{
|
||||||
|
UUID: "server-vendorspec-config",
|
||||||
|
OwnerUsername: "tester",
|
||||||
|
Name: "Cfg VendorSpec Pull",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
|
||||||
|
ServerCount: 1,
|
||||||
|
Line: 50,
|
||||||
|
}
|
||||||
|
total := cfg.Items.Total()
|
||||||
|
cfg.TotalPrice = &total
|
||||||
|
if err := serverDB.Create(&cfg).Error; err != nil {
|
||||||
|
t.Fatalf("seed server config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localSpec := localdb.VendorSpec{
|
||||||
|
{
|
||||||
|
SortOrder: 10,
|
||||||
|
VendorPartnumber: "GPU-NVHGX-H200-8141",
|
||||||
|
Quantity: 1,
|
||||||
|
Description: "NVIDIA HGX Delta-Next GPU Baseboard",
|
||||||
|
LotMappings: []localdb.VendorSpecLotMapping{
|
||||||
|
{LotName: "GPU_NV_H200_141GB_SXM_(HGX)", QuantityPerPN: 8},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
|
||||||
|
UUID: cfg.UUID,
|
||||||
|
OriginalUsername: "tester",
|
||||||
|
Name: "Local cfg",
|
||||||
|
Items: localdb.LocalConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
|
||||||
|
IsActive: true,
|
||||||
|
SyncStatus: "synced",
|
||||||
|
Line: 50,
|
||||||
|
VendorSpec: localSpec,
|
||||||
|
CreatedAt: time.Now().Add(-30 * time.Minute),
|
||||||
|
UpdatedAt: time.Now().Add(-30 * time.Minute),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed local configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||||
|
if _, err := svc.ImportConfigurationsToLocal(); err != nil {
|
||||||
|
t.Fatalf("import configurations to local: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg, err := local.GetConfigurationByUUID(cfg.UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load local config: %v", err)
|
||||||
|
}
|
||||||
|
if len(localCfg.VendorSpec) != 1 {
|
||||||
|
t.Fatalf("expected local vendor_spec preserved, got %d rows", len(localCfg.VendorSpec))
|
||||||
|
}
|
||||||
|
if localCfg.VendorSpec[0].VendorPartnumber != "GPU-NVHGX-H200-8141" {
|
||||||
|
t.Fatalf("unexpected vendor_partnumber after import: %q", localCfg.VendorSpec[0].VendorPartnumber)
|
||||||
|
}
|
||||||
|
if len(localCfg.VendorSpec[0].LotMappings) != 1 || localCfg.VendorSpec[0].LotMappings[0].LotName != "GPU_NV_H200_141GB_SXM_(HGX)" {
|
||||||
|
t.Fatalf("unexpected lot mappings after import: %+v", localCfg.VendorSpec[0].LotMappings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPushPendingChangesCreateThenUpdateBeforeFirstPush(t *testing.T) {
|
func TestPushPendingChangesCreateThenUpdateBeforeFirstPush(t *testing.T) {
|
||||||
local := newLocalDBForSyncTest(t)
|
local := newLocalDBForSyncTest(t)
|
||||||
serverDB := newServerDBForSyncTest(t)
|
serverDB := newServerDBForSyncTest(t)
|
||||||
@@ -361,6 +490,7 @@ CREATE TABLE qt_configurations (
|
|||||||
competitor_pricelist_id INTEGER NULL,
|
competitor_pricelist_id INTEGER NULL,
|
||||||
disable_price_refresh INTEGER NOT NULL DEFAULT 0,
|
disable_price_refresh INTEGER NOT NULL DEFAULT 0,
|
||||||
only_in_stock INTEGER NOT NULL DEFAULT 0,
|
only_in_stock INTEGER NOT NULL DEFAULT 0,
|
||||||
|
line_no INTEGER NULL,
|
||||||
price_updated_at DATETIME NULL,
|
price_updated_at DATETIME NULL,
|
||||||
created_at DATETIME
|
created_at DATETIME
|
||||||
);`).Error; err != nil {
|
);`).Error; err != nil {
|
||||||
|
|||||||
129
internal/services/vendor_spec_resolver.go
Normal file
129
internal/services/vendor_spec_resolver.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolvedBOMRow is the result of resolving a single vendor BOM row.
|
||||||
|
type ResolvedBOMRow struct {
|
||||||
|
localdb.VendorSpecItem
|
||||||
|
// ResolutionSource already on VendorSpecItem: "book", "manual_suggestion", "unresolved"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregatedLOT represents a LOT with its aggregated quantity from the BOM.
|
||||||
|
type AggregatedLOT struct {
|
||||||
|
LotName string
|
||||||
|
Quantity int
|
||||||
|
}
|
||||||
|
|
||||||
|
// VendorSpecResolver resolves vendor BOM rows to LOT names using the active partnumber book.
|
||||||
|
type VendorSpecResolver struct {
|
||||||
|
bookRepo *repository.PartnumberBookRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVendorSpecResolver(bookRepo *repository.PartnumberBookRepository) *VendorSpecResolver {
|
||||||
|
return &VendorSpecResolver{bookRepo: bookRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve resolves each vendor spec item's lot name using the 3-step algorithm.
|
||||||
|
// It returns the resolved items. Manual lot suggestions from the input are preserved as pre-fill.
|
||||||
|
func (r *VendorSpecResolver) Resolve(items []localdb.VendorSpecItem) ([]localdb.VendorSpecItem, error) {
|
||||||
|
// Step 1: Get the active book
|
||||||
|
book, err := r.bookRepo.GetActiveBook()
|
||||||
|
if err != nil {
|
||||||
|
// No book available — mark all as unresolved
|
||||||
|
for i := range items {
|
||||||
|
if items[i].ResolvedLotName == "" {
|
||||||
|
items[i].ResolutionSource = "unresolved"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, item := range items {
|
||||||
|
pn := item.VendorPartnumber
|
||||||
|
|
||||||
|
// Step 1: Look up in active book
|
||||||
|
matches, err := r.bookRepo.FindLotByPartnumber(book.ID, pn)
|
||||||
|
if err == nil && len(matches) > 0 {
|
||||||
|
items[i].ResolvedLotName = matches[0].LotName
|
||||||
|
items[i].ResolutionSource = "book"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Pre-fill from manual_lot_suggestion if provided
|
||||||
|
if item.ManualLotSuggestion != "" {
|
||||||
|
items[i].ResolvedLotName = item.ManualLotSuggestion
|
||||||
|
items[i].ResolutionSource = "manual_suggestion"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Unresolved
|
||||||
|
items[i].ResolvedLotName = ""
|
||||||
|
items[i].ResolutionSource = "unresolved"
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateLOTs applies the qty-logic to compute per-LOT quantities from the resolved BOM.
|
||||||
|
// qty(lot) = SUM(qty of primary PN rows for this lot) if any primary PN exists, else 1.
|
||||||
|
func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumberBook, bookRepo *repository.PartnumberBookRepository) ([]AggregatedLOT, error) {
|
||||||
|
// Gather all unique lot names that resolved
|
||||||
|
lotPrimary := make(map[string]int) // lot_name → sum of primary PN quantities
|
||||||
|
lotAny := make(map[string]bool) // lot_name → seen at least once (non-primary)
|
||||||
|
lotHasPrimary := make(map[string]bool) // lot_name → has at least one primary PN in spec
|
||||||
|
|
||||||
|
if book != nil {
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ResolvedLotName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lot := item.ResolvedLotName
|
||||||
|
pn := item.VendorPartnumber
|
||||||
|
|
||||||
|
// Find if this pn is primary for its lot
|
||||||
|
matches, err := bookRepo.FindLotByPartnumber(book.ID, pn)
|
||||||
|
if err != nil || len(matches) == 0 {
|
||||||
|
// manual/unresolved — treat as non-primary
|
||||||
|
lotAny[lot] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, m := range matches {
|
||||||
|
if m.LotName == lot {
|
||||||
|
if m.IsPrimaryPN {
|
||||||
|
lotPrimary[lot] += item.Quantity
|
||||||
|
lotHasPrimary[lot] = true
|
||||||
|
} else {
|
||||||
|
lotAny[lot] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No book: all resolved rows contribute qty=1 per lot
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ResolvedLotName != "" {
|
||||||
|
lotAny[item.ResolvedLotName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build aggregated list
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var result []AggregatedLOT
|
||||||
|
for _, item := range items {
|
||||||
|
lot := item.ResolvedLotName
|
||||||
|
if lot == "" || seen[lot] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[lot] = true
|
||||||
|
qty := 1
|
||||||
|
if lotHasPrimary[lot] {
|
||||||
|
qty = lotPrimary[lot]
|
||||||
|
}
|
||||||
|
result = append(result, AggregatedLOT{LotName: lot, Quantity: qty})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
413
man/backup.md
413
man/backup.md
@@ -1,413 +0,0 @@
|
|||||||
# AI Implementation Guide: Go Scheduled Backup Rotation (ZIP)
|
|
||||||
|
|
||||||
This document is written **for an AI** to replicate the same backup approach in another Go project. It contains the exact requirements, design notes, and full module listings you can copy.
|
|
||||||
|
|
||||||
## Requirements (Behavioral)
|
|
||||||
- Run backups on a daily schedule at a configured local time (default `00:00`).
|
|
||||||
- At startup, if there is no backup for the current period, create it immediately.
|
|
||||||
- Backup content must include:
|
|
||||||
- Local SQLite DB file (e.g., `qfs.db`).
|
|
||||||
- SQLite sidecars (`-wal`, `-shm`) if present.
|
|
||||||
- Runtime config file (e.g., `config.yaml`) if present.
|
|
||||||
- Backups must be ZIP archives named:
|
|
||||||
- `qfs-backp-YYYY-MM-DD.zip`
|
|
||||||
- Retention policy:
|
|
||||||
- 7 daily, 4 weekly, 12 monthly, 10 yearly archives.
|
|
||||||
- Keep backups in period-specific directories:
|
|
||||||
- `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`.
|
|
||||||
- Prevent duplicate backups for the same period via a marker file.
|
|
||||||
- Log success with the archive path, and log errors on failure.
|
|
||||||
|
|
||||||
## Configuration & Env
|
|
||||||
- Config key: `backup.time` with format `HH:MM` in local time. Default: `00:00`.
|
|
||||||
- Env overrides:
|
|
||||||
- `QFS_BACKUP_DIR` — backup root directory.
|
|
||||||
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`).
|
|
||||||
|
|
||||||
## Integration Steps (Minimal)
|
|
||||||
1. Add `BackupConfig` to your config struct.
|
|
||||||
2. Add a scheduler goroutine that:
|
|
||||||
- On startup: runs backup immediately if needed.
|
|
||||||
- Then sleeps until next configured time and runs daily.
|
|
||||||
3. Add the backup module (below).
|
|
||||||
4. Wire logs for success/failure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Full Go Listings
|
|
||||||
|
|
||||||
## 1) Backup Module (Drop-in)
|
|
||||||
Create: `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")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Scheduler Hook (Main)
|
|
||||||
Add this to your `main.go` (or equivalent). This schedules daily backups and logs success.
|
|
||||||
|
|
||||||
```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 = 0
|
|
||||||
minute = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Startup check: if no backup exists for current periods, create now.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Config Struct (Minimal)
|
|
||||||
Add to config:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type BackupConfig struct {
|
|
||||||
Time string `yaml:"time"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Default:
|
|
||||||
```go
|
|
||||||
if c.Backup.Time == "" {
|
|
||||||
c.Backup.Time = "00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes for Replication
|
|
||||||
- Keep `backup.time` in local time. Do **not** parse with timezone offsets unless required.
|
|
||||||
- The `.period.json` marker is what prevents duplicate backups within the same period.
|
|
||||||
- The archive file name only contains the date. Uniqueness is ensured by per-period directories and the period marker.
|
|
||||||
- If you change naming or retention, update both the file naming and prune logic together.
|
|
||||||
18
migrations/028_add_line_no_to_configurations.sql
Normal file
18
migrations/028_add_line_no_to_configurations.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN IF NOT EXISTS line_no INT NULL AFTER only_in_stock;
|
||||||
|
|
||||||
|
UPDATE qt_configurations q
|
||||||
|
JOIN (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY COALESCE(NULLIF(TRIM(project_uuid), ''), '__NO_PROJECT__')
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
) AS rn
|
||||||
|
FROM qt_configurations
|
||||||
|
WHERE line_no IS NULL OR line_no <= 0
|
||||||
|
) ranked ON ranked.id = q.id
|
||||||
|
SET q.line_no = ranked.rn * 10;
|
||||||
|
|
||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD INDEX IF NOT EXISTS idx_qt_configurations_project_line_no (project_uuid, line_no);
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "QuoteForge",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
1
package.json
Normal file
1
package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
# Промпт для ИИ: Перенос паттерна Прайслист
|
|
||||||
|
|
||||||
Используй этот документ как промпт для ИИ при переносе реализации прайслиста в другой проект.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Задача
|
|
||||||
|
|
||||||
Я имею рабочую реализацию окна "Прайслист" в проекте QuoteForge. Нужно перенести эту реализацию в проект [ДОП_ПРОЕКТ_НАЗВАНИЕ], сохраняя структуру, логику и UI/UX.
|
|
||||||
|
|
||||||
## Что перенести
|
|
||||||
|
|
||||||
### Frontend - Лист прайслистов (`/pricelists`)
|
|
||||||
|
|
||||||
**Файл источник:** QuoteForge/web/templates/pricelists.html
|
|
||||||
|
|
||||||
**Компоненты:**
|
|
||||||
1. **Таблица** - список прайслистов с колонками:
|
|
||||||
- Версия (монофонт)
|
|
||||||
- Тип (estimate/warehouse/competitor)
|
|
||||||
- Дата создания
|
|
||||||
- Автор (обычно "sync")
|
|
||||||
- Позиций (количество товаров)
|
|
||||||
- Исп. (использований)
|
|
||||||
- Статус (зеленый "Активен" / серый "Неактивен")
|
|
||||||
- Действия (Просмотр, Удалить если не используется)
|
|
||||||
|
|
||||||
2. **Пагинация** - навигация по страницам с активной страницей выделена
|
|
||||||
|
|
||||||
3. **Модальное окно** - "Создать прайслист" (если есть прав на запись)
|
|
||||||
|
|
||||||
**Что копировать:**
|
|
||||||
- HTML структуру таблицы из lines 10-30
|
|
||||||
- JavaScript функции:
|
|
||||||
- `loadPricelists(page)` - загрузка списка
|
|
||||||
- `renderPricelists(items)` - рендер таблицы
|
|
||||||
- `renderPagination(total, page, perPage)` - пагинация
|
|
||||||
- `checkPricelistWritePermission()` - проверка прав
|
|
||||||
- Модальные функции: `openCreateModal()`, `closeCreateModal()`, `createPricelist()`
|
|
||||||
- CSS классы Tailwind (скопируются как есть)
|
|
||||||
|
|
||||||
**Где использовать в дочернем проекте:**
|
|
||||||
- URL: `/pricelists` (или адаптировать под ваши маршруты)
|
|
||||||
- API: `GET /api/pricelists?page=1&per_page=20`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Frontend - Детали прайслиста (`/pricelists/:id`)
|
|
||||||
|
|
||||||
**Файл источник:** QuoteForge/web/templates/pricelist_detail.html
|
|
||||||
|
|
||||||
**Компоненты:**
|
|
||||||
1. **Хлебная крошка** - кнопка назад на список
|
|
||||||
|
|
||||||
2. **Инфо-панель** - сводка по прайслисту:
|
|
||||||
- Версия (монофонт)
|
|
||||||
- Дата создания
|
|
||||||
- Автор
|
|
||||||
- Позиций (количество)
|
|
||||||
- Использований (в скольких конфигах)
|
|
||||||
- Статус (зеленый/серый)
|
|
||||||
- Истекает (дата или "-")
|
|
||||||
|
|
||||||
3. **Таблица товаров** - с поиском и пагинацией:
|
|
||||||
- Артикул (монофонт, lot_name)
|
|
||||||
- Категория (извлекается первая часть до "_")
|
|
||||||
- Описание (обрезается до 60 символов с "...")
|
|
||||||
- [УСЛОВНО] Доступно (qty) - только для warehouse источника
|
|
||||||
- [УСЛОВНО] Partnumbers - только для warehouse источника
|
|
||||||
- Цена, $ (с 2 знаками после запятой)
|
|
||||||
- Настройки (аббревиатуры: РУЧН, Сред, Взвеш.мед, периоды (1н, 1м, 3м, 1г), коэффициент, МЕТА)
|
|
||||||
|
|
||||||
4. **Поиск** - дебаунс 300мс, поиск по lot_name
|
|
||||||
|
|
||||||
5. **Динамические колонки** - qty и partnumbers скрываются/показываются в зависимости от source (warehouse или нет)
|
|
||||||
|
|
||||||
**Что копировать:**
|
|
||||||
- HTML структуру из lines 4-78
|
|
||||||
- JavaScript функции:
|
|
||||||
- `loadPricelistInfo()` - загрузка деталей прайслиста
|
|
||||||
- `loadItems(page)` - загрузка товаров
|
|
||||||
- `renderItems(items)` - рендер таблицы товаров
|
|
||||||
- `renderItemsPagination(total, page, perPage)` - пагинация товаров
|
|
||||||
- `isWarehouseSource()` - проверка источника
|
|
||||||
- `toggleWarehouseColumns()` - показать/скрыть conditional колонки
|
|
||||||
- `formatQty(qty)` - форматирование количества
|
|
||||||
- `formatPriceSettings(item)` - форматирование строки настроек
|
|
||||||
- `escapeHtml(text)` - экранирование HTML
|
|
||||||
- Debounce для поиска (lines 300-306)
|
|
||||||
- CSS классы Tailwind
|
|
||||||
- Логику conditional колонок (lines 152-164)
|
|
||||||
|
|
||||||
**Где использовать в дочернем проекте:**
|
|
||||||
- URL: `/pricelists/:id`
|
|
||||||
- API:
|
|
||||||
- `GET /api/pricelists/:id`
|
|
||||||
- `GET /api/pricelists/:id/items?page=1&per_page=50&search=...`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Backend - Handler
|
|
||||||
|
|
||||||
**Файл источник:** QuoteForge/internal/handlers/pricelist.go
|
|
||||||
|
|
||||||
**Методы для реализации:**
|
|
||||||
|
|
||||||
1. **List** (lines 23-89)
|
|
||||||
- Параметры: `page`, `per_page`, `source` (фильтр), `active_only`
|
|
||||||
- Логика:
|
|
||||||
- Получить все прайслисты
|
|
||||||
- Отфильтровать по source (case-insensitive)
|
|
||||||
- Отсортировать по CreatedAt DESC (свежее сверху)
|
|
||||||
- Пагинировать
|
|
||||||
- Для каждого: посчитать товары (CountLocalPricelistItems), использования (IsUsed)
|
|
||||||
- Вернуть JSON с полями: id, source, version, created_by, item_count, usage_count, is_active, created_at, synced_from
|
|
||||||
|
|
||||||
2. **Get** (lines 92-116)
|
|
||||||
- Параметр: `id` (uint из URL)
|
|
||||||
- Логика:
|
|
||||||
- Получить прайслист по ID
|
|
||||||
- Вернуть его детали (id, source, version, item_count, is_active, created_at)
|
|
||||||
- 404 если не найден
|
|
||||||
|
|
||||||
3. **GetItems** (lines 119-181)
|
|
||||||
- Параметры: `id` (URL), `page`, `per_page`, `search` (query)
|
|
||||||
- Логика:
|
|
||||||
- Получить прайслист по ID
|
|
||||||
- Получить товары этого прайслиста
|
|
||||||
- Фильтровать по lot_name LIKE search (если передан)
|
|
||||||
- Посчитать total
|
|
||||||
- Пагинировать
|
|
||||||
- Для каждого товара: извлечь категорию из lot_name (первая часть до "_")
|
|
||||||
- Вернуть JSON: source, items (id, lot_name, price, category, available_qty, partnumbers), total, page, per_page
|
|
||||||
|
|
||||||
4. **GetLotNames** (lines 183-211)
|
|
||||||
- Параметр: `id` (URL)
|
|
||||||
- Логика:
|
|
||||||
- Получить все lot_names из этого прайслиста
|
|
||||||
- Отсортировать alphabetically
|
|
||||||
- Вернуть JSON: lot_names (array of strings), total
|
|
||||||
|
|
||||||
5. **GetLatest** (lines 214-233)
|
|
||||||
- Параметр: `source` (query, default "estimate")
|
|
||||||
- Логика:
|
|
||||||
- Нормализовать source (case-insensitive)
|
|
||||||
- Получить самый свежий прайслист по этому source
|
|
||||||
- Вернуть его детали
|
|
||||||
- 404 если не найден
|
|
||||||
|
|
||||||
**Регистрация маршрутов:**
|
|
||||||
```go
|
|
||||||
pricelists := api.Group("/pricelists")
|
|
||||||
{
|
|
||||||
pricelists.GET("", handler.List)
|
|
||||||
pricelists.GET("/latest", handler.GetLatest)
|
|
||||||
pricelists.GET("/:id", handler.Get)
|
|
||||||
pricelists.GET("/:id/items", handler.GetItems)
|
|
||||||
pricelists.GET("/:id/lots", handler.GetLotNames)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Адаптация для другого проекта
|
|
||||||
|
|
||||||
### Что нужно изменить
|
|
||||||
|
|
||||||
1. **Источник данных**
|
|
||||||
- QuoteForge использует local DB (LocalPricelist, LocalPricelistItem)
|
|
||||||
- В вашем проекте: замените на ваши структуры/таблицы
|
|
||||||
- Сущность "прайслист" может называться по-другому
|
|
||||||
|
|
||||||
2. **API маршруты**
|
|
||||||
- `/api/pricelists` → ваш путь
|
|
||||||
- `:id` - может быть UUID вместо int, адаптировать parsing
|
|
||||||
|
|
||||||
3. **Имена полей**
|
|
||||||
- Если у вас нет поля `version` - используйте ID или дату
|
|
||||||
- Если нет `source` - опустить фильтр
|
|
||||||
- Если нет `IsUsed` - считать как всегда 0
|
|
||||||
|
|
||||||
4. **Структуры данных**
|
|
||||||
- Pricelist должна иметь: id, name/version, created_at, source, item_count
|
|
||||||
- PricelistItem должна иметь: id, lot_name, price, available_qty, partnumbers
|
|
||||||
|
|
||||||
5. **Условные колонки**
|
|
||||||
- Логика: если source == "warehouse", показать qty и partnumbers
|
|
||||||
- Адаптировать под ваши источники/типы
|
|
||||||
|
|
||||||
### Что копировать как есть
|
|
||||||
|
|
||||||
- **HTML структура** - таблицы, модали, классы Tailwind
|
|
||||||
- **JavaScript логика** - все функции загрузки, рендера, пагинации
|
|
||||||
- **CSS классы** - Tailwind работает везде одинаково
|
|
||||||
- **Форматирование функций** - formatPrice, formatQty, formatDate
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Пошаговая инструкция для ИИ
|
|
||||||
|
|
||||||
1. **Прочитай оба файла:**
|
|
||||||
- QuoteForge/web/templates/pricelists.html (список)
|
|
||||||
- QuoteForge/web/templates/pricelist_detail.html (детали)
|
|
||||||
- QuoteForge/internal/handlers/pricelist.go (backend)
|
|
||||||
|
|
||||||
2. **Определи структуры данных в дочернем проекте:**
|
|
||||||
- Какая таблица хранит "прайслисты"?
|
|
||||||
- Какие у неё поля?
|
|
||||||
- Как связаны товары?
|
|
||||||
|
|
||||||
3. **Адаптируй Backend:**
|
|
||||||
- Скопируй методы Handler
|
|
||||||
- Замени DB вызовы на вызовы вашего хранилища
|
|
||||||
- Замени имена полей в JSON ответах если нужно
|
|
||||||
- Убедись, что API возвращает нужный формат
|
|
||||||
|
|
||||||
4. **Адаптируй Frontend - Список:**
|
|
||||||
- Скопируй HTML таблицу
|
|
||||||
- Скопируй функции load/render/pagination
|
|
||||||
- Замени маршруты `/pricelists` → ваши
|
|
||||||
- Замени API endpoint → ваш
|
|
||||||
- Протестируй список загружается
|
|
||||||
|
|
||||||
5. **Адаптируй Frontend - Детали:**
|
|
||||||
- Скопируй HTML для деталей
|
|
||||||
- Скопируй функции loadInfo/loadItems/render
|
|
||||||
- Замени маршруты и endpoints
|
|
||||||
- Особое внимание на conditional колонки (toggleWarehouseColumns)
|
|
||||||
- Протестируй поиск работает
|
|
||||||
|
|
||||||
6. **Протестируй:**
|
|
||||||
- Список загружается
|
|
||||||
- Пагинация работает
|
|
||||||
- Детали открываются
|
|
||||||
- Поиск работает
|
|
||||||
- Conditional колонки показываются/скрываются правильно
|
|
||||||
- Форматирование цен и дат работает
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Пример адаптации
|
|
||||||
|
|
||||||
### Backend (было):
|
|
||||||
```go
|
|
||||||
func (h *PricelistHandler) List(c *gin.Context) {
|
|
||||||
localPLs, err := h.localDB.GetLocalPricelists()
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backend (стало):
|
|
||||||
```go
|
|
||||||
func (h *CatalogHandler) List(c *gin.Context) {
|
|
||||||
catalogs, err := h.service.GetAllCatalogs(page, perPage)
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend (было):
|
|
||||||
```javascript
|
|
||||||
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend (стало):
|
|
||||||
```javascript
|
|
||||||
const resp = await fetch(`/api/catalogs?page=${page}&per_page=20`);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Качество результата
|
|
||||||
|
|
||||||
Когда закончишь:
|
|
||||||
- ✅ Список и детали выглядят идентично QuoteForge
|
|
||||||
- ✅ Все функции работают (load, render, pagination, search, conditional columns)
|
|
||||||
- ✅ Обработка ошибок (404, empty list, network errors)
|
|
||||||
- ✅ Таблицы с Tailwind классами оформлены одинаково
|
|
||||||
- ✅ Форматирование чисел/дат совпадает
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Вопросы для ИИ
|
|
||||||
|
|
||||||
Перед тем как давать этот промпт, ответь на эти вопросы:
|
|
||||||
|
|
||||||
1. **Какие у тебя структуры данных для "прайслиста"?**
|
|
||||||
- Пример: какие поля, как называется таблица
|
|
||||||
|
|
||||||
2. **Какие API endpoints уже есть?**
|
|
||||||
- Или нужно создать с нуля?
|
|
||||||
|
|
||||||
3. **Есть ли уже разница в источниках (estimate/warehouse)?**
|
|
||||||
- Или все одного типа?
|
|
||||||
|
|
||||||
4. **Нужна ли возможность создавать прайслисты?**
|
|
||||||
- Или только просмотр?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Чеклист для проверки
|
|
||||||
|
|
||||||
После переноса проверь:
|
|
||||||
|
|
||||||
- [ ] Backend: List возвращает правильный JSON
|
|
||||||
- [ ] Backend: Get возвращает детали
|
|
||||||
- [ ] Backend: GetItems возвращает товары с поиском
|
|
||||||
- [ ] Frontend: Список загружается на `/pricelists`
|
|
||||||
- [ ] Frontend: Клик на прайслист открывает `/pricelists/:id`
|
|
||||||
- [ ] Frontend: Таблица на детальной странице рендеритсяся
|
|
||||||
- [ ] Frontend: Поиск работает с дебаунсом
|
|
||||||
- [ ] Frontend: Пагинация работает
|
|
||||||
- [ ] Frontend: Conditional колонки показываются/скрываются
|
|
||||||
- [ ] Frontend: Форматирование цен работает (2 знака)
|
|
||||||
- [ ] Frontend: Форматирование дат работает (ru-RU)
|
|
||||||
- [ ] UI: Выглядит идентично QuoteForge
|
|
||||||
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.
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
<div class="hidden md:flex space-x-4">
|
<div class="hidden md:flex space-x-4">
|
||||||
<a href="/projects" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Мои проекты</a>
|
<a href="/projects" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Мои проекты</a>
|
||||||
<a href="/pricelists" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Прайслисты</a>
|
<a href="/pricelists" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Прайслисты</a>
|
||||||
|
<a href="/partnumber-books" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Партномера</a>
|
||||||
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
|
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
256
web/templates/partnumber_books.html
Normal file
256
web/templates/partnumber_books.html
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
{{define "title"}}QuoteForge - Партномера{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Партномера</h1>
|
||||||
|
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div id="summary-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 hidden">
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">Активный лист</div>
|
||||||
|
<div id="card-version" class="font-mono font-semibold text-gray-800 text-sm truncate">—</div>
|
||||||
|
<div id="card-date" class="text-xs text-gray-400 mt-0.5">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">Уникальных LOT</div>
|
||||||
|
<div id="card-lots" class="text-2xl font-bold text-blue-600">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">Всего PN</div>
|
||||||
|
<div id="card-pn-total" class="text-2xl font-bold text-gray-800">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">Primary PN</div>
|
||||||
|
<div id="card-pn-primary" class="text-2xl font-bold text-green-600">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||||
|
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active book items with search -->
|
||||||
|
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
|
||||||
|
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
|
||||||
|
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
|
||||||
|
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||||
|
oninput="filterItems(this.value)">
|
||||||
|
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 text-gray-600 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left">Partnumber</th>
|
||||||
|
<th class="px-4 py-2 text-left">LOT</th>
|
||||||
|
<th class="px-4 py-2 text-center w-24">Primary</th>
|
||||||
|
<th class="px-4 py-2 text-left">Описание</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="active-items-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All books list (collapsed by default) -->
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<!-- Header row — always visible -->
|
||||||
|
<div class="px-4 py-3 flex items-center justify-between">
|
||||||
|
<button onclick="toggleBooksSection()" class="flex items-center gap-2 text-sm font-semibold text-gray-800 hover:text-gray-600 select-none">
|
||||||
|
<svg id="books-chevron" class="w-4 h-4 text-gray-400 transition-transform duration-150" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
Снимки сопоставлений (Partnumber Books)
|
||||||
|
</button>
|
||||||
|
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 text-sm font-medium">
|
||||||
|
Синхронизировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Collapsible body -->
|
||||||
|
<div id="books-section-body" class="hidden border-t">
|
||||||
|
<div id="books-list-loading" class="p-6 text-center text-gray-400 text-sm">Загрузка...</div>
|
||||||
|
<table id="books-table" class="w-full text-sm hidden">
|
||||||
|
<thead class="bg-gray-50 text-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left">Версия</th>
|
||||||
|
<th class="px-4 py-2 text-left">Дата</th>
|
||||||
|
<th class="px-4 py-2 text-right">Позиций</th>
|
||||||
|
<th class="px-4 py-2 text-center">Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="books-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="books-empty" class="hidden p-6 text-center text-gray-400 text-sm">
|
||||||
|
Нет загруженных снимков.
|
||||||
|
</div>
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div id="books-pagination" class="hidden px-4 py-3 border-t flex items-center justify-between text-sm text-gray-600">
|
||||||
|
<span id="books-page-info"></span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="books-prev" onclick="changeBooksPage(-1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">← Назад</button>
|
||||||
|
<button id="books-next" onclick="changeBooksPage(1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">Вперёд →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let allItems = [];
|
||||||
|
let allBooks = [];
|
||||||
|
let booksPage = 1;
|
||||||
|
const BOOKS_PER_PAGE = 10;
|
||||||
|
|
||||||
|
function toggleBooksSection() {
|
||||||
|
const body = document.getElementById('books-section-body');
|
||||||
|
const chevron = document.getElementById('books-chevron');
|
||||||
|
const collapsed = body.classList.toggle('hidden');
|
||||||
|
chevron.style.transform = collapsed ? '' : 'rotate(90deg)';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBooks() {
|
||||||
|
let resp, data;
|
||||||
|
try {
|
||||||
|
resp = await fetch('/api/partnumber-books');
|
||||||
|
data = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('books-list-loading').classList.add('hidden');
|
||||||
|
document.getElementById('books-empty').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
allBooks = data.books || [];
|
||||||
|
document.getElementById('books-list-loading').classList.add('hidden');
|
||||||
|
|
||||||
|
if (!allBooks.length) {
|
||||||
|
document.getElementById('books-empty').classList.remove('hidden');
|
||||||
|
document.getElementById('summary-empty').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
booksPage = 1;
|
||||||
|
renderBooksPage();
|
||||||
|
|
||||||
|
const active = allBooks.find(b => b.is_active) || allBooks[0];
|
||||||
|
await loadActiveBookItems(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBooksPage() {
|
||||||
|
const total = allBooks.length;
|
||||||
|
const totalPages = Math.ceil(total / BOOKS_PER_PAGE);
|
||||||
|
const start = (booksPage - 1) * BOOKS_PER_PAGE;
|
||||||
|
const pageBooks = allBooks.slice(start, start + BOOKS_PER_PAGE);
|
||||||
|
|
||||||
|
const tbody = document.getElementById('books-table-body');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
pageBooks.forEach(b => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'border-b hover:bg-gray-50';
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="px-4 py-2 font-mono text-xs">${b.version}</td>
|
||||||
|
<td class="px-4 py-2 text-gray-500 text-xs">${b.created_at}</td>
|
||||||
|
<td class="px-4 py-2 text-right text-xs">${b.item_count}</td>
|
||||||
|
<td class="px-4 py-2 text-center">
|
||||||
|
${b.is_active
|
||||||
|
? '<span class="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Активный</span>'
|
||||||
|
: '<span class="px-2 py-0.5 bg-gray-100 text-gray-500 rounded text-xs">Архив</span>'}
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
document.getElementById('books-table').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Pagination controls
|
||||||
|
if (total > BOOKS_PER_PAGE) {
|
||||||
|
document.getElementById('books-pagination').classList.remove('hidden');
|
||||||
|
document.getElementById('books-page-info').textContent =
|
||||||
|
`Снимки ${start + 1}–${Math.min(start + BOOKS_PER_PAGE, total)} из ${total}`;
|
||||||
|
document.getElementById('books-prev').disabled = booksPage === 1;
|
||||||
|
document.getElementById('books-next').disabled = booksPage === totalPages;
|
||||||
|
} else {
|
||||||
|
document.getElementById('books-pagination').classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeBooksPage(delta) {
|
||||||
|
const totalPages = Math.ceil(allBooks.length / BOOKS_PER_PAGE);
|
||||||
|
booksPage = Math.max(1, Math.min(totalPages, booksPage + delta));
|
||||||
|
renderBooksPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveBookItems(book) {
|
||||||
|
let resp, data;
|
||||||
|
try {
|
||||||
|
resp = await fetch(`/api/partnumber-books/${book.server_id}`);
|
||||||
|
data = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resp.ok) return;
|
||||||
|
|
||||||
|
allItems = data.items || [];
|
||||||
|
|
||||||
|
const lots = new Set(allItems.map(i => i.lot_name));
|
||||||
|
const primaryCount = allItems.filter(i => i.is_primary_pn).length;
|
||||||
|
|
||||||
|
document.getElementById('card-version').textContent = book.version;
|
||||||
|
document.getElementById('card-date').textContent = book.created_at;
|
||||||
|
document.getElementById('card-lots').textContent = lots.size;
|
||||||
|
document.getElementById('card-pn-total').textContent = allItems.length;
|
||||||
|
document.getElementById('card-pn-primary').textContent = primaryCount;
|
||||||
|
document.getElementById('summary-cards').classList.remove('hidden');
|
||||||
|
document.getElementById('active-book-section').classList.remove('hidden');
|
||||||
|
|
||||||
|
renderItems(allItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItems(items) {
|
||||||
|
const tbody = document.getElementById('active-items-body');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
items.forEach(item => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'border-b hover:bg-gray-50';
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="px-4 py-1.5 font-mono text-xs">${item.partnumber}</td>
|
||||||
|
<td class="px-4 py-1.5 text-xs font-medium text-blue-700">${item.lot_name}</td>
|
||||||
|
<td class="px-4 py-1.5 text-center text-green-600 text-xs">${item.is_primary_pn ? '✓' : ''}</td>
|
||||||
|
<td class="px-4 py-1.5 text-xs text-gray-500">${item.description || ''}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
document.getElementById('pn-count').textContent = `${items.length} из ${allItems.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterItems(query) {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) { renderItems(allItems); return; }
|
||||||
|
renderItems(allItems.filter(i =>
|
||||||
|
i.partnumber.toLowerCase().includes(q) || i.lot_name.toLowerCase().includes(q)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPartnumberBooks() {
|
||||||
|
let resp, data;
|
||||||
|
try {
|
||||||
|
resp = await fetch('/api/sync/partnumber-books', {method: 'POST'});
|
||||||
|
data = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Ошибка синхронизации', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.success) {
|
||||||
|
showToast(`Синхронизировано: ${data.synced} листов`, 'success');
|
||||||
|
loadBooks();
|
||||||
|
} else if (data.blocked) {
|
||||||
|
showToast(`Синк заблокирован: ${data.reason_text}`, 'error');
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadBooks);
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
@@ -211,6 +211,8 @@ const projectUUID = '{{.ProjectUUID}}';
|
|||||||
let configStatusMode = 'active';
|
let configStatusMode = 'active';
|
||||||
let project = null;
|
let project = null;
|
||||||
let allConfigs = [];
|
let allConfigs = [];
|
||||||
|
let dragConfigUUID = '';
|
||||||
|
let isReorderingConfigs = false;
|
||||||
let projectVariants = [];
|
let projectVariants = [];
|
||||||
let projectsCatalog = [];
|
let projectsCatalog = [];
|
||||||
let variantMenuInitialized = false;
|
let variantMenuInitialized = false;
|
||||||
@@ -221,6 +223,11 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMoneyNoDecimals(value) {
|
||||||
|
const safe = Number.isFinite(Number(value)) ? Number(value) : 0;
|
||||||
|
return '$' + Math.round(safe).toLocaleString('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
function resolveProjectTrackerURL(projectData) {
|
function resolveProjectTrackerURL(projectData) {
|
||||||
if (!projectData) return '';
|
if (!projectData) return '';
|
||||||
const explicitURL = (projectData.tracker_url || '').trim();
|
const explicitURL = (projectData.tracker_url || '').trim();
|
||||||
@@ -350,42 +357,53 @@ function renderConfigs(configs) {
|
|||||||
let totalSum = 0;
|
let totalSum = 0;
|
||||||
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||||||
html += '<thead class="bg-gray-50"><tr>';
|
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">Line</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-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">Цена за 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-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-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-2 py-3 text-center text-xs font-medium text-gray-500 uppercase w-12"></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 += '</tr></thead><tbody class="divide-y">';
|
html += '</tr></thead><tbody class="divide-y" id="project-configs-tbody">';
|
||||||
|
|
||||||
configs.forEach(c => {
|
configs.forEach((c, idx) => {
|
||||||
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
|
||||||
const total = c.total_price || 0;
|
const total = c.total_price || 0;
|
||||||
const serverCount = c.server_count || 1;
|
const serverCount = c.server_count || 1;
|
||||||
const author = c.owner_username || (c.user && c.user.username) || '—';
|
const author = c.owner_username || (c.user && c.user.username) || '—';
|
||||||
const unitPrice = serverCount > 0 ? (total / serverCount) : 0;
|
const unitPrice = serverCount > 0 ? (total / serverCount) : 0;
|
||||||
|
const lineValue = (idx + 1) * 10;
|
||||||
|
const serverModel = (c.server_model || '').trim() || '—';
|
||||||
totalSum += total;
|
totalSum += total;
|
||||||
|
|
||||||
html += '<tr class="hover:bg-gray-50">';
|
const draggable = configStatusMode === 'active' ? 'true' : 'false';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
|
html += '<tr class="hover:bg-gray-50" draggable="' + draggable + '" data-config-uuid="' + c.uuid + '" ondragstart="onConfigDragStart(event)" ondragover="onConfigDragOver(event)" ondragleave="onConfigDragLeave(event)" ondrop="onConfigDrop(event)" ondragend="onConfigDragEnd(event)">';
|
||||||
|
if (configStatusMode === 'active') {
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">';
|
||||||
|
html += '<span class="inline-flex items-center gap-2"><span class="drag-handle text-gray-400 hover:text-gray-700 cursor-grab active:cursor-grabbing select-none" title="Перетащить для изменения порядка" aria-label="Перетащить">';
|
||||||
|
html += '<svg class="w-4 h-4 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"></path></svg>';
|
||||||
|
html += '</span><span>' + lineValue + '</span></span></td>';
|
||||||
|
} else {
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + lineValue + '</td>';
|
||||||
|
}
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(serverModel) + '</td>';
|
||||||
if (configStatusMode === 'archived') {
|
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">' + escapeHtml(c.name) + '</td>';
|
||||||
} else {
|
} 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"><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 text-gray-500">' + escapeHtml(author) + '</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">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + formatMoneyNoDecimals(unitPrice) + '</td>';
|
||||||
if (configStatusMode === 'archived') {
|
if (configStatusMode === 'archived') {
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||||
} else {
|
} 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-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>';
|
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">' + formatMoneyNoDecimals(total) + '</td>';
|
||||||
const versionNo = c.current_version_no || 1;
|
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-2 py-3 text-sm text-center text-gray-500 w-12">v' + versionNo + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
html += '<td class="px-4 py-3 text-sm text-right whitespace-nowrap"><div class="inline-flex items-center justify-end gap-2">';
|
||||||
if (configStatusMode === 'archived') {
|
if (configStatusMode === 'archived') {
|
||||||
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
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>';
|
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>';
|
||||||
@@ -397,15 +415,16 @@ function renderConfigs(configs) {
|
|||||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
|
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>';
|
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>';
|
||||||
}
|
}
|
||||||
html += '</td></tr>';
|
html += '</div></td></tr>';
|
||||||
});
|
});
|
||||||
|
|
||||||
html += '</tbody>';
|
html += '</tbody>';
|
||||||
html += '<tfoot class="bg-gray-50 border-t">';
|
html += '<tfoot class="bg-gray-50 border-t">';
|
||||||
html += '<tr>';
|
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 font-medium text-gray-700" colspan="5">Итого по проекту</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-gray-700">' + configs.length + '</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 text-sm text-right font-semibold text-gray-900" data-footer-total="1">' + formatMoneyNoDecimals(totalSum) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3"></td>';
|
||||||
html += '<td class="px-4 py-3"></td>';
|
html += '<td class="px-4 py-3"></td>';
|
||||||
html += '<td class="px-4 py-3"></td>';
|
html += '<td class="px-4 py-3"></td>';
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
@@ -994,6 +1013,105 @@ document.addEventListener('keydown', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onConfigDragStart(event) {
|
||||||
|
if (configStatusMode !== 'active' || isReorderingConfigs) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = event.target.closest('tr[data-config-uuid]');
|
||||||
|
if (!row) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragConfigUUID = row.dataset.configUuid || '';
|
||||||
|
if (!dragConfigUUID) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.classList.add('opacity-50');
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', dragConfigUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfigDragOver(event) {
|
||||||
|
if (!dragConfigUUID || configStatusMode !== 'active') return;
|
||||||
|
event.preventDefault();
|
||||||
|
const row = event.target.closest('tr[data-config-uuid]');
|
||||||
|
if (!row || row.dataset.configUuid === dragConfigUUID) return;
|
||||||
|
row.classList.add('ring-2', 'ring-blue-300');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfigDragLeave(event) {
|
||||||
|
const row = event.target.closest('tr[data-config-uuid]');
|
||||||
|
if (!row) return;
|
||||||
|
row.classList.remove('ring-2', 'ring-blue-300');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfigDrop(event) {
|
||||||
|
if (!dragConfigUUID || configStatusMode !== 'active' || isReorderingConfigs) return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const targetRow = event.target.closest('tr[data-config-uuid]');
|
||||||
|
if (!targetRow) return;
|
||||||
|
targetRow.classList.remove('ring-2', 'ring-blue-300');
|
||||||
|
|
||||||
|
const targetUUID = targetRow.dataset.configUuid || '';
|
||||||
|
if (!targetUUID || targetUUID === dragConfigUUID) return;
|
||||||
|
|
||||||
|
const previous = allConfigs.slice();
|
||||||
|
const fromIndex = allConfigs.findIndex(c => c.uuid === dragConfigUUID);
|
||||||
|
const toIndex = allConfigs.findIndex(c => c.uuid === targetUUID);
|
||||||
|
if (fromIndex < 0 || toIndex < 0) return;
|
||||||
|
|
||||||
|
const [moved] = allConfigs.splice(fromIndex, 1);
|
||||||
|
allConfigs.splice(toIndex, 0, moved);
|
||||||
|
renderConfigs(allConfigs);
|
||||||
|
await saveConfigReorder(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfigDragEnd() {
|
||||||
|
document.querySelectorAll('tr[data-config-uuid]').forEach(row => {
|
||||||
|
row.classList.remove('ring-2', 'ring-blue-300', 'opacity-50');
|
||||||
|
});
|
||||||
|
dragConfigUUID = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfigReorder(previousConfigs) {
|
||||||
|
if (isReorderingConfigs) return;
|
||||||
|
isReorderingConfigs = true;
|
||||||
|
const orderedUUIDs = allConfigs.map(c => c.uuid);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/projects/' + projectUUID + '/configs/reorder', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ordered_uuids: orderedUUIDs}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || 'Не удалось сохранить порядок');
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
allConfigs = data.configurations || allConfigs;
|
||||||
|
renderConfigs(allConfigs);
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Порядок сохранён', 'success');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
allConfigs = previousConfigs.slice();
|
||||||
|
renderConfigs(allConfigs);
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(e.message || 'Не удалось сохранить порядок', 'error');
|
||||||
|
} else {
|
||||||
|
alert(e.message || 'Не удалось сохранить порядок');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isReorderingConfigs = false;
|
||||||
|
dragConfigUUID = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateConfigServerCount(input) {
|
async function updateConfigServerCount(input) {
|
||||||
const uuid = input.dataset.uuid;
|
const uuid = input.dataset.uuid;
|
||||||
const prevValue = parseInt(input.dataset.prev) || 1;
|
const prevValue = parseInt(input.dataset.prev) || 1;
|
||||||
@@ -1018,7 +1136,7 @@ async function updateConfigServerCount(input) {
|
|||||||
// Update row total price
|
// Update row total price
|
||||||
const totalCell = document.querySelector('[data-total-uuid="' + uuid + '"]');
|
const totalCell = document.querySelector('[data-total-uuid="' + uuid + '"]');
|
||||||
if (totalCell && updated.total_price != null) {
|
if (totalCell && updated.total_price != null) {
|
||||||
totalCell.textContent = '$' + updated.total_price.toLocaleString('en-US', {minimumFractionDigits: 2});
|
totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
|
||||||
}
|
}
|
||||||
// Update the config in allConfigs and recalculate footer total
|
// Update the config in allConfigs and recalculate footer total
|
||||||
for (let i = 0; i < allConfigs.length; i++) {
|
for (let i = 0; i < allConfigs.length; i++) {
|
||||||
@@ -1040,7 +1158,7 @@ function updateFooterTotal() {
|
|||||||
allConfigs.forEach(c => { totalSum += (c.total_price || 0); });
|
allConfigs.forEach(c => { totalSum += (c.total_price || 0); });
|
||||||
const footer = document.querySelector('tfoot td[data-footer-total]');
|
const footer = document.querySelector('tfoot td[data-footer-total]');
|
||||||
if (footer) {
|
if (footer) {
|
||||||
footer.textContent = '$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2});
|
footer.textContent = formatMoneyNoDecimals(totalSum);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,40 @@ function formatDateTime(value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateParts(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return {
|
||||||
|
date: date.toLocaleDateString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
}),
|
||||||
|
time: date.toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuditCell(value, user) {
|
||||||
|
const parts = formatDateParts(value);
|
||||||
|
const safeUser = escapeHtml((user || '—').trim() || '—');
|
||||||
|
if (!parts) {
|
||||||
|
return '<div class="leading-tight">' +
|
||||||
|
'<div class="text-gray-400">—</div>' +
|
||||||
|
'<div class="text-gray-400">—</div>' +
|
||||||
|
'<div class="text-gray-500">@ ' + safeUser + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
return '<div class="leading-tight whitespace-nowrap">' +
|
||||||
|
'<div>' + escapeHtml(parts.date) + '</div>' +
|
||||||
|
'<div class="text-gray-500">' + escapeHtml(parts.time) + '</div>' +
|
||||||
|
'<div class="text-gray-600">@ ' + safeUser + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeVariant(variant) {
|
function normalizeVariant(variant) {
|
||||||
const trimmed = (variant || '').trim();
|
const trimmed = (variant || '').trim();
|
||||||
return trimmed === '' ? 'main' : trimmed;
|
return trimmed === '' ? 'main' : trimmed;
|
||||||
@@ -225,20 +259,20 @@ async function loadProjects() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '<div class="overflow-x-auto"><table class="w-full">';
|
let html = '<div class="overflow-x-auto"><table class="w-full table-fixed min-w-[980px]">';
|
||||||
html += '<thead class="bg-gray-50">';
|
html += '<thead class="bg-gray-50">';
|
||||||
html += '<tr>';
|
html += '<tr>';
|
||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Код</th>';
|
html += '<th class="w-28 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 += '<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') {
|
if (sortField === 'name') {
|
||||||
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
||||||
}
|
}
|
||||||
html += '</button></th>';
|
html += '</button></th>';
|
||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Создан @ автор</th>';
|
html += '<th class="w-44 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="w-44 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="w-36 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="w-36 px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
html += '<tr>';
|
html += '<tr>';
|
||||||
html += '<th class="px-4 py-2"></th>';
|
html += '<th class="px-4 py-2"></th>';
|
||||||
@@ -259,14 +293,12 @@ async function loadProjects() {
|
|||||||
const displayName = p.name || '';
|
const displayName = p.name || '';
|
||||||
const createdBy = p.owner_username || '—';
|
const createdBy = p.owner_username || '—';
|
||||||
const updatedBy = '—';
|
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);
|
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 font-medium align-top"><a class="inline-block max-w-full text-blue-600 hover:underline whitespace-nowrap" 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-700 align-top"><div class="truncate" title="' + escapeHtml(displayName) + '">' + escapeHtml(displayName || '—') + '</div></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 align-top">' + renderAuditCell(p.created_at, createdBy) + '</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 text-gray-600 align-top">' + renderAuditCell(p.updated_at, updatedBy) + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm">' + variantChips + '</td>';
|
html += '<td class="px-4 py-3 text-sm align-top"><div class="flex flex-wrap gap-1">' + variantChips + '</div></td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
|
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
|
||||||
|
|
||||||
if (p.is_active) {
|
if (p.is_active) {
|
||||||
|
|||||||
Reference in New Issue
Block a user