Новая вкладка «Аренда» в конфигураторе (доступна для проектов с флагом
rental_enabled): расчёт по методичке "Регламент расчёта стоимости
платного тестирования и аренды GPU-серверов" — Разовый + Еженедельный
платёж по каждому компоненту (New/БУ чекбоксом), с аплифтом к Estimate.
Заодно: поддержка (support_code) в Base-вкладке теперь добавляется как
обычный LOT в спеку (SVC_{срок}y{уровень}_{платформа}) через
autocomplete-пикер вместо выпадающего списка — партномер собирается
тем же lot_name-based механизмом, что и для GPU/CPU/памяти, справочная
цена по регламенту техподдержки хранится в qt_settings.support_pricing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
14 KiB
02 - Architecture
Local-first rule
SQLite is the runtime source of truth. MariaDB is sync transport plus setup and migration tooling.
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 Lockedwith a machine-readable reason; - local work continues even when sync is blocked.
- sync metadata updates must preserve project
updated_at; sync time belongs insynced_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_syncadvances 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_componentstable has been removed; do not recreate it;- component list for the configurator autocomplete comes from
local_pricelist_itemsviaListComponents; - quote calculation reads prices from
local_pricelist_itemsonly; - 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_namemust useUPPER(lot_name) IN ?orUPPER(lot_name) = ?with an uppercased input — never a bare=orINon 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 → originalindex before the query); GetLocalPricesForLotsis the canonical pattern: it uppercases the input list, queries withUPPER(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_categoryfromlocal_pricelist_itemsis 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_mappingsentry 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 withquantity_per_pnfrom_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:
vendorOrigandvendorOrigUnit(BOM unit/total price) are attached to the first LOT sub-row only;- subsequent sub-rows carry empty
data-vendor-origsosetPricingCustomPriceFromVendorcounts 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
mainstate; - 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, andonly_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
mainis reserved and must not be allowed for non-main variants.
Configuration types
Configurations have a config_type field: "server" (default) or "storage".
Rules:
config_typedefaults 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_typeson the server; QF falls back to hardcoded "server/Сервер" and "storage/СХД" when the setting is absent.
Server-driven configurator settings (qt_settings)
QF reads 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 |
support_pricing |
Price data for the Base tab's support-level picker (see below) |
Rules:
- sync runs as part of the pricelist pull; failure is non-fatal (Warn log only);
local_qt_settingsis a read-only cache — never written by user actions;- absent or unparseable settings: QF uses hardcoded fallbacks for that key only;
config_types[].categoriesis an allowlist: a category absent from all types is shown everywhere;qt_categories.nameandqt_categories.name_ruare not used by QF runtime; do not depend on them.
Support as a BOM LOT
The Base tab's support-level picker adds/replaces a synthetic LOT in cart (e.g.
SVC_3yB_HGX-H200, quantity 1) exactly like adding any other component — not a separate
mechanism. This means support flows through total_price, the Pricing tab, exports, and the
rental calc the same way any BOM line does, with no special-casing needed in those paths.
- lot_name shape:
SVC_{years}y{level}_{platform}(e.g.SVC_3yB_HGX-H200);internal/article /generator.go'sbuildSupportSegmentdetects it by theSVC_lot_name prefix (support LOTs aren't in the pricelist catalog, so there's nolot_categoryto key off — same lot_name-pattern approach the generator already uses for GPU/CPU/memory parsing) and emits the{years}y{level}token as the article's SUPPORT segment, replacing the old separateBuildOptions.SupportCodefield/isSupportCodeValidcheck; Configuration.SupportCode(the DB column) is no longer read by article generation; it's inert legacy metadata now — the LOT initemsis the source of truth;- the picker's displayed price is computed client-side per "Регламент расчёта стоимости
технической поддержки серверов": x86 is a percent of the rest of the cart's total (proxy for
"цена продажи"), HGX platforms use a fixed multi-year price by chip generation. The pricing
table itself lives in
qt_settings["support_pricing"]so it can be edited in MariaDB without a QuoteForge release — seebible-local/server-contract-qt-settings.mdfor the schema; the fixed list of offerable level×duration codes stays in the frontend, only the price is server-driven; - platform (x86 vs HGX-H100/H200 vs HGX-B200 vs HGX-B300) is auto-detected client-side from the
cart's GPU components, the same chip-generation classification used by the rental
depreciation calc (
internal/services/rental.go), so the picker only offers combinations valid for the current configuration; - the support LOT has no
lot_category(not in the pricelist), so it renders under the "Other" category tab like any other uncategorized item — no new tab/category was added for it.
Rental / paid-testing pricing contract
QuoteForge can quote paid testing / short-to-mid-term rental of a configuration's hardware, per the draft regulation "Регламент расчёта стоимости платного тестирования и аренды GPU-серверов" (methodology may still change; buyout/Step 9 of the regulation is intentionally not implemented).
Rules:
- rental is enabled per project via
Project.RentalEnabled(qt_projects.rental_enabled); when set, the configurator shows a 4th top-level tab ("Аренда") for every configuration in that project; - New/БУ condition per component lives only on the configuration, in
Configuration.RentalItems(RentalItemCondition{LotName, Condition}) — it is not a property of the lot/pricelist and does not touchConfigItem/BOM; the same lot_name can be "new" in one configuration and "used" in another; - the "Цена" the methodology depreciates against is
Estimate buy price × (1 + RentalUpliftPercent/100)— QuoteForge has no per-component sale price, so a single project-configuration-wide uplift percentage stands in for it; - pricing is always quoted per week — Разовый (one-time layer-1 hit) + Еженедельный (recurring); there is no term/weeks input, a sales rep multiplies the weekly rate by however many weeks are needed;
- annual BASE support price is computed automatically, never entered manually, per "Регламент расчёта
стоимости технической поддержки серверов": any GPU component present classifies the configuration as HGX and
uses the regulation's fixed per-platform price (H100/H200, B200, B300, by lot_name substring); no GPU present
falls back to 5% of the uplifted price sum as an x86 sale-price proxy —
support_codeis pure article-formatting metadata (seeinternal/article/generator.go) and is never resolved to a price, so there is no SVC_-catalog lookup to reuse; - GPU "actual vs stabilized generation" classification for depreciation life (2yr vs 3yr) and the separate
GPU "support platform" classification (H100/H200 vs B200 vs B300) are both hardcoded lot_name substring
lists in
internal/services/rental.go, not sourced fromlot_category; RentalItemsandRentalUpliftPercentare included in the revision dedup fingerprint (BuildConfigurationSpecPriceFingerprint), so rental edits create new revisions like other spec/price-affecting changes;internal/services/rental.go(RentalService.Calculate) is stateless: it re-derives categories viaGetLocalLotCategoriesByServerPricelistIDand computes pricing (including the auto support price) from the request body, so calculate calls do not require saving first.
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, orqt_lot_bundle_items.