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
|
||||
|
||||
## Overview
|
||||
Корпоративный конфигуратор серверов с offline-first архитектурой.
|
||||
Приложение работает через локальную SQLite базу, синхронизация с MariaDB выполняется фоново.
|
||||
## Bible
|
||||
|
||||
## Product Scope
|
||||
- Конфигуратор компонентов и расчёт КП
|
||||
- Проекты и конфигурации
|
||||
- Read-only просмотр прайслистов из локального кэша
|
||||
- Sync (pull компонентов/прайслистов, push локальных изменений)
|
||||
The **[bible/](bible/README.md)** is the single source of truth for this project's architecture, schemas, patterns, and rules. Read it before making any changes.
|
||||
|
||||
Из области исключены:
|
||||
- admin pricing UI/API
|
||||
- stock import
|
||||
- alerts
|
||||
- cron/importer утилиты
|
||||
**Rules:**
|
||||
- Every architectural decision must be recorded in `bible/` in the same commit as the code.
|
||||
- Bible files are written and updated in **English only**.
|
||||
- Before working on the codebase, check `releases/memory/` for the latest release notes.
|
||||
|
||||
## Architecture
|
||||
- Local-first: чтение и запись происходят в SQLite
|
||||
- MariaDB используется как сервер синхронизации
|
||||
- Background worker: периодический sync push+pull
|
||||
- Система ревизий конфигураций: immutable snapshots при каждом сохранении (local_configuration_versions)
|
||||
## Quick Reference
|
||||
|
||||
## Guardrails
|
||||
- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
|
||||
- Runtime-конфиг читается из user state (`config.yaml`) или через `-config` / `QFS_CONFIG_PATH`; не хранить рабочий `config.yaml` в репозитории.
|
||||
- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
|
||||
- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
|
||||
- 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
|
||||
# Development
|
||||
# Verify build
|
||||
go build ./cmd/qfs && go vet ./...
|
||||
|
||||
# Run
|
||||
go run ./cmd/qfs
|
||||
make run
|
||||
|
||||
# Build
|
||||
make build-release
|
||||
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
|
||||
|
||||
# Verification
|
||||
go build ./cmd/qfs
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Code Style
|
||||
- gofmt
|
||||
- structured logging (`slog`)
|
||||
- explicit error wrapping with context
|
||||
|
||||
471
README.md
471
README.md
@@ -1,477 +1,66 @@
|
||||
# QuoteForge
|
||||
|
||||
**Server Configuration & Quotation Tool**
|
||||
**Корпоративный конфигуратор серверов и расчёт КП**
|
||||
|
||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП).
|
||||
Приложение работает в strict local-first режиме: пользовательские операции выполняются через локальную SQLite, MariaDB используется для синхронизации и серверного администрирования прайслистов.
|
||||
Offline-first архитектура: пользовательские операции через локальную SQLite, MariaDB только для синхронизации.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Возможности
|
||||
---
|
||||
|
||||
### Для пользователей
|
||||
- 📱 **Mobile-first интерфейс** — удобная работа с телефона и планшета
|
||||
- 🖥️ **Конфигуратор серверов** — пошаговый выбор компонентов с проверкой совместимости
|
||||
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
|
||||
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
|
||||
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
|
||||
- 🔌 **Полная офлайн-работа** — можно продолжать работу без сети и синхронизировать позже
|
||||
- 🛡️ **Защищенная синхронизация** — sync блокируется preflight-проверкой, если локальная схема не готова
|
||||
## Документация
|
||||
|
||||
### Для ценовых администраторов
|
||||
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
|
||||
- 🎯 **Система алертов** — уведомления о популярных компонентах с устаревшими ценами
|
||||
- 📉 **Аналитика использования** — какие компоненты востребованы в КП
|
||||
- ⚙️ **Гибкие настройки** — периоды расчёта, методы, ручные переопределения
|
||||
Полная архитектурная документация хранится в **[bible/](bible/README.md)**:
|
||||
|
||||
### Индикация актуальности цен
|
||||
| Цвет | Статус | Условие |
|
||||
|------|--------|---------|
|
||||
| 🟢 Зелёный | Свежая | < 30 дней, ≥ 3 источника |
|
||||
| 🟡 Жёлтый | Нормальная | 30-60 дней |
|
||||
| 🟠 Оранжевый | Устаревающая | 60-90 дней |
|
||||
| 🔴 Красный | Устаревшая | > 90 дней или нет данных |
|
||||
| Файл | Тема |
|
||||
|------|------|
|
||||
| [bible/01-overview.md](bible/01-overview.md) | Продукт, возможности, технологии, структура репо |
|
||||
| [bible/02-architecture.md](bible/02-architecture.md) | Local-first, sync, ценообразование, версионность |
|
||||
| [bible/03-database.md](bible/03-database.md) | SQLite и MariaDB схемы, права, миграции |
|
||||
| [bible/04-api.md](bible/04-api.md) | Все API endpoints и web-маршруты |
|
||||
| [bible/05-config.md](bible/05-config.md) | Конфигурация, env vars, установка |
|
||||
| [bible/06-backup.md](bible/06-backup.md) | Резервное копирование |
|
||||
| [bible/07-dev.md](bible/07-dev.md) | Команды разработки, стиль кода, guardrails |
|
||||
|
||||
## Технологии
|
||||
---
|
||||
|
||||
- **Backend:** Go 1.22+, Gin, GORM
|
||||
- **Frontend:** HTML, Tailwind CSS, htmx
|
||||
- **Database:** SQLite (runtime/local-first), MariaDB 11+ (sync + server admin)
|
||||
- **Export:** excelize (XLSX), encoding/csv
|
||||
|
||||
## Требования
|
||||
|
||||
- Go 1.22 или выше
|
||||
- MariaDB 11.x (или MySQL 8.x)
|
||||
- ~50 MB дискового пространства
|
||||
|
||||
## Установка
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-company/quoteforge.git
|
||||
cd quoteforge
|
||||
```
|
||||
|
||||
### 2. Настройка runtime-конфига (опционально)
|
||||
|
||||
`config.yaml` создаётся автоматически при первом старте в той же user-state папке, где находится `qfs.db`.
|
||||
Если найден старый формат, приложение автоматически мигрирует файл в актуальный runtime-формат
|
||||
(оставляя только используемые секции `server` и `logging`).
|
||||
|
||||
При необходимости можно создать/отредактировать файл вручную:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
mode: "release"
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
output: "stdout"
|
||||
```
|
||||
|
||||
### 3. Миграции базы данных
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
# Применить миграции
|
||||
go run ./cmd/qfs -migrate
|
||||
```
|
||||
|
||||
### Мигратор OPS -> проекты (preview/apply)
|
||||
|
||||
Переносит квоты, чьи названия начинаются с `OPS-xxxx` (где `x` — цифра), в проект `OPS-xxxx`.
|
||||
Если проекта нет, он будет создан; если архивный — реактивирован.
|
||||
|
||||
Сначала всегда смотрите preview:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects
|
||||
```
|
||||
|
||||
Применение изменений:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -apply
|
||||
```
|
||||
|
||||
Без интерактивного подтверждения:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -apply -yes
|
||||
```
|
||||
|
||||
### Права БД для пользователя приложения
|
||||
|
||||
#### Полный набор прав для обычного пользователя
|
||||
|
||||
Чтобы выдать существующему пользователю все необходимые права (без переоздания):
|
||||
|
||||
```sql
|
||||
-- Справочные таблицы (только чтение)
|
||||
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
|
||||
|
||||
# Production (with Makefile - recommended)
|
||||
make build-release # Builds with version info
|
||||
./bin/qfs -version # Check version
|
||||
|
||||
# Production (manual)
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
|
||||
./bin/qfs -version
|
||||
# или
|
||||
make run
|
||||
```
|
||||
|
||||
**Makefile команды:**
|
||||
```bash
|
||||
make build-release # Оптимизированная сборка с версией
|
||||
make build-all # Сборка для всех платформ (Linux, macOS, Windows)
|
||||
make build-windows # Только для Windows
|
||||
make run # Запуск dev сервера
|
||||
make test # Запуск тестов
|
||||
make 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-методом:
|
||||
Приложение: http://localhost:8080 → откроется `/setup` для настройки подключения к MariaDB.
|
||||
|
||||
```bash
|
||||
POST /api/configs/:uuid/rollback
|
||||
{
|
||||
"target_version": 3,
|
||||
"note": "optional"
|
||||
}
|
||||
# Сборка
|
||||
make build-release
|
||||
|
||||
# Проверка
|
||||
go build ./cmd/qfs && go vet ./...
|
||||
```
|
||||
|
||||
Результат:
|
||||
- создаётся новая версия `vN` с `data` из целевой версии
|
||||
- `change_note = "rollback to v{target_version}"` (+ note, если передан)
|
||||
- `current_version_id` переключается на новую версию
|
||||
- конфигурация уходит в `sync_status = pending`
|
||||
|
||||
### Локальный config.yaml
|
||||
|
||||
По умолчанию `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
|
||||
|
||||
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
|
||||
- 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 onDemandPullCooldown = 30 * time.Second
|
||||
const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать"
|
||||
|
||||
func main() {
|
||||
showStartupConsoleWarning()
|
||||
|
||||
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
|
||||
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||
resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
|
||||
@@ -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 {
|
||||
if flagValue {
|
||||
return true
|
||||
@@ -547,11 +557,11 @@ func runSetupMode(local *localdb.LocalDB) {
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
staticPath := filepath.Join("web", "static")
|
||||
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
||||
router.Static("/static", staticPath)
|
||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
router.StaticFS("/static", http.FS(staticFS))
|
||||
} else {
|
||||
slog.Error("failed to load embedded static assets", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Setup routes only
|
||||
@@ -837,11 +847,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
router.Use(middleware.OfflineDetector(connMgr, local))
|
||||
|
||||
// Static files (use filepath.Join for Windows compatibility)
|
||||
staticPath := filepath.Join("web", "static")
|
||||
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
||||
router.Static("/static", staticPath)
|
||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
router.StaticFS("/static", http.FS(staticFS))
|
||||
} else {
|
||||
return nil, nil, fmt.Errorf("load embedded static assets: %w", err)
|
||||
}
|
||||
|
||||
// Health check
|
||||
|
||||
@@ -46,8 +46,30 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
}
|
||||
localPLs = filtered
|
||||
}
|
||||
if activeOnly {
|
||||
// Local cache stores only active snapshots for normal operations.
|
||||
type pricelistWithCount struct {
|
||||
pricelist localdb.LocalPricelist
|
||||
itemCount int64
|
||||
usageCount int
|
||||
}
|
||||
withCounts := make([]pricelistWithCount, 0, len(localPLs))
|
||||
for _, lpl := range localPLs {
|
||||
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
|
||||
if activeOnly && itemCount == 0 {
|
||||
continue
|
||||
}
|
||||
usageCount := 0
|
||||
if lpl.IsUsed {
|
||||
usageCount = 1
|
||||
}
|
||||
withCounts = append(withCounts, pricelistWithCount{
|
||||
pricelist: lpl,
|
||||
itemCount: itemCount,
|
||||
usageCount: usageCount,
|
||||
})
|
||||
}
|
||||
localPLs = localPLs[:0]
|
||||
for _, row := range withCounts {
|
||||
localPLs = append(localPLs, row.pricelist)
|
||||
}
|
||||
sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) })
|
||||
total := len(localPLs)
|
||||
@@ -62,10 +84,14 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
pageSlice := localPLs[start:end]
|
||||
summaries := make([]map[string]interface{}, 0, len(pageSlice))
|
||||
for _, lpl := range pageSlice {
|
||||
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
|
||||
itemCount := int64(0)
|
||||
usageCount := 0
|
||||
if lpl.IsUsed {
|
||||
usageCount = 1
|
||||
for _, row := range withCounts {
|
||||
if row.pricelist.ID == lpl.ID {
|
||||
itemCount = row.itemCount
|
||||
usageCount = row.usageCount
|
||||
break
|
||||
}
|
||||
}
|
||||
summaries = append(summaries, map[string]interface{}{
|
||||
"id": lpl.ServerID,
|
||||
|
||||
@@ -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"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -28,7 +26,7 @@ type SetupHandler struct {
|
||||
restartSig chan struct{}
|
||||
}
|
||||
|
||||
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
@@ -37,14 +35,9 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te
|
||||
templates := make(map[string]*template.Template)
|
||||
|
||||
// Load setup template (standalone, no base needed)
|
||||
setupPath := filepath.Join(templatesPath, "setup.html")
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
||||
} else {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||
}
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing setup template: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
stdsync "sync"
|
||||
"time"
|
||||
|
||||
@@ -32,16 +30,9 @@ type SyncHandler struct {
|
||||
}
|
||||
|
||||
// NewSyncHandler creates a new sync handler
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, _ string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
||||
// Load sync_status partial template
|
||||
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||
tmpl, err = template.ParseFiles(partialPath)
|
||||
} else {
|
||||
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
||||
}
|
||||
tmpl, err := template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
@@ -17,7 +15,7 @@ type WebHandler struct {
|
||||
componentService *services.ComponentService
|
||||
}
|
||||
|
||||
func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) {
|
||||
func NewWebHandler(_ string, componentService *services.ComponentService) (*WebHandler, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
@@ -60,27 +58,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
}
|
||||
|
||||
templates := make(map[string]*template.Template)
|
||||
basePath := filepath.Join(templatesPath, "base.html")
|
||||
useDisk := false
|
||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||
useDisk = true
|
||||
}
|
||||
|
||||
// Load each page template with base
|
||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
|
||||
for _, page := range simplePages {
|
||||
pagePath := filepath.Join(templatesPath, page)
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if useDisk {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
||||
} else {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/"+page,
|
||||
)
|
||||
}
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/"+page,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -88,20 +75,14 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
}
|
||||
|
||||
// Index page needs components_list.html as well
|
||||
indexPath := filepath.Join(templatesPath, "index.html")
|
||||
componentsListPath := filepath.Join(templatesPath, "components_list.html")
|
||||
var indexTmpl *template.Template
|
||||
var err error
|
||||
if useDisk {
|
||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
||||
} else {
|
||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/index.html",
|
||||
"web/templates/components_list.html",
|
||||
)
|
||||
}
|
||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/index.html",
|
||||
"web/templates/components_list.html",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -110,17 +91,12 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
// Load partial templates (no base needed)
|
||||
partials := []string{"components_list.html"}
|
||||
for _, partial := range partials {
|
||||
partialPath := filepath.Join(templatesPath, partial)
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if useDisk {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
||||
} else {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/"+partial,
|
||||
)
|
||||
}
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/"+partial,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -692,7 +692,11 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
if err := l.db.
|
||||
Where("source = ?", "estimate").
|
||||
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -701,7 +705,11 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
|
||||
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Where("source = ?", source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
if err := l.db.
|
||||
Where("source = ?", source).
|
||||
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
|
||||
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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -40,7 +41,7 @@ func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
||||
}
|
||||
|
||||
@@ -67,7 +68,7 @@ func (r *PricelistRepository) ListActiveBySource(source string, offset, limit in
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
||||
}
|
||||
|
||||
@@ -148,7 +149,11 @@ func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
|
||||
// GetLatestActiveBySource returns the most recent active pricelist by source.
|
||||
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
||||
var pricelist models.Pricelist
|
||||
if err := r.db.Where("is_active = ? AND source = ?", true, source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
if err := r.db.
|
||||
Where("is_active = ? AND source = ?", true, source).
|
||||
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -241,8 +246,11 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -126,6 +126,101 @@ func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestActiveBySource_SkipsPricelistsWithoutItems(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
db := repo.db
|
||||
ts := time.Now().Add(-time.Minute)
|
||||
source := "test-estimate-skip-empty"
|
||||
|
||||
emptyLatest := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "E-empty",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts.Add(2 * time.Second),
|
||||
}
|
||||
if err := db.Create(&emptyLatest).Error; err != nil {
|
||||
t.Fatalf("create empty pricelist: %v", err)
|
||||
}
|
||||
|
||||
withItems := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "E-with-items",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts,
|
||||
}
|
||||
if err := db.Create(&withItems).Error; err != nil {
|
||||
t.Fatalf("create pricelist with items: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: withItems.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 100,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create pricelist item: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestActiveBySource: %v", err)
|
||||
}
|
||||
if got.ID != withItems.ID {
|
||||
t.Fatalf("expected pricelist with items id=%d, got id=%d", withItems.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestActiveBySource_TieBreaksByID(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
db := repo.db
|
||||
ts := time.Now().Add(-time.Minute)
|
||||
source := "test-warehouse-tie-break"
|
||||
|
||||
first := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "S-1",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts,
|
||||
}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatalf("create first pricelist: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: first.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 101,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create first item: %v", err)
|
||||
}
|
||||
|
||||
second := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "S-2",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts,
|
||||
}
|
||||
if err := db.Create(&second).Error; err != nil {
|
||||
t.Fatalf("create second pricelist: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: second.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 102,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create second item: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestActiveBySource: %v", err)
|
||||
}
|
||||
if got.ID != second.ID {
|
||||
t.Fatalf("expected later inserted pricelist id=%d, got id=%d", second.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -351,6 +351,15 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
// Check if pricelist already exists locally
|
||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
competitor: null
|
||||
};
|
||||
let resolvedAutoPricelistIds = {
|
||||
estimate: null,
|
||||
warehouse: null,
|
||||
competitor: null
|
||||
};
|
||||
let disablePriceRefresh = false;
|
||||
let onlyInStock = false;
|
||||
let activePricelistsBySource = {
|
||||
@@ -498,6 +503,22 @@ function formatDelta(abs, pct) {
|
||||
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
||||
}
|
||||
|
||||
function getEffectivePricelistID(source) {
|
||||
const explicit = selectedPricelistIds[source];
|
||||
if (Number.isFinite(explicit) && explicit > 0) {
|
||||
return Number(explicit);
|
||||
}
|
||||
const resolvedAuto = resolvedAutoPricelistIds[source];
|
||||
if (Number.isFinite(resolvedAuto) && resolvedAuto > 0) {
|
||||
return Number(resolvedAuto);
|
||||
}
|
||||
const fallback = activePricelistsBySource[source]?.[0]?.id;
|
||||
if (Number.isFinite(fallback) && fallback > 0) {
|
||||
return Number(fallback);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshPriceLevels(options = {}) {
|
||||
const force = options.force === true;
|
||||
const noCache = options.noCache === true;
|
||||
@@ -543,12 +564,10 @@ async function refreshPriceLevels(options = {}) {
|
||||
if (data.resolved_pricelist_ids) {
|
||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||||
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
|
||||
selectedPricelistIds[source] = data.resolved_pricelist_ids[source];
|
||||
resolvedAutoPricelistIds[source] = Number(data.resolved_pricelist_ids[source]);
|
||||
}
|
||||
});
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
persistLocalPriceSettings();
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to refresh price levels', e);
|
||||
@@ -581,11 +600,7 @@ function schedulePriceLevelsRefresh(options = {}) {
|
||||
}
|
||||
|
||||
function currentWarehousePricelistID() {
|
||||
const id = selectedPricelistIds.warehouse;
|
||||
if (Number.isFinite(id) && id > 0) return Number(id);
|
||||
const fallback = activePricelistsBySource.warehouse?.[0]?.id;
|
||||
if (Number.isFinite(fallback) && fallback > 0) return Number(fallback);
|
||||
return null;
|
||||
return getEffectivePricelistID('warehouse');
|
||||
}
|
||||
|
||||
async function loadWarehouseInStockLots() {
|
||||
@@ -823,9 +838,7 @@ async function loadActivePricelists(force = false) {
|
||||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
||||
return;
|
||||
}
|
||||
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
|
||||
? Number(activePricelistsBySource[source][0].id)
|
||||
: null;
|
||||
selectedPricelistIds[source] = null;
|
||||
} catch (e) {
|
||||
activePricelistsBySource[source] = [];
|
||||
selectedPricelistIds[source] = null;
|
||||
@@ -961,6 +974,15 @@ function applyPriceSettings() {
|
||||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
||||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
||||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
||||
if (selectedPricelistIds.estimate) {
|
||||
resolvedAutoPricelistIds.estimate = null;
|
||||
}
|
||||
if (selectedPricelistIds.warehouse) {
|
||||
resolvedAutoPricelistIds.warehouse = null;
|
||||
}
|
||||
if (selectedPricelistIds.competitor) {
|
||||
resolvedAutoPricelistIds.competitor = null;
|
||||
}
|
||||
disablePriceRefresh = disableVal;
|
||||
onlyInStock = inStockVal;
|
||||
|
||||
@@ -1861,7 +1883,8 @@ async function previewArticle() {
|
||||
if (!el) return;
|
||||
|
||||
const model = serverModelForQuote.trim();
|
||||
if (!model || !selectedPricelistIds.estimate || cart.length === 0) {
|
||||
const estimatePricelistID = getEffectivePricelistID('estimate');
|
||||
if (!model || !estimatePricelistID || cart.length === 0) {
|
||||
currentArticle = '';
|
||||
el.textContent = 'Артикул: —';
|
||||
return;
|
||||
@@ -1874,7 +1897,7 @@ async function previewArticle() {
|
||||
body: JSON.stringify({
|
||||
server_model: serverModelForQuote,
|
||||
support_code: supportCode,
|
||||
pricelist_id: selectedPricelistIds.estimate,
|
||||
pricelist_id: estimatePricelistID,
|
||||
items: cart.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
quantity: item.quantity,
|
||||
@@ -2408,13 +2431,19 @@ async function refreshPrices() {
|
||||
updatePriceUpdateDate(config.price_updated_at);
|
||||
}
|
||||
if (config.pricelist_id) {
|
||||
selectedPricelistIds.estimate = config.pricelist_id;
|
||||
if (selectedPricelistIds.estimate) {
|
||||
selectedPricelistIds.estimate = config.pricelist_id;
|
||||
} else {
|
||||
resolvedAutoPricelistIds.estimate = Number(config.pricelist_id);
|
||||
}
|
||||
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
||||
await loadActivePricelists();
|
||||
}
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
persistLocalPriceSettings();
|
||||
if (selectedPricelistIds.estimate) {
|
||||
persistLocalPriceSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render UI
|
||||
|
||||
Reference in New Issue
Block a user