Simplify project documentation and release notes
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -75,7 +75,12 @@ Network Trash Folder
|
|||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.apdisk
|
||||||
|
|
||||||
# Release artifacts (binaries, archives, checksums), but DO track releases/memory/ for changelog
|
# Release artifacts (binaries, archives, checksums), but keep markdown notes tracked
|
||||||
releases/*
|
releases/*
|
||||||
|
!releases/README.md
|
||||||
!releases/memory/
|
!releases/memory/
|
||||||
!releases/memory/**
|
!releases/memory/**
|
||||||
|
!releases/**/
|
||||||
|
releases/**/*
|
||||||
|
!releases/README.md
|
||||||
|
!releases/*/RELEASE_NOTES.md
|
||||||
|
|||||||
77
README.md
77
README.md
@@ -1,66 +1,53 @@
|
|||||||
# QuoteForge
|
# QuoteForge
|
||||||
|
|
||||||
**Корпоративный конфигуратор серверов и расчёт КП**
|
Local-first desktop web app for server configuration, quotation, and project work.
|
||||||
|
|
||||||
Offline-first архитектура: пользовательские операции через локальную SQLite, MariaDB только для синхронизации.
|
Runtime model:
|
||||||
|
- user work is stored in local SQLite;
|
||||||
|
- MariaDB is used only for setup checks and background sync;
|
||||||
|
- HTTP server binds to loopback only.
|
||||||
|
|
||||||

|
## What the app does
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
---
|
- configuration editor with price refresh from synced pricelists;
|
||||||
|
- projects with variants and ordered configurations;
|
||||||
|
- vendor BOM import and PN -> LOT resolution;
|
||||||
|
- revision history with rollback;
|
||||||
|
- rotating local backups.
|
||||||
|
|
||||||
## Документация
|
## Run
|
||||||
|
|
||||||
Полная архитектурная документация хранится в **[bible/](bible/README.md)**:
|
|
||||||
|
|
||||||
| Файл | Тема |
|
|
||||||
|------|------|
|
|
||||||
| [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 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Быстрый старт
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Применить миграции
|
|
||||||
go run ./cmd/qfs -migrate
|
|
||||||
|
|
||||||
# Запустить
|
|
||||||
go run ./cmd/qfs
|
go run ./cmd/qfs
|
||||||
# или
|
|
||||||
make run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Приложение: http://localhost:8080 → откроется `/setup` для настройки подключения к MariaDB.
|
Useful commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Сборка
|
go run ./cmd/qfs -migrate
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
make build-release
|
make build-release
|
||||||
|
|
||||||
# Проверка
|
|
||||||
go build ./cmd/qfs && go vet ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
On first run the app creates a minimal `config.yaml`, starts on `http://127.0.0.1:8080`, and opens `/setup` if DB credentials were not saved yet.
|
||||||
|
|
||||||
## Releases & Changelog
|
## Documentation
|
||||||
|
|
||||||
Changelog между версиями: `releases/memory/v{major}.{minor}.{patch}.md`
|
- Shared engineering rules: [bible/README.md](bible/README.md)
|
||||||
|
- Project architecture: [bible-local/README.md](bible-local/README.md)
|
||||||
|
- Release notes: `releases/<version>/RELEASE_NOTES.md`
|
||||||
|
|
||||||
---
|
`bible-local/` is the source of truth for QuoteForge-specific architecture. If code changes behavior, update the matching file there in the same commit.
|
||||||
|
|
||||||
## Поддержка
|
## Repository map
|
||||||
|
|
||||||
- Email: mike@mchus.pro
|
```text
|
||||||
- Internal: @mchus
|
cmd/ entry points and migration tools
|
||||||
|
internal/ application code
|
||||||
## Лицензия
|
web/ templates and static assets
|
||||||
|
bible/ shared engineering rules
|
||||||
Собственность компании, только для внутреннего использования. См. [LICENSE](LICENSE).
|
bible-local/ project architecture and contracts
|
||||||
|
releases/ packaged release artifacts and release notes
|
||||||
|
config.example.yaml runtime config reference
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,131 +1,70 @@
|
|||||||
# 01 — Product Overview
|
# 01 - Overview
|
||||||
|
|
||||||
## What is QuoteForge
|
## Product
|
||||||
|
|
||||||
A corporate server configuration and quotation tool.
|
QuoteForge is a local-first tool for server configuration, quotation, and project tracking.
|
||||||
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only by synchronization and dedicated setup/migration tooling.
|
|
||||||
|
|
||||||
---
|
Core user flows:
|
||||||
|
- create and edit configurations locally;
|
||||||
|
- calculate prices from synced pricelists;
|
||||||
|
- group configurations into projects and variants;
|
||||||
|
- import vendor workspaces and map vendor PNs to internal LOTs;
|
||||||
|
- review revision history and roll back safely.
|
||||||
|
|
||||||
## Features
|
## Runtime model
|
||||||
|
|
||||||
### For Users
|
QuoteForge is a single-user thick client.
|
||||||
- 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
|
|
||||||
|
|
||||||
### Local Client Security Model
|
Rules:
|
||||||
|
- runtime HTTP binds to loopback only;
|
||||||
|
- browser requests are treated as part of the same local user session;
|
||||||
|
- MariaDB is not a live dependency for normal CRUD;
|
||||||
|
- if non-loopback deployment is ever introduced, auth/RBAC must be added first.
|
||||||
|
|
||||||
QuoteForge is currently a **single-user thick client** bound to `localhost`.
|
## Product scope
|
||||||
|
|
||||||
- The local HTTP/UI layer is not treated as a multi-user security boundary.
|
In scope:
|
||||||
- RBAC is not part of the active product contract for the local client.
|
- configurator and quote calculation;
|
||||||
- The authoritative authentication boundary is the remote sync server and its DB credentials captured during setup.
|
- projects, variants, and configuration ordering;
|
||||||
- Runtime startup must reject non-loopback `server.host` values; remote bind is not a supported deployment mode.
|
- local revision history;
|
||||||
- If the app is ever exposed beyond `localhost`, auth/RBAC must be reintroduced as an enforced perimeter before release.
|
- read-only pricelist browsing from SQLite cache;
|
||||||
|
- background sync with MariaDB;
|
||||||
|
- rotating local backups.
|
||||||
|
|
||||||
### Price Freshness Indicators
|
Out of scope and intentionally removed:
|
||||||
|
- admin pricing UI/API;
|
||||||
|
- alerts and notification workflows;
|
||||||
|
- stock import tooling;
|
||||||
|
- cron jobs and importer utilities.
|
||||||
|
|
||||||
| Color | Status | Condition |
|
## Tech stack
|
||||||
|-------|--------|-----------|
|
|
||||||
| 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 |
|
| Layer | Stack |
|
||||||
|-------|-------|
|
| --- | --- |
|
||||||
| Backend | Go 1.22+, Gin, GORM |
|
| Backend | Go, Gin, GORM |
|
||||||
| Frontend | HTML, Tailwind CSS, htmx |
|
| Frontend | HTML templates, htmx, Tailwind CSS |
|
||||||
| Local DB | SQLite (`qfs.db`) |
|
| Local storage | SQLite |
|
||||||
| Server DB | MariaDB 11+ (sync transport only for app runtime) |
|
| Sync transport | MariaDB |
|
||||||
| Export | encoding/csv, excelize (XLSX) |
|
| Export | CSV and XLSX generation |
|
||||||
|
|
||||||
---
|
## Repository map
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd/
|
||||||
|
qfs/ main HTTP runtime
|
||||||
|
migrate/ server migration tool
|
||||||
|
migrate_ops_projects/ OPS project migration helper
|
||||||
|
internal/
|
||||||
|
appstate/ backup and runtime state
|
||||||
|
config/ runtime config parsing
|
||||||
|
handlers/ HTTP handlers
|
||||||
|
localdb/ SQLite models and migrations
|
||||||
|
repository/ repositories
|
||||||
|
services/ business logic and sync
|
||||||
|
web/
|
||||||
|
templates/ HTML templates
|
||||||
|
static/ static assets
|
||||||
|
bible/ shared engineering rules
|
||||||
|
bible-local/ project-specific architecture
|
||||||
|
releases/ release artifacts and notes
|
||||||
```
|
```
|
||||||
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
|
|
||||||
│ ├── 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.
|
|
||||||
|
|
||||||
Hard boundary:
|
|
||||||
|
|
||||||
- normal runtime HTTP handlers, UI flows, pricing, export, BOM resolution, and project/config CRUD must use SQLite only;
|
|
||||||
- MariaDB access is allowed only inside `internal/services/sync/*` and dedicated setup/migration tools under `cmd/`;
|
|
||||||
- any new direct MariaDB query in non-sync runtime code is an architectural violation.
|
|
||||||
|
|
||||||
**Read-only:**
|
|
||||||
- `lot` — component catalog
|
|
||||||
- `qt_lot_metadata` — extended component data
|
|
||||||
- `qt_categories` — categories
|
|
||||||
- `qt_pricelists`, `qt_pricelist_items` — pricelists
|
|
||||||
- `stock_log` — stock quantities consumed during sync enrichment
|
|
||||||
- `qt_partnumber_books`, `qt_partnumber_book_items` — partnumber book snapshots consumed during sync pull
|
|
||||||
|
|
||||||
**Read + Write:**
|
|
||||||
- `qt_configurations` — configurations
|
|
||||||
- `qt_projects` — projects
|
|
||||||
|
|
||||||
**Sync service tables:**
|
|
||||||
- `qt_client_schema_state` — applied migrations state and operational client status per device (`username + hostname`)
|
|
||||||
Fields written by QuoteForge:
|
|
||||||
`app_version`, `last_sync_at`, `last_sync_status`,
|
|
||||||
`pending_changes_count`, `pending_errors_count`, `configurations_count`, `projects_count`,
|
|
||||||
`estimate_pricelist_version`, `warehouse_pricelist_version`, `competitor_pricelist_version`,
|
|
||||||
`last_sync_error_code`, `last_sync_error_text`, `last_checked_at`, `updated_at`
|
|
||||||
- `qt_pricelist_sync_status` — pricelist sync status
|
|
||||||
|
|||||||
@@ -1,251 +1,65 @@
|
|||||||
# 02 — Architecture
|
# 02 - Architecture
|
||||||
|
|
||||||
## Local-First Principle
|
## Local-first rule
|
||||||
|
|
||||||
**SQLite** is the single source of truth for the user.
|
SQLite is the runtime source of truth.
|
||||||
**MariaDB** is a sync server only — it never blocks local operations.
|
MariaDB is sync transport plus setup and migration tooling.
|
||||||
|
|
||||||
```
|
```text
|
||||||
User
|
browser -> Gin handlers -> SQLite
|
||||||
│
|
-> pending_changes
|
||||||
▼
|
background sync <------> MariaDB
|
||||||
SQLite (qfs.db) ← all CRUD operations go here
|
|
||||||
│
|
|
||||||
│ background sync (every 5 min)
|
|
||||||
▼
|
|
||||||
MariaDB (RFQ_LOG) ← pull/push only
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rules:**
|
Rules:
|
||||||
- All CRUD operations go through SQLite only
|
- user CRUD must continue when MariaDB is offline;
|
||||||
- If MariaDB is unavailable → local work continues without restrictions
|
- runtime handlers and pages must read and write SQLite only;
|
||||||
- Changes are queued in `pending_changes` and pushed on next sync
|
- MariaDB access in runtime code is allowed only inside sync and setup flows;
|
||||||
|
- no live MariaDB fallback for reads that already exist in local cache.
|
||||||
|
|
||||||
## MariaDB Boundary
|
## Sync contract
|
||||||
|
|
||||||
MariaDB is not part of the runtime read/write path for user features.
|
Bidirectional:
|
||||||
|
- projects;
|
||||||
|
- configurations;
|
||||||
|
- `vendor_spec`;
|
||||||
|
- pending change metadata.
|
||||||
|
|
||||||
Hard rules:
|
Pull-only:
|
||||||
|
- components;
|
||||||
|
- pricelists and pricelist items;
|
||||||
|
- partnumber books and partnumber book items.
|
||||||
|
|
||||||
- HTTP handlers, web pages, quote calculation, export, vendor BOM resolution, pricelist browsing, project browsing, and configuration CRUD must read/write SQLite only.
|
Readiness guard:
|
||||||
- MariaDB access from the app runtime is allowed only inside the sync subsystem (`internal/services/sync/*`) for explicit pull/push work.
|
- every sync push/pull runs a preflight check;
|
||||||
- Dedicated tooling under `cmd/migrate` and `cmd/migrate_ops_projects` may access MariaDB for operator-run schema/data migration tasks.
|
- blocked sync returns `423 Locked` with a machine-readable reason;
|
||||||
- Setup may test/store connection settings, but after setup the application must treat MariaDB as sync transport only.
|
- local work continues even when sync is blocked.
|
||||||
- Any new repository/service/handler that issues MariaDB queries outside sync is a regression and must be rejected in review.
|
|
||||||
- Local SQLite migrations are code-defined only (`AutoMigrate` + `runLocalMigrations`); there is no server-driven client migration registry.
|
|
||||||
- Read-only local sync caches are disposable. If a local cache table cannot be migrated safely at startup, the client may quarantine/reset that cache and continue booting.
|
|
||||||
|
|
||||||
Forbidden patterns:
|
## Pricing contract
|
||||||
|
|
||||||
- calling `connMgr.GetDB()` from non-sync runtime business code;
|
Prices come only from `local_pricelist_items`.
|
||||||
- constructing MariaDB-backed repositories in handlers for normal user requests;
|
|
||||||
- using MariaDB as online fallback for reads when local SQLite already contains the synced dataset;
|
|
||||||
- adding UI/API features that depend on live MariaDB availability.
|
|
||||||
|
|
||||||
## Local Client Boundary
|
Rules:
|
||||||
|
- `local_components` is metadata-only;
|
||||||
|
- quote calculation must not read prices from components;
|
||||||
|
- latest pricelist selection ignores snapshots without items;
|
||||||
|
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
|
||||||
|
|
||||||
The running app is a localhost-only thick client.
|
## Configuration versioning
|
||||||
|
|
||||||
- Browser/UI requests on the local machine are treated as part of the same trusted user session.
|
Configuration revisions are append-only snapshots stored in `local_configuration_versions`.
|
||||||
- Local routes are not modeled as a hardened multi-user API perimeter.
|
|
||||||
- Authorization to the central server happens through the saved MariaDB connection configured during setup.
|
|
||||||
- Any future deployment that binds beyond `127.0.0.1` must add enforced auth/RBAC before exposure.
|
|
||||||
|
|
||||||
---
|
Rules:
|
||||||
|
- create a new revision only when spec or price content changes;
|
||||||
|
- rollback creates a new head revision from an old snapshot;
|
||||||
|
- rename, reorder, project move, and similar operational edits do not create a new revision snapshot;
|
||||||
|
- current revision pointer must be recoverable if legacy or damaged rows are found locally.
|
||||||
|
|
||||||
## Synchronization
|
## Vendor BOM contract
|
||||||
|
|
||||||
### Data Flow Diagram
|
Vendor BOM is stored in `vendor_spec` on the configuration row.
|
||||||
|
|
||||||
```
|
Rules:
|
||||||
[ SERVER / MariaDB ]
|
- PN to LOT resolution uses the active local partnumber book;
|
||||||
┌───────────────────────────┐
|
- canonical persisted mapping is `lot_mappings[]`;
|
||||||
│ qt_projects │
|
- QuoteForge does not use legacy BOM tables such as `qt_bom`, `qt_lot_bundles`, or `qt_lot_bundle_items`.
|
||||||
│ 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 |
|
|
||||||
| Partnumber books | 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. Is the local client schema initialized and writable?
|
|
||||||
|
|
||||||
**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.
|
|
||||||
Stock enrichment for pricelist rows is persisted into `local_pricelist_items` during sync; UI/runtime must not resolve it live from MariaDB.
|
|
||||||
|
|
||||||
### Lookup Pattern
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Look up a price for a line item
|
|
||||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
|
||||||
if found && price > 0 {
|
|
||||||
// use price
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inside lookupPriceByPricelistID:
|
|
||||||
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
|
|
||||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-Level Pricelists
|
|
||||||
|
|
||||||
A configuration can reference up to three pricelists simultaneously:
|
|
||||||
|
|
||||||
| Field | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `pricelist_id` | Primary (estimate) |
|
|
||||||
| `warehouse_pricelist_id` | Warehouse pricing |
|
|
||||||
| `competitor_pricelist_id` | Competitor pricing |
|
|
||||||
|
|
||||||
Pricelist sources: `estimate` | `warehouse` | `competitor`
|
|
||||||
|
|
||||||
### "Auto" Pricelist Selection
|
|
||||||
|
|
||||||
Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`):
|
|
||||||
|
|
||||||
- **Explicit mode:** concrete `pricelist_id` is set by user in settings.
|
|
||||||
- **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist.
|
|
||||||
|
|
||||||
`auto` must stay `auto` after price-level refresh and after manual "refresh prices":
|
|
||||||
- resolved IDs are runtime-only and must not overwrite user's mode;
|
|
||||||
- switching to explicit selection must clear runtime auto resolution for that source.
|
|
||||||
|
|
||||||
### Latest Pricelist Resolution Rules
|
|
||||||
|
|
||||||
For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with:
|
|
||||||
|
|
||||||
1. only pricelists that have at least one item (`EXISTS ...pricelist_items`);
|
|
||||||
2. deterministic sort: `created_at DESC, id DESC`.
|
|
||||||
|
|
||||||
This prevents selecting empty/incomplete snapshots and removes nondeterministic ties.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration Versioning
|
|
||||||
|
|
||||||
### Principle
|
|
||||||
|
|
||||||
Append-only for **spec+price** changes: immutable snapshots are stored in `local_configuration_versions`.
|
|
||||||
|
|
||||||
```
|
|
||||||
local_configurations
|
|
||||||
└── current_version_id ──► local_configuration_versions (v3) ← active
|
|
||||||
local_configuration_versions (v2)
|
|
||||||
local_configuration_versions (v1)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `version_no = max + 1` when configuration **spec+price** changes
|
|
||||||
- Old versions are never modified or deleted in normal flow
|
|
||||||
- Rollback does **not** rewind history — it creates a **new** version from the snapshot
|
|
||||||
- Operational updates (`line_no` reorder, server count, project move, rename)
|
|
||||||
are synced via `pending_changes` but do **not** create a new revision snapshot
|
|
||||||
|
|
||||||
### Rollback
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /api/configs/:uuid/rollback
|
|
||||||
{
|
|
||||||
"target_version": 3,
|
|
||||||
"note": "optional comment"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Result:
|
|
||||||
- A new version `vN` is created with `data` from the target version
|
|
||||||
- `change_note = "rollback to v{target_version}"` (+ note if provided)
|
|
||||||
- `current_version_id` is switched to the new version
|
|
||||||
- Configuration moves to `sync_status = pending`
|
|
||||||
|
|
||||||
### Sync Status Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
local → pending → synced
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Specification Ordering (`Line`)
|
|
||||||
|
|
||||||
- Each project configuration has persistent `line_no` (`10,20,30...`) in both SQLite and MariaDB.
|
|
||||||
- Project list ordering is deterministic:
|
|
||||||
`line_no ASC`, then `created_at DESC`, then `id DESC`.
|
|
||||||
- Drag-and-drop reorder in project UI updates `line_no` for active project configurations.
|
|
||||||
- Reorder writes are queued as configuration `update` events in `pending_changes`
|
|
||||||
without creating new configuration versions.
|
|
||||||
- Backward compatibility: if remote MariaDB schema does not yet include `line_no`,
|
|
||||||
sync falls back to create/update without `line_no` instead of failing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sync Payload for Versioning
|
|
||||||
|
|
||||||
Events in `pending_changes` for configurations contain:
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| `configuration_uuid` | Identifier |
|
|
||||||
| `operation` | `create` / `update` / `rollback` |
|
|
||||||
| `current_version_id` | Active version ID |
|
|
||||||
| `current_version_no` | Version number |
|
|
||||||
| `snapshot` | Current configuration state |
|
|
||||||
| `idempotency_key` | For idempotent push |
|
|
||||||
| `conflict_policy` | `last_write_wins` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Background Processes
|
|
||||||
|
|
||||||
| Process | Interval | What it does |
|
|
||||||
|---------|----------|--------------|
|
|
||||||
| Sync worker | 5 min | push pending + pull all |
|
|
||||||
| Backup scheduler | configurable (`backup.time`) | creates ZIP archives |
|
|
||||||
|
|||||||
@@ -1,267 +1,67 @@
|
|||||||
# 03 — Database
|
# 03 - Database
|
||||||
|
|
||||||
## SQLite (local, client-side)
|
## SQLite
|
||||||
|
|
||||||
File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
SQLite is the local runtime database.
|
||||||
|
|
||||||
### Tables
|
Main 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` |
|
|
||||||
|
|
||||||
Read-only cache contract:
|
|
||||||
|
|
||||||
- `local_components`, `local_pricelists`, `local_pricelist_items`, `local_partnumber_books`, and `local_partnumber_book_items` are synchronized caches, not user-authored data.
|
|
||||||
- Startup must prefer application availability over preserving a broken cache schema.
|
|
||||||
- If one of these tables cannot be migrated safely, the client may quarantine or drop it and recreate it empty; the next sync repopulates it.
|
|
||||||
|
|
||||||
#### Pricelists
|
|
||||||
|
|
||||||
| Table | Purpose | Key Fields |
|
|
||||||
|-------|---------|------------|
|
|
||||||
| `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` |
|
|
||||||
| `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` |
|
|
||||||
|
|
||||||
#### Partnumber Books (PN → LOT mapping, pull-only from PriceForge)
|
|
||||||
|
|
||||||
| Table | Purpose | Key Fields |
|
|
||||||
|-------|---------|------------|
|
|
||||||
| `local_partnumber_books` | Version snapshots of PN→LOT mappings | `id`, `server_id` (unique), `version`, `created_at`, `is_active` |
|
|
||||||
| `local_partnumber_book_items` | Canonical PN catalog rows | `id`, `partnumber`, `lots_json`, `description` |
|
|
||||||
|
|
||||||
Active book: `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
|
|
||||||
|
|
||||||
#### Configurations and Projects
|
|
||||||
|
|
||||||
| Table | Purpose | Key Fields |
|
|
||||||
|-------|---------|------------|
|
|
||||||
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `vendor_spec` (JSON: PN/qty/description + canonical `lot_mappings[]`), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
|
|
||||||
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
|
|
||||||
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
|
|
||||||
|
|
||||||
#### Sync
|
|
||||||
|
|
||||||
| Table | Purpose |
|
| Table | Purpose |
|
||||||
|-------|---------|
|
| --- | --- |
|
||||||
| `pending_changes` | Queue of changes to push to MariaDB |
|
| `local_components` | synced component metadata |
|
||||||
| `local_schema_migrations` | Applied migrations (idempotency guard) |
|
| `local_pricelists` | local pricelist headers |
|
||||||
|
| `local_pricelist_items` | local pricelist rows, the only runtime price source |
|
||||||
|
| `local_projects` | user projects |
|
||||||
|
| `local_configurations` | user configurations |
|
||||||
|
| `local_configuration_versions` | immutable revision snapshots |
|
||||||
|
| `local_partnumber_books` | partnumber book headers |
|
||||||
|
| `local_partnumber_book_items` | PN -> LOT catalog payload |
|
||||||
|
| `pending_changes` | sync queue |
|
||||||
|
| `connection_settings` | encrypted MariaDB connection settings |
|
||||||
|
| `app_settings` | local app state |
|
||||||
|
| `local_schema_migrations` | applied local migration markers |
|
||||||
|
|
||||||
---
|
Rules:
|
||||||
|
- cache tables may be rebuilt if local migration recovery requires it;
|
||||||
|
- user-authored tables must not be dropped as a recovery shortcut;
|
||||||
|
- `local_pricelist_items` is the only valid runtime source of prices;
|
||||||
|
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows.
|
||||||
|
|
||||||
### Key SQLite Indexes
|
## MariaDB
|
||||||
|
|
||||||
```sql
|
MariaDB is the central sync database.
|
||||||
-- 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
|
Runtime read permissions:
|
||||||
INDEX local_configurations(pricelist_id)
|
- `lot`
|
||||||
INDEX local_configurations(warehouse_pricelist_id)
|
- `qt_lot_metadata`
|
||||||
INDEX local_configurations(competitor_pricelist_id)
|
- `qt_categories`
|
||||||
INDEX local_configurations(project_uuid, line_no) -- project ordering (Line column)
|
- `qt_pricelists`
|
||||||
UNIQUE INDEX local_configurations(uuid)
|
- `qt_pricelist_items`
|
||||||
```
|
- `stock_log`
|
||||||
|
- `qt_partnumber_books`
|
||||||
|
- `qt_partnumber_book_items`
|
||||||
|
|
||||||
---
|
Runtime read/write permissions:
|
||||||
|
- `qt_projects`
|
||||||
|
- `qt_configurations`
|
||||||
|
- `qt_client_schema_state`
|
||||||
|
- `qt_pricelist_sync_status`
|
||||||
|
|
||||||
### `items` JSON Structure in Configurations
|
Insert-only tracking:
|
||||||
|
- `qt_vendor_partnumber_seen`
|
||||||
|
|
||||||
```json
|
Rules:
|
||||||
{
|
- QuoteForge runtime must not depend on any removed legacy BOM tables;
|
||||||
"items": [
|
- stock enrichment happens during sync and is persisted into SQLite;
|
||||||
{
|
- normal UI requests must not query MariaDB tables directly.
|
||||||
"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 |
|
|
||||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment during sync only) | SELECT |
|
|
||||||
| `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE |
|
|
||||||
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
|
||||||
| `qt_client_schema_state` | Applied migrations state + client operational status per `username + hostname` | SELECT, INSERT, UPDATE |
|
|
||||||
| `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE |
|
|
||||||
| `qt_partnumber_books` | Partnumber book headers with snapshot membership in `partnumbers_json` (written by PriceForge) | SELECT |
|
|
||||||
| `qt_partnumber_book_items` | Canonical PN catalog with `lots_json` composition (written by PriceForge) | SELECT |
|
|
||||||
| `qt_vendor_partnumber_seen` | Vendor PN tracking for unresolved/ignored BOM rows (`is_ignored`) | INSERT only for new `partnumber`; existing rows must not be modified |
|
|
||||||
|
|
||||||
Legacy server tables not used by QuoteForge runtime anymore:
|
|
||||||
|
|
||||||
- `qt_bom`
|
|
||||||
- `qt_lot_bundles`
|
|
||||||
- `qt_lot_bundle_items`
|
|
||||||
|
|
||||||
QuoteForge canonical BOM storage is:
|
|
||||||
|
|
||||||
- `qt_configurations.vendor_spec`
|
|
||||||
- row-level PN -> multiple LOT decomposition in `vendor_spec[].lot_mappings[]`
|
|
||||||
|
|
||||||
Partnumber book server read contract:
|
|
||||||
|
|
||||||
1. Read active or target book from `qt_partnumber_books`.
|
|
||||||
2. Parse `partnumbers_json`.
|
|
||||||
3. Load payloads from `qt_partnumber_book_items WHERE partnumber IN (...)`.
|
|
||||||
|
|
||||||
Pricelist stock enrichment contract:
|
|
||||||
|
|
||||||
1. Sync pulls base pricelist rows from `qt_pricelist_items`.
|
|
||||||
2. Sync reads latest stock quantities from `stock_log`.
|
|
||||||
3. Sync resolves `partnumber -> lot` through the local mirror of `qt_partnumber_book_items` (`local_partnumber_book_items.lots_json`).
|
|
||||||
4. Sync stores enriched `available_qty` and `partnumbers` into `local_pricelist_items`.
|
|
||||||
|
|
||||||
Runtime rule:
|
|
||||||
|
|
||||||
- pricelist UI and quote logic read only `local_pricelist_items`;
|
|
||||||
- runtime code must not query `stock_log`, `qt_pricelist_items`, or `qt_partnumber_book_items` directly outside sync.
|
|
||||||
|
|
||||||
`qt_partnumber_book_items` no longer contains `book_id` or `lot_name`.
|
|
||||||
It stores one row per `partnumber` with:
|
|
||||||
|
|
||||||
- `partnumber`
|
|
||||||
- `lots_json` as `[{"lot_name":"CPU_X","qty":2}, ...]`
|
|
||||||
- `description`
|
|
||||||
|
|
||||||
`qt_client_schema_state` current contract:
|
|
||||||
|
|
||||||
- identity key: `username + hostname`
|
|
||||||
- client/runtime state:
|
|
||||||
`app_version`, `last_checked_at`, `updated_at`
|
|
||||||
- operational state:
|
|
||||||
`last_sync_at`, `last_sync_status`
|
|
||||||
- queue health:
|
|
||||||
`pending_changes_count`, `pending_errors_count`
|
|
||||||
- local dataset size:
|
|
||||||
`configurations_count`, `projects_count`
|
|
||||||
- price context:
|
|
||||||
`estimate_pricelist_version`, `warehouse_pricelist_version`, `competitor_pricelist_version`
|
|
||||||
- last known sync problem:
|
|
||||||
`last_sync_error_code`, `last_sync_error_text`
|
|
||||||
|
|
||||||
`last_sync_error_*` source priority:
|
|
||||||
|
|
||||||
1. blocked readiness state from `local_sync_guard_state`
|
|
||||||
2. latest non-empty `pending_changes.last_error`
|
|
||||||
3. `NULL` when no known sync problem exists
|
|
||||||
|
|
||||||
### 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.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, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
|
|
||||||
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
|
|
||||||
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO '<DB_USER>'@'%';
|
|
||||||
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create a New User
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
|
||||||
|
|
||||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.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, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'quote_user'@'%';
|
|
||||||
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'quote_user'@'%';
|
|
||||||
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
SHOW GRANTS FOR 'quote_user'@'%';
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** If pricelists sync but stock enrichment is empty, verify `SELECT` on `qt_pricelist_items`, `qt_partnumber_books`, `qt_partnumber_book_items`, and `stock_log`.
|
|
||||||
|
|
||||||
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
### SQLite Migrations (local) — два уровня, выполняются при каждом старте
|
SQLite:
|
||||||
|
- schema creation and additive changes go through GORM `AutoMigrate`;
|
||||||
|
- data fixes, index repair, and one-off rewrites go through `runLocalMigrations`;
|
||||||
|
- local migration state is tracked in `local_schema_migrations`.
|
||||||
|
|
||||||
**1. GORM AutoMigrate** (`internal/localdb/localdb.go`) — первый и основной уровень.
|
MariaDB:
|
||||||
Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**.
|
- SQL files live in `migrations/`;
|
||||||
|
- they are applied by `go run ./cmd/qfs -migrate`.
|
||||||
Local SQLite partnumber book cache contract:
|
|
||||||
|
|
||||||
- `local_partnumber_books.partnumbers_json` stores PN membership for a pulled book.
|
|
||||||
- `local_partnumber_book_items` is a deduplicated local catalog by `partnumber`.
|
|
||||||
- `local_partnumber_book_items.lots_json` mirrors the server `lots_json` payload.
|
|
||||||
- SQLite migration `2026_03_07_local_partnumber_book_catalog` rebuilds old `book_id + lot_name` rows into the new local cache shape.
|
|
||||||
→ Для добавления новой таблицы или колонки достаточно добавить модель/поле и включить модель в AutoMigrate.
|
|
||||||
|
|
||||||
**2. `runLocalMigrations`** (`internal/localdb/migrations.go`) — второй уровень, для операций которые AutoMigrate не умеет: backfill данных, пересоздание таблиц, создание индексов.
|
|
||||||
Каждая функция выполняется один раз — идемпотентность через запись `id` в `local_schema_migrations`.
|
|
||||||
|
|
||||||
QuoteForge does not use centralized server-driven SQLite migrations.
|
|
||||||
All local SQLite schema/data migrations live in the client codebase.
|
|
||||||
|
|
||||||
### MariaDB Migrations (server-side)
|
|
||||||
|
|
||||||
- Stored in `migrations/` (SQL files)
|
|
||||||
- Applied via `-migrate` flag
|
|
||||||
- `min_app_version` — minimum app version required for the migration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DB Debugging
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Inspect schema
|
|
||||||
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_components"
|
|
||||||
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_configurations"
|
|
||||||
|
|
||||||
# Check pricelist item count
|
|
||||||
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM local_pricelist_items"
|
|
||||||
|
|
||||||
# Check pending sync queue
|
|
||||||
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM pending_changes"
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,171 +1,125 @@
|
|||||||
# 04 — API and Web Routes
|
# 04 - API
|
||||||
|
|
||||||
## API Endpoints
|
## Public web routes
|
||||||
|
|
||||||
### Setup
|
| Route | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | configurator |
|
||||||
|
| `/configs` | configuration list |
|
||||||
|
| `/configs/:uuid/revisions` | revision history page |
|
||||||
|
| `/projects` | project list |
|
||||||
|
| `/projects/:uuid` | project detail |
|
||||||
|
| `/pricelists` | pricelist list |
|
||||||
|
| `/pricelists/:id` | pricelist detail |
|
||||||
|
| `/partnumber-books` | partnumber book page |
|
||||||
|
| `/setup` | DB setup page |
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
## Setup and health
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/setup` | Initial setup page |
|
|
||||||
| POST | `/setup` | Save connection settings |
|
|
||||||
| POST | `/setup/test` | Test MariaDB connection |
|
|
||||||
| GET | `/setup/status` | Setup status |
|
|
||||||
|
|
||||||
### Components
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/health` | process health |
|
||||||
|
| `GET` | `/setup` | setup page |
|
||||||
|
| `POST` | `/setup` | save tested DB settings |
|
||||||
|
| `POST` | `/setup/test` | test DB connection |
|
||||||
|
| `GET` | `/setup/status` | setup status |
|
||||||
|
| `GET` | `/api/db-status` | current DB/sync status |
|
||||||
|
| `GET` | `/api/current-user` | local user identity |
|
||||||
|
| `GET` | `/api/ping` | lightweight API ping |
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
`POST /api/restart` exists only in `debug` mode.
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/components` | List components (metadata only) |
|
|
||||||
| GET | `/api/components/:lot_name` | Component by lot_name |
|
|
||||||
| GET | `/api/categories` | List categories |
|
|
||||||
|
|
||||||
### Quote
|
## Reference data
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
| Method | Path | Purpose |
|
||||||
|--------|----------|---------|
|
| --- | --- | --- |
|
||||||
| POST | `/api/quote/validate` | Validate line items |
|
| `GET` | `/api/components` | list component metadata |
|
||||||
| POST | `/api/quote/calculate` | Calculate quote (prices from pricelist) |
|
| `GET` | `/api/components/:lot_name` | one component |
|
||||||
| POST | `/api/quote/price-levels` | Prices by level (estimate/warehouse/competitor) |
|
| `GET` | `/api/categories` | list categories |
|
||||||
|
| `GET` | `/api/pricelists` | list local pricelists |
|
||||||
|
| `GET` | `/api/pricelists/latest` | latest pricelist by source |
|
||||||
|
| `GET` | `/api/pricelists/:id` | pricelist header |
|
||||||
|
| `GET` | `/api/pricelists/:id/items` | pricelist rows |
|
||||||
|
| `GET` | `/api/pricelists/:id/lots` | lot names in a pricelist |
|
||||||
|
| `GET` | `/api/partnumber-books` | local partnumber books |
|
||||||
|
| `GET` | `/api/partnumber-books/:id` | book items by `server_id` |
|
||||||
|
|
||||||
### Pricelists (read-only)
|
## Quote and export
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
| Method | Path | Purpose |
|
||||||
|--------|----------|---------|
|
| --- | --- | --- |
|
||||||
| GET | `/api/pricelists` | List pricelists (`source`, `active_only`, pagination) |
|
| `POST` | `/api/quote/validate` | validate config items |
|
||||||
| GET | `/api/pricelists/latest` | Latest pricelist by source |
|
| `POST` | `/api/quote/calculate` | calculate quote totals |
|
||||||
| GET | `/api/pricelists/:id` | Pricelist by ID |
|
| `POST` | `/api/quote/price-levels` | resolve estimate/warehouse/competitor prices |
|
||||||
| GET | `/api/pricelists/:id/items` | Pricelist line items |
|
| `POST` | `/api/export/csv` | export a single configuration |
|
||||||
| GET | `/api/pricelists/:id/lots` | Lot names in pricelist |
|
| `GET` | `/api/configs/:uuid/export` | export a stored configuration |
|
||||||
|
| `GET` | `/api/projects/:uuid/export` | legacy project BOM export |
|
||||||
|
| `POST` | `/api/projects/:uuid/export` | pricing-tab project export |
|
||||||
|
|
||||||
`GET /api/pricelists?active_only=true` returns only pricelists that have synced items (`item_count > 0`).
|
## Configurations
|
||||||
|
|
||||||
### Configurations
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/configs` | list configurations |
|
||||||
|
| `POST` | `/api/configs/import` | import configurations from server |
|
||||||
|
| `POST` | `/api/configs` | create configuration |
|
||||||
|
| `POST` | `/api/configs/preview-article` | preview article |
|
||||||
|
| `GET` | `/api/configs/:uuid` | get configuration |
|
||||||
|
| `PUT` | `/api/configs/:uuid` | update configuration |
|
||||||
|
| `DELETE` | `/api/configs/:uuid` | archive configuration |
|
||||||
|
| `POST` | `/api/configs/:uuid/reactivate` | reactivate configuration |
|
||||||
|
| `PATCH` | `/api/configs/:uuid/rename` | rename configuration |
|
||||||
|
| `POST` | `/api/configs/:uuid/clone` | clone configuration |
|
||||||
|
| `POST` | `/api/configs/:uuid/refresh-prices` | refresh prices |
|
||||||
|
| `PATCH` | `/api/configs/:uuid/project` | move configuration to project |
|
||||||
|
| `GET` | `/api/configs/:uuid/versions` | list revisions |
|
||||||
|
| `GET` | `/api/configs/:uuid/versions/:version` | get one revision |
|
||||||
|
| `POST` | `/api/configs/:uuid/rollback` | rollback by creating a new head revision |
|
||||||
|
| `PATCH` | `/api/configs/:uuid/server-count` | update server count |
|
||||||
|
| `GET` | `/api/configs/:uuid/vendor-spec` | read vendor BOM |
|
||||||
|
| `PUT` | `/api/configs/:uuid/vendor-spec` | replace vendor BOM |
|
||||||
|
| `POST` | `/api/configs/:uuid/vendor-spec/resolve` | resolve PN -> LOT |
|
||||||
|
| `POST` | `/api/configs/:uuid/vendor-spec/apply` | apply BOM to cart |
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
## Projects
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/configs` | List configurations |
|
|
||||||
| POST | `/api/configs` | Create configuration |
|
|
||||||
| GET | `/api/configs/:uuid` | Get configuration |
|
|
||||||
| PUT | `/api/configs/:uuid` | Update configuration |
|
|
||||||
| DELETE | `/api/configs/:uuid` | Archive configuration |
|
|
||||||
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
|
|
||||||
| POST | `/api/configs/:uuid/clone` | Clone configuration |
|
|
||||||
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
|
|
||||||
| POST | `/api/configs/:uuid/rename` | Rename configuration |
|
|
||||||
| POST | `/api/configs/preview-article` | Preview generated article for a configuration |
|
|
||||||
| POST | `/api/configs/:uuid/rollback` | Roll back to a version |
|
|
||||||
| GET | `/api/configs/:uuid/versions` | List versions |
|
|
||||||
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
|
|
||||||
|
|
||||||
`line` field in configuration payloads is backed by persistent `line_no` in DB.
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/projects` | paginated project list |
|
||||||
|
| `GET` | `/api/projects/all` | lightweight list for dropdowns |
|
||||||
|
| `POST` | `/api/projects` | create project |
|
||||||
|
| `GET` | `/api/projects/:uuid` | get project |
|
||||||
|
| `PUT` | `/api/projects/:uuid` | update project |
|
||||||
|
| `POST` | `/api/projects/:uuid/archive` | archive project |
|
||||||
|
| `POST` | `/api/projects/:uuid/reactivate` | reactivate project |
|
||||||
|
| `DELETE` | `/api/projects/:uuid` | delete project variant only |
|
||||||
|
| `GET` | `/api/projects/:uuid/configs` | list project configurations |
|
||||||
|
| `PATCH` | `/api/projects/:uuid/configs/reorder` | persist line order |
|
||||||
|
| `POST` | `/api/projects/:uuid/configs` | create configuration inside project |
|
||||||
|
| `POST` | `/api/projects/:uuid/configs/:config_uuid/clone` | clone config into project |
|
||||||
|
| `POST` | `/api/projects/:uuid/vendor-import` | import CFXML workspace into project |
|
||||||
|
|
||||||
### Projects
|
Vendor import contract:
|
||||||
|
- multipart field name is `file`;
|
||||||
|
- file limit is `1 GiB`;
|
||||||
|
- oversized payloads are rejected before XML parsing.
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
## Sync
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/projects` | List projects |
|
|
||||||
| POST | `/api/projects` | Create project |
|
|
||||||
| GET | `/api/projects/:uuid` | Get project |
|
|
||||||
| PUT | `/api/projects/:uuid` | Update project |
|
|
||||||
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
|
|
||||||
| GET | `/api/projects/:uuid/configs` | Project configurations |
|
|
||||||
| PATCH | `/api/projects/:uuid/configs/reorder` | Reorder active project configurations (`ordered_uuids`) and persist `line_no` |
|
|
||||||
| POST | `/api/projects/:uuid/vendor-import` | Import a vendor `CFXML` workspace into the existing project |
|
|
||||||
|
|
||||||
`GET /api/projects/:uuid/configs` ordering:
|
| Method | Path | Purpose |
|
||||||
`line ASC`, then `created_at DESC`, then `id DESC`.
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/sync/status` | sync status |
|
||||||
|
| `GET` | `/api/sync/readiness` | sync readiness |
|
||||||
|
| `GET` | `/api/sync/info` | sync modal data |
|
||||||
|
| `GET` | `/api/sync/users-status` | remote user status |
|
||||||
|
| `GET` | `/api/sync/pending/count` | pending queue count |
|
||||||
|
| `GET` | `/api/sync/pending` | pending queue rows |
|
||||||
|
| `POST` | `/api/sync/components` | pull components |
|
||||||
|
| `POST` | `/api/sync/pricelists` | pull pricelists |
|
||||||
|
| `POST` | `/api/sync/partnumber-books` | pull partnumber books |
|
||||||
|
| `POST` | `/api/sync/partnumber-seen` | report unresolved vendor PN |
|
||||||
|
| `POST` | `/api/sync/all` | push and pull full sync |
|
||||||
|
| `POST` | `/api/sync/push` | push pending changes |
|
||||||
|
| `POST` | `/api/sync/repair` | repair broken pending rows |
|
||||||
|
|
||||||
`POST /api/projects/:uuid/vendor-import` accepts `multipart/form-data` with one required file field:
|
When readiness is blocked, sync write endpoints return `423 Locked`.
|
||||||
|
|
||||||
- `file` — vendor configurator export in `CFXML` format
|
|
||||||
- max request file size: `1 GiB`; oversized uploads are rejected before parsing
|
|
||||||
|
|
||||||
### Sync
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose | Flow |
|
|
||||||
|--------|----------|---------|------|
|
|
||||||
| GET | `/api/sync/status` | Overall sync status | read-only |
|
|
||||||
| GET | `/api/sync/readiness` | Preflight status (ready/blocked/unknown) | read-only |
|
|
||||||
| GET | `/api/sync/info` | Data for sync modal | read-only |
|
|
||||||
| GET | `/api/sync/users-status` | Users status | read-only |
|
|
||||||
| GET | `/api/sync/pending` | List pending changes | read-only |
|
|
||||||
| GET | `/api/sync/pending/count` | Count of pending changes | read-only |
|
|
||||||
| POST | `/api/sync/push` | Push pending → MariaDB | SQLite → MariaDB |
|
|
||||||
| POST | `/api/sync/components` | Pull components | MariaDB → SQLite |
|
|
||||||
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
|
|
||||||
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
|
|
||||||
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
|
|
||||||
| POST | `/api/sync/partnumber-seen` | Report unresolved/ignored vendor PNs for server-side tracking | QuoteForge → MariaDB |
|
|
||||||
|
|
||||||
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
|
|
||||||
|
|
||||||
### Vendor Spec (BOM)
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored vendor BOM |
|
|
||||||
| PUT | `/api/configs/:uuid/vendor-spec` | Replace vendor BOM (full update) |
|
|
||||||
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (read-only) |
|
|
||||||
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `GET` / `PUT /api/configs/:uuid/vendor-spec` exchange normalized BOM rows (`vendor_spec`), not raw pasted Excel layout.
|
|
||||||
- BOM row contract stores canonical LOT mapping list as seen in BOM UI:
|
|
||||||
- `lot_mappings[]`
|
|
||||||
- each mapping contains `lot_name` + `quantity_per_pn`
|
|
||||||
- `POST /api/configs/:uuid/vendor-spec/apply` rebuilds cart items from explicit BOM mappings:
|
|
||||||
- all LOTs from `lot_mappings[]`
|
|
||||||
|
|
||||||
### Partnumber Books (read-only)
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/partnumber-books` | List local book snapshots |
|
|
||||||
| GET | `/api/partnumber-books/:id` | Items for a book by `server_id` (`page`, `per_page`, `search`) |
|
|
||||||
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
|
|
||||||
|
|
||||||
See [09-vendor-spec.md](09-vendor-spec.md) for schema and pull logic.
|
|
||||||
See [09-vendor-spec.md](09-vendor-spec.md) for `vendor_spec` JSON schema and BOM UI mapping contract.
|
|
||||||
|
|
||||||
### Export
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| POST | `/api/export/csv` | Export configuration to CSV |
|
|
||||||
| GET | `/api/projects/:uuid/export` | Legacy project CSV export in block BOM format |
|
|
||||||
| POST | `/api/projects/:uuid/export` | Project CSV export in pricing-tab format with selectable columns (`include_lot`, `include_bom`, `include_estimate`, `include_stock`, `include_competitor`) |
|
|
||||||
|
|
||||||
**Export filename format:** `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
|
||||||
(uses `project.Code`, not `project.Name`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Web Routes
|
|
||||||
|
|
||||||
| Route | Page |
|
|
||||||
|-------|------|
|
|
||||||
| `/configs` | Configuration list |
|
|
||||||
| `/configurator` | Configurator |
|
|
||||||
| `/configs/:uuid/revisions` | Configuration revision history |
|
|
||||||
| `/projects` | Project list |
|
|
||||||
| `/projects/:uuid` | Project details |
|
|
||||||
| `/pricelists` | Pricelist list |
|
|
||||||
| `/pricelists/:id` | Pricelist details |
|
|
||||||
| `/partnumber-books` | Partnumber books (active book summary + snapshot history) |
|
|
||||||
| `/setup` | Connection settings |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rollback API (details)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /api/configs/:uuid/rollback
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"target_version": 3,
|
|
||||||
"note": "optional comment"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response: updated configuration with the new version.
|
|
||||||
|
|||||||
@@ -1,144 +1,73 @@
|
|||||||
# 05 — Configuration and Environment
|
# 05 - Config
|
||||||
|
|
||||||
## File Paths
|
## Runtime files
|
||||||
|
|
||||||
### SQLite database (`qfs.db`)
|
| Artifact | Default location |
|
||||||
|
| --- | --- |
|
||||||
|
| `qfs.db` | OS-specific user state directory |
|
||||||
|
| `config.yaml` | same state directory as `qfs.db` |
|
||||||
|
| `local_encryption.key` | same state directory as `qfs.db` |
|
||||||
|
| `backups/` | next to `qfs.db` unless overridden |
|
||||||
|
|
||||||
| OS | Default path |
|
The runtime state directory can be overridden with `QFS_STATE_DIR`.
|
||||||
|----|-------------|
|
Direct paths can be overridden with `QFS_DB_PATH` and `QFS_CONFIG_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`.
|
## Runtime config shape
|
||||||
|
|
||||||
### config.yaml
|
Runtime keeps `config.yaml` intentionally small:
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Local encryption key
|
|
||||||
|
|
||||||
Saved MariaDB credentials in SQLite are encrypted with:
|
|
||||||
|
|
||||||
1. `QUOTEFORGE_ENCRYPTION_KEY` if explicitly provided, otherwise
|
|
||||||
2. an application-managed random key file stored at `<state dir>/local_encryption.key`.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- The key file is created automatically with mode `0600`.
|
|
||||||
- The key file is not committed and is not included in normal backups.
|
|
||||||
- Restoring `qfs.db` on another machine requires re-entering DB credentials unless the key file is migrated separately.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## config.yaml Structure
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server:
|
server:
|
||||||
host: "127.0.0.1"
|
host: "127.0.0.1"
|
||||||
port: 8080
|
port: 8080
|
||||||
mode: "release" # release | debug
|
mode: "release"
|
||||||
|
read_timeout: 30s
|
||||||
logging:
|
write_timeout: 30s
|
||||||
level: "info" # debug | info | warn | error
|
|
||||||
format: "json" # json | text
|
|
||||||
output: "stdout" # stdout | stderr | /path/to/file
|
|
||||||
|
|
||||||
backup:
|
backup:
|
||||||
time: "00:00" # HH:MM in local time
|
time: "00:00"
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: "info"
|
||||||
|
format: "json"
|
||||||
|
output: "stdout"
|
||||||
```
|
```
|
||||||
|
|
||||||
`server.host` must stay on loopback (`127.0.0.1`, `localhost`, or `::1`).
|
Rules:
|
||||||
QuoteForge startup rejects non-loopback bind addresses because the local client has no auth/RBAC perimeter.
|
- QuoteForge creates this file automatically if it does not exist;
|
||||||
|
- startup rewrites legacy config files into this minimal runtime shape;
|
||||||
|
- `server.host` must stay on loopback.
|
||||||
|
|
||||||
---
|
Saved MariaDB credentials do not live in `config.yaml`.
|
||||||
|
They are stored in SQLite and encrypted with `local_encryption.key` unless `QUOTEFORGE_ENCRYPTION_KEY` overrides the key material.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Purpose |
|
||||||
|----------|-------------|---------|
|
| --- | --- |
|
||||||
| `QFS_DB_PATH` | Full path to SQLite DB | OS-specific user state dir |
|
| `QFS_STATE_DIR` | override runtime state directory |
|
||||||
| `QFS_STATE_DIR` | State directory (if `QFS_DB_PATH` is not set) | OS-specific user state dir |
|
| `QFS_DB_PATH` | explicit SQLite path |
|
||||||
| `QFS_CONFIG_PATH` | Full path to `config.yaml` | OS-specific user state dir |
|
| `QFS_CONFIG_PATH` | explicit config path |
|
||||||
| `QFS_BACKUP_DIR` | Root directory for rotating backups | `<db dir>/backups` |
|
| `QFS_BACKUP_DIR` | explicit backup root |
|
||||||
| `QFS_BACKUP_DISABLE` | Disable automatic backups | — |
|
| `QFS_BACKUP_DISABLE` | disable rotating backups |
|
||||||
| `QUOTEFORGE_ENCRYPTION_KEY` | Explicit override for local credential encryption key | app-managed key file |
|
| `QUOTEFORGE_ENCRYPTION_KEY` | override encryption key |
|
||||||
| `QF_DB_HOST` | MariaDB host | localhost |
|
| `QF_SERVER_PORT` | override HTTP port |
|
||||||
| `QF_DB_PORT` | MariaDB port | 3306 |
|
|
||||||
| `QF_DB_NAME` | Database name | RFQ_LOG |
|
|
||||||
| `QF_DB_USER` | DB user | — |
|
|
||||||
| `QF_DB_PASSWORD` | DB password | — |
|
|
||||||
| `QF_SERVER_PORT` | HTTP server port | 8080 |
|
|
||||||
|
|
||||||
`QFS_BACKUP_DISABLE` accepts: `1`, `true`, `yes`.
|
`QFS_BACKUP_DISABLE` accepts `1`, `true`, or `yes`.
|
||||||
|
|
||||||
---
|
## CLI flags
|
||||||
|
|
||||||
## CLI Flags
|
| Flag | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `-config <path>` | config file path |
|
||||||
|
| `-localdb <path>` | SQLite path |
|
||||||
|
| `-reset-localdb` | destructive local DB reset |
|
||||||
|
| `-migrate` | apply server migrations and exit |
|
||||||
|
| `-version` | print app version and exit |
|
||||||
|
|
||||||
| Flag | Description |
|
## First run
|
||||||
|------|-------------|
|
|
||||||
| `-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 |
|
|
||||||
|
|
||||||
---
|
1. runtime ensures `config.yaml` exists;
|
||||||
|
2. runtime opens the local SQLite database;
|
||||||
## Installation and First Run
|
3. if no stored MariaDB credentials exist, `/setup` is served;
|
||||||
|
4. after setup, runtime works locally and sync uses saved DB settings in the background.
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,227 +1,55 @@
|
|||||||
# 06 — Backup
|
# 06 - Backup
|
||||||
|
|
||||||
## Overview
|
## Scope
|
||||||
|
|
||||||
Automatic rotating ZIP backup system for local data.
|
QuoteForge creates rotating local ZIP backups of:
|
||||||
|
- a consistent SQLite snapshot saved as `qfs.db`;
|
||||||
|
- `config.yaml` when present.
|
||||||
|
|
||||||
**What is included in each archive:**
|
The backup intentionally does not include `local_encryption.key`.
|
||||||
- Consistent SQLite snapshot stored as `qfs.db`
|
|
||||||
- `config.yaml` if present
|
|
||||||
|
|
||||||
**Archive name format:** `qfs-backp-YYYY-MM-DD.zip`
|
## Location and naming
|
||||||
|
|
||||||
|
Default root:
|
||||||
|
- `<db dir>/backups`
|
||||||
|
|
||||||
|
Subdirectories:
|
||||||
|
- `daily/`
|
||||||
|
- `weekly/`
|
||||||
|
- `monthly/`
|
||||||
|
- `yearly/`
|
||||||
|
|
||||||
|
Archive name:
|
||||||
|
- `qfs-backp-YYYY-MM-DD.zip`
|
||||||
|
|
||||||
|
## Retention
|
||||||
|
|
||||||
**Retention policy:**
|
|
||||||
| Period | Keep |
|
| Period | Keep |
|
||||||
|--------|------|
|
| --- | --- |
|
||||||
| Daily | 7 archives |
|
| Daily | 7 |
|
||||||
| Weekly | 4 archives |
|
| Weekly | 4 |
|
||||||
| Monthly | 12 archives |
|
| Monthly | 12 |
|
||||||
| Yearly | 10 archives |
|
| Yearly | 10 |
|
||||||
|
|
||||||
**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`)
|
|
||||||
|
|
||||||
**Safety rules:**
|
|
||||||
- Backup root must resolve outside any git worktree.
|
|
||||||
- If `qfs.db` is placed inside a repository checkout, default backups are rejected until `QFS_BACKUP_DIR` points outside the repo.
|
|
||||||
- Backup archives intentionally do **not** include `local_encryption.key`; restored installations on another machine must re-enter DB credentials.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- **At startup:** if no backup exists for the current period, one is created immediately
|
- on startup, QuoteForge creates a backup if the current period has none yet;
|
||||||
- **Daily:** at the configured time, a new backup is created
|
- a daily scheduler creates the next backup at `backup.time`;
|
||||||
- **Deduplication:** prevented via a `.period.json` marker file in each period directory
|
- duplicate snapshots inside the same period are prevented by a period marker file;
|
||||||
- **Rotation:** excess old archives are deleted automatically
|
- old archives are pruned automatically.
|
||||||
|
|
||||||
---
|
## Safety rules
|
||||||
|
|
||||||
## Implementation
|
- backup root must be outside the git worktree;
|
||||||
|
- backup creation is blocked if the resolved backup root sits inside the repository;
|
||||||
|
- SQLite snapshot must be created from a consistent database copy, not by copying live WAL files directly;
|
||||||
|
- restore to another machine requires re-entering DB credentials unless the encryption key is migrated separately.
|
||||||
|
|
||||||
Module: `internal/appstate/backup.go`
|
## Restore
|
||||||
|
|
||||||
Main function:
|
1. stop QuoteForge;
|
||||||
```go
|
2. unpack the chosen archive outside the repository;
|
||||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error)
|
3. replace `qfs.db`;
|
||||||
```
|
4. replace `config.yaml` if needed;
|
||||||
|
5. restart the app;
|
||||||
Scheduler (in `main.go`):
|
6. re-enter MariaDB credentials if the original encryption key is unavailable.
|
||||||
```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
|
|
||||||
- Backup captures a consistent SQLite snapshot via `VACUUM INTO` before zipping; it does not archive live `-wal` / `-shm` sidecars directly
|
|
||||||
- `.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
|
|
||||||
- Git worktree detection is path-based (`.git` ancestor check) and blocks backup creation inside the repo tree
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,141 +1,34 @@
|
|||||||
# 07 — Development
|
# 07 - Development
|
||||||
|
|
||||||
## Commands
|
## Common commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run (dev)
|
|
||||||
go run ./cmd/qfs
|
go run ./cmd/qfs
|
||||||
make run
|
go run ./cmd/qfs -migrate
|
||||||
|
|
||||||
# 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 ./...
|
go test ./...
|
||||||
make test
|
go vet ./...
|
||||||
|
make build-release
|
||||||
# Utilities
|
make install-hooks
|
||||||
make install-hooks # Git hooks (block committing secrets)
|
|
||||||
make clean # Clean bin/
|
|
||||||
make help # All available commands
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
- **Formatting:** `gofmt` (mandatory)
|
|
||||||
- **Logging:** `slog` only (structured logging to the binary's stdout/stderr). No `console.log` or any other logging in browser-side JS — the browser console is never used for diagnostics.
|
|
||||||
- **Errors:** explicit wrapping with context (`fmt.Errorf("context: %w", err)`)
|
|
||||||
- **Style:** no unnecessary abstractions; minimum code for the task
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
## Guardrails
|
||||||
|
|
||||||
### What Must Never Be Restored
|
- run `gofmt` before commit;
|
||||||
|
- use `slog` for server logging;
|
||||||
|
- keep runtime business logic SQLite-only;
|
||||||
|
- limit MariaDB access to sync, setup, and migration tooling;
|
||||||
|
- keep `config.yaml` out of git and use `config.example.yaml` only as a template;
|
||||||
|
- update `bible-local/` in the same commit as architecture changes.
|
||||||
|
|
||||||
The following components were **intentionally removed** and must not be brought back:
|
## Removed features that must not return
|
||||||
- cron jobs
|
|
||||||
- importer utility
|
|
||||||
- admin pricing UI/API
|
|
||||||
- alerts
|
|
||||||
- stock import
|
|
||||||
|
|
||||||
### Configuration Files
|
- admin pricing UI/API;
|
||||||
|
- alerts and notification workflows;
|
||||||
|
- stock import tooling;
|
||||||
|
- cron jobs;
|
||||||
|
- standalone importer utility.
|
||||||
|
|
||||||
- `config.yaml` — runtime user file, **not stored in the repository**
|
## Release notes
|
||||||
- `config.example.yaml` — the only config template in the repo
|
|
||||||
|
|
||||||
### Sync and Local-First
|
Release history belongs under `releases/<version>/RELEASE_NOTES.md`.
|
||||||
|
Do not keep temporary change summaries in the repository root.
|
||||||
- Any sync changes must preserve local-first behavior
|
|
||||||
- Local CRUD must not be blocked when MariaDB is unavailable
|
|
||||||
- Runtime business code must not query MariaDB directly; all normal reads/writes go through SQLite snapshots
|
|
||||||
- Direct MariaDB access is allowed only in `internal/services/sync/*` and dedicated setup/migration tools under `cmd/`
|
|
||||||
- `connMgr.GetDB()` in handlers/services outside sync is a code review failure unless the code is strictly setup or operator tooling
|
|
||||||
- Local SQLite migrations must be implemented in code; do not add a server-side registry of client SQLite SQL patches
|
|
||||||
- Read-only local cache tables may be reset during startup recovery if migration fails; do not apply that strategy to user-authored tables like configurations, projects, pending changes, or connection settings
|
|
||||||
|
|
||||||
### 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 SQLite-only; do not add a live MariaDB fallback
|
|
||||||
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)
|
|
||||||
|
|||||||
@@ -1,569 +1,64 @@
|
|||||||
# 09 — Vendor Spec (BOM Import)
|
# 09 - Vendor BOM
|
||||||
|
|
||||||
## Overview
|
## Storage contract
|
||||||
|
|
||||||
The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a configuration. It maps vendor part numbers (PN) to internal LOT names using an active partnumber book (snapshot pulled from PriceForge), then aggregates quantities to populate the Estimate (cart).
|
Vendor BOM is stored in `local_configurations.vendor_spec` and synced with `qt_configurations.vendor_spec`.
|
||||||
|
|
||||||
---
|
Each row uses this canonical shape:
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Storage
|
|
||||||
|
|
||||||
| Data | Storage | Sync direction |
|
|
||||||
|------|---------|---------------|
|
|
||||||
| `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT, JSON-encoded) | Two-way via `pending_changes` |
|
|
||||||
| Partnumber book snapshots | `local_partnumber_books` + `local_partnumber_book_items` | Pull-only from PriceForge |
|
|
||||||
|
|
||||||
`vendor_spec` is a JSON array of `VendorSpecItem` objects stored inside the configuration row.
|
|
||||||
It syncs to MariaDB `qt_configurations.vendor_spec` via the existing pending_changes mechanism.
|
|
||||||
|
|
||||||
Legacy storage note:
|
|
||||||
|
|
||||||
- QuoteForge does not use `qt_bom`
|
|
||||||
- QuoteForge does not use `qt_lot_bundles`
|
|
||||||
- QuoteForge does not use `qt_lot_bundle_items`
|
|
||||||
|
|
||||||
The only canonical persisted BOM contract for QuoteForge is `qt_configurations.vendor_spec`.
|
|
||||||
|
|
||||||
### `vendor_spec` JSON Schema
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"sort_order": 10,
|
|
||||||
"vendor_partnumber": "ABC-123",
|
|
||||||
"quantity": 2,
|
|
||||||
"description": "...",
|
|
||||||
"unit_price": 4500.00,
|
|
||||||
"total_price": 9000.00,
|
|
||||||
"lot_mappings": [
|
|
||||||
{ "lot_name": "LOT_A", "quantity_per_pn": 1 },
|
|
||||||
{ "lot_name": "LOT_B", "quantity_per_pn": 2 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
`lot_mappings[]` is the canonical persisted LOT mapping list for a BOM row.
|
|
||||||
Each mapping entry stores:
|
|
||||||
|
|
||||||
- `lot_name`
|
|
||||||
- `quantity_per_pn` (how many units of this LOT are included in **one vendor PN**)
|
|
||||||
|
|
||||||
### PN → LOT Mapping Contract (single LOT, multiplier, bundle)
|
|
||||||
|
|
||||||
QuoteForge expects the server to return/store BOM rows (`vendor_spec`) using a single canonical mapping list:
|
|
||||||
|
|
||||||
- `lot_mappings[]` contains **all** LOT mappings for the PN row (single LOT and bundle cases alike)
|
|
||||||
- the list stores exactly what the user sees in BOM (LOT + "LOT в 1 PN")
|
|
||||||
- the DB contract does **not** split mappings into "base LOT" vs "bundle LOTs"
|
|
||||||
|
|
||||||
#### Final quantity contribution to Estimate
|
|
||||||
|
|
||||||
For one BOM row with vendor PN quantity `pn_qty`:
|
|
||||||
|
|
||||||
- each mapping contribution:
|
|
||||||
- `lot_qty = pn_qty * lot_mappings[i].quantity_per_pn`
|
|
||||||
|
|
||||||
#### Example: one PN maps to multiple LOTs
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"vendor_partnumber": "SYS-821GE-TNHR",
|
"sort_order": 10,
|
||||||
"quantity": 3,
|
"vendor_partnumber": "ABC-123",
|
||||||
|
"quantity": 2,
|
||||||
|
"description": "row description",
|
||||||
|
"unit_price": 4500.0,
|
||||||
|
"total_price": 9000.0,
|
||||||
"lot_mappings": [
|
"lot_mappings": [
|
||||||
{ "lot_name": "CHASSIS_X13_8GPU", "quantity_per_pn": 1 },
|
{ "lot_name": "LOT_A", "quantity_per_pn": 1 }
|
||||||
{ "lot_name": "PS_3000W_Titanium", "quantity_per_pn": 2 },
|
|
||||||
{ "lot_name": "RAILKIT_X13", "quantity_per_pn": 1 }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This row contributes to Estimate:
|
Rules:
|
||||||
|
- `lot_mappings[]` is the only persisted PN -> LOT mapping contract;
|
||||||
|
- QuoteForge does not use legacy BOM tables;
|
||||||
|
- apply flow rebuilds cart rows from `lot_mappings[]`.
|
||||||
|
|
||||||
- `CHASSIS_X13_8GPU` → `3 * 1 = 3`
|
## Partnumber books
|
||||||
- `PS_3000W_Titanium` → `3 * 2 = 6`
|
|
||||||
- `RAILKIT_X13` → `3 * 1 = 3`
|
|
||||||
|
|
||||||
---
|
Partnumber books are pull-only snapshots from PriceForge.
|
||||||
|
|
||||||
## Partnumber Books (Snapshots)
|
Local tables:
|
||||||
|
- `local_partnumber_books`
|
||||||
|
- `local_partnumber_book_items`
|
||||||
|
|
||||||
Partnumber books are immutable versioned snapshots of the global PN→LOT mapping table, analogous to pricelists. PriceForge creates new snapshots; QuoteForge only pulls and reads them.
|
Server tables:
|
||||||
|
- `qt_partnumber_books`
|
||||||
|
- `qt_partnumber_book_items`
|
||||||
|
|
||||||
### SQLite (local mirror)
|
Resolution flow:
|
||||||
|
1. load the active local book;
|
||||||
|
2. find `vendor_partnumber`;
|
||||||
|
3. copy `lots_json` into `lot_mappings[]`;
|
||||||
|
4. keep unresolved rows editable in the UI.
|
||||||
|
|
||||||
```sql
|
## CFXML import
|
||||||
CREATE TABLE local_partnumber_books (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
server_id INTEGER UNIQUE NOT NULL, -- id from qt_partnumber_books
|
|
||||||
version TEXT NOT NULL, -- format YYYY-MM-DD-NNN
|
|
||||||
created_at DATETIME NOT NULL,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE local_partnumber_book_items (
|
`POST /api/projects/:uuid/vendor-import` imports one vendor workspace into an existing project.
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
partnumber TEXT NOT NULL,
|
|
||||||
lots_json TEXT NOT NULL,
|
|
||||||
description TEXT
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX idx_local_book_pn ON local_partnumber_book_items(partnumber);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Active book query:** `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
|
|
||||||
|
|
||||||
**Schema creation:** GORM AutoMigrate (not `runLocalMigrations`).
|
|
||||||
|
|
||||||
### MariaDB (managed exclusively by PriceForge)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE qt_partnumber_books (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
version VARCHAR(50) NOT NULL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
partnumbers_json LONGTEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE qt_partnumber_book_items (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
partnumber VARCHAR(255) NOT NULL,
|
|
||||||
lots_json LONGTEXT NOT NULL,
|
|
||||||
description VARCHAR(10000) NULL,
|
|
||||||
UNIQUE KEY uq_qt_partnumber_book_items_partnumber (partnumber)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE qt_configurations ADD COLUMN vendor_spec JSON NULL;
|
|
||||||
```
|
|
||||||
|
|
||||||
QuoteForge has `SELECT` permission only on `qt_partnumber_books` and `qt_partnumber_book_items`. All writes are managed by PriceForge.
|
|
||||||
|
|
||||||
**Grant (add to existing user setup):**
|
|
||||||
```sql
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resolution Algorithm (3-step)
|
|
||||||
|
|
||||||
For each `vendor_partnumber` in the BOM, QuoteForge builds/updates UI-visible LOT mappings:
|
|
||||||
|
|
||||||
1. **Active book lookup** — read active `local_partnumber_books`, verify PN membership in `partnumbers_json`, then query `local_partnumber_book_items WHERE partnumber = ?`.
|
|
||||||
2. **Populate BOM UI** — if a match exists, BOM row gets `lot_mappings[]` from `lots_json` (user can still edit it).
|
|
||||||
3. **Unresolved** — red row + inline LOT input with strict autocomplete.
|
|
||||||
|
|
||||||
Persistence note: the application stores the final user-visible mappings in `lot_mappings[]` (not separate "resolved/manual" persisted fields).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CFXML Workspace Import Contract
|
|
||||||
|
|
||||||
QuoteForge may import a vendor configurator workspace in `CFXML` format as an existing project update path.
|
|
||||||
This import path must convert one external workspace into one QuoteForge project containing multiple configurations.
|
|
||||||
|
|
||||||
### Import Unit Boundaries
|
|
||||||
|
|
||||||
- One `CFXML` workspace file = one QuoteForge project import session.
|
|
||||||
- One top-level configuration group inside the workspace = one QuoteForge configuration.
|
|
||||||
- Software rows are **not** imported as standalone configurations.
|
|
||||||
- All software rows must be attached to the configuration group they belong to.
|
|
||||||
- Upload guardrail: the incoming `CFXML` file must not exceed `1 GiB`; larger payloads are rejected before XML parsing.
|
|
||||||
|
|
||||||
### Configuration Grouping
|
|
||||||
|
|
||||||
Top-level `ProductLineItem` rows are grouped by:
|
|
||||||
|
|
||||||
- `ProprietaryGroupIdentifier`
|
|
||||||
|
|
||||||
This field is the canonical boundary of one imported configuration.
|
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
- accepted file field is `file`;
|
||||||
1. Read all top-level `ProductLineItem` rows in document order.
|
- maximum file size is `1 GiB`;
|
||||||
2. Group them by `ProprietaryGroupIdentifier`.
|
- one `ProprietaryGroupIdentifier` becomes one QuoteForge configuration;
|
||||||
3. Preserve document order of groups by the first encountered `ProductLineNumber`.
|
- software rows stay inside their hardware group and never become standalone configurations;
|
||||||
4. Import each group as exactly one QuoteForge configuration.
|
- primary group row is selected structurally, without vendor-specific SKU hardcoding;
|
||||||
|
- imported configuration order follows workspace order.
|
||||||
`ConfigurationGroupLineNumberReference` is not sufficient for grouping imported configurations because
|
|
||||||
multiple independent configuration groups may share the same value in one workspace.
|
Imported configuration fields:
|
||||||
|
- `name` from primary row `ProductName`
|
||||||
### Primary Row Selection (no SKU hardcode)
|
- `server_count` from primary row `Quantity`
|
||||||
|
- `server_model` from primary row `ProductDescription`
|
||||||
The importer must not hardcode vendor, model, or server SKU values.
|
- `article` or `support_code` from `ProprietaryProductIdentifier`
|
||||||
|
|
||||||
Within each `ProprietaryGroupIdentifier` group, the importer selects one primary top-level row using
|
Imported BOM rows become `vendor_spec` rows and are resolved through the active local partnumber book when possible.
|
||||||
structural rules only:
|
|
||||||
|
|
||||||
1. Prefer rows with `ProductTypeCode = Hardware`.
|
|
||||||
2. If multiple rows match, prefer the row with the largest number of `ProductSubLineItem` children.
|
|
||||||
3. If there is still a tie, prefer the first row by `ProductLineNumber`.
|
|
||||||
|
|
||||||
The primary row provides configuration-level metadata such as:
|
|
||||||
|
|
||||||
- configuration name
|
|
||||||
- server count
|
|
||||||
- server model / description
|
|
||||||
- article / support code candidate
|
|
||||||
|
|
||||||
### Software Inclusion Rule
|
|
||||||
|
|
||||||
All top-level rows belonging to the same `ProprietaryGroupIdentifier` must be imported into the same
|
|
||||||
QuoteForge configuration, including:
|
|
||||||
|
|
||||||
- `Hardware`
|
|
||||||
- `Software`
|
|
||||||
- instruction / service rows represented as software-like items
|
|
||||||
|
|
||||||
Effects:
|
|
||||||
|
|
||||||
- a workspace never creates a separate configuration made only of software;
|
|
||||||
- `software1`, `software2`, license rows, and instruction rows stay inside the related configuration;
|
|
||||||
- the user sees one complete configuration instead of fragmented partial imports.
|
|
||||||
|
|
||||||
### Mapping to QuoteForge Project / Configuration
|
|
||||||
|
|
||||||
For one imported configuration group:
|
|
||||||
|
|
||||||
- QuoteForge configuration `name` <- primary row `ProductName`
|
|
||||||
- QuoteForge configuration `server_count` <- primary row `Quantity`
|
|
||||||
- QuoteForge configuration `server_model` <- primary row `ProductDescription`
|
|
||||||
- QuoteForge configuration `article` or `support_code` <- primary row `ProprietaryProductIdentifier`
|
|
||||||
- QuoteForge configuration `line` <- stable order by group appearance in the workspace
|
|
||||||
|
|
||||||
Project-level fields such as QuoteForge `code`, `name`, and `variant` are not reliably defined by `CFXML`
|
|
||||||
itself and should come from the existing target project context or explicit user input.
|
|
||||||
|
|
||||||
### Mapping to `vendor_spec`
|
|
||||||
|
|
||||||
The importer must build one combined `vendor_spec` array per configuration group.
|
|
||||||
|
|
||||||
Source rows:
|
|
||||||
|
|
||||||
- all `ProductSubLineItem` rows from the primary top-level row;
|
|
||||||
- all `ProductSubLineItem` rows from every non-primary top-level row in the same group;
|
|
||||||
- if a top-level row has no `ProductSubLineItem`, the top-level row itself may be converted into one
|
|
||||||
`vendor_spec` row so that software-only content is not lost.
|
|
||||||
|
|
||||||
Each imported row maps into one `VendorSpecItem`:
|
|
||||||
|
|
||||||
- `sort_order` <- stable sequence within the group
|
|
||||||
- `vendor_partnumber` <- `ProprietaryProductIdentifier`
|
|
||||||
- `quantity` <- `Quantity`
|
|
||||||
- `description` <- `ProductDescription`
|
|
||||||
- `unit_price` <- `UnitListPrice.FinancialAmount.MonetaryAmount` when present
|
|
||||||
- `total_price` <- `quantity * unit_price` when unit price is present
|
|
||||||
- `lot_mappings` <- resolved immediately from the active partnumber book using `lots_json`
|
|
||||||
|
|
||||||
The importer stores vendor-native rows in `vendor_spec`, then immediately runs the same logical flow as BOM
|
|
||||||
Resolve + Apply:
|
|
||||||
|
|
||||||
- resolve vendor PN rows through the active partnumber book
|
|
||||||
- persist canonical `lot_mappings[]`
|
|
||||||
- build normalized configuration `items` from `row.quantity * quantity_per_pn`
|
|
||||||
- fill `items.unit_price` from the latest local `estimate` pricelist
|
|
||||||
- recalculate configuration `total_price`
|
|
||||||
|
|
||||||
### Import Pipeline
|
|
||||||
|
|
||||||
Recommended parser pipeline:
|
|
||||||
|
|
||||||
1. Parse XML into top-level `ProductLineItem` rows.
|
|
||||||
2. Group rows by `ProprietaryGroupIdentifier`.
|
|
||||||
3. Select one primary row per group using structural rules.
|
|
||||||
4. Build one QuoteForge configuration DTO per group.
|
|
||||||
5. Merge all hardware/software rows of the group into one `vendor_spec`.
|
|
||||||
6. Resolve imported PN rows into canonical `lot_mappings[]` using the active partnumber book.
|
|
||||||
7. Build configuration `items` from resolved `lot_mappings[]`.
|
|
||||||
8. Price those `items` from the latest local `estimate` pricelist.
|
|
||||||
9. Save or update the QuoteForge configuration inside the existing project.
|
|
||||||
|
|
||||||
### Recommended Internal DTO
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ImportedProject struct {
|
|
||||||
SourceFormat string
|
|
||||||
SourceFilePath string
|
|
||||||
SourceDocID string
|
|
||||||
|
|
||||||
Code string
|
|
||||||
Name string
|
|
||||||
Variant string
|
|
||||||
|
|
||||||
Configurations []ImportedConfiguration
|
|
||||||
}
|
|
||||||
|
|
||||||
type ImportedConfiguration struct {
|
|
||||||
GroupID string
|
|
||||||
|
|
||||||
Name string
|
|
||||||
Line int
|
|
||||||
ServerCount int
|
|
||||||
|
|
||||||
ServerModel string
|
|
||||||
Article string
|
|
||||||
SupportCode string
|
|
||||||
CurrencyCode string
|
|
||||||
|
|
||||||
TopLevelRows []ImportedTopLevelRow
|
|
||||||
VendorSpec []ImportedVendorRow
|
|
||||||
}
|
|
||||||
|
|
||||||
type ImportedTopLevelRow struct {
|
|
||||||
ProductLineNumber string
|
|
||||||
ItemNo string
|
|
||||||
GroupID string
|
|
||||||
|
|
||||||
ProductType string
|
|
||||||
ProductCode string
|
|
||||||
ProductName string
|
|
||||||
Description string
|
|
||||||
Quantity int
|
|
||||||
UnitPrice *float64
|
|
||||||
IsPrimary bool
|
|
||||||
|
|
||||||
SubRows []ImportedVendorRow
|
|
||||||
}
|
|
||||||
|
|
||||||
type ImportedVendorRow struct {
|
|
||||||
SortOrder int
|
|
||||||
|
|
||||||
SourceLineNumber string
|
|
||||||
SourceParentLine string
|
|
||||||
SourceProductType string
|
|
||||||
|
|
||||||
VendorPartnumber string
|
|
||||||
Description string
|
|
||||||
Quantity int
|
|
||||||
UnitPrice *float64
|
|
||||||
TotalPrice *float64
|
|
||||||
|
|
||||||
ProductCharacter string
|
|
||||||
ProductCharPath string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Current Product Assumption
|
|
||||||
|
|
||||||
For QuoteForge product behavior, the correct user-facing interpretation is:
|
|
||||||
|
|
||||||
- one external project/workspace contains several configurations;
|
|
||||||
- each configuration contains both hardware and software rows that belong to it;
|
|
||||||
- the importer must preserve that grouping exactly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Qty Aggregation Logic
|
|
||||||
|
|
||||||
After resolution, qty per LOT is computed from the BOM row quantity multiplied by the matched `lots_json.qty`:
|
|
||||||
|
|
||||||
```
|
|
||||||
qty(lot) = SUM(quantity_of_pn_row * quantity_of_lot_inside_lots_json)
|
|
||||||
```
|
|
||||||
|
|
||||||
Examples (book: PN_X → `[{LOT_A, qty:2}, {LOT_B, qty:1}]`):
|
|
||||||
- BOM: PN_X ×3 → `LOT_A ×6`, `LOT_B ×3`
|
|
||||||
- BOM: PN_X ×1 and PN_X ×2 → `LOT_A ×6`, `LOT_B ×3`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UI: Three Top-Level Tabs
|
|
||||||
|
|
||||||
The configurator (`/configurator`) has three tabs:
|
|
||||||
|
|
||||||
1. **Estimate** — existing cart/component configurator (unchanged).
|
|
||||||
2. **BOM** — paste/import vendor BOM, manual column mapping, LOT matching, bundle decomposition (`1 PN -> multiple LOTs`), "Пересчитать эстимейт", "Очистить".
|
|
||||||
3. **Ценообразование** — pricing summary table + custom price input.
|
|
||||||
|
|
||||||
BOM data is shared between tabs 2 and 3.
|
|
||||||
|
|
||||||
### BOM Import UI (raw table, manual column mapping)
|
|
||||||
|
|
||||||
After paste (`Ctrl+V`) QuoteForge renders an editable raw table (not auto-detected parsing).
|
|
||||||
|
|
||||||
- The pasted rows are shown **as-is** (including header rows, if present).
|
|
||||||
- The user selects a type for each column manually:
|
|
||||||
- `P/N`
|
|
||||||
- `Кол-во`
|
|
||||||
- `Цена`
|
|
||||||
- `Описание`
|
|
||||||
- `Не использовать`
|
|
||||||
- Required mapping:
|
|
||||||
- exactly one `P/N`
|
|
||||||
- exactly one `Кол-во`
|
|
||||||
- Optional mapping:
|
|
||||||
- `Цена` (0..1)
|
|
||||||
- `Описание` (0..1)
|
|
||||||
- Rows can be:
|
|
||||||
- ignored (UI-only, excluded from `vendor_spec`)
|
|
||||||
- deleted
|
|
||||||
- Raw cells are editable inline after paste.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- There is **no auto column detection**.
|
|
||||||
- There is **no auto header-row skip**.
|
|
||||||
- Raw import layout itself is not stored on server; only normalized `vendor_spec` is stored.
|
|
||||||
|
|
||||||
### LOT matching in BOM table
|
|
||||||
|
|
||||||
The BOM table adds service columns on the right:
|
|
||||||
|
|
||||||
- `LOT`
|
|
||||||
- `LOT в 1 PN`
|
|
||||||
- actions (`+`, ignore, delete)
|
|
||||||
|
|
||||||
`LOT` behavior:
|
|
||||||
- The first LOT row shown in the BOM UI is the primary LOT mapping for the PN row.
|
|
||||||
- Additional LOT rows are added via the `+` action.
|
|
||||||
- inline LOT input is strict:
|
|
||||||
- autocomplete source = full local components list (`/api/components?per_page=5000`)
|
|
||||||
- free text that does not match an existing LOT is rejected
|
|
||||||
|
|
||||||
`LOT в 1 PN` behavior:
|
|
||||||
- quantity multiplier for each visible LOT row in BOM (`quantity_per_pn` in persisted `lot_mappings[]`)
|
|
||||||
- default = `1`
|
|
||||||
- editable inline
|
|
||||||
|
|
||||||
### Bundle mode (`1 PN -> multiple LOTs`)
|
|
||||||
|
|
||||||
The `+` action in BOM rows adds an extra LOT mapping row for the same vendor PN row.
|
|
||||||
|
|
||||||
- All visible LOT rows (first + added rows) are persisted uniformly in `lot_mappings[]`
|
|
||||||
- Each mapping row has:
|
|
||||||
- LOT
|
|
||||||
- qty (`LOT in 1 PN` = `quantity_per_pn`)
|
|
||||||
|
|
||||||
### BOM restore on config open
|
|
||||||
|
|
||||||
On config open, QuoteForge loads `vendor_spec` from server and reconstructs the editable BOM table in normalized form:
|
|
||||||
|
|
||||||
- columns restored as: `Qty | P/N | Description | Price`
|
|
||||||
- column mapping restored as:
|
|
||||||
- `qty`, `pn`, `description`, `price`
|
|
||||||
- LOT / `LOT в 1 PN` rows are restored from `vendor_spec.lot_mappings[]`
|
|
||||||
|
|
||||||
This restores the BOM editing state, but not the original raw Excel layout (extra columns, ignored rows, original headers).
|
|
||||||
|
|
||||||
### Pricing Tab: column order
|
|
||||||
|
|
||||||
```
|
|
||||||
LOT | PN вендора | Описание | Кол-во | Estimate | Цена вендора | Склад | Конк.
|
|
||||||
```
|
|
||||||
|
|
||||||
**If BOM is empty** — the pricing tab still renders, using cart items as rows (PN вендора = "—", Цена вендора = "—").
|
|
||||||
|
|
||||||
**Description source priority:** BOM row description → LOT description from `local_components`.
|
|
||||||
|
|
||||||
### Pricing Tab: BOM + Estimate merge behavior
|
|
||||||
|
|
||||||
When BOM exists, the pricing tab renders:
|
|
||||||
|
|
||||||
- BOM-based rows (including rows resolved via manual LOT and bundle mappings)
|
|
||||||
- plus **Estimate-only LOTs** (rows currently in cart but not covered by BOM mappings)
|
|
||||||
|
|
||||||
Estimate-only rows are shown as separate rows with:
|
|
||||||
- `PN вендора = "—"`
|
|
||||||
- vendor price = `—`
|
|
||||||
- description from local components
|
|
||||||
|
|
||||||
### Pricing Tab: "Своя цена" input
|
|
||||||
|
|
||||||
- Manual entry → proportionally redistributes custom price into "Цена вендора" cells (proportional to each row's Estimate share). Last row absorbs rounding remainder.
|
|
||||||
- "Проставить цены BOM" button → restores per-row original BOM prices directly (no proportional redistribution). Sets "Своя цена" to their sum.
|
|
||||||
- Both paths show "Скидка от Estimate: X%" info.
|
|
||||||
- "Экспорт CSV" button → downloads `pricing_<uuid>.csv` with UTF-8 BOM, same column order as table, plus Итого row.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
| Method | URL | Description |
|
|
||||||
|--------|-----|-------------|
|
|
||||||
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored BOM |
|
|
||||||
| PUT | `/api/configs/:uuid/vendor-spec` | Replace BOM (full update) |
|
|
||||||
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (no cart mutation) |
|
|
||||||
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
|
|
||||||
| POST | `/api/projects/:uuid/vendor-import` | Import `CFXML` workspace into an existing project and create grouped configurations |
|
|
||||||
| GET | `/api/partnumber-books` | List local book snapshots |
|
|
||||||
| GET | `/api/partnumber-books/:id` | Items for a book by server_id |
|
|
||||||
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
|
|
||||||
| POST | `/api/sync/partnumber-seen` | Push unresolved PNs to `qt_vendor_partnumber_seen` on MariaDB |
|
|
||||||
|
|
||||||
## Unresolved PN Tracking (`qt_vendor_partnumber_seen`)
|
|
||||||
|
|
||||||
After each `resolveBOM()` call, QuoteForge pushes PN rows to `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored):
|
|
||||||
|
|
||||||
- unresolved BOM rows (`ignored = false`)
|
|
||||||
- raw BOM rows explicitly marked as ignored in UI (`ignored = true`) — these rows are **not** saved to `vendor_spec`, but are reported for server-side tracking
|
|
||||||
|
|
||||||
The handler calls `sync.PushPartnumberSeen()` which inserts into `qt_vendor_partnumber_seen`.
|
|
||||||
If a row with the same `partnumber` already exists, QuoteForge must leave it untouched:
|
|
||||||
|
|
||||||
- do not update `last_seen_at`
|
|
||||||
- do not update `is_ignored`
|
|
||||||
- do not update `description`
|
|
||||||
|
|
||||||
Canonical insert behavior:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
|
||||||
VALUES ('manual', '', ?, ?, ?, NOW())
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
partnumber = partnumber
|
|
||||||
```
|
|
||||||
|
|
||||||
Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge uses this table for populating the partnumber book.
|
|
||||||
|
|
||||||
Partnumber book sync contract:
|
|
||||||
|
|
||||||
- PriceForge writes membership snapshots to `qt_partnumber_books.partnumbers_json`.
|
|
||||||
- PriceForge writes canonical PN payloads to `qt_partnumber_book_items`.
|
|
||||||
- QuoteForge syncs book headers first, then pulls PN payloads with:
|
|
||||||
`SELECT partnumber, lots_json, description FROM qt_partnumber_book_items WHERE partnumber IN (...)`
|
|
||||||
|
|
||||||
## BOM Persistence
|
|
||||||
|
|
||||||
- `vendor_spec` is saved to server via `PUT /api/configs/:uuid/vendor-spec`.
|
|
||||||
- `GET` / `PUT` `vendor_spec` must preserve row-level mapping fields used by the UI:
|
|
||||||
- `lot_mappings[]`
|
|
||||||
- each item: `lot_name`, `quantity_per_pn`
|
|
||||||
- `description` is persisted in each BOM row and is used by the Pricing tab when available.
|
|
||||||
- Ignored raw rows are **not** persisted into `vendor_spec`.
|
|
||||||
- The PUT handler explicitly marshals `VendorSpec` to JSON string before passing to GORM `Update` (GORM does not reliably call `driver.Valuer` for custom types in `Update(column, value)`).
|
|
||||||
- BOM is autosaved (debounced) after BOM-changing actions, including:
|
|
||||||
- `resolveBOM()`
|
|
||||||
- LOT row qty (`LOT в 1 PN`) changes
|
|
||||||
- LOT row add/remove (`+` / delete in bundle context)
|
|
||||||
- "Сохранить BOM" button triggers explicit save.
|
|
||||||
|
|
||||||
## Pricing Tab: Estimate Price Source
|
|
||||||
|
|
||||||
`renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with LOTs collected from:
|
|
||||||
|
|
||||||
- `lot_mappings[]` from BOM rows
|
|
||||||
- current Estimate/cart LOTs not covered by BOM mappings (to show estimate-only rows)
|
|
||||||
|
|
||||||
This ensures Estimate prices appear for:
|
|
||||||
|
|
||||||
- manually matched LOTs in the BOM tab
|
|
||||||
- bundle LOTs
|
|
||||||
- LOTs already present in Estimate but not mapped from BOM
|
|
||||||
|
|
||||||
### Apply to Estimate (`Пересчитать эстимейт`)
|
|
||||||
|
|
||||||
When applying BOM to Estimate, QuoteForge builds cart rows from explicit UI mappings stored in `lot_mappings[]`.
|
|
||||||
|
|
||||||
For a BOM row with PN qty = `Q`:
|
|
||||||
|
|
||||||
- each mapped LOT contributes `Q * quantity_per_pn`
|
|
||||||
|
|
||||||
Rows without any valid LOT mapping are skipped.
|
|
||||||
|
|
||||||
## Web Route
|
|
||||||
|
|
||||||
| Route | Page |
|
|
||||||
|-------|------|
|
|
||||||
| `/partnumber-books` | Partnumber books — active book summary (unique LOTs, total PN, primary PN count), searchable items table, collapsible snapshot history |
|
|
||||||
|
|||||||
@@ -1,55 +1,30 @@
|
|||||||
# QuoteForge Bible — Architectural Documentation
|
# QuoteForge Bible
|
||||||
|
|
||||||
The single source of truth for architecture, schemas, and patterns.
|
Project-specific architecture and operational contracts.
|
||||||
|
|
||||||
---
|
## Files
|
||||||
|
|
||||||
## Table of Contents
|
| File | Scope |
|
||||||
|
| --- | --- |
|
||||||
|
| [01-overview.md](01-overview.md) | Product scope, runtime model, repository map |
|
||||||
|
| [02-architecture.md](02-architecture.md) | Local-first rules, sync, pricing, versioning |
|
||||||
|
| [03-database.md](03-database.md) | SQLite and MariaDB data model, permissions, migrations |
|
||||||
|
| [04-api.md](04-api.md) | HTTP routes and API contract |
|
||||||
|
| [05-config.md](05-config.md) | Runtime config, paths, env vars, startup behavior |
|
||||||
|
| [06-backup.md](06-backup.md) | Backup contract and restore workflow |
|
||||||
|
| [07-dev.md](07-dev.md) | Development commands and guardrails |
|
||||||
|
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
|
||||||
|
|
||||||
| File | Topic |
|
## Rules
|
||||||
|------|-------|
|
|
||||||
| [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-local/` is the source of truth for QuoteForge-specific behavior.
|
||||||
|
- Keep these files in English.
|
||||||
|
- Update the matching file in the same commit as any architectural change.
|
||||||
|
- Remove stale documentation instead of preserving history in place.
|
||||||
|
|
||||||
## Bible Rules
|
## Quick reference
|
||||||
|
|
||||||
> **Every architectural decision must be recorded in the Bible.**
|
- Local DB path: see [05-config.md](05-config.md)
|
||||||
>
|
- Runtime bind: loopback only
|
||||||
> Any change to DB schema, data access patterns, sync behavior, API contracts,
|
- Local backups: see [06-backup.md](06-backup.md)
|
||||||
> configuration format, or any other system-level aspect — the corresponding `bible/` file
|
- Release notes: `releases/<version>/RELEASE_NOTES.md`
|
||||||
> **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`
|
|
||||||
|
|||||||
@@ -329,24 +329,6 @@ func setConfigDefaults(cfg *config.Config) {
|
|||||||
if cfg.Server.WriteTimeout == 0 {
|
if cfg.Server.WriteTimeout == 0 {
|
||||||
cfg.Server.WriteTimeout = 30 * time.Second
|
cfg.Server.WriteTimeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
if cfg.Pricing.DefaultMethod == "" {
|
|
||||||
cfg.Pricing.DefaultMethod = "weighted_median"
|
|
||||||
}
|
|
||||||
if cfg.Pricing.DefaultPeriodDays == 0 {
|
|
||||||
cfg.Pricing.DefaultPeriodDays = 90
|
|
||||||
}
|
|
||||||
if cfg.Pricing.FreshnessGreenDays == 0 {
|
|
||||||
cfg.Pricing.FreshnessGreenDays = 30
|
|
||||||
}
|
|
||||||
if cfg.Pricing.FreshnessYellowDays == 0 {
|
|
||||||
cfg.Pricing.FreshnessYellowDays = 60
|
|
||||||
}
|
|
||||||
if cfg.Pricing.FreshnessRedDays == 0 {
|
|
||||||
cfg.Pricing.FreshnessRedDays = 90
|
|
||||||
}
|
|
||||||
if cfg.Pricing.MinQuotesForMedian == 0 {
|
|
||||||
cfg.Pricing.MinQuotesForMedian = 3
|
|
||||||
}
|
|
||||||
if cfg.Backup.Time == "" {
|
if cfg.Backup.Time == "" {
|
||||||
cfg.Backup.Time = "00:00"
|
cfg.Backup.Time = "00:00"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# QuoteForge Configuration
|
# QuoteForge runtime config
|
||||||
# Copy this file to config.yaml and update values
|
# Runtime creates a minimal config automatically on first start.
|
||||||
|
# This file is only a reference template.
|
||||||
|
|
||||||
server:
|
server:
|
||||||
host: "127.0.0.1" # Loopback only; remote HTTP binding is unsupported
|
host: "127.0.0.1" # Loopback only; remote HTTP binding is unsupported
|
||||||
@@ -8,49 +9,10 @@ server:
|
|||||||
read_timeout: "30s"
|
read_timeout: "30s"
|
||||||
write_timeout: "30s"
|
write_timeout: "30s"
|
||||||
|
|
||||||
database:
|
|
||||||
host: "localhost"
|
|
||||||
port: 3306
|
|
||||||
name: "RFQ_LOG"
|
|
||||||
user: "quoteforge"
|
|
||||||
password: "CHANGE_ME"
|
|
||||||
max_open_conns: 25
|
|
||||||
max_idle_conns: 5
|
|
||||||
conn_max_lifetime: "5m"
|
|
||||||
|
|
||||||
pricing:
|
|
||||||
default_method: "weighted_median" # median | average | weighted_median
|
|
||||||
default_period_days: 90
|
|
||||||
freshness_green_days: 30
|
|
||||||
freshness_yellow_days: 60
|
|
||||||
freshness_red_days: 90
|
|
||||||
min_quotes_for_median: 3
|
|
||||||
popularity_decay_days: 180
|
|
||||||
|
|
||||||
export:
|
|
||||||
temp_dir: "/tmp/quoteforge-exports"
|
|
||||||
max_file_age: "1h"
|
|
||||||
company_name: "Your Company Name"
|
|
||||||
|
|
||||||
backup:
|
backup:
|
||||||
time: "00:00"
|
time: "00:00"
|
||||||
|
|
||||||
alerts:
|
|
||||||
enabled: true
|
|
||||||
check_interval: "1h"
|
|
||||||
high_demand_threshold: 5 # КП за 30 дней
|
|
||||||
trending_threshold_percent: 50 # % роста для алерта
|
|
||||||
|
|
||||||
notifications:
|
|
||||||
email_enabled: false
|
|
||||||
smtp_host: "smtp.example.com"
|
|
||||||
smtp_port: 587
|
|
||||||
smtp_user: ""
|
|
||||||
smtp_password: ""
|
|
||||||
from_address: "quoteforge@example.com"
|
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: "info" # debug | info | warn | error
|
level: "info" # debug | info | warn | error
|
||||||
format: "json" # json | text
|
format: "json" # json | text
|
||||||
output: "stdout" # stdout | file
|
output: "stdout" # stdout | stderr | /path/to/file
|
||||||
file_path: "/var/log/quoteforge/app.log"
|
|
||||||
|
|||||||
@@ -7,19 +7,14 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
Database DatabaseConfig `yaml:"database"`
|
Export ExportConfig `yaml:"export"`
|
||||||
Pricing PricingConfig `yaml:"pricing"`
|
Logging LoggingConfig `yaml:"logging"`
|
||||||
Export ExportConfig `yaml:"export"`
|
Backup BackupConfig `yaml:"backup"`
|
||||||
Alerts AlertsConfig `yaml:"alerts"`
|
|
||||||
Notifications NotificationsConfig `yaml:"notifications"`
|
|
||||||
Logging LoggingConfig `yaml:"logging"`
|
|
||||||
Backup BackupConfig `yaml:"backup"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@@ -30,64 +25,6 @@ type ServerConfig struct {
|
|||||||
WriteTimeout time.Duration `yaml:"write_timeout"`
|
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
|
||||||
Host string `yaml:"host"`
|
|
||||||
Port int `yaml:"port"`
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
User string `yaml:"user"`
|
|
||||||
Password string `yaml:"password"`
|
|
||||||
MaxOpenConns int `yaml:"max_open_conns"`
|
|
||||||
MaxIdleConns int `yaml:"max_idle_conns"`
|
|
||||||
ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DatabaseConfig) DSN() string {
|
|
||||||
cfg := mysqlDriver.NewConfig()
|
|
||||||
cfg.User = d.User
|
|
||||||
cfg.Passwd = d.Password
|
|
||||||
cfg.Net = "tcp"
|
|
||||||
cfg.Addr = net.JoinHostPort(d.Host, strconv.Itoa(d.Port))
|
|
||||||
cfg.DBName = d.Name
|
|
||||||
cfg.ParseTime = true
|
|
||||||
cfg.Loc = time.Local
|
|
||||||
cfg.Params = map[string]string{
|
|
||||||
"charset": "utf8mb4",
|
|
||||||
}
|
|
||||||
return cfg.FormatDSN()
|
|
||||||
}
|
|
||||||
|
|
||||||
type PricingConfig struct {
|
|
||||||
DefaultMethod string `yaml:"default_method"`
|
|
||||||
DefaultPeriodDays int `yaml:"default_period_days"`
|
|
||||||
FreshnessGreenDays int `yaml:"freshness_green_days"`
|
|
||||||
FreshnessYellowDays int `yaml:"freshness_yellow_days"`
|
|
||||||
FreshnessRedDays int `yaml:"freshness_red_days"`
|
|
||||||
MinQuotesForMedian int `yaml:"min_quotes_for_median"`
|
|
||||||
PopularityDecayDays int `yaml:"popularity_decay_days"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExportConfig struct {
|
|
||||||
TempDir string `yaml:"temp_dir"`
|
|
||||||
MaxFileAge time.Duration `yaml:"max_file_age"`
|
|
||||||
CompanyName string `yaml:"company_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertsConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
CheckInterval time.Duration `yaml:"check_interval"`
|
|
||||||
HighDemandThreshold int `yaml:"high_demand_threshold"`
|
|
||||||
TrendingThresholdPercent int `yaml:"trending_threshold_percent"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NotificationsConfig struct {
|
|
||||||
EmailEnabled bool `yaml:"email_enabled"`
|
|
||||||
SMTPHost string `yaml:"smtp_host"`
|
|
||||||
SMTPPort int `yaml:"smtp_port"`
|
|
||||||
SMTPUser string `yaml:"smtp_user"`
|
|
||||||
SMTPPassword string `yaml:"smtp_password"`
|
|
||||||
FromAddress string `yaml:"from_address"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoggingConfig struct {
|
type LoggingConfig struct {
|
||||||
Level string `yaml:"level"`
|
Level string `yaml:"level"`
|
||||||
Format string `yaml:"format"`
|
Format string `yaml:"format"`
|
||||||
@@ -95,6 +32,10 @@ type LoggingConfig struct {
|
|||||||
FilePath string `yaml:"file_path"`
|
FilePath string `yaml:"file_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportConfig is kept for constructor compatibility in export services.
|
||||||
|
// Runtime no longer persists an export section in config.yaml.
|
||||||
|
type ExportConfig struct{}
|
||||||
|
|
||||||
type BackupConfig struct {
|
type BackupConfig struct {
|
||||||
Time string `yaml:"time"`
|
Time string `yaml:"time"`
|
||||||
}
|
}
|
||||||
@@ -132,38 +73,6 @@ func (c *Config) setDefaults() {
|
|||||||
c.Server.WriteTimeout = 30 * time.Second
|
c.Server.WriteTimeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Database.Port == 0 {
|
|
||||||
c.Database.Port = 3306
|
|
||||||
}
|
|
||||||
if c.Database.MaxOpenConns == 0 {
|
|
||||||
c.Database.MaxOpenConns = 25
|
|
||||||
}
|
|
||||||
if c.Database.MaxIdleConns == 0 {
|
|
||||||
c.Database.MaxIdleConns = 5
|
|
||||||
}
|
|
||||||
if c.Database.ConnMaxLifetime == 0 {
|
|
||||||
c.Database.ConnMaxLifetime = 5 * time.Minute
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Pricing.DefaultMethod == "" {
|
|
||||||
c.Pricing.DefaultMethod = "weighted_median"
|
|
||||||
}
|
|
||||||
if c.Pricing.DefaultPeriodDays == 0 {
|
|
||||||
c.Pricing.DefaultPeriodDays = 90
|
|
||||||
}
|
|
||||||
if c.Pricing.FreshnessGreenDays == 0 {
|
|
||||||
c.Pricing.FreshnessGreenDays = 30
|
|
||||||
}
|
|
||||||
if c.Pricing.FreshnessYellowDays == 0 {
|
|
||||||
c.Pricing.FreshnessYellowDays = 60
|
|
||||||
}
|
|
||||||
if c.Pricing.FreshnessRedDays == 0 {
|
|
||||||
c.Pricing.FreshnessRedDays = 90
|
|
||||||
}
|
|
||||||
if c.Pricing.MinQuotesForMedian == 0 {
|
|
||||||
c.Pricing.MinQuotesForMedian = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Logging.Level == "" {
|
if c.Logging.Level == "" {
|
||||||
c.Logging.Level = "info"
|
c.Logging.Level = "info"
|
||||||
}
|
}
|
||||||
|
|||||||
41
memory.md
41
memory.md
@@ -1,41 +0,0 @@
|
|||||||
# Changes summary (2026-02-11)
|
|
||||||
|
|
||||||
Implemented strict `lot_category` flow using `pricelist_items.lot_category` only (no parsing from `lot_name`), plus local caching and backfill:
|
|
||||||
|
|
||||||
1. Local DB schema + migrations
|
|
||||||
- Added `lot_category` column to `local_pricelist_items` via `LocalPricelistItem` model.
|
|
||||||
- Added local migration `2026_02_11_local_pricelist_item_category` to add the column if missing and create indexes:
|
|
||||||
- `idx_local_pricelist_items_pricelist_lot (pricelist_id, lot_name)`
|
|
||||||
- `idx_local_pricelist_items_lot_category (lot_category)`
|
|
||||||
|
|
||||||
2. Server model/repository
|
|
||||||
- Added `LotCategory` field to `models.PricelistItem`.
|
|
||||||
- `PricelistRepository.GetItems` now sets `Category` from `LotCategory` (no parsing from `lot_name`).
|
|
||||||
|
|
||||||
3. Sync + local DB helpers
|
|
||||||
- `SyncPricelistItems` now saves `lot_category` into local cache via `PricelistItemToLocal`.
|
|
||||||
- Added `LocalDB.CountLocalPricelistItemsWithEmptyCategory` and `LocalDB.ReplaceLocalPricelistItems`.
|
|
||||||
- Added `LocalDB.GetLocalLotCategoriesByServerPricelistID` for strict category lookup.
|
|
||||||
- Added `SyncPricelists` backfill step: for used active pricelists with empty categories, force refresh items from server.
|
|
||||||
|
|
||||||
4. API handler
|
|
||||||
- `GET /api/pricelists/:id/items` returns `category` from `local_pricelist_items.lot_category` (no parsing from `lot_name`).
|
|
||||||
|
|
||||||
5. Article category foundation
|
|
||||||
- New package `internal/article`:
|
|
||||||
- `ResolveLotCategoriesStrict` pulls categories from local pricelist items and errors on missing category.
|
|
||||||
- `GroupForLotCategory` maps only allowed codes (CPU/MEM/GPU/M2/SSD/HDD/EDSFF/HHHL/NIC/HCA/DPU/PSU/PS) to article groups; excludes `SFP`.
|
|
||||||
- Error type `MissingCategoryForLotError` with base `ErrMissingCategoryForLot`.
|
|
||||||
|
|
||||||
6. Tests
|
|
||||||
- Added unit tests for converters and article category resolver.
|
|
||||||
- Added handler test to ensure `/api/pricelists/:id/items` returns `lot_category`.
|
|
||||||
- Added sync test for category backfill on used pricelist items.
|
|
||||||
- `go test ./...` passed.
|
|
||||||
|
|
||||||
Additional fixes (2026-02-11):
|
|
||||||
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
|
|
||||||
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).
|
|
||||||
|
|
||||||
UI rule (2026-02-19):
|
|
||||||
- In all breadcrumbs, truncate long specification/configuration names to 16 characters using ellipsis.
|
|
||||||
18
releases/README.md
Normal file
18
releases/README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Releases
|
||||||
|
|
||||||
|
This directory stores packaged release artifacts and per-release notes.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- keep release notes next to the matching release directory as `RELEASE_NOTES.md`;
|
||||||
|
- do not keep duplicate changelog memory files elsewhere in the repository;
|
||||||
|
- if a release directory has no notes yet, add them there instead of creating side documents.
|
||||||
|
|
||||||
|
Current convention:
|
||||||
|
|
||||||
|
```text
|
||||||
|
releases/
|
||||||
|
v1.5.0/
|
||||||
|
RELEASE_NOTES.md
|
||||||
|
SHA256SUMS.txt
|
||||||
|
qfs-...
|
||||||
|
```
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# v1.2.1 Release Notes
|
|
||||||
|
|
||||||
**Date:** 2026-02-09
|
|
||||||
**Changes since v1.2.0:** 2 commits
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Fixed configurator component substitution by updating to work with new pricelist-based pricing model. Addresses regression from v1.2.0 refactor that removed `CurrentPrice` field from components.
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
|
|
||||||
### 1. Refactor: Remove CurrentPrice from local_components (5984a57)
|
|
||||||
**Type:** Refactor
|
|
||||||
**Files Changed:** 11 files, +167 insertions, -194 deletions
|
|
||||||
|
|
||||||
#### Overview
|
|
||||||
Transitioned from component-based pricing to pricelist-based pricing model:
|
|
||||||
- Removed `CurrentPrice` and `SyncedAt` from LocalComponent (metadata-only now)
|
|
||||||
- Added `WarehousePricelistID` and `CompetitorPricelistID` to LocalConfiguration
|
|
||||||
- Removed 2 unused methods: UpdateComponentPricesFromPricelist, EnsureComponentPricesFromPricelists
|
|
||||||
|
|
||||||
#### Key Changes
|
|
||||||
- **Data Model:**
|
|
||||||
- LocalComponent: now stores only metadata (LotName, LotDescription, Category, Model)
|
|
||||||
- LocalConfiguration: added warehouse and competitor pricelist references
|
|
||||||
|
|
||||||
- **Migrations:**
|
|
||||||
- drop_component_unused_fields - removes CurrentPrice, SyncedAt columns
|
|
||||||
- add_warehouse_competitor_pricelists - adds new pricelist fields
|
|
||||||
|
|
||||||
- **Quote Calculation:**
|
|
||||||
- Updated to use pricelist_items instead of component.CurrentPrice
|
|
||||||
- Added PricelistID field to QuoteRequest
|
|
||||||
- Maintains offline-first behavior
|
|
||||||
|
|
||||||
- **API:**
|
|
||||||
- Removed CurrentPrice from ComponentView
|
|
||||||
- Components API no longer returns pricing
|
|
||||||
|
|
||||||
### 2. Fix: Load component prices via API (acf7c8a)
|
|
||||||
**Type:** Bug Fix
|
|
||||||
**Files Changed:** 1 file (web/templates/index.html), +66 insertions, -12 deletions
|
|
||||||
|
|
||||||
#### Problem
|
|
||||||
After v1.2.0 refactor, the configurator's autocomplete was filtering out all components because it checked for the removed `current_price` field on component objects.
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
Implemented on-demand price loading via API:
|
|
||||||
- Added `ensurePricesLoaded()` function to fetch prices from `/api/quote/price-levels`
|
|
||||||
- Added `componentPricesCache` to cache loaded prices in memory
|
|
||||||
- Updated all 3 autocomplete modes (single, multi, section) to load prices when input is focused
|
|
||||||
- Changed price validation from `c.current_price` to `hasComponentPrice(lot_name)`
|
|
||||||
- Updated cart item creation to use cached API prices
|
|
||||||
|
|
||||||
#### Impact
|
|
||||||
- Components without prices are still filtered out (as required)
|
|
||||||
- Price checks now use API data instead of removed database field
|
|
||||||
- Frontend loads prices on-demand for better performance
|
|
||||||
|
|
||||||
## Testing Notes
|
|
||||||
- ✅ Configurator component substitution now works
|
|
||||||
- ✅ Prices load correctly from pricelist
|
|
||||||
- ✅ Offline mode still supported (prices cached after initial load)
|
|
||||||
- ✅ Multi-pricelist support functional (estimate/warehouse/competitor)
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
None
|
|
||||||
|
|
||||||
## Migration Path
|
|
||||||
No database migration needed from v1.2.0 - migrations were applied in v1.2.0 release.
|
|
||||||
|
|
||||||
## Breaking Changes
|
|
||||||
None for end users. Internal: `ComponentView` no longer includes `CurrentPrice` in API responses.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Release v1.2.2 (2026-02-09)
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Fixed CSV export filename inconsistency where project names weren't being resolved correctly. Standardized export format across both manual exports and project configuration exports to use `YYYY-MM-DD (project_name) config_name BOM.csv`.
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
|
|
||||||
- `8f596ce` fix: standardize CSV export filename format to use project name
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
### CSV Export Filename Standardization
|
|
||||||
|
|
||||||
**Problem:**
|
|
||||||
- ExportCSV and ExportConfigCSV had inconsistent filename formats
|
|
||||||
- Project names sometimes fell back to config names when not explicitly provided
|
|
||||||
- Export timestamps didn't reflect actual price update time
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Unified format: `YYYY-MM-DD (project_name) config_name BOM.csv`
|
|
||||||
- Both export paths now use PriceUpdatedAt if available, otherwise CreatedAt
|
|
||||||
- Project name resolved from ProjectUUID via ProjectService for both paths
|
|
||||||
- Frontend passes project_uuid context when exporting
|
|
||||||
|
|
||||||
**Technical Details:**
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
- Added `ProjectUUID` field to `ExportRequest` struct in handlers/export.go
|
|
||||||
- Updated ExportCSV to look up project name from ProjectUUID using ProjectService
|
|
||||||
- Ensured ExportConfigCSV gets project name from config's ProjectUUID
|
|
||||||
- Both use CreatedAt (for ExportCSV) or PriceUpdatedAt/CreatedAt (for ExportConfigCSV)
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
- Added `projectUUID` and `projectName` state variables in index.html
|
|
||||||
- Load and store projectUUID when configuration is loaded
|
|
||||||
- Pass `project_uuid` in JSON body for both export requests
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
- `internal/handlers/export.go` - Project name resolution and ExportRequest update
|
|
||||||
- `internal/handlers/export_test.go` - Updated mock initialization with projectService param
|
|
||||||
- `cmd/qfs/main.go` - Pass projectService to ExportHandler constructor
|
|
||||||
- `web/templates/index.html` - Add projectUUID tracking and export payload updates
|
|
||||||
|
|
||||||
## Testing Notes
|
|
||||||
|
|
||||||
✅ All existing tests updated and passing
|
|
||||||
✅ Code builds without errors
|
|
||||||
✅ Export filename now includes correct project name
|
|
||||||
✅ Works for both form-based and project-based exports
|
|
||||||
|
|
||||||
## Breaking Changes
|
|
||||||
|
|
||||||
None - API response format unchanged, only filename generation updated.
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
|
|
||||||
None identified.
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# Release v1.2.3 (2026-02-10)
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Unified synchronization functionality with event-driven UI updates. Resolved user confusion about duplicate sync buttons by implementing a single sync source with automatic page refreshes.
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
### Main Feature: Sync Event System
|
|
||||||
|
|
||||||
- **Added `sync-completed` event** in base.html's `syncAction()` function
|
|
||||||
- Dispatched after successful `/api/sync/all` or `/api/sync/push`
|
|
||||||
- Includes endpoint and response data in event detail
|
|
||||||
- Enables pages to react automatically to sync completion
|
|
||||||
|
|
||||||
### Configs Page (`configs.html`)
|
|
||||||
|
|
||||||
- **Removed "Импорт с сервера" button** - duplicate functionality no longer needed
|
|
||||||
- **Updated layout** - changed from 2-column grid to single button layout
|
|
||||||
- **Removed `importConfigsFromServer()` function** - functionality now handled by navbar sync
|
|
||||||
- **Added sync-completed event listener**:
|
|
||||||
- Automatically reloads configurations list after sync
|
|
||||||
- Resets pagination to first page
|
|
||||||
- New configurations appear immediately without manual refresh
|
|
||||||
|
|
||||||
### Projects Page (`projects.html`)
|
|
||||||
|
|
||||||
- **Wrapped initialization in DOMContentLoaded**:
|
|
||||||
- Moved `loadProjects()` and all event listeners inside handler
|
|
||||||
- Ensures DOM is fully loaded before accessing elements
|
|
||||||
- **Added sync-completed event listener**:
|
|
||||||
- Automatically reloads projects list after sync
|
|
||||||
- New projects appear immediately without manual refresh
|
|
||||||
|
|
||||||
### Pricelists Page (`pricelists.html`)
|
|
||||||
|
|
||||||
- **Added sync-completed event listener** to existing DOMContentLoaded:
|
|
||||||
- Automatically reloads pricelists when sync completes
|
|
||||||
- Maintains existing permissions and modal functionality
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
### User Experience
|
|
||||||
- ✅ Single "Синхронизация" button in navbar - no confusion about sync sources
|
|
||||||
- ✅ Automatic list updates after sync - no need for manual F5 refresh
|
|
||||||
- ✅ Consistent behavior across all pages (configs, projects, pricelists)
|
|
||||||
- ✅ Better feedback: toast notification + automatic UI refresh
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
- ✅ Event-driven loose coupling between navbar and pages
|
|
||||||
- ✅ Easy to extend to other pages (just add event listener)
|
|
||||||
- ✅ No backend changes needed
|
|
||||||
- ✅ Production-ready
|
|
||||||
|
|
||||||
## Breaking Changes
|
|
||||||
|
|
||||||
- **`/api/configs/import` endpoint** still works but UI button removed
|
|
||||||
- Users should use navbar "Синхронизация" button instead
|
|
||||||
- Backend API remains unchanged for backward compatibility
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
1. `web/templates/base.html` - Added sync-completed event dispatch
|
|
||||||
2. `web/templates/configs.html` - Event listener + removed duplicate UI
|
|
||||||
3. `web/templates/projects.html` - DOMContentLoaded wrapper + event listener
|
|
||||||
4. `web/templates/pricelists.html` - Event listener for auto-refresh
|
|
||||||
|
|
||||||
**Stats:** 4 files changed, 59 insertions(+), 65 deletions(-)
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
|
|
||||||
- `99fd80b` - feat: unify sync functionality with event-driven UI updates
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [x] Configs page: New configurations appear after navbar sync
|
|
||||||
- [x] Projects page: New projects appear after navbar sync
|
|
||||||
- [x] Pricelists page: Pricelists refresh after navbar sync
|
|
||||||
- [x] Both `/api/sync/all` and `/api/sync/push` trigger updates
|
|
||||||
- [x] Toast notifications still show correctly
|
|
||||||
- [x] Sync status indicator updates
|
|
||||||
- [x] Error handling (423, network errors) still works
|
|
||||||
- [x] Mode switching (Active/Archive) works correctly
|
|
||||||
- [x] Backward compatibility maintained
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
|
|
||||||
None - implementation is production-ready
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
No migration needed. Changes are frontend-only and backward compatible:
|
|
||||||
- Old `/api/configs/import` endpoint still functional
|
|
||||||
- No database schema changes
|
|
||||||
- No configuration changes needed
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# Release v1.3.0 (2026-02-11)
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Introduced article generation with pricelist categories, added local configuration storage, and expanded sync/export capabilities. Simplified article generator compression and loosened project update constraints.
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
### Main Features: Articles + Pricelist Categories
|
|
||||||
|
|
||||||
- **Article generation pipeline**
|
|
||||||
- New generator and tests under `internal/article/`
|
|
||||||
- Category support with test coverage
|
|
||||||
- **Pricelist category integration**
|
|
||||||
- Handler and repository updates
|
|
||||||
- Sync backfill test for category propagation
|
|
||||||
|
|
||||||
### Local Configuration Storage
|
|
||||||
|
|
||||||
- **Local DB support**
|
|
||||||
- New localdb models, converters, snapshots, and migrations
|
|
||||||
- Local configuration service for cached configurations
|
|
||||||
|
|
||||||
### Export & UI
|
|
||||||
|
|
||||||
- **Export handler updates** for article data output
|
|
||||||
- **Configs and index templates** adjusted for new article-related fields
|
|
||||||
|
|
||||||
### Behavior Changes
|
|
||||||
|
|
||||||
- **Cross-user project updates allowed**
|
|
||||||
- Removed restriction in project service
|
|
||||||
- **Article compression refinement**
|
|
||||||
- Generator logic simplified to reduce complexity
|
|
||||||
|
|
||||||
## Breaking Changes
|
|
||||||
|
|
||||||
None identified. Existing APIs remain intact.
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
1. `internal/article/*` - Article generator + categories + tests
|
|
||||||
2. `internal/localdb/*` - Local DB models, migrations, snapshots
|
|
||||||
3. `internal/handlers/export.go` - Export updates
|
|
||||||
4. `internal/handlers/pricelist.go` - Category handling
|
|
||||||
5. `internal/services/sync/service.go` - Category backfill logic
|
|
||||||
6. `web/templates/configs.html` - Article field updates
|
|
||||||
7. `web/templates/index.html` - Article field updates
|
|
||||||
|
|
||||||
**Stats:** 33 files changed, 2059 insertions(+), 329 deletions(-)
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
|
|
||||||
- `5edffe8` - Add article generation and pricelist categories
|
|
||||||
- `e355903` - Allow cross-user project updates
|
|
||||||
- `e58fd35` - Refine article compression and simplify generator
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Tests not run (not requested)
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
- New migrations:
|
|
||||||
- `022_add_article_to_configurations.sql`
|
|
||||||
- `023_add_server_model_to_configurations.sql`
|
|
||||||
- `024_add_support_code_to_configurations.sql`
|
|
||||||
- Ensure migrations are applied before running v1.3.0
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Release v1.3.2 (2026-02-19)
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Release focuses on stability and data integrity for local configurations. Added configuration revision history, stronger recovery for broken local sync/version states, improved sync self-healing, and clearer API error logging.
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
### Configuration Revisions
|
|
||||||
|
|
||||||
- Added full local configuration revision flow with storage and UI support.
|
|
||||||
- Introduced revisions page/template and backend plumbing for browsing revisions.
|
|
||||||
- Prevented duplicate revisions when content did not actually change.
|
|
||||||
|
|
||||||
### Local Data Integrity and Recovery
|
|
||||||
|
|
||||||
- Added migration and snapshot support for local configuration version data.
|
|
||||||
- Hardened updates for legacy/orphaned configuration rows:
|
|
||||||
- allow update when project UUID is unchanged even if referenced project is missing locally;
|
|
||||||
- recover gracefully when `current_version_id` is stale or version rows are missing.
|
|
||||||
- Added regression tests for orphan-project and missing-current-version scenarios.
|
|
||||||
|
|
||||||
### Sync Reliability
|
|
||||||
|
|
||||||
- Added smart self-healing path for sync errors.
|
|
||||||
- Fixed duplicate-project sync edge cases.
|
|
||||||
|
|
||||||
### API and Logging
|
|
||||||
|
|
||||||
- Improved HTTP error mapping for configuration updates (`404/403` instead of generic `500` in known cases).
|
|
||||||
- Enhanced request logger to capture error responses (status, response body snippet, gin errors) for failed requests.
|
|
||||||
|
|
||||||
### UI and Export
|
|
||||||
|
|
||||||
- Updated project detail and index templates for revisions and related UX improvements.
|
|
||||||
- Updated export pipeline and tests to align with revisions/project behavior changes.
|
|
||||||
|
|
||||||
## Breaking Changes
|
|
||||||
|
|
||||||
None identified.
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
- 24 files changed, 2394 insertions(+), 482 deletions(-)
|
|
||||||
- Main touched areas:
|
|
||||||
- `internal/services/local_configuration.go`
|
|
||||||
- `internal/services/local_configuration_versioning_test.go`
|
|
||||||
- `internal/localdb/{localdb.go,migrations.go,snapshots.go,local_migrations_test.go}`
|
|
||||||
- `internal/services/export.go`
|
|
||||||
- `cmd/qfs/main.go`
|
|
||||||
- `web/templates/{config_revisions.html,project_detail.html,index.html,base.html}`
|
|
||||||
|
|
||||||
## Commits Included (`v1.3.1..v1.3.2`)
|
|
||||||
|
|
||||||
- `b153afb` - Add smart self-healing for sync errors
|
|
||||||
- `8508ee2` - Fix sync errors for duplicate projects and add modal scrolling
|
|
||||||
- `2e973b6` - Add configuration revisions system and project variant deletion
|
|
||||||
- `71f73e2` - chore: save current changes
|
|
||||||
- `cbaeafa` - Deduplicate configuration revisions and update revisions UI
|
|
||||||
- `075fc70` - Harden local config updates and error logging
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- [x] Targeted tests for local configuration update/version recovery:
|
|
||||||
- `go test ./internal/services -run 'TestUpdateNoAuth(AllowsOrphanProjectWhenUUIDUnchanged|RecoversWhenCurrentVersionMissing|KeepsProjectWhenProjectUUIDOmitted)$'`
|
|
||||||
- [ ] Full regression suite not run in this release step.
|
|
||||||
@@ -1,89 +1,20 @@
|
|||||||
# QuoteForge v1.2.1
|
# QuoteForge v1.2.1
|
||||||
|
|
||||||
**Дата релиза:** 2026-02-09
|
Дата релиза: 2026-02-09
|
||||||
**Тег:** `v1.2.1`
|
Тег: `v1.2.1`
|
||||||
**GitHub:** https://git.mchus.pro/mchus/QuoteForge/releases/tag/v1.2.1
|
|
||||||
|
|
||||||
## Резюме
|
## Ключевые изменения
|
||||||
|
|
||||||
Быстрый патч-релиз, исправляющий регрессию в конфигураторе после рефактора v1.2.0. После удаления поля `CurrentPrice` из компонентов, autocomplete перестал показывать компоненты. Теперь используется на-demand загрузка цен через API.
|
- исправлена регрессия autocomplete после отказа от `CurrentPrice` в компонентах;
|
||||||
|
- цены компонентов подгружаются через `/api/quote/price-levels`;
|
||||||
|
- подготовлена сопровождающая release documentation.
|
||||||
|
|
||||||
## Что исправлено
|
## Коммиты релиза
|
||||||
|
|
||||||
### 🐛 Configurator Component Substitution (acf7c8a)
|
- `acf7c8a` fix: load component prices via API instead of removed current_price field
|
||||||
- **Проблема:** После рефактора в v1.2.0, autocomplete фильтровал ВСЕ компоненты, потому что проверял удаленное поле `current_price`
|
- `5984a57` refactor: remove CurrentPrice from local_components and transition to pricelist-based pricing
|
||||||
- **Решение:** Загрузка цен на-demand через `/api/quote/price-levels`
|
- `8fd27d1` docs: update v1.2.1 release notes with full changelog
|
||||||
- Добавлен `componentPricesCache` для кэширования цен в памяти
|
|
||||||
- Функция `ensurePricesLoaded()` загружает цены при фокусе на поле поиска
|
|
||||||
- Все 3 режима autocomplete (single, multi, section) обновлены
|
|
||||||
- Компоненты без цен по-прежнему фильтруются (как требуется), но проверка использует API
|
|
||||||
- **Затронутые файлы:** `web/templates/index.html` (+66 строк, -12 строк)
|
|
||||||
|
|
||||||
## История v1.2.0 → v1.2.1
|
## Совместимость
|
||||||
|
|
||||||
Всего коммитов: **2**
|
- дополнительных миграций поверх `v1.2.0` не требуется.
|
||||||
|
|
||||||
| Хеш | Автор | Сообщение |
|
|
||||||
|-----|-------|-----------|
|
|
||||||
| `acf7c8a` | Claude | fix: load component prices via API instead of removed current_price field |
|
|
||||||
| `5984a57` | Claude | refactor: remove CurrentPrice from local_components and transition to pricelist-based pricing |
|
|
||||||
|
|
||||||
## Тестирование
|
|
||||||
|
|
||||||
✅ Configurator component substitution работает
|
|
||||||
✅ Цены загружаются корректно из pricelist
|
|
||||||
✅ Offline режим поддерживается (цены кэшируются после первой загрузки)
|
|
||||||
✅ Multi-pricelist поддержка функциональна (estimate/warehouse/competitor)
|
|
||||||
|
|
||||||
## Breaking Changes
|
|
||||||
|
|
||||||
Нет критических изменений для конечных пользователей.
|
|
||||||
|
|
||||||
⚠️ **Для разработчиков:** `ComponentView` API больше не возвращает `CurrentPrice`.
|
|
||||||
|
|
||||||
## Миграция
|
|
||||||
|
|
||||||
Не требуется миграция БД — все миграции были применены в v1.2.0.
|
|
||||||
|
|
||||||
## Установка
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Скачать и распаковать
|
|
||||||
tar xzf qfs-v1.2.1-darwin-arm64.tar.gz # для Apple Silicon
|
|
||||||
# или
|
|
||||||
tar xzf qfs-v1.2.1-darwin-amd64.tar.gz # для Intel Mac
|
|
||||||
|
|
||||||
# Снять ограничение Gatekeeper (если требуется)
|
|
||||||
xattr -d com.apple.quarantine ./qfs
|
|
||||||
|
|
||||||
# Запустить
|
|
||||||
./qfs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tar xzf qfs-v1.2.1-linux-amd64.tar.gz
|
|
||||||
./qfs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Распаковать qfs-v1.2.1-windows-amd64.zip
|
|
||||||
# Запустить qfs.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
## Известные проблемы
|
|
||||||
|
|
||||||
Нет известных проблем на момент релиза.
|
|
||||||
|
|
||||||
## Поддержка
|
|
||||||
|
|
||||||
По вопросам обращайтесь: [@mchus](https://git.mchus.pro/mchus)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Отправлено с ❤️ через Claude Code*
|
|
||||||
|
|||||||
25
releases/v1.5.3/RELEASE_NOTES.md
Normal file
25
releases/v1.5.3/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# QuoteForge v1.5.3
|
||||||
|
|
||||||
|
Дата релиза: 2026-03-15
|
||||||
|
Тег: `v1.5.3`
|
||||||
|
|
||||||
|
## Ключевые изменения
|
||||||
|
|
||||||
|
- документация проекта очищена и приведена к одному формату;
|
||||||
|
- `bible-local/` сокращён до актуальных архитектурных контрактов без исторического шума;
|
||||||
|
- удалены временные заметки и дублирующий changelog в `releases/memory`;
|
||||||
|
- runtime config упрощён: из активной схемы убраны мёртвые секции, оставлены только используемые части.
|
||||||
|
|
||||||
|
## Затронутые области
|
||||||
|
|
||||||
|
- корневой `README.md`;
|
||||||
|
- весь `bible-local/`;
|
||||||
|
- `config.example.yaml`;
|
||||||
|
- `internal/config/config.go`;
|
||||||
|
- release notes и правила их хранения в `releases/`.
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
- релиз не меняет пользовательскую модель данных;
|
||||||
|
- локальные и серверные миграции не требуются;
|
||||||
|
- основное изменение касается документации и формы runtime-конфига.
|
||||||
78
todo.md
78
todo.md
@@ -1,78 +0,0 @@
|
|||||||
# QuoteForge — План очистки (удаление admin pricing)
|
|
||||||
|
|
||||||
Цель: убрать всё, что связано с администрированием цен, складскими справками, алертами.
|
|
||||||
Оставить: конфигуратор, проекты, read-only просмотр прайслистов, sync, offline-first.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Удалить файлы
|
|
||||||
|
|
||||||
- [x] `internal/handlers/pricing.go` (40.6KB) — весь admin pricing UI
|
|
||||||
- [x] `internal/services/pricing/` — весь пакет расчёта цен
|
|
||||||
- [x] `internal/services/pricelist/` — весь пакет управления прайслистами
|
|
||||||
- [x] `internal/services/stock_import.go` — импорт складских справок
|
|
||||||
- [x] `internal/services/alerts/` — весь пакет алертов
|
|
||||||
- [x] `internal/warehouse/` — алгоритмы расчёта цен по складу
|
|
||||||
- [x] `web/templates/admin_pricing.html` (109KB) — страница admin pricing
|
|
||||||
- [x] `cmd/cron/` — cron jobs (cleanup-pricelists, update-prices, update-popularity)
|
|
||||||
- [x] `cmd/importer/` — утилита импорта данных
|
|
||||||
|
|
||||||
## 2. Упростить `internal/handlers/pricelist.go` (read-only)
|
|
||||||
|
|
||||||
Read-only методы (List, Get, GetItems, GetLotNames, GetLatest) уже работают
|
|
||||||
только через `h.localDB` (SQLite) без `pricelist.Service`.
|
|
||||||
|
|
||||||
- [x] Убрать поле `service *pricelist.Service` из структуры `PricelistHandler`
|
|
||||||
- [x] Изменить конструктор: `NewPricelistHandler(localDB *localdb.LocalDB)`
|
|
||||||
- [x] Удалить write-методы: `Create()`, `CreateWithProgress()`, `Delete()`, `SetActive()`, `CanWrite()`
|
|
||||||
- [x] Удалить метод `refreshLocalPricelistCacheFromServer()` (зависит от service)
|
|
||||||
- [x] Удалить import `pricelist` пакета
|
|
||||||
- [x] Оставить: `List()`, `Get()`, `GetItems()`, `GetLotNames()`, `GetLatest()`
|
|
||||||
|
|
||||||
## 3. Упростить `cmd/qfs/main.go`
|
|
||||||
|
|
||||||
- [x] Удалить создание сервисов: `pricingService`, `alertService`, `pricelistService`, `stockImportService`
|
|
||||||
- [x] Удалить хэндлер: `pricingHandler`
|
|
||||||
- [x] Изменить создание `pricelistHandler`: `NewPricelistHandler(local)` (без service)
|
|
||||||
- [x] Удалить repositories: `priceRepo`, `alertRepo` (statsRepo оставить — nil-safe)
|
|
||||||
- [x] Удалить все routes `/api/admin/pricing/*` (строки ~1407-1430)
|
|
||||||
- [x] Из `/api/pricelists/*` оставить только read-only:
|
|
||||||
- `GET ""` (List), `GET "/latest"`, `GET "/:id"`, `GET "/:id/items"`, `GET "/:id/lots"`
|
|
||||||
- [x] Удалить write routes: `POST ""`, `POST "/create-with-progress"`, `PATCH "/:id/active"`, `DELETE "/:id"`, `GET "/can-write"`
|
|
||||||
- [x] Удалить web page `/admin/pricing`
|
|
||||||
- [x] Исправить `/pricelists` — вместо redirect на admin/pricing сделать страницу
|
|
||||||
- [x] В `QuoteService` конструкторе: передавать `nil` для `pricingService`
|
|
||||||
- [x] Удалить imports: `pricing`, `pricelist`, `alerts` пакеты
|
|
||||||
|
|
||||||
## 4. Упростить `handlers/web.go`
|
|
||||||
|
|
||||||
- [x] Удалить из `simplePages`: `admin_pricing.html`
|
|
||||||
- [x] Удалить метод: `AdminPricing()`
|
|
||||||
- [x] Оставить все остальные методы включая `Pricelists()` и `PricelistDetail()`
|
|
||||||
|
|
||||||
## 5. Упростить `base.html` (навигация)
|
|
||||||
|
|
||||||
- [x] Убрать ссылку "Администратор цен"
|
|
||||||
- [x] Добавить ссылку "Прайслисты" (на `/pricelists`)
|
|
||||||
- [x] Оставить: "Мои проекты", "Прайслисты", sync indicator
|
|
||||||
|
|
||||||
## 6. Sync — оставить полностью
|
|
||||||
|
|
||||||
- Background worker: pull компоненты + прайслисты, push конфигурации
|
|
||||||
- Все `/api/sync/*` endpoints остаются
|
|
||||||
- Это ядро offline-first архитектуры
|
|
||||||
|
|
||||||
## 7. Верификация
|
|
||||||
|
|
||||||
- [x] `go build ./cmd/qfs` — компилируется
|
|
||||||
- [x] `go vet ./...` — без ошибок
|
|
||||||
- [ ] Запуск → `/configs` работает
|
|
||||||
- [ ] `/pricelists` — read-only список работает
|
|
||||||
- [ ] `/pricelists/:id` — детали работают
|
|
||||||
- [ ] Sync с сервером работает
|
|
||||||
- [ ] Нет ссылок на admin pricing в UI
|
|
||||||
|
|
||||||
## 8. Обновить CLAUDE.md
|
|
||||||
- [x] Убрать разделы про admin pricing, stock import, alerts, cron
|
|
||||||
- [x] Обновить API endpoints список
|
|
||||||
- [x] Обновить описание приложения
|
|
||||||
Reference in New Issue
Block a user