Add shared bible submodule, rename local bible to bible-local

- Add bible.git as submodule at bible/
- Rename bible/ → bible-local/ (project-specific architecture)
- Update CLAUDE.md to reference both bible/ and bible-local/
- Add AGENTS.md for Codex with same structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 16:38:01 +03:00
parent 6e0335af7c
commit eb7c3739ce
15 changed files with 33 additions and 18 deletions

119
bible-local/01-overview.md Normal file
View File

@@ -0,0 +1,119 @@
# 01 — Product Overview
## What is QuoteForge
A corporate server configuration and quotation tool.
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only for synchronization.
---
## Features
### For Users
- Mobile-first interface — works comfortably on phones and tablets
- Server configurator — step-by-step component selection
- Automatic price calculation — based on pricelists from local cache
- CSV export — ready-to-use specifications for clients
- Configuration history — versioned snapshots with rollback support
- Full offline operation — continue working without network, sync later
- Guarded synchronization — sync is blocked by preflight check if local schema is not ready
### User Roles
| Role | Permissions |
|------|-------------|
| `viewer` | View, create quotes, export |
| `editor` | + save configurations |
| `pricing_admin` | + manage prices and alerts |
| `admin` | Full access, user management |
### Price Freshness Indicators
| Color | Status | Condition |
|-------|--------|-----------|
| Green | Fresh | < 30 days, ≥ 3 sources |
| Yellow | Normal | 3060 days |
| Orange | Aging | 6090 days |
| Red | Stale | > 90 days or no data |
---
## Tech Stack
| Layer | Stack |
|-------|-------|
| Backend | Go 1.22+, Gin, GORM |
| Frontend | HTML, Tailwind CSS, htmx |
| Local DB | SQLite (`qfs.db`) |
| Server DB | MariaDB 11+ (sync + server admin) |
| Export | encoding/csv, excelize (XLSX) |
---
## Product Scope
**In scope:**
- Component configurator and quotation calculation
- Projects and configurations
- Read-only pricelist viewing from local cache
- Sync (pull components/pricelists, push local changes)
**Out of scope (removed intentionally — do not restore):**
- Admin pricing UI/API
- Stock import
- Alerts
- Cron/importer utilities
---
## Repository Structure
```
quoteforge/
├── cmd/
│ ├── qfs/main.go # HTTP server entry point
│ ├── migrate/ # Migration tool
│ └── migrate_ops_projects/ # OPS project migrator
├── internal/
│ ├── appmeta/ # App version metadata
│ ├── appstate/ # State management, backup
│ ├── article/ # Article generation
│ ├── config/ # Config parsing
│ ├── db/ # DB initialization
│ ├── handlers/ # HTTP handlers
│ ├── localdb/ # SQLite layer
│ ├── lotmatch/ # Lot matching logic
│ ├── middleware/ # Auth, CORS, etc.
│ ├── models/ # GORM models
│ ├── repository/ # Repository layer
│ └── services/ # Business logic
├── web/
│ ├── templates/ # HTML templates + partials
│ └── static/ # CSS, JS, assets
├── migrations/ # SQL migration files (30+)
├── bible/ # Architectural documentation (this section)
├── releases/memory/ # Per-version changelogs
├── config.example.yaml # Config template (the only one in repo)
└── go.mod
```
---
## Integration with Existing DB
QuoteForge integrates with the existing `RFQ_LOG` database:
**Read-only:**
- `lot` — component catalog
- `qt_lot_metadata` — extended component data
- `qt_categories` — categories
- `qt_pricelists`, `qt_pricelist_items` — pricelists
**Read + Write:**
- `qt_configurations` — configurations
- `qt_projects` — projects
**Sync service tables:**
- `qt_client_local_migrations` — migration catalog (SELECT only)
- `qt_client_schema_state` — applied migrations state
- `qt_pricelist_sync_status` — pricelist sync status

View File

@@ -0,0 +1,220 @@
# 02 — Architecture
## Local-First Principle
**SQLite** is the single source of truth for the user.
**MariaDB** is a sync server only — it never blocks local operations.
```
User
SQLite (qfs.db) ← all CRUD operations go here
│ background sync (every 5 min)
MariaDB (RFQ_LOG) ← pull/push only
```
**Rules:**
- All CRUD operations go through SQLite only
- If MariaDB is unavailable → local work continues without restrictions
- Changes are queued in `pending_changes` and pushed on next sync
---
## Synchronization
### Data Flow Diagram
```
[ SERVER / MariaDB ]
┌───────────────────────────┐
│ qt_projects │
│ qt_configurations │
│ qt_pricelists │
│ qt_pricelist_items │
│ qt_pricelist_sync_status │
└─────────────┬─────────────┘
pull (projects/configs/pricelists)
┌────────────────────┴────────────────────┐
│ │
[ CLIENT A / SQLite ] [ CLIENT B / SQLite ]
local_projects local_projects
local_configurations local_configurations
local_pricelists local_pricelists
local_pricelist_items local_pricelist_items
pending_changes pending_changes
│ │
└────── push (projects/configs only) ─────┘
[ SERVER / MariaDB ]
```
### Sync Direction by Entity
| Entity | Direction |
|--------|-----------|
| Configurations | Client ↔ Server ↔ Other Clients |
| Projects | Client ↔ Server ↔ Other Clients |
| Pricelists | Server → Clients only (no push) |
| Components | Server → Clients only |
Local pricelists not present on the server and not referenced by active configurations are deleted automatically on sync.
### Soft Deletes (Archive Pattern)
Configurations and projects are **never hard-deleted**. Deletion is archive via `is_active = false`.
- `DELETE /api/configs/:uuid` → sets `is_active = false` (archived); can be restored via `reactivate`
- `DELETE /api/projects/:uuid` → archives a project **variant** only (`variant` field must be non-empty); main projects cannot be deleted via this endpoint
## Sync Readiness Guard
Before every push/pull, a preflight check runs:
1. Is the server (MariaDB) reachable?
2. Can centralized local DB migrations be applied?
3. Does the application version satisfy `min_app_version` of pending migrations?
**If the check fails:**
- Local CRUD continues without restriction
- Sync API returns `423 Locked` with `reason_code` and `reason_text`
- UI shows a red indicator with the block reason
---
## Pricing
### Principle
**Prices come only from `local_pricelist_items`.**
Components (`local_components`) are metadata-only — they contain no pricing information.
### Lookup Pattern
```go
// Look up a price for a line item
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
if found && price > 0 {
// use price
}
// Inside lookupPriceByPricelistID:
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
```
### Multi-Level Pricelists
A configuration can reference up to three pricelists simultaneously:
| Field | Purpose |
|-------|---------|
| `pricelist_id` | Primary (estimate) |
| `warehouse_pricelist_id` | Warehouse pricing |
| `competitor_pricelist_id` | Competitor pricing |
Pricelist sources: `estimate` | `warehouse` | `competitor`
### "Auto" Pricelist Selection
Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`):
- **Explicit mode:** concrete `pricelist_id` is set by user in settings.
- **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist.
`auto` must stay `auto` after price-level refresh and after manual "refresh prices":
- resolved IDs are runtime-only and must not overwrite user's mode;
- switching to explicit selection must clear runtime auto resolution for that source.
### Latest Pricelist Resolution Rules
For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with:
1. only pricelists that have at least one item (`EXISTS ...pricelist_items`);
2. deterministic sort: `created_at DESC, id DESC`.
This prevents selecting empty/incomplete snapshots and removes nondeterministic ties.
---
## Configuration Versioning
### Principle
Append-only for **spec+price** changes: immutable snapshots are stored in `local_configuration_versions`.
```
local_configurations
└── current_version_id ──► local_configuration_versions (v3) ← active
local_configuration_versions (v2)
local_configuration_versions (v1)
```
- `version_no = max + 1` when configuration **spec+price** changes
- Old versions are never modified or deleted in normal flow
- Rollback does **not** rewind history — it creates a **new** version from the snapshot
- Operational updates (`line_no` reorder, server count, project move, rename)
are synced via `pending_changes` but do **not** create a new revision snapshot
### Rollback
```bash
POST /api/configs/:uuid/rollback
{
"target_version": 3,
"note": "optional comment"
}
```
Result:
- A new version `vN` is created with `data` from the target version
- `change_note = "rollback to v{target_version}"` (+ note if provided)
- `current_version_id` is switched to the new version
- Configuration moves to `sync_status = pending`
### Sync Status Flow
```
local → pending → synced
```
---
## Project Specification Ordering (`Line`)
- Each project configuration has persistent `line_no` (`10,20,30...`) in both SQLite and MariaDB.
- Project list ordering is deterministic:
`line_no ASC`, then `created_at DESC`, then `id DESC`.
- Drag-and-drop reorder in project UI updates `line_no` for active project configurations.
- Reorder writes are queued as configuration `update` events in `pending_changes`
without creating new configuration versions.
- Backward compatibility: if remote MariaDB schema does not yet include `line_no`,
sync falls back to create/update without `line_no` instead of failing.
---
## Sync Payload for Versioning
Events in `pending_changes` for configurations contain:
| Field | Description |
|-------|-------------|
| `configuration_uuid` | Identifier |
| `operation` | `create` / `update` / `rollback` |
| `current_version_id` | Active version ID |
| `current_version_no` | Version number |
| `snapshot` | Current configuration state |
| `idempotency_key` | For idempotent push |
| `conflict_policy` | `last_write_wins` |
---
## Background Processes
| Process | Interval | What it does |
|---------|----------|--------------|
| Sync worker | 5 min | push pending + pull all |
| Backup scheduler | configurable (`backup.time`) | creates ZIP archives |

204
bible-local/03-database.md Normal file
View File

@@ -0,0 +1,204 @@
# 03 — Database
## SQLite (local, client-side)
File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
### Tables
#### Components and Reference Data
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_components` | Component metadata (NO prices) | `lot_name` (PK), `lot_description`, `category`, `model` |
| `connection_settings` | MariaDB connection settings | key-value store |
| `app_settings` | Application settings | `key` (PK), `value`, `updated_at` |
#### Pricelists
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` |
| `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` |
#### Partnumber Books (PN → LOT mapping, pull-only from PriceForge)
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_partnumber_books` | Version snapshots of PN→LOT mappings | `id`, `server_id` (unique), `version`, `created_at`, `is_active` |
| `local_partnumber_book_items` | PN→LOT mapping rows | `id`, `book_id` (FK), `partnumber`, `lot_name`, `is_primary_pn` |
Active book: `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
`is_primary_pn=1` means this PN's quantity in the vendor BOM determines qty(LOT). If only non-primary PNs are present for a LOT, qty defaults to 1.
#### Configurations and Projects
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `vendor_spec` (JSON: PN/qty/description + canonical `lot_mappings[]`), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
#### Sync
| Table | Purpose |
|-------|---------|
| `pending_changes` | Queue of changes to push to MariaDB |
| `local_schema_migrations` | Applied migrations (idempotency guard) |
---
### Key SQLite Indexes
```sql
-- Pricelists
INDEX local_pricelist_items(pricelist_id)
UNIQUE INDEX local_pricelists(server_id)
INDEX local_pricelists(source, created_at) -- used for "latest by source" queries
-- latest-by-source runtime query also applies deterministic tie-break by id DESC
-- Configurations
INDEX local_configurations(pricelist_id)
INDEX local_configurations(warehouse_pricelist_id)
INDEX local_configurations(competitor_pricelist_id)
INDEX local_configurations(project_uuid, line_no) -- project ordering (Line column)
UNIQUE INDEX local_configurations(uuid)
```
---
### `items` JSON Structure in Configurations
```json
{
"items": [
{
"lot_name": "CPU_AMD_9654",
"quantity": 2,
"unit_price": 123456.78,
"section": "Processors"
}
]
}
```
Prices are stored inside the `items` JSON field and refreshed from the pricelist on configuration refresh.
---
## MariaDB (server-side, sync-only)
Database: `RFQ_LOG`
### Tables and Permissions
| Table | Purpose | Permissions |
|-------|---------|-------------|
| `lot` | Component catalog | SELECT |
| `qt_lot_metadata` | Extended component data | SELECT |
| `qt_categories` | Component categories | SELECT |
| `qt_pricelists` | Pricelists | SELECT |
| `qt_pricelist_items` | Pricelist line items | SELECT |
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
| `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE |
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
| `qt_client_local_migrations` | Migration catalog | SELECT only |
| `qt_client_schema_state` | Applied migrations state | SELECT, INSERT, UPDATE |
| `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE |
| `qt_partnumber_books` | Partnumber book snapshots (written by PriceForge) | SELECT |
| `qt_partnumber_book_items` | PN→LOT mapping rows (written by PriceForge) | SELECT |
| `qt_vendor_partnumber_seen` | Vendor PN tracking for unresolved/ignored BOM rows (`is_ignored`) | INSERT, UPDATE |
### Grant Permissions to Existing User
```sql
GRANT SELECT ON RFQ_LOG.lot TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.stock_log TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO '<DB_USER>'@'%';
FLUSH PRIVILEGES;
```
### Create a New User
```sql
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.stock_log TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'quote_user'@'%';
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'quote_user'@'%';
FLUSH PRIVILEGES;
SHOW GRANTS FOR 'quote_user'@'%';
```
**Note:** If pricelists sync but show `0` positions (or logs contain `enriching pricelist items with stock` + `SELECT denied`), verify `SELECT` on `lot_partnumbers` and `stock_log` in addition to `qt_pricelist_items`.
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
---
## Migrations
### SQLite Migrations (local) — три уровня, выполняются при каждом старте
**1. GORM AutoMigrate** (`internal/localdb/localdb.go`) — первый и основной уровень.
Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**.
→ Для добавления новой таблицы или колонки достаточно добавить модель/поле и включить модель в AutoMigrate.
**2. `runLocalMigrations`** (`internal/localdb/migrations.go`) — второй уровень, для операций которые AutoMigrate не умеет: backfill данных, пересоздание таблиц, создание индексов.
Каждая функция выполняется один раз — идемпотентность через запись `id` в `local_schema_migrations`.
**3. Централизованные (server-side)** — третий уровень, при проверке готовности к синку.
SQL-тексты хранятся в `qt_client_local_migrations` (MariaDB, пишет только PriceForge). Клиент читает, применяет к локальной SQLite, записывает в `local_remote_migrations_applied` + `qt_client_schema_state`.
### MariaDB Migrations (server-side)
- Stored in `migrations/` (SQL files)
- Applied via `-migrate` flag
- `min_app_version` — minimum app version required for the migration
---
## DB Debugging
```bash
# Inspect schema
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_components"
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_configurations"
# Check pricelist item count
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM local_pricelist_items"
# Check pending sync queue
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM pending_changes"
```

163
bible-local/04-api.md Normal file
View File

@@ -0,0 +1,163 @@
# 04 — API and Web Routes
## API Endpoints
### Setup
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/setup` | Initial setup page |
| POST | `/setup` | Save connection settings |
| POST | `/setup/test` | Test MariaDB connection |
| GET | `/setup/status` | Setup status |
### Components
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/components` | List components (metadata only) |
| GET | `/api/components/:lot_name` | Component by lot_name |
| GET | `/api/categories` | List categories |
### Quote
| Method | Endpoint | Purpose |
|--------|----------|---------|
| POST | `/api/quote/validate` | Validate line items |
| POST | `/api/quote/calculate` | Calculate quote (prices from pricelist) |
| POST | `/api/quote/price-levels` | Prices by level (estimate/warehouse/competitor) |
### Pricelists (read-only)
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/pricelists` | List pricelists (`source`, `active_only`, pagination) |
| GET | `/api/pricelists/latest` | Latest pricelist by source |
| GET | `/api/pricelists/:id` | Pricelist by ID |
| GET | `/api/pricelists/:id/items` | Pricelist line items |
| GET | `/api/pricelists/:id/lots` | Lot names in pricelist |
`GET /api/pricelists?active_only=true` returns only pricelists that have synced items (`item_count > 0`).
### Configurations
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/configs` | List configurations |
| POST | `/api/configs` | Create configuration |
| GET | `/api/configs/:uuid` | Get configuration |
| PUT | `/api/configs/:uuid` | Update configuration |
| DELETE | `/api/configs/:uuid` | Archive configuration |
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
| POST | `/api/configs/:uuid/clone` | Clone configuration |
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
| POST | `/api/configs/:uuid/rename` | Rename configuration |
| POST | `/api/configs/preview-article` | Preview generated article for a configuration |
| POST | `/api/configs/:uuid/rollback` | Roll back to a version |
| GET | `/api/configs/:uuid/versions` | List versions |
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
`line` field in configuration payloads is backed by persistent `line_no` in DB.
### Projects
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/projects` | List projects |
| POST | `/api/projects` | Create project |
| GET | `/api/projects/:uuid` | Get project |
| PUT | `/api/projects/:uuid` | Update project |
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
| GET | `/api/projects/:uuid/configs` | Project configurations |
| PATCH | `/api/projects/:uuid/configs/reorder` | Reorder active project configurations (`ordered_uuids`) and persist `line_no` |
`GET /api/projects/:uuid/configs` ordering:
`line ASC`, then `created_at DESC`, then `id DESC`.
### Sync
| Method | Endpoint | Purpose | Flow |
|--------|----------|---------|------|
| GET | `/api/sync/status` | Overall sync status | read-only |
| GET | `/api/sync/readiness` | Preflight status (ready/blocked/unknown) | read-only |
| GET | `/api/sync/info` | Data for sync modal | read-only |
| GET | `/api/sync/users-status` | Users status | read-only |
| GET | `/api/sync/pending` | List pending changes | read-only |
| GET | `/api/sync/pending/count` | Count of pending changes | read-only |
| POST | `/api/sync/push` | Push pending → MariaDB | SQLite → MariaDB |
| POST | `/api/sync/components` | Pull components | MariaDB → SQLite |
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
| POST | `/api/sync/partnumber-seen` | Report unresolved/ignored vendor PNs for server-side tracking | QuoteForge → MariaDB |
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
### Vendor Spec (BOM)
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored vendor BOM |
| PUT | `/api/configs/:uuid/vendor-spec` | Replace vendor BOM (full update) |
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (read-only) |
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
Notes:
- `GET` / `PUT /api/configs/:uuid/vendor-spec` exchange normalized BOM rows (`vendor_spec`), not raw pasted Excel layout.
- BOM row contract stores canonical LOT mapping list as seen in BOM UI:
- `lot_mappings[]`
- each mapping contains `lot_name` + `quantity_per_pn`
- `POST /api/configs/:uuid/vendor-spec/apply` rebuilds cart items from explicit BOM mappings:
- all LOTs from `lot_mappings[]`
### Partnumber Books (read-only)
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/partnumber-books` | List local book snapshots |
| GET | `/api/partnumber-books/:id` | Items for a book by `server_id` |
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
See [09-vendor-spec.md](09-vendor-spec.md) for schema and pull logic.
See [09-vendor-spec.md](09-vendor-spec.md) for `vendor_spec` JSON schema and BOM UI mapping contract.
### Export
| Method | Endpoint | Purpose |
|--------|----------|---------|
| POST | `/api/export/csv` | Export configuration to CSV |
**Export filename format:** `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
(uses `project.Code`, not `project.Name`)
---
## Web Routes
| Route | Page |
|-------|------|
| `/configs` | Configuration list |
| `/configurator` | Configurator |
| `/configs/:uuid/revisions` | Configuration revision history |
| `/projects` | Project list |
| `/projects/:uuid` | Project details |
| `/pricelists` | Pricelist list |
| `/pricelists/:id` | Pricelist details |
| `/partnumber-books` | Partnumber books (active book summary + snapshot history) |
| `/setup` | Connection settings |
---
## Rollback API (details)
```bash
POST /api/configs/:uuid/rollback
Content-Type: application/json
{
"target_version": 3,
"note": "optional comment"
}
```
Response: updated configuration with the new version.

129
bible-local/05-config.md Normal file
View File

@@ -0,0 +1,129 @@
# 05 — Configuration and Environment
## File Paths
### SQLite database (`qfs.db`)
| OS | Default path |
|----|-------------|
| macOS | `~/Library/Application Support/QuoteForge/qfs.db` |
| Linux | `$XDG_STATE_HOME/quoteforge/qfs.db` or `~/.local/state/quoteforge/qfs.db` |
| Windows | `%LOCALAPPDATA%\QuoteForge\qfs.db` |
Override: `-localdb <path>` or `QFS_DB_PATH`.
### config.yaml
Searched in the same user-state directory as `qfs.db` by default.
If the file does not exist, it is created automatically.
If the format is outdated, it is automatically migrated to the runtime format (`server` + `logging` sections only).
Override: `-config <path>` or `QFS_CONFIG_PATH`.
**Important:** `config.yaml` is a runtime user file — it is **not stored in the repository**.
`config.example.yaml` is the only config template in the repo.
---
## config.yaml Structure
```yaml
server:
host: "0.0.0.0"
port: 8080
mode: "release" # release | debug
logging:
level: "info" # debug | info | warn | error
format: "json" # json | text
output: "stdout" # stdout | stderr | /path/to/file
backup:
time: "00:00" # HH:MM in local time
```
---
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `QFS_DB_PATH` | Full path to SQLite DB | OS-specific user state dir |
| `QFS_STATE_DIR` | State directory (if `QFS_DB_PATH` is not set) | OS-specific user state dir |
| `QFS_CONFIG_PATH` | Full path to `config.yaml` | OS-specific user state dir |
| `QFS_BACKUP_DIR` | Root directory for rotating backups | `<db dir>/backups` |
| `QFS_BACKUP_DISABLE` | Disable automatic backups | — |
| `QF_DB_HOST` | MariaDB host | localhost |
| `QF_DB_PORT` | MariaDB port | 3306 |
| `QF_DB_NAME` | Database name | RFQ_LOG |
| `QF_DB_USER` | DB user | — |
| `QF_DB_PASSWORD` | DB password | — |
| `QF_JWT_SECRET` | JWT secret | — |
| `QF_SERVER_PORT` | HTTP server port | 8080 |
`QFS_BACKUP_DISABLE` accepts: `1`, `true`, `yes`.
---
## CLI Flags
| Flag | Description |
|------|-------------|
| `-config <path>` | Path to config.yaml |
| `-localdb <path>` | Path to SQLite DB |
| `-reset-localdb` | Reset local DB (destructive!) |
| `-migrate` | Apply pending migrations and exit |
| `-version` | Print version and exit |
---
## Installation and First Run
### Requirements
- Go 1.22 or higher
- MariaDB 11.x (or MySQL 8.x)
- ~50 MB disk space
### Steps
```bash
# 1. Clone the repository
git clone <repo-url>
cd quoteforge
# 2. Apply migrations
go run ./cmd/qfs -migrate
# 3. Start
go run ./cmd/qfs
# or
make run
```
Application is available at: http://localhost:8080
On first run, `/setup` opens for configuring the MariaDB connection.
### OPS Project Migrator
Migrates quotes whose names start with `OPS-xxxx` (where `x` is a digit) into a project named `OPS-xxxx`.
```bash
# Preview first (always)
go run ./cmd/migrate_ops_projects
# Apply
go run ./cmd/migrate_ops_projects -apply
# Apply without interactive confirmation
go run ./cmd/migrate_ops_projects -apply -yes
```
---
## Docker
```bash
docker build -t quoteforge .
docker-compose up -d
```

221
bible-local/06-backup.md Normal file
View File

@@ -0,0 +1,221 @@
# 06 — Backup
## Overview
Automatic rotating ZIP backup system for local data.
**What is included in each archive:**
- SQLite DB (`qfs.db`)
- SQLite sidecars (`qfs.db-wal`, `qfs.db-shm`) if present
- `config.yaml` if present
**Archive name format:** `qfs-backp-YYYY-MM-DD.zip`
**Retention policy:**
| Period | Keep |
|--------|------|
| Daily | 7 archives |
| Weekly | 4 archives |
| Monthly | 12 archives |
| Yearly | 10 archives |
**Directories:** `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`
---
## Configuration
```yaml
backup:
time: "00:00" # Trigger time in local time (HH:MM format)
```
**Environment variables:**
- `QFS_BACKUP_DIR` — backup root directory (default: `<db dir>/backups`)
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`)
---
## Behavior
- **At startup:** if no backup exists for the current period, one is created immediately
- **Daily:** at the configured time, a new backup is created
- **Deduplication:** prevented via a `.period.json` marker file in each period directory
- **Rotation:** excess old archives are deleted automatically
---
## Implementation
Module: `internal/appstate/backup.go`
Main function:
```go
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error)
```
Scheduler (in `main.go`):
```go
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string)
```
### Config struct
```go
type BackupConfig struct {
Time string `yaml:"time"`
}
// Default: "00:00"
```
---
## Implementation Notes
- `backup.time` is in **local time** without timezone offset parsing
- `.period.json` is the marker that prevents duplicate backups within the same period
- Archive filenames contain only the date; uniqueness is ensured by per-period directories + the period marker
- When changing naming or retention: update both the filename logic and the prune logic together
---
## Full Listing: `internal/appstate/backup.go`
```go
package appstate
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type backupPeriod struct {
name string
retention int
key func(time.Time) string
date func(time.Time) string
}
var backupPeriods = []backupPeriod{
{
name: "daily",
retention: 7,
key: func(t time.Time) string { return t.Format("2006-01-02") },
date: func(t time.Time) string { return t.Format("2006-01-02") },
},
{
name: "weekly",
retention: 4,
key: func(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", y, w)
},
date: func(t time.Time) string { return t.Format("2006-01-02") },
},
{
name: "monthly",
retention: 12,
key: func(t time.Time) string { return t.Format("2006-01") },
date: func(t time.Time) string { return t.Format("2006-01-02") },
},
{
name: "yearly",
retention: 10,
key: func(t time.Time) string { return t.Format("2006") },
date: func(t time.Time) string { return t.Format("2006-01-02") },
},
}
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
if isBackupDisabled() || dbPath == "" {
return nil, nil
}
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, nil
}
root := resolveBackupRoot(dbPath)
now := time.Now()
created := make([]string, 0)
for _, period := range backupPeriods {
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
if err != nil {
return created, err
}
created = append(created, newFiles...)
}
return created, nil
}
```
---
## Full Listing: Scheduler Hook (`main.go`)
```go
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
if cfg == nil {
return
}
hour, minute, err := parseBackupTime(cfg.Backup.Time)
if err != nil {
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
hour, minute = 0, 0
}
// Startup check: create backup immediately if none exists for current periods
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
slog.Error("local backup failed", "error", backupErr)
} else {
for _, path := range created {
slog.Info("local backup completed", "archive", path)
}
}
for {
next := nextBackupTime(time.Now(), hour, minute)
timer := time.NewTimer(time.Until(next))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
start := time.Now()
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
duration := time.Since(start)
if backupErr != nil {
slog.Error("local backup failed", "error", backupErr, "duration", duration)
} else {
for _, path := range created {
slog.Info("local backup completed", "archive", path, "duration", duration)
}
}
}
}
}
func parseBackupTime(value string) (int, int, error) {
if strings.TrimSpace(value) == "" {
return 0, 0, fmt.Errorf("empty backup time")
}
parsed, err := time.Parse("15:04", value)
if err != nil {
return 0, 0, err
}
return parsed.Hour(), parsed.Minute(), nil
}
func nextBackupTime(now time.Time, hour, minute int) time.Time {
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
if !now.Before(target) {
target = target.Add(24 * time.Hour)
}
return target
}
```

136
bible-local/07-dev.md Normal file
View File

@@ -0,0 +1,136 @@
# 07 — Development
## Commands
```bash
# Run (dev)
go run ./cmd/qfs
make run
# Build
make build-release # Optimized build with version info
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
# Cross-platform build
make build-all # Linux, macOS, Windows
make build-windows # Windows only
# Verification
go build ./cmd/qfs # Must compile without errors
go vet ./... # Linter
# Tests
go test ./...
make test
# Utilities
make install-hooks # Git hooks (block committing secrets)
make clean # Clean bin/
make help # All available commands
```
---
## Code Style
- **Formatting:** `gofmt` (mandatory)
- **Logging:** `slog` only (structured logging to the binary's stdout/stderr). No `console.log` or any other logging in browser-side JS — the browser console is never used for diagnostics.
- **Errors:** explicit wrapping with context (`fmt.Errorf("context: %w", err)`)
- **Style:** no unnecessary abstractions; minimum code for the task
---
## Guardrails
### What Must Never Be Restored
The following components were **intentionally removed** and must not be brought back:
- cron jobs
- importer utility
- admin pricing UI/API
- alerts
- stock import
### Configuration Files
- `config.yaml` — runtime user file, **not stored in the repository**
- `config.example.yaml` — the only config template in the repo
### Sync and Local-First
- Any sync changes must preserve local-first behavior
- Local CRUD must not be blocked when MariaDB is unavailable
### Formats and UI
- **CSV export:** filename must use **project code** (`project.Code`), not project name
Format: `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
- **Breadcrumbs UI:** names longer than 16 characters must be truncated with an ellipsis
### Architecture Documentation
- **Every architectural decision must be recorded in `bible/`**
- The corresponding Bible file must be updated **in the same commit** as the code change
- On every user-requested commit, review and update the Bible in that same commit
---
## Common Tasks
### Add a Field to Configuration
1. Add the field to `LocalConfiguration` struct (`internal/models/`)
2. Add GORM tags for the DB column
3. Write a SQL migration (`migrations/`)
4. Update `ConfigurationToLocal` / `LocalToConfiguration` converters
5. Update API handlers and services
### Add a Field to Component
1. Add the field to `LocalComponent` struct (`internal/models/`)
2. Update the SQL query in `SyncComponents()`
3. Update the `componentRow` struct to match
4. Update converter functions
### Add a Pricelist Price Lookup
```go
// Modern pattern
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
if found && price > 0 {
// use price
}
```
---
## Known Gotchas
1. **`CurrentPrice` removed from components** — any code using it will fail to compile
2. **`HasPrice` filter removed** — `component.go ListComponents` no longer supports this filter
3. **Quote calculation:** always offline-first (SQLite); online path is separate
4. **Items JSON:** prices are stored in the `items` field of the configuration, not fetched from components
5. **Migrations are additive:** already-applied migrations are skipped (checked by `id` in `local_schema_migrations`)
6. **`SyncedAt` removed:** last component sync time is now in `app_settings` (key=`last_component_sync`)
---
## Debugging Price Issues
**Problem: quote returns no prices**
1. Check that `pricelist_id` is set on the configuration
2. Check that pricelist items exist: `SELECT COUNT(*) FROM local_pricelist_items`
3. Check `lookupPriceByPricelistID()` in `quote.go`
4. Verify the correct source is used (estimate/warehouse/competitor)
**Problem: component sync not working**
1. Components sync as metadata only — no prices
2. Prices come via a separate pricelist sync
3. Check `SyncComponents()` and the MariaDB query
**Problem: configuration refresh does not update prices**
1. Refresh uses the latest estimate pricelist by default
2. Latest resolution ignores pricelists without items (`EXISTS local_pricelist_items`)
3. Old prices in `config.items` are preserved if a line item is not found in the pricelist
4. To force a pricelist update: set `configuration.pricelist_id`
5. In configurator, `Авто` must remain auto-mode (runtime resolved ID must not be persisted as explicit selection)

View File

@@ -0,0 +1,364 @@
# 09 — Vendor Spec (BOM Import)
## Overview
The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a configuration. It maps vendor part numbers (PN) to internal LOT names using an active partnumber book (snapshot pulled from PriceForge), then aggregates quantities to populate the Estimate (cart).
---
## Architecture
### Storage
| Data | Storage | Sync direction |
|------|---------|---------------|
| `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT, JSON-encoded) | Two-way via `pending_changes` |
| Partnumber book snapshots | `local_partnumber_books` + `local_partnumber_book_items` | Pull-only from PriceForge |
`vendor_spec` is a JSON array of `VendorSpecItem` objects stored inside the configuration row. It syncs to MariaDB `qt_configurations.vendor_spec` via the existing pending_changes mechanism.
### `vendor_spec` JSON Schema
```json
[
{
"sort_order": 10,
"vendor_partnumber": "ABC-123",
"quantity": 2,
"description": "...",
"unit_price": 4500.00,
"total_price": 9000.00,
"lot_mappings": [
{ "lot_name": "LOT_A", "quantity_per_pn": 1 },
{ "lot_name": "LOT_B", "quantity_per_pn": 2 }
]
}
]
```
`lot_mappings[]` is the canonical persisted LOT mapping list for a BOM row.
Each mapping entry stores:
- `lot_name`
- `quantity_per_pn` (how many units of this LOT are included in **one vendor PN**)
### PN → LOT Mapping Contract (single LOT, multiplier, bundle)
QuoteForge expects the server to return/store BOM rows (`vendor_spec`) using a single canonical mapping list:
- `lot_mappings[]` contains **all** LOT mappings for the PN row (single LOT and bundle cases alike)
- the list stores exactly what the user sees in BOM (LOT + "LOT в 1 PN")
- the DB contract does **not** split mappings into "base LOT" vs "bundle LOTs"
#### Final quantity contribution to Estimate
For one BOM row with vendor PN quantity `pn_qty`:
- each mapping contribution:
- `lot_qty = pn_qty * lot_mappings[i].quantity_per_pn`
#### Example: one PN maps to multiple LOTs
```json
{
"vendor_partnumber": "SYS-821GE-TNHR",
"quantity": 3,
"lot_mappings": [
{ "lot_name": "CHASSIS_X13_8GPU", "quantity_per_pn": 1 },
{ "lot_name": "PS_3000W_Titanium", "quantity_per_pn": 2 },
{ "lot_name": "RAILKIT_X13", "quantity_per_pn": 1 }
]
}
```
This row contributes to Estimate:
- `CHASSIS_X13_8GPU``3 * 1 = 3`
- `PS_3000W_Titanium``3 * 2 = 6`
- `RAILKIT_X13``3 * 1 = 3`
---
## Partnumber Books (Snapshots)
Partnumber books are immutable versioned snapshots of the global PN→LOT mapping table, analogous to pricelists. PriceForge creates new snapshots; QuoteForge only pulls and reads them.
### SQLite (local mirror)
```sql
CREATE TABLE local_partnumber_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER UNIQUE NOT NULL, -- id from qt_partnumber_books
version TEXT NOT NULL, -- format YYYY-MM-DD-NNN
created_at DATETIME NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id INTEGER NOT NULL, -- FK → local_partnumber_books.id
partnumber TEXT NOT NULL,
lot_name TEXT NOT NULL,
is_primary_pn INTEGER NOT NULL DEFAULT 0,
description TEXT
);
CREATE INDEX idx_local_book_pn ON local_partnumber_book_items(book_id, partnumber);
```
**Active book query:** `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
**Schema creation:** GORM AutoMigrate (not `runLocalMigrations`).
### MariaDB (managed exclusively by PriceForge)
```sql
CREATE TABLE qt_partnumber_books (
id INT AUTO_INCREMENT PRIMARY KEY,
version VARCHAR(50) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active TINYINT(1) NOT NULL DEFAULT 1
);
CREATE TABLE qt_partnumber_book_items (
id INT AUTO_INCREMENT PRIMARY KEY,
book_id INT NOT NULL,
partnumber VARCHAR(255) NOT NULL,
lot_name VARCHAR(255) NOT NULL,
is_primary_pn TINYINT(1) NOT NULL DEFAULT 0,
description VARCHAR(10000) NULL,
INDEX idx_book_pn (book_id, partnumber),
FOREIGN KEY (book_id) REFERENCES qt_partnumber_books(id)
);
ALTER TABLE qt_configurations ADD COLUMN vendor_spec JSON NULL;
```
QuoteForge has `SELECT` permission only on `qt_partnumber_books` and `qt_partnumber_book_items`. All writes are managed by PriceForge.
**Grant (add to existing user setup):**
```sql
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
```
### `is_primary_pn` semantics
- `1` = primary PN for this `lot_name`. Its quantity in the vendor BOM determines `qty(LOT)`.
- `0` = non-primary (trigger) PN. If no primary PN for the LOT is found in BOM, contributes `qty=1`.
---
## Resolution Algorithm (3-step)
For each `vendor_partnumber` in the BOM, QuoteForge builds/updates UI-visible LOT mappings:
1. **Active book lookup** — query `local_partnumber_book_items WHERE book_id = activeBook.id AND partnumber = ?`.
2. **Populate BOM UI** — if a match exists, BOM row shows a LOT value (user can still edit it).
3. **Unresolved** — red row + inline LOT input with strict autocomplete.
Persistence note: the application stores the final user-visible mappings in `lot_mappings[]` (not separate "resolved/manual" persisted fields).
---
## Qty Aggregation Logic
After resolution, qty per LOT is computed as:
```
qty(lot) = SUM(quantity of rows where is_primary_pn=1 AND resolved_lot=lot)
if at least one primary PN for this lot was found in BOM
= 1
if only non-primary PNs for this lot were found
```
Examples (book: LOT_A → x1[primary], x2, x3):
- BOM: x2×1, x3×2 → 1×LOT_A (no primary PN)
- BOM: x1×2, x2×1 → 2×LOT_A (primary qty=2)
- BOM: x1×1, x2×3 → 1×LOT_A (primary qty=1)
---
## UI: Three Top-Level Tabs
The configurator (`/configurator`) has three tabs:
1. **Estimate** — existing cart/component configurator (unchanged).
2. **BOM** — paste/import vendor BOM, manual column mapping, LOT matching, bundle decomposition (`1 PN -> multiple LOTs`), "Пересчитать эстимейт", "Очистить".
3. **Ценообразование** — pricing summary table + custom price input.
BOM data is shared between tabs 2 and 3.
### BOM Import UI (raw table, manual column mapping)
After paste (`Ctrl+V`) QuoteForge renders an editable raw table (not auto-detected parsing).
- The pasted rows are shown **as-is** (including header rows, if present).
- The user selects a type for each column manually:
- `P/N`
- `Кол-во`
- `Цена`
- `Описание`
- `Не использовать`
- Required mapping:
- exactly one `P/N`
- exactly one `Кол-во`
- Optional mapping:
- `Цена` (0..1)
- `Описание` (0..1)
- Rows can be:
- ignored (UI-only, excluded from `vendor_spec`)
- deleted
- Raw cells are editable inline after paste.
Notes:
- There is **no auto column detection**.
- There is **no auto header-row skip**.
- Raw import layout itself is not stored on server; only normalized `vendor_spec` is stored.
### LOT matching in BOM table
The BOM table adds service columns on the right:
- `LOT`
- `LOT в 1 PN`
- actions (`+`, ignore, delete)
`LOT` behavior:
- The first LOT row shown in the BOM UI is the primary LOT mapping for the PN row.
- Additional LOT rows are added via the `+` action.
- inline LOT input is strict:
- autocomplete source = full local components list (`/api/components?per_page=5000`)
- free text that does not match an existing LOT is rejected
`LOT в 1 PN` behavior:
- quantity multiplier for each visible LOT row in BOM (`quantity_per_pn` in persisted `lot_mappings[]`)
- default = `1`
- editable inline
### Bundle mode (`1 PN -> multiple LOTs`)
The `+` action in BOM rows adds an extra LOT mapping row for the same vendor PN row.
- All visible LOT rows (first + added rows) are persisted uniformly in `lot_mappings[]`
- Each mapping row has:
- LOT
- qty (`LOT in 1 PN` = `quantity_per_pn`)
### BOM restore on config open
On config open, QuoteForge loads `vendor_spec` from server and reconstructs the editable BOM table in normalized form:
- columns restored as: `Qty | P/N | Description | Price`
- column mapping restored as:
- `qty`, `pn`, `description`, `price`
- LOT / `LOT в 1 PN` rows are restored from `vendor_spec.lot_mappings[]`
This restores the BOM editing state, but not the original raw Excel layout (extra columns, ignored rows, original headers).
### Pricing Tab: column order
```
LOT | PN вендора | Описание | Кол-во | Estimate | Цена вендора | Склад | Конк.
```
**If BOM is empty** — the pricing tab still renders, using cart items as rows (PN вендора = "—", Цена вендора = "—").
**Description source priority:** BOM row description → LOT description from `local_components`.
### Pricing Tab: BOM + Estimate merge behavior
When BOM exists, the pricing tab renders:
- BOM-based rows (including rows resolved via manual LOT and bundle mappings)
- plus **Estimate-only LOTs** (rows currently in cart but not covered by BOM mappings)
Estimate-only rows are shown as separate rows with:
- `PN вендора = "—"`
- vendor price = `—`
- description from local components
### Pricing Tab: "Своя цена" input
- Manual entry → proportionally redistributes custom price into "Цена вендора" cells (proportional to each row's Estimate share). Last row absorbs rounding remainder.
- "Проставить цены BOM" button → restores per-row original BOM prices directly (no proportional redistribution). Sets "Своя цена" to their sum.
- Both paths show "Скидка от Estimate: X%" info.
- "Экспорт CSV" button → downloads `pricing_<uuid>.csv` with UTF-8 BOM, same column order as table, plus Итого row.
---
## API Endpoints
| Method | URL | Description |
|--------|-----|-------------|
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored BOM |
| PUT | `/api/configs/:uuid/vendor-spec` | Replace BOM (full update) |
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (no cart mutation) |
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
| GET | `/api/partnumber-books` | List local book snapshots |
| GET | `/api/partnumber-books/:id` | Items for a book by server_id |
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
| POST | `/api/sync/partnumber-seen` | Push unresolved PNs to `qt_vendor_partnumber_seen` on MariaDB |
## Unresolved PN Tracking (`qt_vendor_partnumber_seen`)
After each `resolveBOM()` call, QuoteForge pushes PN rows to `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored):
- unresolved BOM rows (`ignored = false`)
- raw BOM rows explicitly marked as ignored in UI (`ignored = true`) — these rows are **not** saved to `vendor_spec`, but are reported for server-side tracking
The handler calls `sync.PushPartnumberSeen()` which upserts into `qt_vendor_partnumber_seen`:
```sql
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, last_seen_at)
VALUES ('manual', '', ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
last_seen_at = VALUES(last_seen_at),
is_ignored = VALUES(is_ignored),
description = COALESCE(NULLIF(VALUES(description), ''), description)
```
Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge uses this table for populating the partnumber book.
## BOM Persistence
- `vendor_spec` is saved to server via `PUT /api/configs/:uuid/vendor-spec`.
- `GET` / `PUT` `vendor_spec` must preserve row-level mapping fields used by the UI:
- `lot_mappings[]`
- each item: `lot_name`, `quantity_per_pn`
- `description` is persisted in each BOM row and is used by the Pricing tab when available.
- Ignored raw rows are **not** persisted into `vendor_spec`.
- The PUT handler explicitly marshals `VendorSpec` to JSON string before passing to GORM `Update` (GORM does not reliably call `driver.Valuer` for custom types in `Update(column, value)`).
- BOM is autosaved (debounced) after BOM-changing actions, including:
- `resolveBOM()`
- LOT row qty (`LOT в 1 PN`) changes
- LOT row add/remove (`+` / delete in bundle context)
- "Сохранить BOM" button triggers explicit save.
## Pricing Tab: Estimate Price Source
`renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with LOTs collected from:
- `lot_mappings[]` from BOM rows
- current Estimate/cart LOTs not covered by BOM mappings (to show estimate-only rows)
This ensures Estimate prices appear for:
- manually matched LOTs in the BOM tab
- bundle LOTs
- LOTs already present in Estimate but not mapped from BOM
### Apply to Estimate (`Пересчитать эстимейт`)
When applying BOM to Estimate, QuoteForge builds cart rows from explicit UI mappings stored in `lot_mappings[]`.
For a BOM row with PN qty = `Q`:
- each mapped LOT contributes `Q * quantity_per_pn`
Rows without any valid LOT mapping are skipped.
## Web Route
| Route | Page |
|-------|------|
| `/partnumber-books` | Partnumber books — active book summary (unique LOTs, total PN, primary PN count), searchable items table, collapsible snapshot history |

55
bible-local/README.md Normal file
View File

@@ -0,0 +1,55 @@
# QuoteForge Bible — Architectural Documentation
The single source of truth for architecture, schemas, and patterns.
---
## Table of Contents
| File | Topic |
|------|-------|
| [01-overview.md](01-overview.md) | Product: purpose, features, tech stack, repository structure |
| [02-architecture.md](02-architecture.md) | Architecture: local-first, sync, pricing, versioning |
| [03-database.md](03-database.md) | DB schemas: SQLite + MariaDB, permissions, indexes |
| [04-api.md](04-api.md) | API endpoints and web routes |
| [05-config.md](05-config.md) | Configuration, environment variables, paths, installation |
| [06-backup.md](06-backup.md) | Backup: implementation, rotation policy |
| [07-dev.md](07-dev.md) | Development: commands, code style, guardrails |
---
## Bible Rules
> **Every architectural decision must be recorded in the Bible.**
>
> Any change to DB schema, data access patterns, sync behavior, API contracts,
> configuration format, or any other system-level aspect — the corresponding `bible/` file
> **must be updated in the same commit** as the code.
>
> On every user-requested commit, the Bible must be reviewed and updated in that commit.
>
> The Bible is the single source of truth for architecture. Outdated documentation is worse than none.
> **Documentation language: English.**
>
> All files in `bible/` are written and updated **in English only**.
> Mixing languages is not allowed.
---
## Quick Reference
**Where is user data stored?**
SQLite → `~/Library/Application Support/QuoteForge/qfs.db` (macOS). MariaDB is sync-only.
**How to look up a price for a line item?**
`local_pricelist_items` → by `pricelist_id` from config + `lot_name`. Prices are **never** taken from `local_components`.
**Pre-commit check?**
`go build ./cmd/qfs && go vet ./...`
**What must never be restored?**
cron jobs, admin pricing, alerts, stock import, importer utility — all removed intentionally.
**Where is the release changelog?**
`releases/memory/v{major}.{minor}.{patch}.md`