# 02 - Architecture ## Local-first rule SQLite is the runtime source of truth. MariaDB is sync transport plus setup and migration tooling. ```text browser -> Gin handlers -> SQLite -> pending_changes background sync <------> MariaDB ``` 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. ## Sync contract Bidirectional: - projects; - configurations; - `vendor_spec`; - pending change metadata. Pull-only: - components; - pricelists and pricelist items; - partnumber books and partnumber book items. 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. - sync metadata updates must preserve project `updated_at`; sync time belongs in `synced_at`, not in the user-facing last-modified timestamp. - pricelist pull must persist a new local snapshot atomically: header and items appear together, and `last_pricelist_sync` advances only after item download succeeds. - UI sync status must distinguish "last sync failed" from "up to date"; if the app can prove newer server pricelist data exists, the indicator must say local cache is incomplete. ## Pricing contract `local_pricelist_items` is the single source of truth for both prices and component catalog (lot_name + lot_category). There is no separate component catalog table. Rules: - `local_components` table has been removed; do not recreate it; - component list for the configurator autocomplete comes from `local_pricelist_items` via `ListComponents`; - quote calculation reads prices from `local_pricelist_items` only; - latest pricelist selection ignores snapshots without items; - auto pricelist mode stays auto and must not be persisted as an explicit resolved ID. ## lot_name case handling lot_names in `local_pricelist_items` may be stored in mixed case in databases synced before normalization was enforced. `NormalizeLotName` (uppercase + trim) is applied at sync time via `PricelistItemToLocal`, but existing rows are not retroactively updated. Rules: - all SQLite queries that filter by `lot_name` must use `UPPER(lot_name) IN ?` or `UPPER(lot_name) = ?` with an uppercased input — never a bare `=` or `IN` on a string that may have been sourced from user input or a legacy row; - result map keys must preserve the original case passed by the caller (build a `uppercase → original` index before the query); - `GetLocalPricesForLots` is the canonical pattern: it uppercases the input list, queries with `UPPER(lot_name) IN ?`, and returns keys that match the input lot_names; - frontend JS must never infer a component category from the lot_name prefix; `lot_category` from `local_pricelist_items` is the only valid source; items without a category fall into the "Other" tab. ## Pricing tab layout The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи). Column order (both tables): ``` PN вендора | Описание | LOT | Кол-во | Estimate | Склад | Конкуренты | Ручная цена ``` Per-LOT row expansion rules: - each `lot_mappings` entry in a BOM row becomes its own table row with its own quantity and prices; - `baseLot` (resolved LOT without an explicit mapping) is treated as the first sub-row with `quantity_per_pn` from `_getRowLotQtyPerPN`; - when one vendor PN expands into N LOT sub-rows, PN вендора and Описание cells use `rowspan="N"` and appear only on the first sub-row; - a visual top border (`border-t border-gray-200`) separates each vendor PN group. Vendor price attachment: - `vendorOrig` and `vendorOrigUnit` (BOM unit/total price) are attached to the first LOT sub-row only; - subsequent sub-rows carry empty `data-vendor-orig` so `setPricingCustomPriceFromVendor` counts each vendor PN exactly once. Controls terminology: - custom price input is labeled **Ручная цена** (not "Своя цена"); - the button that fills custom price from BOM totals is labeled **BOM Цена** (not "Проставить цены BOM"). CSV export reads PN вендора, Описание, and LOT from `data-vendor-pn`, `data-desc`, `data-lot` row attributes to bypass the rowspan cell offset problem. ## Configuration versioning Configuration revisions are append-only snapshots stored in `local_configuration_versions`. Rules: - the editable working configuration is always the implicit head named `main`; UI must not switch the user to a numbered revision after save; - create a new revision when spec, BOM, or pricing content changes; - revision history is retrospective: the revisions page shows past snapshots, not the current `main` state; - 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; - revision deduplication includes `items`, `server_count`, `total_price`, `custom_price`, `vendor_spec`, pricelist selectors, `disable_price_refresh`, and `only_in_stock`; - BOM updates must use version-aware save flow, not a direct SQL field update; - current revision pointer must be recoverable if legacy or damaged rows are found locally. ## Sync UX UI-facing sync status must never block on live MariaDB calls. Rules: - navbar sync indicator and sync info modal read only local cached state from SQLite/app settings; - background/manual sync may talk to MariaDB, but polling endpoints must stay fast even on slow or broken connections; - any MariaDB timeout/invalid-connection during sync must invalidate the cached remote handle immediately so UI stops treating the connection as healthy. ## Naming collisions UI-driven rename and copy flows use one suffix convention for conflicts. Rules: - configuration and variant names must auto-resolve collisions with `_копия`, then `_копия2`, `_копия3`, and so on; - copy checkboxes and copy modals must prefill `_копия`, not ` (копия)`; - the literal variant name `main` is reserved and must not be allowed for non-main variants. ## Configuration types Configurations have a `config_type` field: `"server"` (default) or `"storage"`. Rules: - `config_type` defaults to `"server"` for all existing and new configurations unless explicitly set; - the configurator page is shared for both types; the SW tab is always visible regardless of type; - storage configurations use the same vendor_spec + PN→LOT + pricing flow as server configurations; - storage component categories map to existing tabs: `ENC`/`DKC`/`CTL` → Base, `HIC` → PCI (HIC-карты СХД; `HBA`/`NIC` — серверные, не смешивать), `SSD`/`HDD` → Storage (используют существующие серверные LOT), `ACC` → Accessories (используют существующие серверные LOT), `SW` → SW. - `DKC` = контроллерная полка (модель СХД + тип дисков + кол-во слотов + кол-во контроллеров); `CTL` = контроллер (кэш + встроенные порты); `ENC` = дисковая полка без контроллера. - the available config types and their localized names flow from `qt_settings.config_types` on the server; QF falls back to hardcoded "server/Сервер" and "storage/СХД" when the setting is absent. ## Server-driven configurator settings (`qt_settings`) QF reads four settings from `qt_settings` (MariaDB) and caches them in `local_qt_settings` (SQLite). They are synced during every component sync. See `bible-local/server-contract-qt-settings.md` for the full contract and JSON schemas. | Setting key | Effect in QF | |-------------|-------------| | `config_types` | New-config modal buttons; category allowlist per config type | | `tab_config` | Configurator tab structure, sections, singleSelect | | `always_visible_tabs` | Which tabs are shown even when empty | | `required_categories` | Per-config-type badge on tabs with unfilled required categories | Rules: - sync runs as part of the pricelist pull; failure is non-fatal (Warn log only); - `local_qt_settings` is a read-only cache — never written by user actions; - absent or unparseable settings: QF uses hardcoded fallbacks for that key only; - `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere; - `qt_categories.name` and `qt_categories.name_ru` are not used by QF runtime; do not depend on them. ## Vendor BOM contract Vendor BOM is stored in `vendor_spec` on the configuration row. 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`.