Files
QuoteForge/bible-local/02-architecture.md
T
Mikhail ChusavitinandClaude Sonnet 5 7263dd4572 feat: накидка по строке в таблице «Цена продажи» + минимальный CSV
Sale-таблица показывает LOT/Описание/Кол-во/Накидка,%/Цена вместо Estimate/
Склад/Конкуренты/Ручная цена; итоговая цена строки = база (raw estimate или
доля от общей «Ручная цена») × (Аплифт к estimate + своя Накидка%). Экспорт
CSV этой таблицы теперь выводит только LOT;Описание;Кол-во;Цена. Buy-таблица
и массовый экспорт по проекту не затронуты — новые поля запроса опциональны
и включаются только кнопкой «Экспорт CSV» у Sale-таблицы.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:39:54 +03:00

288 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (Цена продажи).
Their column sets differ (see below); the shared per-LOT row expansion/grouping rules apply to both.
Buy table column order:
```
PN вендора | Описание | LOT | Кол-во | Estimate | Склад | Конкуренты | Ручная цена
```
Sale table column order:
```
PN вендора | Описание | LOT | Кол-во | Накидка, % | Цена
```
Sale per-row pricing (`_saleRowPrice`/`recomputeSalePrices` in `index.html`,
`applySaleRowPricing` in `internal/services/export.go`):
- each row's base is its raw Estimate unit price × qty, unless the table-wide "Ручная
цена" input is set — then the base is that row's proportional share of the manual
total (same distribution as before this existed, last row absorbs the rounding
remainder), overwriting the row's own base rather than filling a separate column;
- `Цена = base × (Аплифт к estimate + row's own Накидка% / 100)` — Накидка is a
per-row percentage, additive to the table-wide uplift, entered directly in the
row's own input; unset rows default to 0% (no change to older configs' totals);
Склад/Конкуренты are not shown or exported for the Sale table;
- per-row Накидка is keyed by `"<vendorPN>::<lot>"` (uppercased, "NONE" when there's
no vendor PN) — plain `lot_name` isn't a safe key since the same LOT can appear
under more than one vendor-PN group (see row expansion rules below) — and persisted
as an additive key, `Notes.pricing_ui.sale_row_markups`, alongside the existing
`sale_uplift`/`sale_custom_price` (see
[decisions/2026-09-15-sale-row-markup-and-minimal-csv.md](decisions/2026-09-15-sale-row-markup-and-minimal-csv.md));
- the Sale table's own "Экспорт CSV" button additionally sends
`minimal_sale_columns: true` + `sale_row_markups`, which collapses
`POST /api/configs/:uuid/export/pricing` output to exactly `LOT;Описание;Кол-во;Цена`
(`ProjectPricingExportOptions.MinimalSaleColumns` in `internal/services/export.go`).
This is scoped to that one request: Buy export and the project-level bulk export
never set it and are unaffected.
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`.