252 lines
9.3 KiB
Markdown
252 lines
9.3 KiB
Markdown
# 02 — Architecture
|
|
|
|
## Local-First Principle
|
|
|
|
**SQLite** is the single source of truth for the user.
|
|
**MariaDB** is a sync server only — it never blocks local operations.
|
|
|
|
```
|
|
User
|
|
│
|
|
▼
|
|
SQLite (qfs.db) ← all CRUD operations go here
|
|
│
|
|
│ background sync (every 5 min)
|
|
▼
|
|
MariaDB (RFQ_LOG) ← pull/push only
|
|
```
|
|
|
|
**Rules:**
|
|
- All CRUD operations go through SQLite only
|
|
- If MariaDB is unavailable → local work continues without restrictions
|
|
- Changes are queued in `pending_changes` and pushed on next sync
|
|
|
|
## MariaDB Boundary
|
|
|
|
MariaDB is not part of the runtime read/write path for user features.
|
|
|
|
Hard rules:
|
|
|
|
- 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.
|
|
|
|
Forbidden patterns:
|
|
|
|
- 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.
|
|
|
|
## Local Client Boundary
|
|
|
|
The running app is a localhost-only thick client.
|
|
|
|
- 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.
|
|
|
|
---
|
|
|
|
## Synchronization
|
|
|
|
### Data Flow Diagram
|
|
|
|
```
|
|
[ SERVER / MariaDB ]
|
|
┌───────────────────────────┐
|
|
│ qt_projects │
|
|
│ qt_configurations │
|
|
│ qt_pricelists │
|
|
│ qt_pricelist_items │
|
|
│ qt_pricelist_sync_status │
|
|
└─────────────┬─────────────┘
|
|
│
|
|
pull (projects/configs/pricelists)
|
|
│
|
|
┌────────────────────┴────────────────────┐
|
|
│ │
|
|
[ CLIENT A / SQLite ] [ CLIENT B / SQLite ]
|
|
local_projects local_projects
|
|
local_configurations local_configurations
|
|
local_pricelists local_pricelists
|
|
local_pricelist_items local_pricelist_items
|
|
pending_changes pending_changes
|
|
│ │
|
|
└────── push (projects/configs only) ─────┘
|
|
│
|
|
[ SERVER / MariaDB ]
|
|
```
|
|
|
|
### Sync Direction by Entity
|
|
|
|
| Entity | Direction |
|
|
|--------|-----------|
|
|
| Configurations | Client ↔ Server ↔ Other Clients |
|
|
| Projects | Client ↔ Server ↔ Other Clients |
|
|
| Pricelists | Server → Clients only (no push) |
|
|
| Components | Server → Clients only |
|
|
| 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 |
|