Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d7fab39b4 | ||
|
|
1906a74759 | ||
|
|
3c46cd7bf0 | ||
|
|
7f8491d197 | ||
|
|
3fd7a2231a | ||
|
|
c295b60dd8 |
86
CLAUDE.md
86
CLAUDE.md
@@ -1,88 +1,24 @@
|
|||||||
# QuoteForge - Claude Code Instructions
|
# QuoteForge - Claude Code Instructions
|
||||||
|
|
||||||
## Overview
|
## Bible
|
||||||
Корпоративный конфигуратор серверов с offline-first архитектурой.
|
|
||||||
Приложение работает через локальную SQLite базу, синхронизация с MariaDB выполняется фоново.
|
|
||||||
|
|
||||||
## Product Scope
|
The **[bible/](bible/README.md)** is the single source of truth for this project's architecture, schemas, patterns, and rules. Read it before making any changes.
|
||||||
- Конфигуратор компонентов и расчёт КП
|
|
||||||
- Проекты и конфигурации
|
|
||||||
- Read-only просмотр прайслистов из локального кэша
|
|
||||||
- Sync (pull компонентов/прайслистов, push локальных изменений)
|
|
||||||
|
|
||||||
Из области исключены:
|
**Rules:**
|
||||||
- admin pricing UI/API
|
- Every architectural decision must be recorded in `bible/` in the same commit as the code.
|
||||||
- stock import
|
- Bible files are written and updated in **English only**.
|
||||||
- alerts
|
- Before working on the codebase, check `releases/memory/` for the latest release notes.
|
||||||
- cron/importer утилиты
|
|
||||||
|
|
||||||
## Architecture
|
## Quick Reference
|
||||||
- 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
|
# Verify build
|
||||||
|
go build ./cmd/qfs && go vet ./...
|
||||||
|
|
||||||
|
# Run
|
||||||
go run ./cmd/qfs
|
go run ./cmd/qfs
|
||||||
make run
|
make run
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
make build-release
|
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) для подробностей.
|
|
||||||
|
|||||||
119
bible/01-overview.md
Normal file
119
bible/01-overview.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# 01 — Product Overview
|
||||||
|
|
||||||
|
## What is QuoteForge
|
||||||
|
|
||||||
|
A corporate server configuration and quotation tool.
|
||||||
|
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only for synchronization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
- Mobile-first interface — works comfortably on phones and tablets
|
||||||
|
- Server configurator — step-by-step component selection
|
||||||
|
- Automatic price calculation — based on pricelists from local cache
|
||||||
|
- CSV export — ready-to-use specifications for clients
|
||||||
|
- Configuration history — versioned snapshots with rollback support
|
||||||
|
- Full offline operation — continue working without network, sync later
|
||||||
|
- Guarded synchronization — sync is blocked by preflight check if local schema is not ready
|
||||||
|
|
||||||
|
### User Roles
|
||||||
|
|
||||||
|
| Role | Permissions |
|
||||||
|
|------|-------------|
|
||||||
|
| `viewer` | View, create quotes, export |
|
||||||
|
| `editor` | + save configurations |
|
||||||
|
| `pricing_admin` | + manage prices and alerts |
|
||||||
|
| `admin` | Full access, user management |
|
||||||
|
|
||||||
|
### Price Freshness Indicators
|
||||||
|
|
||||||
|
| Color | Status | Condition |
|
||||||
|
|-------|--------|-----------|
|
||||||
|
| Green | Fresh | < 30 days, ≥ 3 sources |
|
||||||
|
| Yellow | Normal | 30–60 days |
|
||||||
|
| Orange | Aging | 60–90 days |
|
||||||
|
| Red | Stale | > 90 days or no data |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Stack |
|
||||||
|
|-------|-------|
|
||||||
|
| Backend | Go 1.22+, Gin, GORM |
|
||||||
|
| Frontend | HTML, Tailwind CSS, htmx |
|
||||||
|
| Local DB | SQLite (`qfs.db`) |
|
||||||
|
| Server DB | MariaDB 11+ (sync + server admin) |
|
||||||
|
| Export | encoding/csv, excelize (XLSX) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Scope
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
- Component configurator and quotation calculation
|
||||||
|
- Projects and configurations
|
||||||
|
- Read-only pricelist viewing from local cache
|
||||||
|
- Sync (pull components/pricelists, push local changes)
|
||||||
|
|
||||||
|
**Out of scope (removed intentionally — do not restore):**
|
||||||
|
- Admin pricing UI/API
|
||||||
|
- Stock import
|
||||||
|
- Alerts
|
||||||
|
- Cron/importer utilities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
quoteforge/
|
||||||
|
├── cmd/
|
||||||
|
│ ├── qfs/main.go # HTTP server entry point
|
||||||
|
│ ├── migrate/ # Migration tool
|
||||||
|
│ └── migrate_ops_projects/ # OPS project migrator
|
||||||
|
├── internal/
|
||||||
|
│ ├── appmeta/ # App version metadata
|
||||||
|
│ ├── appstate/ # State management, backup
|
||||||
|
│ ├── article/ # Article generation
|
||||||
|
│ ├── config/ # Config parsing
|
||||||
|
│ ├── db/ # DB initialization
|
||||||
|
│ ├── handlers/ # HTTP handlers
|
||||||
|
│ ├── localdb/ # SQLite layer
|
||||||
|
│ ├── lotmatch/ # Lot matching logic
|
||||||
|
│ ├── middleware/ # Auth, CORS, etc.
|
||||||
|
│ ├── models/ # GORM models
|
||||||
|
│ ├── repository/ # Repository layer
|
||||||
|
│ └── services/ # Business logic
|
||||||
|
├── web/
|
||||||
|
│ ├── templates/ # HTML templates + partials
|
||||||
|
│ └── static/ # CSS, JS, assets
|
||||||
|
├── migrations/ # SQL migration files (30+)
|
||||||
|
├── bible/ # Architectural documentation (this section)
|
||||||
|
├── releases/memory/ # Per-version changelogs
|
||||||
|
├── config.example.yaml # Config template (the only one in repo)
|
||||||
|
└── go.mod
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration with Existing DB
|
||||||
|
|
||||||
|
QuoteForge integrates with the existing `RFQ_LOG` database:
|
||||||
|
|
||||||
|
**Read-only:**
|
||||||
|
- `lot` — component catalog
|
||||||
|
- `qt_lot_metadata` — extended component data
|
||||||
|
- `qt_categories` — categories
|
||||||
|
- `qt_pricelists`, `qt_pricelist_items` — pricelists
|
||||||
|
|
||||||
|
**Read + Write:**
|
||||||
|
- `qt_configurations` — configurations
|
||||||
|
- `qt_projects` — projects
|
||||||
|
|
||||||
|
**Sync service tables:**
|
||||||
|
- `qt_client_local_migrations` — migration catalog (SELECT only)
|
||||||
|
- `qt_client_schema_state` — applied migrations state
|
||||||
|
- `qt_pricelist_sync_status` — pricelist sync status
|
||||||
205
bible/02-architecture.md
Normal file
205
bible/02-architecture.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# 02 — Architecture
|
||||||
|
|
||||||
|
## Local-First Principle
|
||||||
|
|
||||||
|
**SQLite** is the single source of truth for the user.
|
||||||
|
**MariaDB** is a sync server only — it never blocks local operations.
|
||||||
|
|
||||||
|
```
|
||||||
|
User
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SQLite (qfs.db) ← all CRUD operations go here
|
||||||
|
│
|
||||||
|
│ background sync (every 5 min)
|
||||||
|
▼
|
||||||
|
MariaDB (RFQ_LOG) ← pull/push only
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- All CRUD operations go through SQLite only
|
||||||
|
- If MariaDB is unavailable → local work continues without restrictions
|
||||||
|
- Changes are queued in `pending_changes` and pushed on next sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Synchronization
|
||||||
|
|
||||||
|
### Data Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
[ SERVER / MariaDB ]
|
||||||
|
┌───────────────────────────┐
|
||||||
|
│ qt_projects │
|
||||||
|
│ qt_configurations │
|
||||||
|
│ qt_pricelists │
|
||||||
|
│ qt_pricelist_items │
|
||||||
|
│ qt_pricelist_sync_status │
|
||||||
|
└─────────────┬─────────────┘
|
||||||
|
│
|
||||||
|
pull (projects/configs/pricelists)
|
||||||
|
│
|
||||||
|
┌────────────────────┴────────────────────┐
|
||||||
|
│ │
|
||||||
|
[ CLIENT A / SQLite ] [ CLIENT B / SQLite ]
|
||||||
|
local_projects local_projects
|
||||||
|
local_configurations local_configurations
|
||||||
|
local_pricelists local_pricelists
|
||||||
|
local_pricelist_items local_pricelist_items
|
||||||
|
pending_changes pending_changes
|
||||||
|
│ │
|
||||||
|
└────── push (projects/configs only) ─────┘
|
||||||
|
│
|
||||||
|
[ SERVER / MariaDB ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sync Direction by Entity
|
||||||
|
|
||||||
|
| Entity | Direction |
|
||||||
|
|--------|-----------|
|
||||||
|
| Configurations | Client ↔ Server ↔ Other Clients |
|
||||||
|
| Projects | Client ↔ Server ↔ Other Clients |
|
||||||
|
| Pricelists | Server → Clients only (no push) |
|
||||||
|
| Components | Server → Clients only |
|
||||||
|
|
||||||
|
Local pricelists not present on the server and not referenced by active configurations are deleted automatically on sync.
|
||||||
|
|
||||||
|
### Soft Deletes (Archive Pattern)
|
||||||
|
|
||||||
|
Configurations and projects are **never hard-deleted**. Deletion is archive via `is_active = false`.
|
||||||
|
|
||||||
|
- `DELETE /api/configs/:uuid` → sets `is_active = false` (archived); can be restored via `reactivate`
|
||||||
|
- `DELETE /api/projects/:uuid` → archives a project **variant** only (`variant` field must be non-empty); main projects cannot be deleted via this endpoint
|
||||||
|
|
||||||
|
## Sync Readiness Guard
|
||||||
|
|
||||||
|
Before every push/pull, a preflight check runs:
|
||||||
|
1. Is the server (MariaDB) reachable?
|
||||||
|
2. Can centralized local DB migrations be applied?
|
||||||
|
3. Does the application version satisfy `min_app_version` of pending migrations?
|
||||||
|
|
||||||
|
**If the check fails:**
|
||||||
|
- Local CRUD continues without restriction
|
||||||
|
- Sync API returns `423 Locked` with `reason_code` and `reason_text`
|
||||||
|
- UI shows a red indicator with the block reason
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pricing
|
||||||
|
|
||||||
|
### Principle
|
||||||
|
|
||||||
|
**Prices come only from `local_pricelist_items`.**
|
||||||
|
Components (`local_components`) are metadata-only — they contain no pricing information.
|
||||||
|
|
||||||
|
### Lookup Pattern
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Look up a price for a line item
|
||||||
|
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
||||||
|
if found && price > 0 {
|
||||||
|
// use price
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside lookupPriceByPricelistID:
|
||||||
|
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
|
||||||
|
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Level Pricelists
|
||||||
|
|
||||||
|
A configuration can reference up to three pricelists simultaneously:
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `pricelist_id` | Primary (estimate) |
|
||||||
|
| `warehouse_pricelist_id` | Warehouse pricing |
|
||||||
|
| `competitor_pricelist_id` | Competitor pricing |
|
||||||
|
|
||||||
|
Pricelist sources: `estimate` | `warehouse` | `competitor`
|
||||||
|
|
||||||
|
### "Auto" Pricelist Selection
|
||||||
|
|
||||||
|
Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`):
|
||||||
|
|
||||||
|
- **Explicit mode:** concrete `pricelist_id` is set by user in settings.
|
||||||
|
- **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist.
|
||||||
|
|
||||||
|
`auto` must stay `auto` after price-level refresh and after manual "refresh prices":
|
||||||
|
- resolved IDs are runtime-only and must not overwrite user's mode;
|
||||||
|
- switching to explicit selection must clear runtime auto resolution for that source.
|
||||||
|
|
||||||
|
### Latest Pricelist Resolution Rules
|
||||||
|
|
||||||
|
For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with:
|
||||||
|
|
||||||
|
1. only pricelists that have at least one item (`EXISTS ...pricelist_items`);
|
||||||
|
2. deterministic sort: `created_at DESC, id DESC`.
|
||||||
|
|
||||||
|
This prevents selecting empty/incomplete snapshots and removes nondeterministic ties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Versioning
|
||||||
|
|
||||||
|
### Principle
|
||||||
|
|
||||||
|
Append-only: every save creates an immutable snapshot in `local_configuration_versions`.
|
||||||
|
|
||||||
|
```
|
||||||
|
local_configurations
|
||||||
|
└── current_version_id ──► local_configuration_versions (v3) ← active
|
||||||
|
local_configuration_versions (v2)
|
||||||
|
local_configuration_versions (v1)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `version_no = max + 1` on every save
|
||||||
|
- Old versions are never modified or deleted in normal flow
|
||||||
|
- Rollback does **not** rewind history — it creates a **new** version from the snapshot
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/configs/:uuid/rollback
|
||||||
|
{
|
||||||
|
"target_version": 3,
|
||||||
|
"note": "optional comment"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- A new version `vN` is created with `data` from the target version
|
||||||
|
- `change_note = "rollback to v{target_version}"` (+ note if provided)
|
||||||
|
- `current_version_id` is switched to the new version
|
||||||
|
- Configuration moves to `sync_status = pending`
|
||||||
|
|
||||||
|
### Sync Status Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
local → pending → synced
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync Payload for Versioning
|
||||||
|
|
||||||
|
Events in `pending_changes` for configurations contain:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `configuration_uuid` | Identifier |
|
||||||
|
| `operation` | `create` / `update` / `rollback` |
|
||||||
|
| `current_version_id` | Active version ID |
|
||||||
|
| `current_version_no` | Version number |
|
||||||
|
| `snapshot` | Current configuration state |
|
||||||
|
| `idempotency_key` | For idempotent push |
|
||||||
|
| `conflict_policy` | `last_write_wins` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background Processes
|
||||||
|
|
||||||
|
| Process | Interval | What it does |
|
||||||
|
|---------|----------|--------------|
|
||||||
|
| Sync worker | 5 min | push pending + pull all |
|
||||||
|
| Backup scheduler | configurable (`backup.time`) | creates ZIP archives |
|
||||||
181
bible/03-database.md
Normal file
181
bible/03-database.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# 03 — Database
|
||||||
|
|
||||||
|
## SQLite (local, client-side)
|
||||||
|
|
||||||
|
File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
#### Components and Reference Data
|
||||||
|
|
||||||
|
| Table | Purpose | Key Fields |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| `local_components` | Component metadata (NO prices) | `lot_name` (PK), `lot_description`, `category`, `model` |
|
||||||
|
| `connection_settings` | MariaDB connection settings | key-value store |
|
||||||
|
| `app_settings` | Application settings | `key` (PK), `value`, `updated_at` |
|
||||||
|
|
||||||
|
#### Pricelists
|
||||||
|
|
||||||
|
| Table | Purpose | Key Fields |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` |
|
||||||
|
| `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` |
|
||||||
|
|
||||||
|
#### Configurations and Projects
|
||||||
|
|
||||||
|
| Table | Purpose | Key Fields |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
|
||||||
|
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
|
||||||
|
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
|
||||||
|
|
||||||
|
#### Sync
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `pending_changes` | Queue of changes to push to MariaDB |
|
||||||
|
| `local_schema_migrations` | Applied migrations (idempotency guard) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Key SQLite Indexes
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Pricelists
|
||||||
|
INDEX local_pricelist_items(pricelist_id)
|
||||||
|
UNIQUE INDEX local_pricelists(server_id)
|
||||||
|
INDEX local_pricelists(source, created_at) -- used for "latest by source" queries
|
||||||
|
-- latest-by-source runtime query also applies deterministic tie-break by id DESC
|
||||||
|
|
||||||
|
-- Configurations
|
||||||
|
INDEX local_configurations(pricelist_id)
|
||||||
|
INDEX local_configurations(warehouse_pricelist_id)
|
||||||
|
INDEX local_configurations(competitor_pricelist_id)
|
||||||
|
UNIQUE INDEX local_configurations(uuid)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `items` JSON Structure in Configurations
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"lot_name": "CPU_AMD_9654",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit_price": 123456.78,
|
||||||
|
"section": "Processors"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prices are stored inside the `items` JSON field and refreshed from the pricelist on configuration refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MariaDB (server-side, sync-only)
|
||||||
|
|
||||||
|
Database: `RFQ_LOG`
|
||||||
|
|
||||||
|
### Tables and Permissions
|
||||||
|
|
||||||
|
| Table | Purpose | Permissions |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `lot` | Component catalog | SELECT |
|
||||||
|
| `qt_lot_metadata` | Extended component data | SELECT |
|
||||||
|
| `qt_categories` | Component categories | SELECT |
|
||||||
|
| `qt_pricelists` | Pricelists | SELECT |
|
||||||
|
| `qt_pricelist_items` | Pricelist line items | SELECT |
|
||||||
|
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
|
||||||
|
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
|
||||||
|
| `qt_configurations` | Saved configurations | SELECT, INSERT, UPDATE |
|
||||||
|
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
||||||
|
| `qt_client_local_migrations` | Migration catalog | SELECT only |
|
||||||
|
| `qt_client_schema_state` | Applied migrations state | SELECT, INSERT, UPDATE |
|
||||||
|
| `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE |
|
||||||
|
|
||||||
|
### Grant Permissions to Existing User
|
||||||
|
|
||||||
|
```sql
|
||||||
|
GRANT SELECT ON RFQ_LOG.lot TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.stock_log TO '<DB_USER>'@'%';
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
|
||||||
|
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
|
||||||
|
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a New User
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
||||||
|
|
||||||
|
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.stock_log TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
||||||
|
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
SHOW GRANTS FOR 'quote_user'@'%';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** If pricelists sync but show `0` positions (or logs contain `enriching pricelist items with stock` + `SELECT denied`), verify `SELECT` on `lot_partnumbers` and `stock_log` in addition to `qt_pricelist_items`.
|
||||||
|
|
||||||
|
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
### SQLite Migrations (local)
|
||||||
|
|
||||||
|
- Stored in `migrations/` (SQL files)
|
||||||
|
- Applied via `-migrate` flag or automatically on first run
|
||||||
|
- Idempotent: checked by `id` in `local_schema_migrations`
|
||||||
|
- Already-applied migrations are skipped
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/qfs -migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Centralized Migrations (server-side)
|
||||||
|
|
||||||
|
- Stored in `qt_client_local_migrations` (MariaDB)
|
||||||
|
- Applied automatically during sync readiness check
|
||||||
|
- `min_app_version` — minimum app version required for the migration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DB Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inspect schema
|
||||||
|
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_components"
|
||||||
|
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_configurations"
|
||||||
|
|
||||||
|
# Check pricelist item count
|
||||||
|
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM local_pricelist_items"
|
||||||
|
|
||||||
|
# Check pending sync queue
|
||||||
|
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM pending_changes"
|
||||||
|
```
|
||||||
127
bible/04-api.md
Normal file
127
bible/04-api.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# 04 — API and Web Routes
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | `/setup` | Initial setup page |
|
||||||
|
| POST | `/setup` | Save connection settings |
|
||||||
|
| POST | `/setup/test` | Test MariaDB connection |
|
||||||
|
| GET | `/setup/status` | Setup status |
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | `/api/components` | List components (metadata only) |
|
||||||
|
| GET | `/api/components/:lot_name` | Component by lot_name |
|
||||||
|
| GET | `/api/categories` | List categories |
|
||||||
|
|
||||||
|
### Quote
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| POST | `/api/quote/validate` | Validate line items |
|
||||||
|
| POST | `/api/quote/calculate` | Calculate quote (prices from pricelist) |
|
||||||
|
| POST | `/api/quote/price-levels` | Prices by level (estimate/warehouse/competitor) |
|
||||||
|
|
||||||
|
### Pricelists (read-only)
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | `/api/pricelists` | List pricelists (`source`, `active_only`, pagination) |
|
||||||
|
| GET | `/api/pricelists/latest` | Latest pricelist by source |
|
||||||
|
| GET | `/api/pricelists/:id` | Pricelist by ID |
|
||||||
|
| GET | `/api/pricelists/:id/items` | Pricelist line items |
|
||||||
|
| GET | `/api/pricelists/:id/lots` | Lot names in pricelist |
|
||||||
|
|
||||||
|
`GET /api/pricelists?active_only=true` returns only pricelists that have synced items (`item_count > 0`).
|
||||||
|
|
||||||
|
### Configurations
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | `/api/configs` | List configurations |
|
||||||
|
| POST | `/api/configs` | Create configuration |
|
||||||
|
| GET | `/api/configs/:uuid` | Get configuration |
|
||||||
|
| PUT | `/api/configs/:uuid` | Update configuration |
|
||||||
|
| DELETE | `/api/configs/:uuid` | Archive configuration |
|
||||||
|
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
|
||||||
|
| POST | `/api/configs/:uuid/clone` | Clone configuration |
|
||||||
|
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
|
||||||
|
| POST | `/api/configs/:uuid/rename` | Rename configuration |
|
||||||
|
| POST | `/api/configs/preview-article` | Preview generated article for a configuration |
|
||||||
|
| POST | `/api/configs/:uuid/rollback` | Roll back to a version |
|
||||||
|
| GET | `/api/configs/:uuid/versions` | List versions |
|
||||||
|
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| GET | `/api/projects` | List projects |
|
||||||
|
| POST | `/api/projects` | Create project |
|
||||||
|
| GET | `/api/projects/:uuid` | Get project |
|
||||||
|
| PUT | `/api/projects/:uuid` | Update project |
|
||||||
|
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
|
||||||
|
| GET | `/api/projects/:uuid/configs` | Project configurations |
|
||||||
|
|
||||||
|
### Sync
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose | Flow |
|
||||||
|
|--------|----------|---------|------|
|
||||||
|
| GET | `/api/sync/status` | Overall sync status | read-only |
|
||||||
|
| GET | `/api/sync/readiness` | Preflight status (ready/blocked/unknown) | read-only |
|
||||||
|
| GET | `/api/sync/info` | Data for sync modal | read-only |
|
||||||
|
| GET | `/api/sync/users-status` | Users status | read-only |
|
||||||
|
| GET | `/api/sync/pending` | List pending changes | read-only |
|
||||||
|
| GET | `/api/sync/pending/count` | Count of pending changes | read-only |
|
||||||
|
| POST | `/api/sync/push` | Push pending → MariaDB | SQLite → MariaDB |
|
||||||
|
| POST | `/api/sync/components` | Pull components | MariaDB → SQLite |
|
||||||
|
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
|
||||||
|
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
|
||||||
|
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
|
||||||
|
|
||||||
|
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| POST | `/api/export/csv` | Export configuration to CSV |
|
||||||
|
|
||||||
|
**Export filename format:** `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
||||||
|
(uses `project.Code`, not `project.Name`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Routes
|
||||||
|
|
||||||
|
| Route | Page |
|
||||||
|
|-------|------|
|
||||||
|
| `/configs` | Configuration list |
|
||||||
|
| `/configurator` | Configurator |
|
||||||
|
| `/configs/:uuid/revisions` | Configuration revision history |
|
||||||
|
| `/projects` | Project list |
|
||||||
|
| `/projects/:uuid` | Project details |
|
||||||
|
| `/pricelists` | Pricelist list |
|
||||||
|
| `/pricelists/:id` | Pricelist details |
|
||||||
|
| `/setup` | Connection settings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback API (details)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/configs/:uuid/rollback
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"target_version": 3,
|
||||||
|
"note": "optional comment"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: updated configuration with the new version.
|
||||||
129
bible/05-config.md
Normal file
129
bible/05-config.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# 05 — Configuration and Environment
|
||||||
|
|
||||||
|
## File Paths
|
||||||
|
|
||||||
|
### SQLite database (`qfs.db`)
|
||||||
|
|
||||||
|
| OS | Default path |
|
||||||
|
|----|-------------|
|
||||||
|
| macOS | `~/Library/Application Support/QuoteForge/qfs.db` |
|
||||||
|
| Linux | `$XDG_STATE_HOME/quoteforge/qfs.db` or `~/.local/state/quoteforge/qfs.db` |
|
||||||
|
| Windows | `%LOCALAPPDATA%\QuoteForge\qfs.db` |
|
||||||
|
|
||||||
|
Override: `-localdb <path>` or `QFS_DB_PATH`.
|
||||||
|
|
||||||
|
### config.yaml
|
||||||
|
|
||||||
|
Searched in the same user-state directory as `qfs.db` by default.
|
||||||
|
If the file does not exist, it is created automatically.
|
||||||
|
If the format is outdated, it is automatically migrated to the runtime format (`server` + `logging` sections only).
|
||||||
|
|
||||||
|
Override: `-config <path>` or `QFS_CONFIG_PATH`.
|
||||||
|
|
||||||
|
**Important:** `config.yaml` is a runtime user file — it is **not stored in the repository**.
|
||||||
|
`config.example.yaml` is the only config template in the repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## config.yaml Structure
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
mode: "release" # release | debug
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: "info" # debug | info | warn | error
|
||||||
|
format: "json" # json | text
|
||||||
|
output: "stdout" # stdout | stderr | /path/to/file
|
||||||
|
|
||||||
|
backup:
|
||||||
|
time: "00:00" # HH:MM in local time
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `QFS_DB_PATH` | Full path to SQLite DB | OS-specific user state dir |
|
||||||
|
| `QFS_STATE_DIR` | State directory (if `QFS_DB_PATH` is not set) | OS-specific user state dir |
|
||||||
|
| `QFS_CONFIG_PATH` | Full path to `config.yaml` | OS-specific user state dir |
|
||||||
|
| `QFS_BACKUP_DIR` | Root directory for rotating backups | `<db dir>/backups` |
|
||||||
|
| `QFS_BACKUP_DISABLE` | Disable automatic backups | — |
|
||||||
|
| `QF_DB_HOST` | MariaDB host | localhost |
|
||||||
|
| `QF_DB_PORT` | MariaDB port | 3306 |
|
||||||
|
| `QF_DB_NAME` | Database name | RFQ_LOG |
|
||||||
|
| `QF_DB_USER` | DB user | — |
|
||||||
|
| `QF_DB_PASSWORD` | DB password | — |
|
||||||
|
| `QF_JWT_SECRET` | JWT secret | — |
|
||||||
|
| `QF_SERVER_PORT` | HTTP server port | 8080 |
|
||||||
|
|
||||||
|
`QFS_BACKUP_DISABLE` accepts: `1`, `true`, `yes`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Flags
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `-config <path>` | Path to config.yaml |
|
||||||
|
| `-localdb <path>` | Path to SQLite DB |
|
||||||
|
| `-reset-localdb` | Reset local DB (destructive!) |
|
||||||
|
| `-migrate` | Apply pending migrations and exit |
|
||||||
|
| `-version` | Print version and exit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation and First Run
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Go 1.22 or higher
|
||||||
|
- MariaDB 11.x (or MySQL 8.x)
|
||||||
|
- ~50 MB disk space
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone the repository
|
||||||
|
git clone <repo-url>
|
||||||
|
cd quoteforge
|
||||||
|
|
||||||
|
# 2. Apply migrations
|
||||||
|
go run ./cmd/qfs -migrate
|
||||||
|
|
||||||
|
# 3. Start
|
||||||
|
go run ./cmd/qfs
|
||||||
|
# or
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
Application is available at: http://localhost:8080
|
||||||
|
|
||||||
|
On first run, `/setup` opens for configuring the MariaDB connection.
|
||||||
|
|
||||||
|
### OPS Project Migrator
|
||||||
|
|
||||||
|
Migrates quotes whose names start with `OPS-xxxx` (where `x` is a digit) into a project named `OPS-xxxx`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preview first (always)
|
||||||
|
go run ./cmd/migrate_ops_projects
|
||||||
|
|
||||||
|
# Apply
|
||||||
|
go run ./cmd/migrate_ops_projects -apply
|
||||||
|
|
||||||
|
# Apply without interactive confirmation
|
||||||
|
go run ./cmd/migrate_ops_projects -apply -yes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t quoteforge .
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
221
bible/06-backup.md
Normal file
221
bible/06-backup.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# 06 — Backup
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Automatic rotating ZIP backup system for local data.
|
||||||
|
|
||||||
|
**What is included in each archive:**
|
||||||
|
- SQLite DB (`qfs.db`)
|
||||||
|
- SQLite sidecars (`qfs.db-wal`, `qfs.db-shm`) if present
|
||||||
|
- `config.yaml` if present
|
||||||
|
|
||||||
|
**Archive name format:** `qfs-backp-YYYY-MM-DD.zip`
|
||||||
|
|
||||||
|
**Retention policy:**
|
||||||
|
| Period | Keep |
|
||||||
|
|--------|------|
|
||||||
|
| Daily | 7 archives |
|
||||||
|
| Weekly | 4 archives |
|
||||||
|
| Monthly | 12 archives |
|
||||||
|
| Yearly | 10 archives |
|
||||||
|
|
||||||
|
**Directories:** `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
backup:
|
||||||
|
time: "00:00" # Trigger time in local time (HH:MM format)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment variables:**
|
||||||
|
- `QFS_BACKUP_DIR` — backup root directory (default: `<db dir>/backups`)
|
||||||
|
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- **At startup:** if no backup exists for the current period, one is created immediately
|
||||||
|
- **Daily:** at the configured time, a new backup is created
|
||||||
|
- **Deduplication:** prevented via a `.period.json` marker file in each period directory
|
||||||
|
- **Rotation:** excess old archives are deleted automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
Module: `internal/appstate/backup.go`
|
||||||
|
|
||||||
|
Main function:
|
||||||
|
```go
|
||||||
|
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Scheduler (in `main.go`):
|
||||||
|
```go
|
||||||
|
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config struct
|
||||||
|
|
||||||
|
```go
|
||||||
|
type BackupConfig struct {
|
||||||
|
Time string `yaml:"time"`
|
||||||
|
}
|
||||||
|
// Default: "00:00"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- `backup.time` is in **local time** without timezone offset parsing
|
||||||
|
- `.period.json` is the marker that prevents duplicate backups within the same period
|
||||||
|
- Archive filenames contain only the date; uniqueness is ensured by per-period directories + the period marker
|
||||||
|
- When changing naming or retention: update both the filename logic and the prune logic together
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Full Listing: `internal/appstate/backup.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
package appstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type backupPeriod struct {
|
||||||
|
name string
|
||||||
|
retention int
|
||||||
|
key func(time.Time) string
|
||||||
|
date func(time.Time) string
|
||||||
|
}
|
||||||
|
|
||||||
|
var backupPeriods = []backupPeriod{
|
||||||
|
{
|
||||||
|
name: "daily",
|
||||||
|
retention: 7,
|
||||||
|
key: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||||
|
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "weekly",
|
||||||
|
retention: 4,
|
||||||
|
key: func(t time.Time) string {
|
||||||
|
y, w := t.ISOWeek()
|
||||||
|
return fmt.Sprintf("%04d-W%02d", y, w)
|
||||||
|
},
|
||||||
|
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "monthly",
|
||||||
|
retention: 12,
|
||||||
|
key: func(t time.Time) string { return t.Format("2006-01") },
|
||||||
|
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yearly",
|
||||||
|
retention: 10,
|
||||||
|
key: func(t time.Time) string { return t.Format("2006") },
|
||||||
|
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
||||||
|
if isBackupDisabled() || dbPath == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
root := resolveBackupRoot(dbPath)
|
||||||
|
now := time.Now()
|
||||||
|
created := make([]string, 0)
|
||||||
|
for _, period := range backupPeriods {
|
||||||
|
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
|
||||||
|
if err != nil {
|
||||||
|
return created, err
|
||||||
|
}
|
||||||
|
created = append(created, newFiles...)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Full Listing: Scheduler Hook (`main.go`)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hour, minute, err := parseBackupTime(cfg.Backup.Time)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
|
||||||
|
hour, minute = 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Startup check: create backup immediately if none exists for current periods
|
||||||
|
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
|
||||||
|
slog.Error("local backup failed", "error", backupErr)
|
||||||
|
} else {
|
||||||
|
for _, path := range created {
|
||||||
|
slog.Info("local backup completed", "archive", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
next := nextBackupTime(time.Now(), hour, minute)
|
||||||
|
timer := time.NewTimer(time.Until(next))
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
start := time.Now()
|
||||||
|
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
|
||||||
|
duration := time.Since(start)
|
||||||
|
if backupErr != nil {
|
||||||
|
slog.Error("local backup failed", "error", backupErr, "duration", duration)
|
||||||
|
} else {
|
||||||
|
for _, path := range created {
|
||||||
|
slog.Info("local backup completed", "archive", path, "duration", duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBackupTime(value string) (int, int, error) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return 0, 0, fmt.Errorf("empty backup time")
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse("15:04", value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return parsed.Hour(), parsed.Minute(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextBackupTime(now time.Time, hour, minute int) time.Time {
|
||||||
|
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
||||||
|
if !now.Before(target) {
|
||||||
|
target = target.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
```
|
||||||
136
bible/07-dev.md
Normal file
136
bible/07-dev.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# 07 — Development
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run (dev)
|
||||||
|
go run ./cmd/qfs
|
||||||
|
make run
|
||||||
|
|
||||||
|
# Build
|
||||||
|
make build-release # Optimized build with version info
|
||||||
|
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
|
||||||
|
|
||||||
|
# Cross-platform build
|
||||||
|
make build-all # Linux, macOS, Windows
|
||||||
|
make build-windows # Windows only
|
||||||
|
|
||||||
|
# Verification
|
||||||
|
go build ./cmd/qfs # Must compile without errors
|
||||||
|
go vet ./... # Linter
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
go test ./...
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
make install-hooks # Git hooks (block committing secrets)
|
||||||
|
make clean # Clean bin/
|
||||||
|
make help # All available commands
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- **Formatting:** `gofmt` (mandatory)
|
||||||
|
- **Logging:** `slog` only (structured logging)
|
||||||
|
- **Errors:** explicit wrapping with context (`fmt.Errorf("context: %w", err)`)
|
||||||
|
- **Style:** no unnecessary abstractions; minimum code for the task
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
### What Must Never Be Restored
|
||||||
|
|
||||||
|
The following components were **intentionally removed** and must not be brought back:
|
||||||
|
- cron jobs
|
||||||
|
- importer utility
|
||||||
|
- admin pricing UI/API
|
||||||
|
- alerts
|
||||||
|
- stock import
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
|
||||||
|
- `config.yaml` — runtime user file, **not stored in the repository**
|
||||||
|
- `config.example.yaml` — the only config template in the repo
|
||||||
|
|
||||||
|
### Sync and Local-First
|
||||||
|
|
||||||
|
- Any sync changes must preserve local-first behavior
|
||||||
|
- Local CRUD must not be blocked when MariaDB is unavailable
|
||||||
|
|
||||||
|
### Formats and UI
|
||||||
|
|
||||||
|
- **CSV export:** filename must use **project code** (`project.Code`), not project name
|
||||||
|
Format: `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
||||||
|
- **Breadcrumbs UI:** names longer than 16 characters must be truncated with an ellipsis
|
||||||
|
|
||||||
|
### Architecture Documentation
|
||||||
|
|
||||||
|
- **Every architectural decision must be recorded in `bible/`**
|
||||||
|
- The corresponding Bible file must be updated **in the same commit** as the code change
|
||||||
|
- On every user-requested commit, review and update the Bible in that same commit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Add a Field to Configuration
|
||||||
|
|
||||||
|
1. Add the field to `LocalConfiguration` struct (`internal/models/`)
|
||||||
|
2. Add GORM tags for the DB column
|
||||||
|
3. Write a SQL migration (`migrations/`)
|
||||||
|
4. Update `ConfigurationToLocal` / `LocalToConfiguration` converters
|
||||||
|
5. Update API handlers and services
|
||||||
|
|
||||||
|
### Add a Field to Component
|
||||||
|
|
||||||
|
1. Add the field to `LocalComponent` struct (`internal/models/`)
|
||||||
|
2. Update the SQL query in `SyncComponents()`
|
||||||
|
3. Update the `componentRow` struct to match
|
||||||
|
4. Update converter functions
|
||||||
|
|
||||||
|
### Add a Pricelist Price Lookup
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Modern pattern
|
||||||
|
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
||||||
|
if found && price > 0 {
|
||||||
|
// use price
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Gotchas
|
||||||
|
|
||||||
|
1. **`CurrentPrice` removed from components** — any code using it will fail to compile
|
||||||
|
2. **`HasPrice` filter removed** — `component.go ListComponents` no longer supports this filter
|
||||||
|
3. **Quote calculation:** always offline-first (SQLite); online path is separate
|
||||||
|
4. **Items JSON:** prices are stored in the `items` field of the configuration, not fetched from components
|
||||||
|
5. **Migrations are additive:** already-applied migrations are skipped (checked by `id` in `local_schema_migrations`)
|
||||||
|
6. **`SyncedAt` removed:** last component sync time is now in `app_settings` (key=`last_component_sync`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging Price Issues
|
||||||
|
|
||||||
|
**Problem: quote returns no prices**
|
||||||
|
1. Check that `pricelist_id` is set on the configuration
|
||||||
|
2. Check that pricelist items exist: `SELECT COUNT(*) FROM local_pricelist_items`
|
||||||
|
3. Check `lookupPriceByPricelistID()` in `quote.go`
|
||||||
|
4. Verify the correct source is used (estimate/warehouse/competitor)
|
||||||
|
|
||||||
|
**Problem: component sync not working**
|
||||||
|
1. Components sync as metadata only — no prices
|
||||||
|
2. Prices come via a separate pricelist sync
|
||||||
|
3. Check `SyncComponents()` and the MariaDB query
|
||||||
|
|
||||||
|
**Problem: configuration refresh does not update prices**
|
||||||
|
1. Refresh uses the latest estimate pricelist by default
|
||||||
|
2. Latest resolution ignores pricelists without items (`EXISTS local_pricelist_items`)
|
||||||
|
3. Old prices in `config.items` are preserved if a line item is not found in the pricelist
|
||||||
|
4. To force a pricelist update: set `configuration.pricelist_id`
|
||||||
|
5. In configurator, `Авто` must remain auto-mode (runtime resolved ID must not be persisted as explicit selection)
|
||||||
55
bible/README.md
Normal file
55
bible/README.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# QuoteForge Bible — Architectural Documentation
|
||||||
|
|
||||||
|
The single source of truth for architecture, schemas, and patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
| File | Topic |
|
||||||
|
|------|-------|
|
||||||
|
| [01-overview.md](01-overview.md) | Product: purpose, features, tech stack, repository structure |
|
||||||
|
| [02-architecture.md](02-architecture.md) | Architecture: local-first, sync, pricing, versioning |
|
||||||
|
| [03-database.md](03-database.md) | DB schemas: SQLite + MariaDB, permissions, indexes |
|
||||||
|
| [04-api.md](04-api.md) | API endpoints and web routes |
|
||||||
|
| [05-config.md](05-config.md) | Configuration, environment variables, paths, installation |
|
||||||
|
| [06-backup.md](06-backup.md) | Backup: implementation, rotation policy |
|
||||||
|
| [07-dev.md](07-dev.md) | Development: commands, code style, guardrails |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bible Rules
|
||||||
|
|
||||||
|
> **Every architectural decision must be recorded in the Bible.**
|
||||||
|
>
|
||||||
|
> Any change to DB schema, data access patterns, sync behavior, API contracts,
|
||||||
|
> configuration format, or any other system-level aspect — the corresponding `bible/` file
|
||||||
|
> **must be updated in the same commit** as the code.
|
||||||
|
>
|
||||||
|
> On every user-requested commit, the Bible must be reviewed and updated in that commit.
|
||||||
|
>
|
||||||
|
> The Bible is the single source of truth for architecture. Outdated documentation is worse than none.
|
||||||
|
|
||||||
|
> **Documentation language: English.**
|
||||||
|
>
|
||||||
|
> All files in `bible/` are written and updated **in English only**.
|
||||||
|
> Mixing languages is not allowed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
**Where is user data stored?**
|
||||||
|
SQLite → `~/Library/Application Support/QuoteForge/qfs.db` (macOS). MariaDB is sync-only.
|
||||||
|
|
||||||
|
**How to look up a price for a line item?**
|
||||||
|
`local_pricelist_items` → by `pricelist_id` from config + `lot_name`. Prices are **never** taken from `local_components`.
|
||||||
|
|
||||||
|
**Pre-commit check?**
|
||||||
|
`go build ./cmd/qfs && go vet ./...`
|
||||||
|
|
||||||
|
**What must never be restored?**
|
||||||
|
cron jobs, admin pricing, alerts, stock import, importer utility — all removed intentionally.
|
||||||
|
|
||||||
|
**Where is the release changelog?**
|
||||||
|
`releases/memory/v{major}.{minor}.{patch}.md`
|
||||||
@@ -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
|
||||||
@@ -547,11 +557,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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
|
||||||
} else {
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
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).ParseFS(
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
qfassets.TemplatesFS,
|
||||||
} else {
|
"web/templates/base.html",
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
"web/templates/"+page,
|
||||||
qfassets.TemplatesFS,
|
)
|
||||||
"web/templates/base.html",
|
|
||||||
"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).ParseFS(
|
||||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
qfassets.TemplatesFS,
|
||||||
} else {
|
"web/templates/base.html",
|
||||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
"web/templates/index.html",
|
||||||
qfassets.TemplatesFS,
|
"web/templates/components_list.html",
|
||||||
"web/templates/base.html",
|
)
|
||||||
"web/templates/index.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).ParseFS(
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
qfassets.TemplatesFS,
|
||||||
} else {
|
"web/templates/"+partial,
|
||||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
)
|
||||||
qfassets.TemplatesFS,
|
|
||||||
"web/templates/"+partial,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -692,7 +692,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 +705,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
|
||||||
|
|||||||
128
internal/localdb/pricelist_latest_test.go
Normal file
128
internal/localdb/pricelist_latest_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetLatestLocalPricelistBySource_SkipsPricelistWithoutItems(t *testing.T) {
|
||||||
|
local, err := New(filepath.Join(t.TempDir(), "latest_without_items.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
base := time.Now().Add(-time.Minute)
|
||||||
|
withItems := &LocalPricelist{
|
||||||
|
ServerID: 1001,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "E-1",
|
||||||
|
Name: "with-items",
|
||||||
|
CreatedAt: base,
|
||||||
|
SyncedAt: base,
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelist(withItems); err != nil {
|
||||||
|
t.Fatalf("save pricelist with items: %v", err)
|
||||||
|
}
|
||||||
|
storedWithItems, err := local.GetLocalPricelistByServerID(withItems.ServerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load pricelist with items: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||||
|
{
|
||||||
|
PricelistID: storedWithItems.ID,
|
||||||
|
LotName: "CPU_A",
|
||||||
|
Price: 100,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save pricelist items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutItems := &LocalPricelist{
|
||||||
|
ServerID: 1002,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "E-2",
|
||||||
|
Name: "without-items",
|
||||||
|
CreatedAt: base.Add(2 * time.Second),
|
||||||
|
SyncedAt: base.Add(2 * time.Second),
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelist(withoutItems); err != nil {
|
||||||
|
t.Fatalf("save pricelist without items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := local.GetLatestLocalPricelistBySource("estimate")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
|
||||||
|
}
|
||||||
|
if got.ServerID != withItems.ServerID {
|
||||||
|
t.Fatalf("expected server_id=%d, got %d", withItems.ServerID, got.ServerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLatestLocalPricelistBySource_TieBreaksByID(t *testing.T) {
|
||||||
|
local, err := New(filepath.Join(t.TempDir(), "latest_tie_break.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
base := time.Now().Add(-time.Minute)
|
||||||
|
first := &LocalPricelist{
|
||||||
|
ServerID: 2001,
|
||||||
|
Source: "warehouse",
|
||||||
|
Version: "S-1",
|
||||||
|
Name: "first",
|
||||||
|
CreatedAt: base,
|
||||||
|
SyncedAt: base,
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelist(first); err != nil {
|
||||||
|
t.Fatalf("save first pricelist: %v", err)
|
||||||
|
}
|
||||||
|
storedFirst, err := local.GetLocalPricelistByServerID(first.ServerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load first pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||||
|
{
|
||||||
|
PricelistID: storedFirst.ID,
|
||||||
|
LotName: "CPU_A",
|
||||||
|
Price: 101,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save first items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := &LocalPricelist{
|
||||||
|
ServerID: 2002,
|
||||||
|
Source: "warehouse",
|
||||||
|
Version: "S-2",
|
||||||
|
Name: "second",
|
||||||
|
CreatedAt: base,
|
||||||
|
SyncedAt: base,
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelist(second); err != nil {
|
||||||
|
t.Fatalf("save second pricelist: %v", err)
|
||||||
|
}
|
||||||
|
storedSecond, err := local.GetLocalPricelistByServerID(second.ServerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load second pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||||
|
{
|
||||||
|
PricelistID: storedSecond.ID,
|
||||||
|
LotName: "CPU_A",
|
||||||
|
Price: 102,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save second items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := local.GetLatestLocalPricelistBySource("warehouse")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
|
||||||
|
}
|
||||||
|
if got.ServerID != second.ServerID {
|
||||||
|
t.Fatalf("expected server_id=%d, got %d", second.ServerID, got.ServerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package 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()
|
||||||
|
|
||||||
|
|||||||
@@ -351,6 +351,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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.
|
|
||||||
@@ -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
|
|
||||||
@@ -433,6 +433,11 @@ let selectedPricelistIds = {
|
|||||||
warehouse: null,
|
warehouse: null,
|
||||||
competitor: null
|
competitor: null
|
||||||
};
|
};
|
||||||
|
let resolvedAutoPricelistIds = {
|
||||||
|
estimate: null,
|
||||||
|
warehouse: null,
|
||||||
|
competitor: null
|
||||||
|
};
|
||||||
let disablePriceRefresh = false;
|
let disablePriceRefresh = false;
|
||||||
let onlyInStock = false;
|
let onlyInStock = false;
|
||||||
let activePricelistsBySource = {
|
let activePricelistsBySource = {
|
||||||
@@ -498,6 +503,22 @@ function formatDelta(abs, pct) {
|
|||||||
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEffectivePricelistID(source) {
|
||||||
|
const explicit = selectedPricelistIds[source];
|
||||||
|
if (Number.isFinite(explicit) && explicit > 0) {
|
||||||
|
return Number(explicit);
|
||||||
|
}
|
||||||
|
const resolvedAuto = resolvedAutoPricelistIds[source];
|
||||||
|
if (Number.isFinite(resolvedAuto) && resolvedAuto > 0) {
|
||||||
|
return Number(resolvedAuto);
|
||||||
|
}
|
||||||
|
const fallback = activePricelistsBySource[source]?.[0]?.id;
|
||||||
|
if (Number.isFinite(fallback) && fallback > 0) {
|
||||||
|
return Number(fallback);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshPriceLevels(options = {}) {
|
async function refreshPriceLevels(options = {}) {
|
||||||
const force = options.force === true;
|
const force = options.force === true;
|
||||||
const noCache = options.noCache === true;
|
const noCache = options.noCache === true;
|
||||||
@@ -543,12 +564,10 @@ async function refreshPriceLevels(options = {}) {
|
|||||||
if (data.resolved_pricelist_ids) {
|
if (data.resolved_pricelist_ids) {
|
||||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||||||
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
|
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
|
||||||
selectedPricelistIds[source] = data.resolved_pricelist_ids[source];
|
resolvedAutoPricelistIds[source] = Number(data.resolved_pricelist_ids[source]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
syncPriceSettingsControls();
|
|
||||||
renderPricelistSettingsSummary();
|
renderPricelistSettingsSummary();
|
||||||
persistLocalPriceSettings();
|
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('Failed to refresh price levels', e);
|
console.error('Failed to refresh price levels', e);
|
||||||
@@ -581,11 +600,7 @@ function schedulePriceLevelsRefresh(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentWarehousePricelistID() {
|
function currentWarehousePricelistID() {
|
||||||
const id = selectedPricelistIds.warehouse;
|
return getEffectivePricelistID('warehouse');
|
||||||
if (Number.isFinite(id) && id > 0) return Number(id);
|
|
||||||
const fallback = activePricelistsBySource.warehouse?.[0]?.id;
|
|
||||||
if (Number.isFinite(fallback) && fallback > 0) return Number(fallback);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWarehouseInStockLots() {
|
async function loadWarehouseInStockLots() {
|
||||||
@@ -823,9 +838,7 @@ async function loadActivePricelists(force = false) {
|
|||||||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
|
selectedPricelistIds[source] = null;
|
||||||
? Number(activePricelistsBySource[source][0].id)
|
|
||||||
: null;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
activePricelistsBySource[source] = [];
|
activePricelistsBySource[source] = [];
|
||||||
selectedPricelistIds[source] = null;
|
selectedPricelistIds[source] = null;
|
||||||
@@ -961,6 +974,15 @@ function applyPriceSettings() {
|
|||||||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
||||||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
||||||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
||||||
|
if (selectedPricelistIds.estimate) {
|
||||||
|
resolvedAutoPricelistIds.estimate = null;
|
||||||
|
}
|
||||||
|
if (selectedPricelistIds.warehouse) {
|
||||||
|
resolvedAutoPricelistIds.warehouse = null;
|
||||||
|
}
|
||||||
|
if (selectedPricelistIds.competitor) {
|
||||||
|
resolvedAutoPricelistIds.competitor = null;
|
||||||
|
}
|
||||||
disablePriceRefresh = disableVal;
|
disablePriceRefresh = disableVal;
|
||||||
onlyInStock = inStockVal;
|
onlyInStock = inStockVal;
|
||||||
|
|
||||||
@@ -1861,7 +1883,8 @@ async function previewArticle() {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const model = serverModelForQuote.trim();
|
const model = serverModelForQuote.trim();
|
||||||
if (!model || !selectedPricelistIds.estimate || cart.length === 0) {
|
const estimatePricelistID = getEffectivePricelistID('estimate');
|
||||||
|
if (!model || !estimatePricelistID || cart.length === 0) {
|
||||||
currentArticle = '';
|
currentArticle = '';
|
||||||
el.textContent = 'Артикул: —';
|
el.textContent = 'Артикул: —';
|
||||||
return;
|
return;
|
||||||
@@ -1874,7 +1897,7 @@ async function previewArticle() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
server_model: serverModelForQuote,
|
server_model: serverModelForQuote,
|
||||||
support_code: supportCode,
|
support_code: supportCode,
|
||||||
pricelist_id: selectedPricelistIds.estimate,
|
pricelist_id: estimatePricelistID,
|
||||||
items: cart.map(item => ({
|
items: cart.map(item => ({
|
||||||
lot_name: item.lot_name,
|
lot_name: item.lot_name,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
@@ -2408,13 +2431,19 @@ async function refreshPrices() {
|
|||||||
updatePriceUpdateDate(config.price_updated_at);
|
updatePriceUpdateDate(config.price_updated_at);
|
||||||
}
|
}
|
||||||
if (config.pricelist_id) {
|
if (config.pricelist_id) {
|
||||||
selectedPricelistIds.estimate = config.pricelist_id;
|
if (selectedPricelistIds.estimate) {
|
||||||
|
selectedPricelistIds.estimate = config.pricelist_id;
|
||||||
|
} else {
|
||||||
|
resolvedAutoPricelistIds.estimate = Number(config.pricelist_id);
|
||||||
|
}
|
||||||
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
||||||
await loadActivePricelists();
|
await loadActivePricelists();
|
||||||
}
|
}
|
||||||
syncPriceSettingsControls();
|
syncPriceSettingsControls();
|
||||||
renderPricelistSettingsSummary();
|
renderPricelistSettingsSummary();
|
||||||
persistLocalPriceSettings();
|
if (selectedPricelistIds.estimate) {
|
||||||
|
persistLocalPriceSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-render UI
|
// Re-render UI
|
||||||
|
|||||||
Reference in New Issue
Block a user