Генерация артикула резолвила lot_category из одного прайслиста конфигурации (GetLocalLotCategoriesByServerPricelistID), поэтому world-only LOT (напр. GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE, есть только в world) молча выпадал из артикула. Теперь категории берутся через GetLocalComponentCategoriesByLotNames (тот же world ∪ estimate, что и весь конфигуратор). BuildOptions.ServerPricelist убран; preview-article принимает pricelist_id, но игнорирует. Нераспознанные токены больше не пишутся как UNK: в артикул идёт lot_category как плейсхолдер (4xGPU, 2xCPU), сегмент помечается Recognized=false, добавляется warning с именем LOT. Конфигуратор подсвечивает такие сегменты (amber) и выводит список предупреждений; сохранение/обновление/откат логируют WARN. Без каталога вендоров в репо детектируется только структурный сбой формы имени. parseGPUModel: принимает суффикс-букву в номере модели (6000D → RTX6000D), раньше терял её и схлопывал до RTX_84GB. ADL bible-local/decisions/2026-09-01-article-category-from-component-universe.md, 2026-09-01-article-degraded-token-visibility.md; раздел «Article generation» в 02-architecture.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RAhfF4P1ySRZ67yyUUeVw2
258 lines
16 KiB
Markdown
258 lines
16 KiB
Markdown
# 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.
|
||
|
||
In the colour-optional indicator mode (`app_settings.indicator_mode = "accessible"`, see
|
||
[03-database.md](03-database.md) and [decisions/2026-08-31-accessible-indicator-mode.md](decisions/2026-08-31-accessible-indicator-mode.md))
|
||
both tables gain a narrow leading price-quality meter column (per sub-row), the row background
|
||
tint is removed, `world`-fallback cells show a `W` text marker instead of amber, and the footer
|
||
total shows a `⚠` prefix instead of red. CSV output is unaffected.
|
||
|
||
## 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 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_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.
|
||
|
||
## Article generation
|
||
|
||
`internal/article` builds the `article` string (`{MODEL}-{CPU}-{MEM}-{GPU}-{DISK}-{NET}-{PSU}-{SUPPORT}`)
|
||
from the configuration's `items` + `server_model`.
|
||
|
||
- which segment a cart LOT belongs to is decided by its `lot_category`, resolved through
|
||
`ResolveLotCategories` → `GetLocalComponentCategoriesByLotNames`, i.e. the **component
|
||
universe** (latest active `world` ∪ `estimate`), the same source the configurator/BOM/pricing
|
||
tab use. It must **not** be scoped to the configuration's pinned pricelist: a world-only LOT
|
||
(e.g. `GPU_NV_RTX_PRO_6000D_...`, priced by the world fallback, never added to the estimate
|
||
pricelist) is a legitimate cart member and still carries a real `lot_category` in `world`.
|
||
Scoping to one pricelist silently dropped such LOTs from the article. See
|
||
[decisions/2026-09-01-article-category-from-component-universe.md](decisions/2026-09-01-article-category-from-component-universe.md);
|
||
- `BuildOptions` no longer takes a pricelist; `POST /api/configs/preview-article` still accepts
|
||
`pricelist_id` but ignores it;
|
||
- category comes only from real synced pricelist columns — never inferred from the `lot_name`
|
||
prefix (the `SVC_` SUPPORT segment is the sole lot_name-pattern exception, see below);
|
||
- within a segment the model/capacity/speed **token** is still parsed from the `lot_name` —
|
||
that is the article text itself, not categorization;
|
||
- when a token can't be parsed (the `lot_name` doesn't fit `{GROUP}_{VENDOR}_{MODEL}[_{SPEC}…]`)
|
||
the segment carries the LOT's `lot_category` as the token — **never** a bare `UNK`. Such a
|
||
segment comes back with `Recognized = false` in `BuildResult.Segments`, plus a `Warnings`
|
||
entry naming the `lot_name`. The configurator highlights the segment (amber) and lists the
|
||
warnings; create/update/rollback log `WARN "article generation degraded"`. No catalog of real
|
||
lot_names / model names lives in the repo (`no-hardcoded-vendors`), so only *structural*
|
||
parse failure is detected, not a wrong-but-well-formed token. See
|
||
[decisions/2026-09-01-article-degraded-token-visibility.md](decisions/2026-09-01-article-degraded-token-visibility.md).
|
||
|
||
## 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`'s `buildSupportSegment` detects it by the `SVC_` lot_name prefix (support LOTs
|
||
aren't in the pricelist catalog, so there's no `lot_category` to 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 separate `BuildOptions.SupportCode`
|
||
field/`isSupportCodeValid` check;
|
||
- `Configuration.SupportCode` (the DB column) is no longer read by article generation; it's
|
||
inert legacy metadata now — the LOT in `items` is 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 — see `bible-local/server-contract-qt-settings.md` for 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 touch
|
||
`ConfigItem`/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_code` is pure
|
||
article-formatting metadata (see `internal/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 from `lot_category`;
|
||
- `RentalItems` and `RentalUpliftPercent` are 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 via
|
||
`GetLocalLotCategoriesByServerPricelistID` and 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`, or `qt_lot_bundle_items`.
|