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