Simplify project documentation and release notes
This commit is contained in:
@@ -1,131 +1,70 @@
|
||||
# 01 — Product Overview
|
||||
# 01 - Overview
|
||||
|
||||
## What is QuoteForge
|
||||
## Product
|
||||
|
||||
A corporate server configuration and quotation tool.
|
||||
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only by synchronization and dedicated setup/migration tooling.
|
||||
QuoteForge is a local-first tool for server configuration, quotation, and project tracking.
|
||||
|
||||
---
|
||||
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
|
||||
- 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
|
||||
QuoteForge is a single-user thick client.
|
||||
|
||||
### 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.
|
||||
- RBAC is not part of the active product contract for the local client.
|
||||
- The authoritative authentication boundary is the remote sync server and its DB credentials captured during setup.
|
||||
- Runtime startup must reject non-loopback `server.host` values; remote bind is not a supported deployment mode.
|
||||
- If the app is ever exposed beyond `localhost`, auth/RBAC must be reintroduced as an enforced perimeter before release.
|
||||
In scope:
|
||||
- configurator and quote calculation;
|
||||
- projects, variants, and configuration ordering;
|
||||
- local revision history;
|
||||
- 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 |
|
||||
|-------|--------|-----------|
|
||||
| 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
|
||||
## 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 transport only for app runtime) |
|
||||
| Export | encoding/csv, excelize (XLSX) |
|
||||
| --- | --- |
|
||||
| Backend | Go, Gin, GORM |
|
||||
| Frontend | HTML templates, htmx, Tailwind CSS |
|
||||
| Local storage | SQLite |
|
||||
| Sync transport | MariaDB |
|
||||
| Export | CSV and XLSX generation |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
## Repository map
|
||||
|
||||
```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.
|
||||
**MariaDB** is a sync server only — it never blocks local operations.
|
||||
SQLite is the runtime source of truth.
|
||||
MariaDB is sync transport plus setup and migration tooling.
|
||||
|
||||
```
|
||||
User
|
||||
│
|
||||
▼
|
||||
SQLite (qfs.db) ← all CRUD operations go here
|
||||
│
|
||||
│ background sync (every 5 min)
|
||||
▼
|
||||
MariaDB (RFQ_LOG) ← pull/push only
|
||||
```text
|
||||
browser -> Gin handlers -> SQLite
|
||||
-> pending_changes
|
||||
background sync <------> MariaDB
|
||||
```
|
||||
|
||||
**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
|
||||
Rules:
|
||||
- user CRUD must continue when MariaDB is offline;
|
||||
- runtime handlers and pages must read and write SQLite only;
|
||||
- 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.
|
||||
- MariaDB access from the app runtime is allowed only inside the sync subsystem (`internal/services/sync/*`) for explicit pull/push work.
|
||||
- Dedicated tooling under `cmd/migrate` and `cmd/migrate_ops_projects` may access MariaDB for operator-run schema/data migration tasks.
|
||||
- Setup may test/store connection settings, but after setup the application must treat MariaDB as sync transport only.
|
||||
- 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.
|
||||
Readiness guard:
|
||||
- every sync push/pull runs a preflight check;
|
||||
- blocked sync returns `423 Locked` with a machine-readable reason;
|
||||
- local work continues even when sync is blocked.
|
||||
|
||||
Forbidden patterns:
|
||||
## Pricing contract
|
||||
|
||||
- calling `connMgr.GetDB()` from non-sync runtime business code;
|
||||
- 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.
|
||||
Prices come only from `local_pricelist_items`.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
Configuration revisions are append-only snapshots stored in `local_configuration_versions`.
|
||||
|
||||
---
|
||||
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.
|
||||
|
||||
```
|
||||
[ 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 |
|
||||
| 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 |
|
||||
Rules:
|
||||
- PN to LOT resolution uses the active local partnumber book;
|
||||
- canonical persisted mapping is `lot_mappings[]`;
|
||||
- QuoteForge does not use legacy BOM tables such as `qt_bom`, `qt_lot_bundles`, or `qt_lot_bundle_items`.
|
||||
|
||||
@@ -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
|
||||
|
||||
#### 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
|
||||
Main tables:
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `pending_changes` | Queue of changes to push to MariaDB |
|
||||
| `local_schema_migrations` | Applied migrations (idempotency guard) |
|
||||
| --- | --- |
|
||||
| `local_components` | synced component metadata |
|
||||
| `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
|
||||
-- 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
|
||||
MariaDB is the central sync database.
|
||||
|
||||
-- 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)
|
||||
```
|
||||
Runtime read permissions:
|
||||
- `lot`
|
||||
- `qt_lot_metadata`
|
||||
- `qt_categories`
|
||||
- `qt_pricelists`
|
||||
- `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
|
||||
{
|
||||
"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 |
|
||||
| `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@'%').
|
||||
|
||||
---
|
||||
Rules:
|
||||
- QuoteForge runtime must not depend on any removed legacy BOM tables;
|
||||
- stock enrichment happens during sync and is persisted into SQLite;
|
||||
- normal UI requests must not query MariaDB tables directly.
|
||||
|
||||
## 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`) — первый и основной уровень.
|
||||
Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**.
|
||||
|
||||
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"
|
||||
```
|
||||
MariaDB:
|
||||
- SQL files live in `migrations/`;
|
||||
- they are applied by `go run ./cmd/qfs -migrate`.
|
||||
|
||||
@@ -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 |
|
||||
|--------|----------|---------|
|
||||
| GET | `/setup` | Initial setup page |
|
||||
| POST | `/setup` | Save connection settings |
|
||||
| POST | `/setup/test` | Test MariaDB connection |
|
||||
| GET | `/setup/status` | Setup status |
|
||||
## Setup and health
|
||||
|
||||
### 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 |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/components` | List components (metadata only) |
|
||||
| GET | `/api/components/:lot_name` | Component by lot_name |
|
||||
| GET | `/api/categories` | List categories |
|
||||
`POST /api/restart` exists only in `debug` mode.
|
||||
|
||||
### Quote
|
||||
## Reference data
|
||||
|
||||
| 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) |
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/components` | list component metadata |
|
||||
| `GET` | `/api/components/:lot_name` | one component |
|
||||
| `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 |
|
||||
|--------|----------|---------|
|
||||
| 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 |
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/quote/validate` | validate config items |
|
||||
| `POST` | `/api/quote/calculate` | calculate quote totals |
|
||||
| `POST` | `/api/quote/price-levels` | resolve estimate/warehouse/competitor prices |
|
||||
| `POST` | `/api/export/csv` | export a single configuration |
|
||||
| `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 |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/configs` | List configurations |
|
||||
| POST | `/api/configs` | Create configuration |
|
||||
| GET | `/api/configs/:uuid` | Get configuration |
|
||||
| PUT | `/api/configs/:uuid` | Update configuration |
|
||||
| DELETE | `/api/configs/:uuid` | Archive configuration |
|
||||
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
|
||||
| POST | `/api/configs/:uuid/clone` | Clone configuration |
|
||||
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
|
||||
| POST | `/api/configs/:uuid/rename` | Rename configuration |
|
||||
| POST | `/api/configs/preview-article` | Preview generated article for a configuration |
|
||||
| POST | `/api/configs/:uuid/rollback` | Roll back to a version |
|
||||
| GET | `/api/configs/:uuid/versions` | List versions |
|
||||
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
|
||||
## Projects
|
||||
|
||||
`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 |
|
||||
|--------|----------|---------|
|
||||
| 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 |
|
||||
## Sync
|
||||
|
||||
`GET /api/projects/:uuid/configs` ordering:
|
||||
`line ASC`, then `created_at DESC`, then `id DESC`.
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `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:
|
||||
|
||||
- `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.
|
||||
When readiness is blocked, sync write endpoints return `423 Locked`.
|
||||
|
||||
@@ -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 |
|
||||
|----|-------------|
|
||||
| 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` |
|
||||
The runtime state directory can be overridden with `QFS_STATE_DIR`.
|
||||
Direct paths can be overridden with `QFS_DB_PATH` and `QFS_CONFIG_PATH`.
|
||||
|
||||
Override: `-localdb <path>` or `QFS_DB_PATH`.
|
||||
## Runtime config shape
|
||||
|
||||
### 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.
|
||||
|
||||
### 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
|
||||
Runtime keeps `config.yaml` intentionally small:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 8080
|
||||
mode: "release" # release | debug
|
||||
|
||||
logging:
|
||||
level: "info" # debug | info | warn | error
|
||||
format: "json" # json | text
|
||||
output: "stdout" # stdout | stderr | /path/to/file
|
||||
mode: "release"
|
||||
read_timeout: 30s
|
||||
write_timeout: 30s
|
||||
|
||||
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`).
|
||||
QuoteForge startup rejects non-loopback bind addresses because the local client has no auth/RBAC perimeter.
|
||||
Rules:
|
||||
- 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 |
|
||||
|----------|-------------|---------|
|
||||
| `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 | — |
|
||||
| `QUOTEFORGE_ENCRYPTION_KEY` | Explicit override for local credential encryption key | app-managed key file |
|
||||
| `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_SERVER_PORT` | HTTP server port | 8080 |
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `QFS_STATE_DIR` | override runtime state directory |
|
||||
| `QFS_DB_PATH` | explicit SQLite path |
|
||||
| `QFS_CONFIG_PATH` | explicit config path |
|
||||
| `QFS_BACKUP_DIR` | explicit backup root |
|
||||
| `QFS_BACKUP_DISABLE` | disable rotating backups |
|
||||
| `QUOTEFORGE_ENCRYPTION_KEY` | override encryption key |
|
||||
| `QF_SERVER_PORT` | override HTTP port |
|
||||
|
||||
`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 |
|
||||
|------|-------------|
|
||||
| `-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 |
|
||||
## First run
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
1. runtime ensures `config.yaml` exists;
|
||||
2. runtime opens the local SQLite database;
|
||||
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.
|
||||
|
||||
@@ -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:**
|
||||
- Consistent SQLite snapshot stored as `qfs.db`
|
||||
- `config.yaml` if present
|
||||
The backup intentionally does not include `local_encryption.key`.
|
||||
|
||||
**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 |
|
||||
|--------|------|
|
||||
| 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`)
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
| --- | --- |
|
||||
| Daily | 7 |
|
||||
| Weekly | 4 |
|
||||
| Monthly | 12 |
|
||||
| Yearly | 10 |
|
||||
|
||||
## 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
|
||||
- on startup, QuoteForge creates a backup if the current period has none yet;
|
||||
- a daily scheduler creates the next backup at `backup.time`;
|
||||
- duplicate snapshots inside the same period are prevented by a period marker file;
|
||||
- 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:
|
||||
```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
|
||||
- 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. stop QuoteForge;
|
||||
2. unpack the chosen archive outside the repository;
|
||||
3. replace `qfs.db`;
|
||||
4. replace `config.yaml` if needed;
|
||||
5. restart the app;
|
||||
6. re-enter MariaDB credentials if the original encryption key is unavailable.
|
||||
|
||||
@@ -1,141 +1,34 @@
|
||||
# 07 — Development
|
||||
# 07 - Development
|
||||
|
||||
## Commands
|
||||
## Common 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 run ./cmd/qfs -migrate
|
||||
go test ./...
|
||||
make test
|
||||
|
||||
# Utilities
|
||||
make install-hooks # Git hooks (block committing secrets)
|
||||
make clean # Clean bin/
|
||||
make help # All available commands
|
||||
go vet ./...
|
||||
make build-release
|
||||
make install-hooks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- 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:
|
||||
- cron jobs
|
||||
- importer utility
|
||||
- admin pricing UI/API
|
||||
- alerts
|
||||
- stock import
|
||||
## Removed features that must not return
|
||||
|
||||
### 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**
|
||||
- `config.example.yaml` — the only config template in the repo
|
||||
## Release notes
|
||||
|
||||
### Sync and Local-First
|
||||
|
||||
- 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)
|
||||
Release history belongs under `releases/<version>/RELEASE_NOTES.md`.
|
||||
Do not keep temporary change summaries in the repository root.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
Each row uses this canonical shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"vendor_partnumber": "SYS-821GE-TNHR",
|
||||
"quantity": 3,
|
||||
"sort_order": 10,
|
||||
"vendor_partnumber": "ABC-123",
|
||||
"quantity": 2,
|
||||
"description": "row description",
|
||||
"unit_price": 4500.0,
|
||||
"total_price": 9000.0,
|
||||
"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 }
|
||||
{ "lot_name": "LOT_A", "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`
|
||||
- `PS_3000W_Titanium` → `3 * 2 = 6`
|
||||
- `RAILKIT_X13` → `3 * 1 = 3`
|
||||
## Partnumber books
|
||||
|
||||
---
|
||||
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
|
||||
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
|
||||
);
|
||||
## CFXML import
|
||||
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
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.
|
||||
`POST /api/projects/:uuid/vendor-import` imports one vendor workspace into an existing project.
|
||||
|
||||
Rules:
|
||||
|
||||
1. Read all top-level `ProductLineItem` rows in document order.
|
||||
2. Group them by `ProprietaryGroupIdentifier`.
|
||||
3. Preserve document order of groups by the first encountered `ProductLineNumber`.
|
||||
4. Import each group as exactly one QuoteForge configuration.
|
||||
|
||||
`ConfigurationGroupLineNumberReference` is not sufficient for grouping imported configurations because
|
||||
multiple independent configuration groups may share the same value in one workspace.
|
||||
|
||||
### Primary Row Selection (no SKU hardcode)
|
||||
|
||||
The importer must not hardcode vendor, model, or server SKU values.
|
||||
|
||||
Within each `ProprietaryGroupIdentifier` group, the importer selects one primary top-level row using
|
||||
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 |
|
||||
- accepted file field is `file`;
|
||||
- maximum file size is `1 GiB`;
|
||||
- one `ProprietaryGroupIdentifier` becomes one QuoteForge configuration;
|
||||
- software rows stay inside their hardware group and never become standalone configurations;
|
||||
- primary group row is selected structurally, without vendor-specific SKU hardcoding;
|
||||
- imported configuration order follows workspace order.
|
||||
|
||||
Imported configuration fields:
|
||||
- `name` from primary row `ProductName`
|
||||
- `server_count` from primary row `Quantity`
|
||||
- `server_model` from primary row `ProductDescription`
|
||||
- `article` or `support_code` from `ProprietaryProductIdentifier`
|
||||
|
||||
Imported BOM rows become `vendor_spec` rows and are resolved through the active local partnumber book when possible.
|
||||
|
||||
@@ -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 |
|
||||
|------|-------|
|
||||
| [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 |
|
||||
## Rules
|
||||
|
||||
---
|
||||
- `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.**
|
||||
>
|
||||
> 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`
|
||||
- Local DB path: see [05-config.md](05-config.md)
|
||||
- Runtime bind: loopback only
|
||||
- Local backups: see [06-backup.md](06-backup.md)
|
||||
- Release notes: `releases/<version>/RELEASE_NOTES.md`
|
||||
|
||||
Reference in New Issue
Block a user