Compare commits

...

22 Commits
v1.15 ... v2.21

Author SHA1 Message Date
8b2dc6652a docs: release notes v2.21 (полный диф от v2.19)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:14:51 +03:00
cea979e327 docs: release notes v2.21
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:13:57 +03:00
4d002671ae chore: обновление субмодуля bible
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:10:16 +03:00
949479550c fix: устранение race condition и улучшение диагностики синхронизации
- SyncPricelists() теперь захватывает pricelistMu, предотвращая параллельный
  запуск фонового тикера и ручного sync (было причиной UNIQUE constraint ошибки)
- Дедупликация lot_name в fetchServerPricelistItems на случай дублей на сервере
- PushPendingChanges пишет запись в sync_log (тип "changes") при каждом запуске
- syncPricelists вызывает reportClientSchemaState через defer — состояние
  клиента отправляется на сервер независимо от исхода синхронизации

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:08:20 +03:00
Mikhail Chusavitin
677b5d898f fix: не зачёркивать старую цену в итоге конфигурации если изменений нет
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 11:59:06 +03:00
Mikhail Chusavitin
b3cab3477b feat: /:code/:variant URL для вариантов опти + валидация имени варианта
- Роут GET /:code/:variant → редирект на /projects/:uuid (case-insensitive)
- Валидация имени варианта: только URL-безопасные символы [A-Za-z0-9._-]
  (бэкенд validateProjectVariantName + клиентская проверка в обеих формах)
- Подсказки в UI: «Используется в URL: /КОД/Вариант»

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 11:39:50 +03:00
Mikhail Chusavitin
6d4a37df8b feat: ревизия до обновления цен + короткие ссылки /:code для опти
- При нажатии «обновить цены» создаётся ревизия текущего состояния
  («до обновления цен») через новый эндпоинт POST /api/configs/:uuid/snapshot,
  затем saveConfig создаёт ревизию с новыми ценами
- Роут GET /:code → редирект на /projects/:uuid по коду опти (регистронезависимо)
- Валидация кода опти: только URL-безопасные символы [A-Za-z0-9._-]
  (бэкенд + клиентская проверка + подсказка в форме)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 11:29:40 +03:00
Mikhail Chusavitin
7cc101d24d docs: release notes v2.19
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:49:44 +03:00
Mikhail Chusavitin
4900cd073c feat: server-driven configurator settings via qt_settings
Replaces hardcoded JS category filters and config-type buttons with
server-pushed settings synced from qt_settings (MariaDB) → local_qt_settings (SQLite).

- new table local_qt_settings (AutoMigrate) — synced after component sync
- GET /api/configurator-settings returns config_types, tab_config,
  always_visible_tabs, required_categories with hardcoded fallbacks
- new-config modal: type buttons rendered from server data (Сервер/СХД static fallback)
- configurator: TAB_CONFIG and category filter driven by server; required-category badge on tabs
- SyncQtSettings wired into SyncComponents and SyncAll handlers (non-fatal on old server)
- bible-local/server-contract-qt-settings.md — contract for server-side agent
- page titles: OFS → QFS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:33:31 +03:00
Mikhail Chusavitin
c0588e9710 chore: release.sh — только darwin-arm64 и windows-amd64
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 14:34:12 +03:00
Mikhail Chusavitin
0cd4f99b46 docs: release notes v1.18
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 14:26:36 +03:00
Mikhail Chusavitin
4982adbe41 fix: сортировка строк по категории в pricing CSV и вкладке Ценообразование (no-BOM)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 14:25:23 +03:00
Mikhail Chusavitin
5359ae6ded fix: pricing-таблица использует qty из корзины (source of truth)
Ценообразование показывало неверное количество для LOT-ов с bundle
(lot_qty_per_pn > 1) или устаревшим quantity_per_pn в vendor_spec.
Итог Estimate расходился с Estimate-табом.

Теперь qty берётся из корзины если LOT там присутствует; BOM-расчёт
(row.quantity × lot_qty_per_pn) остаётся fallback для ещё не применённых строк.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 12:20:13 +03:00
Mikhail Chusavitin
76d93c6be8 feat: сохранение и экспорт ручной цены (buy/sale) из вкладки Ценообразование
Сохранение:
- restoreAutosaveDraftIfAny теперь восстанавливает pricing_ui из notes драфта
- saveConfigOnExit привязан к pagehide и visibilitychange — цены сохраняются
  на сервер при уходе со страницы без явного нажатия «Сохранить»

Экспорт CSV:
- exportPricingCSV передаёт manual_price (buy для FOB, sale для DDP)
- ProjectPricingExportOptions.ManualPrice *float64 — новое поле
- distributeManualPrice распределяет ручную цену пропорционально estimate
  с коррекцией остатка на последней строке
- Колонка «Ручная цена» в CSV (заголовок, строки, итог конфига)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 09:59:27 +03:00
Mikhail Chusavitin
c6385f6cf1 fix: CSV экспорт — bundle (1 PN → N LOT) разворачивается в отдельные строки
buildPricingExportBlock теперь создаёт одну строку на каждый LOT mapping,
а не одну строку на BOM-строку. BOM-цена ставится только в первую подстроку
(как vendorOrig в фронтенде). Добавлен computeSingleLotTotal, удалён
неиспользуемый formatLotDisplay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 07:53:37 +03:00
Mikhail Chusavitin
1ab5186d0c fix: BOM — cart-LOT priority в дропдауне + корректный qtyMismatch при lot_qty_per_pn > 1
- filterAutocompleteBOM: LOT из текущего конфигуратора выводятся первыми
  с разделителем «── прочие ──», остальные — по popularity_score
- qtyMismatch теперь сравнивает cartQty с pn_qty × lot_qty_per_pn во всех
  трёх местах рендера BOM-таблицы; «8 LOT = 1 PN» больше не даёт ложного
  жёлтого предупреждения

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 07:48:40 +03:00
Mikhail Chusavitin
b6fdac1caa feat: Nx BOM import — формат <qty>x <description>
Добавлен новый вариант импорта спеки: quantity-first формат, где каждая
строка начинается с `<qty>x <description>` (например, «2x Intel Xeon 8570»).
Порядок детекции: Inspur → Nx → Text BOM. Заголовок «, в составе:» работает
так же, как в Text BOM — последний токен перед запятой становится server_model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 07:42:46 +03:00
Mikhail Chusavitin
b837ca7866 docs: release notes v1.17
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:56:58 +03:00
Mikhail Chusavitin
c8092da370 fix: поиск по LOT в книгах партномеров — CAST(lots_json AS TEXT) LIKE
modernc.org/sqlite (glebarez/sqlite) не приводит BLOB к TEXT при LIKE,
в отличие от нативного sqlite3. lots_json хранится как BLOB (json.Marshal
возвращает []byte), поэтому `lots_json LIKE ?` всегда возвращал 0.
Исправлено на CAST(lots_json AS TEXT) LIKE — теперь поиск по LOT имени
работает корректно.

Заодно обновлён плейсхолдер поля поиска: «PN, LOT или описание».

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:52:58 +03:00
Mikhail Chusavitin
4f105822c6 docs: release notes v1.16
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:30:05 +03:00
Mikhail Chusavitin
6df262b8ee fix: self-heal застрявших pending changes при broken project reference
- ensureConfigurationProject: если project не найден ни на сервере, ни локально
  (stale UUID после удаления), падаем в fallback «Без проекта» вместо вечной ошибки
- PushPendingChanges: автоматически вызывает RepairPendingChanges() перед циклом,
  чтобы локально-исправимые проблемы чинились до попытки отправки
- maxPendingChangeAttempts=20: после 20 неудачных попыток change считается
  unrecoverable и удаляется из очереди (логируется ERROR)
- pushSingleChange/pushConfigurationChange: unknown entity type / operation
  теперь дропается с warn вместо вечного error в цикле
- latestSyncErrorState: last_sync_error_text в qt_client_schema_state теперь
  содержит JSON-массив с type/uuid/op/attempts/error по всем застрявшим changes
  (до 20 штук) вместо текста только последней ошибки

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:28:07 +03:00
Mikhail Chusavitin
0fc0366bb1 feat: синхронизировать книги партномеров вместе с прайслистами
PullPartnumberBooks вызывается автоматически после каждой синхронизации
прайслистов — в фоновом воркере, при ручном триггере /api/sync/pricelists
и при полной синхронизации /api/sync/all. Отдельная кнопка «Синхронизировать»
на странице Партномера удалена.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:09:14 +03:00
35 changed files with 1457 additions and 200 deletions

2
bible

Submodule bible updated: 52444350c1...1977730d93

View File

@@ -116,6 +116,28 @@ Rules:
- 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 after `SyncComponents`; 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

View File

@@ -20,6 +20,7 @@ Main tables:
| `connection_settings` | encrypted MariaDB connection settings |
| `app_settings` | local app state |
| `local_schema_migrations` | applied local migration markers |
| `local_qt_settings` | server-pushed configurator settings cache (from `qt_settings`) |
Rules:
- cache tables may be rebuilt if local migration recovery requires it;
@@ -34,12 +35,13 @@ MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
### QuoteForge tables (qt_*)
Runtime read:
- `qt_categories` — pricelist categories
- `qt_categories` — pricelist categories (note: `name`/`name_ru` columns being removed; QF does not use them)
- `qt_lot_metadata` — component metadata, price settings
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
- `qt_pricelist_items` — pricelist rows
- `qt_partnumber_books` — partnumber book headers
- `qt_partnumber_book_items` — PN→LOT catalog payload
- `qt_settings` — server-pushed configurator settings; schema managed by server-side agent (see `bible-local/server-contract-qt-settings.md`)
Runtime read/write:
- `qt_projects` — projects
@@ -91,11 +93,26 @@ Full column reference as of 2026-03-21 (`RFQ_LOG` final schema).
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| code | varchar(20) UNIQUE NOT NULL | |
| name | varchar(100) NOT NULL | |
| name_ru | varchar(100) | |
| name | varchar(100) NOT NULL | being removed; QF does not use at runtime |
| name_ru | varchar(100) | being removed; QF does not use at runtime |
| display_order | bigint DEFAULT 0 | |
| is_required | tinyint(1) DEFAULT 0 | |
### qt_settings
Managed by the server-side agent. QF has SELECT-only access.
See `bible-local/server-contract-qt-settings.md` for full schema and value formats.
| Column | Type | Notes |
|--------|------|-------|
| name | varchar(100) PK | setting key |
| value | TEXT NOT NULL | JSON-encoded value |
### local_qt_settings (SQLite)
Read-only cache of `qt_settings`. Synced during component sync.
| Column | Type | Notes |
|--------|------|-------|
| name | text PK | setting key |
| value | text | JSON value as-is from server |
### qt_client_schema_state
PK: (username, hostname)
| Column | Type | Notes |

View File

@@ -112,6 +112,41 @@ Rules:
- lines that do not match `<description> - <quantity> шт.` are skipped;
- no price data is present in the format; `unit_price` and `total_price` are left nil.
## Nx BOM import (quantity-first)
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a quantity-first BOM
where each item line begins with `<qty>x <description>`.
Format: an optional header line ending with `, в составе:` followed by one component per line as
`<qty>x <description>`. The `x` separator is case-insensitive; parentheses, commas, and hyphens
inside the description are preserved as-is.
Example:
```
Сервер G893-SD1-AAX3, в составе:
1x 8U 2CPU 8GPU Server System (32x DDR5 DIMM Slots,8x 2.5" Hot-Swap Drive Bays, 4+4 3000W, 2x 10Gb/s RJ45, 2x IPMI RJ45)
2x Intel Xeon 8570 (56 cores, 2.1GHz, 300MB, 350W)
32x 64GB DDR5 ECC RDIMM
1x GPU Nvidia HGX H200 141GB 8GPU
3x 1.92TB NVMe PCIe SFF RI
5x 7.68TB NVMe PCIe SFF RI
8x 1-port 400G NDR OSFP CX7
2x 2-port 100GbE QSFP56 CX6
1x 2-port 10GbE RJ45
```
Rules:
- the entire file becomes a single configuration (`server_count = 1`);
- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the
last whitespace-separated token before the comma;
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and
`description`, so rows resolve through the active partnumber book when matched and otherwise stay
unresolved and editable in the UI;
- lines that do not match `<qty>x <description>` are skipped;
- no price data is present in the format; `unit_price` and `total_price` are left nil;
- detection runs before Text BOM in the format switch (Inspur → Nx → Text).
## Pasted BOM text parsing
`POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM
@@ -119,7 +154,7 @@ Rules:
`{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`.
This shares the exact detectors and parsers used by the file-import path
(`ParsePastedBOMText``IsInspurBOM`/`parseInspurBOM`, `IsTextBOM`/`parseTextBOM`), so paste and upload
behave identically — there is no second parser in the frontend. The configurator's BOM paste box calls
this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real spreadsheet table)
falls back to the manual column-mapping grid.
(`ParsePastedBOMText``IsInspurBOM`/`parseInspurBOM`, `IsNxBOM`/`parseNxBOM`, `IsTextBOM`/`parseTextBOM`),
so paste and upload behave identically — there is no second parser in the frontend. The configurator's BOM
paste box calls this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real
spreadsheet table) falls back to the manual column-mapping grid.

View File

@@ -0,0 +1,165 @@
# Server contract: qt_settings
## Purpose
`qt_settings` is a general-purpose key→JSON-value table that the price management
application uses to push configuration into QuoteForge clients. QF reads it during
component sync and caches the result in `local_qt_settings` (SQLite).
## Required MariaDB changes (implemented by server-side agent)
```sql
CREATE TABLE IF NOT EXISTS qt_settings (
name VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NOT NULL -- JSON-encoded value
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
GRANT SELECT ON RFQ_LOG.qt_settings TO 'qfs_user'@'%';
```
## Settings consumed by QuoteForge
All values are JSON. Missing or unparseable entries are silently skipped; QF
falls back to hardcoded defaults for each missing key.
---
### `config_types`
Defines the available device configuration types, their localized names, and the
category codes that are allowed for each type. QF uses this for:
- the new-config modal (button list + labels);
- the configurator's category filter per `config_type`.
**Value format:** JSON array of objects.
```json
[
{
"code": "server",
"name_ru": "Сервер",
"display_order": 10,
"categories": [
"MB","CPU","MEM","RAID",
"SSD","HDD","M2","EDSFF","HHHL",
"GPU","NIC","HCA","DPU","HBA",
"PSU","PS","ACC","RISERS","CARD","BB"
]
},
{
"code": "storage",
"name_ru": "СХД",
"display_order": 20,
"categories": [
"DKC","CPU","MEM","PS",
"SSD","HDD","M2","EDSFF","HHHL",
"NIC","HBA","HCA","ACC","CARD"
]
}
]
```
Fields:
| Field | Type | Description |
|-------|------|-------------|
| `code` | string | Identifier stored on `qt_configurations.config_type`. Must be stable. |
| `name_ru` | string | Display name in Russian for the QF UI. |
| `display_order` | int | Sort order for the modal button list. |
| `categories` | string[] | Allowlist of LOT category codes visible in this config type. A category absent from ALL entries is visible in all types. |
---
### `tab_config`
Defines the configurator tab layout: which tabs exist, which categories each tab
contains, optional sub-sections within a tab, and whether the tab uses
single-select mode.
**Value format:** JSON array of tab objects (ordered — defines tab bar order).
```json
[
{
"key": "base",
"label": "Base",
"single_select": true,
"categories": ["MB","CPU","MEM","ENC","DKC","CTL"],
"sections": null
},
{
"key": "storage",
"label": "Storage",
"single_select": false,
"categories": ["RAID","M2","SSD","HDD","EDSFF","HHHL"],
"sections": [
{ "title": "RAID Контроллеры", "categories": ["RAID"] },
{ "title": "Диски", "categories": ["M2","SSD","HDD","EDSFF","HHHL"] }
]
},
{
"key": "pci",
"label": "PCI",
"single_select": false,
"categories": ["GPU","DPU","NIC","HCA","HBA","HIC"],
"sections": [
{ "title": "GPU / DPU", "categories": ["GPU","DPU"] },
{ "title": "NIC / HCA", "categories": ["NIC","HCA"] },
{ "title": "HBA", "categories": ["HBA"] },
{ "title": "HIC", "categories": ["HIC"] }
]
},
{ "key": "power", "label": "Power", "single_select": false, "categories": ["PS","PSU"] },
{ "key": "accessories", "label": "Accessories", "single_select": false, "categories": ["ACC","CARD"] },
{ "key": "sw", "label": "SW", "single_select": false, "categories": ["SW"] }
]
```
The QF frontend always appends an "other" tab for any categories not listed here.
---
### `always_visible_tabs`
Tab keys that are always shown in the configurator regardless of whether they
contain any items. Other tabs are hidden when empty.
**Value format:** JSON string array.
```json
["base", "storage", "pci"]
```
---
### `required_categories`
Category codes that must have at least one LOT selected for a configuration to
be considered complete. Keyed by `config_type` code. QF uses this to show a
badge on the tab label when required categories are missing.
**Value format:** JSON object mapping config_type code → string array.
```json
{
"server": ["CPU", "MEM", "BB"],
"storage": ["DKC", "CPU", "MEM"]
}
```
---
## Backward compatibility
- If `qt_settings` does not exist (old server): QF logs `Warn` during sync and
leaves `local_qt_settings` empty. The frontend falls back to hardcoded defaults
for all four settings. No crash, no data loss.
- If a specific key is absent from `qt_settings`: QF falls back to the hardcoded
default for that key only.
- Old QF clients that do not know about `local_qt_settings` continue to use their
hardcoded JS constants unchanged.
## Note on `qt_categories`
`qt_categories.name` and `qt_categories.name_ru` are being removed.
QF runtime does not depend on them — `GetCategories` derives `Name` from the
category code string stored in `local_components`.

View File

@@ -894,6 +894,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/partnumber-books", webHandler.PartnumberBooks)
// Short project URLs: /:code → main variant, /:code/:variant → named variant
router.GET("/:code", func(c *gin.Context) {
code := c.Param("code")
project, err := projectService.GetByCode(code)
if err != nil {
c.Redirect(http.StatusFound, "/projects")
return
}
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
})
router.GET("/:code/:variant", func(c *gin.Context) {
code := c.Param("code")
variant := c.Param("variant")
project, err := projectService.GetByCodeAndVariant(code, variant)
if err != nil {
c.Redirect(http.StatusFound, "/projects")
return
}
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
})
// htmx partials
partials := router.Group("/partials")
{
@@ -919,6 +940,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Categories (public)
api.GET("/categories", componentHandler.GetCategories)
api.GET("/configurator-settings", componentHandler.GetConfiguratorSettings)
// Quote (public)
quote := api.Group("/quote")
@@ -1147,6 +1169,15 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusOK, config)
})
configs.POST("/:uuid/snapshot", func(c *gin.Context) {
uuid := c.Param("uuid")
if err := configService.SnapshotCurrentState(uuid); err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
configs.PATCH("/:uuid/project", func(c *gin.Context) {
uuid := c.Param("uuid")
var req struct {
@@ -1516,7 +1547,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
project, err := projectService.Create(dbUsername, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrReservedMainVariant):
case errors.Is(err, services.ErrReservedMainVariant),
errors.Is(err, services.ErrProjectCodeInvalidChars),
errors.Is(err, services.ErrProjectVariantInvalidChars):
respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectCodeExists):
respondError(c, http.StatusConflict, "conflict detected", err)
@@ -1554,7 +1587,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil {
switch {
case errors.Is(err, services.ErrReservedMainVariant),
errors.Is(err, services.ErrCannotRenameMainVariant):
errors.Is(err, services.ErrCannotRenameMainVariant),
errors.Is(err, services.ErrProjectCodeInvalidChars),
errors.Is(err, services.ErrProjectVariantInvalidChars):
respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectCodeExists):
respondError(c, http.StatusConflict, "conflict detected", err)

View File

@@ -125,3 +125,102 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
c.JSON(http.StatusOK, models.DefaultCategories)
}
func (h *ComponentHandler) GetConfiguratorSettings(c *gin.Context) {
s, _ := h.localDB.GetConfiguratorSettings()
if s == nil {
s = &localdb.ConfiguratorSettings{}
}
if len(s.ConfigTypes) == 0 {
s.ConfigTypes = defaultConfigTypes()
}
if len(s.TabConfig) == 0 {
s.TabConfig = defaultTabConfig()
}
if len(s.AlwaysVisibleTabs) == 0 {
s.AlwaysVisibleTabs = []string{"base", "storage", "pci"}
}
if len(s.RequiredCategories) == 0 {
s.RequiredCategories = map[string][]string{"server": {"CPU", "MEM", "BB"}}
}
c.JSON(http.StatusOK, s)
}
func defaultConfigTypes() []localdb.ConfigTypeDef {
return []localdb.ConfigTypeDef{
{
Code: "server",
NameRu: "Сервер",
DisplayOrder: 10,
Categories: []string{
"MB", "CPU", "MEM", "RAID",
"SSD", "HDD", "M2", "EDSFF", "HHHL",
"GPU", "NIC", "HCA", "DPU", "HBA",
"PSU", "PS", "ACC", "RISERS", "CARD", "BB",
},
},
{
Code: "storage",
NameRu: "СХД",
DisplayOrder: 20,
Categories: []string{
"DKC", "CPU", "MEM", "PS",
"SSD", "HDD", "M2", "EDSFF", "HHHL",
"NIC", "HBA", "HCA", "ACC", "CARD",
},
},
}
}
func defaultTabConfig() []localdb.TabDef {
return []localdb.TabDef{
{
Key: "base",
Label: "Base",
SingleSelect: true,
Categories: []string{"MB", "CPU", "MEM", "ENC", "DKC", "CTL"},
},
{
Key: "storage",
Label: "Storage",
SingleSelect: false,
Categories: []string{"RAID", "M2", "SSD", "HDD", "EDSFF", "HHHL"},
Sections: []localdb.TabSection{
{Title: "RAID Контроллеры", Categories: []string{"RAID"}},
{Title: "Диски", Categories: []string{"M2", "SSD", "HDD", "EDSFF", "HHHL"}},
},
},
{
Key: "pci",
Label: "PCI",
SingleSelect: false,
Categories: []string{"GPU", "DPU", "NIC", "HCA", "HBA", "HIC"},
Sections: []localdb.TabSection{
{Title: "GPU / DPU", Categories: []string{"GPU", "DPU"}},
{Title: "NIC / HCA", Categories: []string{"NIC", "HCA"}},
{Title: "HBA", Categories: []string{"HBA"}},
{Title: "HIC", Categories: []string{"HIC"}},
},
},
{
Key: "power",
Label: "Power",
SingleSelect: false,
Categories: []string{"PS", "PSU"},
},
{
Key: "accessories",
Label: "Accessories",
SingleSelect: false,
Categories: []string{"ACC", "CARD"},
},
{
Key: "sw",
Label: "SW",
SingleSelect: false,
Categories: []string{"SW"},
},
}
}

View File

@@ -203,6 +203,10 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
_ = h.localDB.SetComponentSyncResult("ok", "", now)
h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds())
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
slog.Warn("qt_settings sync failed", "error", err)
}
c.JSON(http.StatusOK, SyncResultResponse{
Success: true,
Message: "Components synced successfully",
@@ -232,6 +236,10 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
}
h.localDB.AppendSyncLog("pricelists", "ok", "", synced, startTime, time.Since(startTime).Milliseconds())
if _, err := h.syncService.PullPartnumberBooks(); err != nil {
slog.Warn("partnumber books pull failed after pricelist sync", "error", err)
}
c.JSON(http.StatusOK, SyncResultResponse{
Success: true,
Message: "Pricelists synced successfully",
@@ -335,6 +343,10 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds())
componentsSynced = compResult.TotalSynced
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
slog.Warn("qt_settings sync failed", "error", err)
}
// Sync pricelists
plNow := time.Now()
pricelistsSynced, err = h.syncService.SyncPricelists()
@@ -352,6 +364,10 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
}
h.localDB.AppendSyncLog("pricelists", "ok", "", pricelistsSynced, plNow, time.Since(plNow).Milliseconds())
if _, err := h.syncService.PullPartnumberBooks(); err != nil {
slog.Warn("partnumber books pull failed during full sync", "error", err)
}
projectsResult, err := h.syncService.ImportProjectsToLocal()
if err != nil {
slog.Error("project import failed during full sync", "error", err)

View File

@@ -230,6 +230,7 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
&PendingChange{},
&LocalPartnumberBook{},
&SyncLogEntry{},
&LocalQtSetting{},
)
}
@@ -691,6 +692,22 @@ func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) {
return &project, nil
}
func (l *LocalDB) GetProjectByCode(code string) (*LocalProject, error) {
var project LocalProject
if err := l.db.Where("LOWER(code) = LOWER(?) AND variant = ''", code).First(&project).Error; err != nil {
return nil, err
}
return &project, nil
}
func (l *LocalDB) GetProjectByCodeAndVariant(code, variant string) (*LocalProject, error) {
var project LocalProject
if err := l.db.Where("LOWER(code) = LOWER(?) AND LOWER(variant) = LOWER(?)", code, variant).First(&project).Error; err != nil {
return nil, err
}
return &project, nil
}
func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) {
var project LocalProject
if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil {

View File

@@ -356,3 +356,12 @@ func (v *VendorSpec) Scan(value interface{}) error {
}
return json.Unmarshal(bytes, v)
}
// LocalQtSetting caches server-pushed settings from qt_settings (MariaDB) into local SQLite.
// Synced during component sync. Each row is a JSON-valued setting identified by name.
type LocalQtSetting struct {
Name string `gorm:"primaryKey;size:100"`
Value string `gorm:"type:text"`
}
func (LocalQtSetting) TableName() string { return "local_qt_settings" }

View File

@@ -0,0 +1,122 @@
package localdb
import (
"encoding/json"
"fmt"
"log/slog"
"gorm.io/gorm"
)
// ConfigTypeDef describes one device configuration type as synced from qt_settings.
type ConfigTypeDef struct {
Code string `json:"code"`
NameRu string `json:"name_ru"`
DisplayOrder int `json:"display_order"`
Categories []string `json:"categories"`
}
// TabSection is a named sub-group of categories within a configurator tab.
type TabSection struct {
Title string `json:"title"`
Categories []string `json:"categories"`
}
// TabDef describes one tab in the configurator as synced from qt_settings.
type TabDef struct {
Key string `json:"key"`
Label string `json:"label"`
SingleSelect bool `json:"single_select"`
Categories []string `json:"categories"`
Sections []TabSection `json:"sections,omitempty"`
}
// ConfiguratorSettings holds all four server-driven settings consumed by the configurator.
// Fields are nil/empty when the corresponding qt_settings key is absent or unparseable;
// callers are expected to apply hardcoded fallbacks in that case.
type ConfiguratorSettings struct {
ConfigTypes []ConfigTypeDef `json:"config_types"`
TabConfig []TabDef `json:"tab_config"`
AlwaysVisibleTabs []string `json:"always_visible_tabs"`
RequiredCategories map[string][]string `json:"required_categories"`
}
// SyncQtSettings reads all rows from qt_settings on MariaDB and replaces the
// local_qt_settings cache in a single SQLite transaction. Returns an error if
// the qt_settings table doesn't exist on the server (old server without the
// table) or on any query/write failure.
func (l *LocalDB) SyncQtSettings(mariaDB *gorm.DB) error {
var rows []LocalQtSetting
if err := mariaDB.
Table("qt_settings").
Select("name, value").
Find(&rows).Error; err != nil {
return fmt.Errorf("reading qt_settings from MariaDB: %w", err)
}
return l.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("DELETE FROM local_qt_settings").Error; err != nil {
return fmt.Errorf("clearing local_qt_settings: %w", err)
}
if len(rows) == 0 {
return nil
}
if err := tx.Create(&rows).Error; err != nil {
return fmt.Errorf("inserting local_qt_settings: %w", err)
}
slog.Info("qt_settings synced", "count", len(rows))
return nil
})
}
// GetQtSetting returns the raw JSON value for a named setting.
// found is false when the key does not exist.
func (l *LocalDB) GetQtSetting(name string) (value string, found bool, err error) {
var row LocalQtSetting
res := l.db.Where("name = ?", name).First(&row)
if res.Error != nil {
if res.Error == gorm.ErrRecordNotFound {
return "", false, nil
}
return "", false, res.Error
}
return row.Value, true, nil
}
// GetConfiguratorSettings reads all four known settings from local_qt_settings and
// parses them. Any missing or unparseable key is left as nil/zero in the result;
// the caller must apply fallbacks.
func (l *LocalDB) GetConfiguratorSettings() (*ConfiguratorSettings, error) {
out := &ConfiguratorSettings{}
keys := []string{"config_types", "tab_config", "always_visible_tabs", "required_categories"}
for _, key := range keys {
raw, found, err := l.GetQtSetting(key)
if err != nil {
return out, fmt.Errorf("reading setting %q: %w", key, err)
}
if !found || raw == "" {
continue
}
switch key {
case "config_types":
if err := json.Unmarshal([]byte(raw), &out.ConfigTypes); err != nil {
slog.Warn("failed to parse config_types setting", "error", err)
}
case "tab_config":
if err := json.Unmarshal([]byte(raw), &out.TabConfig); err != nil {
slog.Warn("failed to parse tab_config setting", "error", err)
}
case "always_visible_tabs":
if err := json.Unmarshal([]byte(raw), &out.AlwaysVisibleTabs); err != nil {
slog.Warn("failed to parse always_visible_tabs setting", "error", err)
}
case "required_categories":
if err := json.Unmarshal([]byte(raw), &out.RequiredCategories); err != nil {
slog.Warn("failed to parse required_categories setting", "error", err)
}
}
}
return out, nil
}

View File

@@ -0,0 +1,10 @@
package models
// QtSetting is the MariaDB-side model for qt_settings.
// The table is managed by the server-side agent; QF only reads from it.
type QtSetting struct {
Name string `gorm:"primaryKey;size:100" json:"name"`
Value string `gorm:"type:text" json:"value"`
}
func (QtSetting) TableName() string { return "qt_settings" }

View File

@@ -157,7 +157,7 @@ func (r *PartnumberBookRepository) listCatalogItems(partnumbers localdb.LocalStr
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers))
if search != "" {
trimmedSearch := "%" + search + "%"
query = query.Where("partnumber LIKE ? OR lots_json LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
query = query.Where("partnumber LIKE ? OR CAST(lots_json AS TEXT) LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
}
var total int64

View File

@@ -53,13 +53,14 @@ type ProjectExportData struct {
}
type ProjectPricingExportOptions struct {
IncludeLOT bool `json:"include_lot"`
IncludeBOM bool `json:"include_bom"`
IncludeEstimate bool `json:"include_estimate"`
IncludeStock bool `json:"include_stock"`
IncludeCompetitor bool `json:"include_competitor"`
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
IncludeLOT bool `json:"include_lot"`
IncludeBOM bool `json:"include_bom"`
IncludeEstimate bool `json:"include_estimate"`
IncludeStock bool `json:"include_stock"`
IncludeCompetitor bool `json:"include_competitor"`
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows
}
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
@@ -87,14 +88,15 @@ type ProjectPricingExportConfig struct {
}
type ProjectPricingExportRow struct {
LotDisplay string
VendorPN string
Description string
Quantity int
BOMTotal *float64
Estimate *float64
Stock *float64
Competitor *float64
LotDisplay string
VendorPN string
Description string
Quantity int
BOMTotal *float64
Estimate *float64
Stock *float64
Competitor *float64
ManualPrice *float64 // proportional share of the user-defined total price
}
// ToCSV writes project export data in the new structured CSV format.
@@ -388,17 +390,37 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
description = componentDescriptions[rowMappings[0].LotName]
}
pricingRow := ProjectPricingExportRow{
LotDisplay: formatLotDisplay(rowMappings),
VendorPN: row.VendorPartnumber,
Description: description,
Quantity: exportPositiveInt(row.Quantity, 1),
BOMTotal: vendorRowTotal(row),
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
if len(rowMappings) == 0 {
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: "н/д",
VendorPN: row.VendorPartnumber,
Description: description,
Quantity: exportPositiveInt(row.Quantity, 1),
BOMTotal: vendorRowTotal(row),
})
continue
}
// One export row per LOT mapping so that bundles (1 PN → N LOTs) appear
// as separate lines, matching the frontend pricing table layout.
pnQty := exportPositiveInt(row.Quantity, 1)
for i, mapping := range rowMappings {
lotQty := pnQty * mapping.QuantityPerPN
var bomTotal *float64
if i == 0 {
bomTotal = vendorRowTotal(row)
}
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: mapping.LotName,
VendorPN: row.VendorPartnumber,
Description: description,
Quantity: lotQty,
BOMTotal: bomTotal,
Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }),
Stock: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Stock }),
Competitor: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Competitor }),
})
}
block.Rows = append(block.Rows, pricingRow)
}
for _, item := range cfg.Items {
@@ -422,10 +444,22 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
}
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
distributeManualPrice(block.Rows, *opts.ManualPrice)
}
return block, nil
}
catOrder := defaultCategoryOrder()
lotNames := make([]string, 0, len(cfg.Items))
for _, item := range cfg.Items {
if item.LotName != "" {
lotNames = append(lotNames, item.LotName)
}
}
itemCategories := s.resolveCategories(cfg.PricelistID, lotNames)
sortedItems := sortConfigItemsByCategoryMap(cfg.Items, catOrder, itemCategories)
for _, item := range sortedItems {
if item.LotName == "" {
continue
}
@@ -444,10 +478,29 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
}
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
distributeManualPrice(block.Rows, *opts.ManualPrice)
}
return block, nil
}
// sortConfigItemsByCategoryMap returns a copy of items sorted by category display order.
// categories maps lot_name → category code; catOrder maps category code → display order.
func sortConfigItemsByCategoryMap(items models.ConfigItems, catOrder map[string]int, categories map[string]string) models.ConfigItems {
sorted := make(models.ConfigItems, len(items))
copy(sorted, items)
sort.SliceStable(sorted, func(i, j int) bool {
orderI, hasI := categoryDisplayOrder(catOrder, categories[sorted[i].LotName])
orderJ, hasJ := categoryDisplayOrder(catOrder, categories[sorted[j].LotName])
if hasI && hasJ {
return orderI < orderJ
}
return hasI && !hasJ
})
return sorted
}
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
for i := range rows {
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
@@ -696,6 +749,52 @@ func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.V
return floatPtr(total)
}
// distributeManualPrice sets ManualPrice on each row proportionally based on the
// row's Estimate share. The last row with a price absorbs rounding remainder so
// the sum of ManualPrice values always equals manualPrice exactly.
func distributeManualPrice(rows []ProjectPricingExportRow, manualPrice float64) {
if manualPrice <= 0 || len(rows) == 0 {
return
}
totalEstimate := 0.0
for _, row := range rows {
if row.Estimate != nil && *row.Estimate > 0 {
totalEstimate += *row.Estimate
}
}
if totalEstimate <= 0 {
return
}
lastIdx := -1
for i, row := range rows {
if row.Estimate != nil && *row.Estimate > 0 {
lastIdx = i
}
}
assigned := 0.0
for i, row := range rows {
if row.Estimate == nil || *row.Estimate <= 0 {
continue
}
var share float64
if i == lastIdx {
share = math.Round((manualPrice-assigned)*100) / 100
} else {
share = math.Round((*row.Estimate/totalEstimate)*manualPrice*100) / 100
assigned += share
}
rows[i].ManualPrice = floatPtr(share)
}
}
func computeSingleLotTotal(priceMap map[string]pricingLevels, lotName string, qty int, selector func(pricingLevels) *float64) *float64 {
price := selector(priceMap[lotName])
if price == nil || *price <= 0 {
return nil
}
return floatPtr(*price * float64(qty))
}
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
if unitPrice == nil || *unitPrice <= 0 {
return nil
@@ -716,7 +815,7 @@ func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quanti
}
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
headers := make([]string, 0, 8)
headers := make([]string, 0, 9)
headers = append(headers, "Line Item")
if opts.IncludeLOT {
headers = append(headers, "LOT")
@@ -734,11 +833,14 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
if opts.IncludeCompetitor {
headers = append(headers, "Конкуренты")
}
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
headers = append(headers, "Ручная цена")
}
return headers
}
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
record := make([]string, 0, 8)
record := make([]string, 0, 9)
record = append(record, "")
if opts.IncludeLOT {
record = append(record, emptyDash(row.LotDisplay))
@@ -760,11 +862,14 @@ func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions
if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(row.Competitor))
}
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
record = append(record, formatMoneyValue(row.ManualPrice))
}
return record
}
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
record := make([]string, 0, 8)
record := make([]string, 0, 9)
record = append(record, fmt.Sprintf("%d", cfg.Line))
if opts.IncludeLOT {
record = append(record, "")
@@ -786,19 +891,12 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
}
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
record = append(record, formatMoneyValue(opts.ManualPrice))
}
return record
}
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
switch len(mappings) {
case 0:
return "н/д"
case 1:
return mappings[0].LotName
default:
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
}
}
func formatMoneyValue(value *float64) string {
if value == nil {

View File

@@ -423,6 +423,13 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
}
}
// Capture fingerprint of the current state before any mutations.
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
}
preRefreshCfg := *localCfg
// Update prices for all items from pricelist
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
@@ -462,6 +469,18 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
localCfg.UpdatedAt = now
localCfg.SyncStatus = "pending"
// Before saving the new prices, snapshot the pre-refresh state so the revision
// history shows a clear before/after for every price update.
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
}
if preRefreshFP != postRefreshFP {
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
}
}
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
if err != nil {
return nil, fmt.Errorf("refresh prices with version: %w", err)
@@ -820,6 +839,13 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
}
}
// Capture fingerprint of the current state before any mutations.
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
}
preRefreshCfg := *localCfg
// Update prices for all items from pricelist
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
@@ -859,6 +885,18 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
localCfg.UpdatedAt = now
localCfg.SyncStatus = "pending"
// Before saving the new prices, snapshot the pre-refresh state so the revision
// history shows a clear before/after for every price update.
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
}
if preRefreshFP != postRefreshFP {
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ""); err != nil {
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
}
}
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, fmt.Errorf("refresh prices without auth with version: %w", err)
@@ -866,6 +904,16 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
return cfg, nil
}
// SnapshotCurrentState creates a revision of the current configuration state without modifying it.
// Called before a client-side price refresh so the revision history has a clear before/after.
func (s *LocalConfigurationService) SnapshotCurrentState(uuid string) error {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
return s.snapshotPreRefreshTx(localCfg, "")
}
// UpdateServerCount updates server count and recalculates total price without creating a new version.
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
if serverCount < 1 {
@@ -1432,12 +1480,25 @@ func (s *LocalConfigurationService) appendVersionTx(
localCfg *localdb.LocalConfiguration,
operation string,
createdBy string,
) (*localdb.LocalConfigurationVersion, error) {
return s.appendVersionTxNote(tx, localCfg, operation, createdBy, "")
}
func (s *LocalConfigurationService) appendVersionTxNote(
tx *gorm.DB,
localCfg *localdb.LocalConfiguration,
operation string,
createdBy string,
noteOverride string,
) (*localdb.LocalConfigurationVersion, error) {
snapshot, err := s.buildConfigurationSnapshot(localCfg)
if err != nil {
return nil, fmt.Errorf("build snapshot: %w", err)
}
changeNote := fmt.Sprintf("%s via local-first flow", operation)
if noteOverride != "" {
changeNote = noteOverride
}
var createdByPtr *string
if createdBy != "" {
@@ -1478,6 +1539,35 @@ func (s *LocalConfigurationService) appendVersionTx(
return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID)
}
// snapshotPreRefreshTx creates a revision of the current configuration state before a price
// refresh so the history clearly shows what existed before prices were updated.
// Called only when prices are about to change (fingerprints differ).
func (s *LocalConfigurationService) snapshotPreRefreshTx(localCfg *localdb.LocalConfiguration, createdBy string) error {
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
var locked localdb.LocalConfiguration
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("uuid = ?", localCfg.UUID).
First(&locked).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConfigNotFound
}
return fmt.Errorf("lock row for pre-refresh snapshot: %w", err)
}
version, err := s.appendVersionTxNote(tx, localCfg, "update", createdBy, "до обновления цен")
if err != nil {
return fmt.Errorf("append pre-refresh version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current_version_id for pre-refresh snapshot: %w", err)
}
return nil
})
}
func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) {
return localdb.BuildConfigurationSnapshot(localCfg)
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/url"
"regexp"
"strings"
"time"
@@ -16,14 +17,19 @@ import (
)
var (
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
ErrProjectVariantInvalidChars = errors.New("имя варианта содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
)
// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs.
var projectCodeRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
type ProjectService struct {
localDB *localdb.LocalDB
}
@@ -64,6 +70,9 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
if code == "" {
return nil, fmt.Errorf("project code is required")
}
if !projectCodeRe.MatchString(code) {
return nil, ErrProjectCodeInvalidChars
}
variant := strings.TrimSpace(req.Variant)
if err := validateProjectVariantName(variant); err != nil {
return nil, err
@@ -106,6 +115,9 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
if code == "" {
return nil, fmt.Errorf("project code is required")
}
if !projectCodeRe.MatchString(code) {
return nil, ErrProjectCodeInvalidChars
}
localProject.Code = code
}
if req.Variant != nil {
@@ -183,6 +195,9 @@ func validateProjectVariantName(variant string) error {
if normalizeProjectVariant(variant) == "main" {
return ErrReservedMainVariant
}
if variant != "" && !projectCodeRe.MatchString(variant) {
return ErrProjectVariantInvalidChars
}
return nil
}
@@ -282,6 +297,24 @@ func (s *ProjectService) GetByUUID(projectUUID, ownerUsername string) (*models.P
return localdb.LocalToProject(localProject), nil
}
// GetByCode finds the main variant of a project by its code (case-insensitive).
func (s *ProjectService) GetByCode(code string) (*models.Project, error) {
localProject, err := s.localDB.GetProjectByCode(code)
if err != nil {
return nil, ErrProjectNotFound
}
return localdb.LocalToProject(localProject), nil
}
// GetByCodeAndVariant finds a project by code + variant (both case-insensitive).
func (s *ProjectService) GetByCodeAndVariant(code, variant string) (*models.Project, error) {
localProject, err := s.localDB.GetProjectByCodeAndVariant(code, variant)
if err != nil {
return nil, ErrProjectNotFound
}
return localdb.LocalToProject(localProject), nil
}
func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) {
project, err := s.GetByUUID(projectUUID, ownerUsername)
if err != nil {

View File

@@ -1,6 +1,7 @@
package sync
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -320,14 +321,37 @@ func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
}
var pending localdb.PendingChange
var errored []localdb.PendingChange
if err := local.DB().
Where("TRIM(COALESCE(last_error, '')) <> ''").
Order("id DESC").
First(&pending).Error; err == nil {
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
Limit(20).
Find(&errored).Error; err != nil || len(errored) == 0 {
return nil, nil
}
return nil, nil
type errorEntry struct {
Type string `json:"type"`
UUID string `json:"uuid"`
Op string `json:"op"`
Attempts int `json:"attempts"`
Error string `json:"error"`
}
entries := make([]errorEntry, 0, len(errored))
for _, ch := range errored {
entries = append(entries, errorEntry{
Type: ch.EntityType,
UUID: ch.EntityUUID,
Op: ch.Operation,
Attempts: ch.Attempts,
Error: strings.TrimSpace(ch.LastError),
})
}
detail, jsonErr := json.Marshal(entries)
if jsonErr != nil {
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(errored[0].LastError))
}
return optionalString("PENDING_CHANGE_ERROR"), optionalString(string(detail))
}
func optionalString(value string) *string {

View File

@@ -322,6 +322,12 @@ func (s *Service) NeedSync() (bool, error) {
// SyncPricelists synchronizes all active pricelists from server to local SQLite
func (s *Service) SyncPricelists() (int, error) {
s.pricelistMu.Lock()
defer s.pricelistMu.Unlock()
return s.syncPricelists()
}
func (s *Service) syncPricelists() (int, error) {
slog.Info("starting pricelist sync")
plSyncStart := time.Now()
if _, err := s.EnsureReadinessForSync(); err != nil {
@@ -336,6 +342,12 @@ func (s *Service) SyncPricelists() (int, error) {
return 0, fmt.Errorf("database not available: %w", err)
}
defer func() {
if reportErr := s.reportClientSchemaState(mariaDB, time.Now().UTC()); reportErr != nil {
slog.Warn("failed to report client state after pricelist sync", "error", reportErr)
}
}()
// Create repository
pricelistRepo := repository.NewPricelistRepository(mariaDB)
@@ -764,9 +776,16 @@ func (s *Service) fetchServerPricelistItems(serverPricelistID uint) ([]localdb.L
return nil, fmt.Errorf("getting server pricelist items: %w", err)
}
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i, item := range serverItems {
localItems[i] = *localdb.PricelistItemToLocal(&item, 0)
seen := make(map[string]struct{}, len(serverItems))
localItems := make([]localdb.LocalPricelistItem, 0, len(serverItems))
for i := range serverItems {
lotName := serverItems[i].LotName
if _, dup := seen[lotName]; dup {
slog.Warn("duplicate lot_name in server pricelist, skipping", "pricelist_id", serverPricelistID, "lot_name", lotName)
continue
}
seen[lotName] = struct{}{}
localItems = append(localItems, *localdb.PricelistItemToLocal(&serverItems[i], 0))
}
return localItems, nil
@@ -843,7 +862,7 @@ func (s *Service) SyncPricelistsIfNeeded() error {
}
slog.Info("new pricelists detected, syncing...")
_, err = s.SyncPricelists()
_, err = s.syncPricelists()
if err != nil {
return fmt.Errorf("syncing pricelists: %w", err)
}
@@ -851,6 +870,11 @@ func (s *Service) SyncPricelistsIfNeeded() error {
return nil
}
// maxPendingChangeAttempts is the number of failed attempts after which a pending change
// is considered unrecoverable and removed from the queue. Applies only to changes that
// fail with a non-transient error (e.g. corrupt payload, unknown operation).
const maxPendingChangeAttempts = 20
// PushPendingChanges pushes all pending changes to the server
func (s *Service) PushPendingChanges() (int, error) {
if _, err := s.EnsureReadinessForSync(); err != nil {
@@ -864,6 +888,14 @@ func (s *Service) PushPendingChanges() (int, error) {
slog.Info("purged orphan configuration pending changes", "removed", removed)
}
// Auto-repair locally-fixable problems (e.g. stale project references)
// before attempting to push, so that repaired changes succeed on this cycle.
if repaired, _, repairErr := s.localDB.RepairPendingChanges(); repairErr != nil {
slog.Warn("auto-repair of errored pending changes failed", "error", repairErr)
} else if repaired > 0 {
slog.Info("auto-repaired errored pending changes", "repaired", repaired)
}
changes, err := s.localDB.GetPendingChanges()
if err != nil {
return 0, fmt.Errorf("getting pending changes: %w", err)
@@ -875,7 +907,10 @@ func (s *Service) PushPendingChanges() (int, error) {
}
slog.Info("pushing pending changes", "count", len(changes))
pushStart := time.Now()
pushed := 0
failed := 0
var firstErr string
var syncedIDs []int64
sortedChanges := prioritizeProjectChanges(changes)
@@ -884,8 +919,18 @@ func (s *Service) PushPendingChanges() (int, error) {
if err != nil {
s.markConnectionBroken(err)
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
// Increment attempts
newAttempts := change.Attempts + 1
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
if firstErr == "" {
firstErr = err.Error()
}
failed++
if newAttempts >= maxPendingChangeAttempts {
slog.Error("abandoning pending change after max attempts",
"id", change.ID, "type", change.EntityType, "op", change.Operation,
"attempts", newAttempts, "last_error", err.Error())
syncedIDs = append(syncedIDs, change.ID)
}
continue
}
@@ -900,7 +945,13 @@ func (s *Service) PushPendingChanges() (int, error) {
}
}
slog.Info("pending changes pushed", "pushed", pushed, "failed", len(changes)-pushed)
if failed > 0 {
s.localDB.AppendSyncLog("changes", "error", firstErr, pushed, pushStart, time.Since(pushStart).Milliseconds())
} else {
s.localDB.AppendSyncLog("changes", "ok", "", pushed, pushStart, time.Since(pushStart).Milliseconds())
}
slog.Info("pending changes pushed", "pushed", pushed, "failed", failed)
return pushed, nil
}
@@ -912,7 +963,11 @@ func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
case "configuration":
return s.pushConfigurationChange(change)
default:
return fmt.Errorf("unknown entity type: %s", change.EntityType)
// Unknown entity type: this change was queued by a newer or different build
// and cannot be processed. Remove it from the queue.
slog.Warn("dropping pending change with unknown entity type",
"id", change.ID, "type", change.EntityType, "op", change.Operation)
return nil
}
}
@@ -1045,7 +1100,10 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
case "delete":
return s.pushConfigurationDelete(change)
default:
return fmt.Errorf("unknown operation: %s", change.Operation)
// Unknown operation: queued by a newer or different build. Drop from queue.
slog.Warn("dropping pending change with unknown operation",
"id", change.ID, "type", change.EntityType, "op", change.Operation)
return nil
}
}
@@ -1245,24 +1303,30 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
if localErr != nil {
return err
// Project not found locally either: stale reference (project was deleted).
// Fall through to system project so this configuration is not stuck forever.
slog.Warn("configuration references missing project, assigning to system project",
"cfg_uuid", cfg.UUID,
"project_uuid", *cfg.ProjectUUID,
)
} else {
modelProject := localdb.LocalToProject(localProject)
if modelProject.OwnerUsername == "" {
modelProject.OwnerUsername = cfg.OwnerUsername
}
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
return createErr
}
if modelProject.ID > 0 {
serverID := modelProject.ID
localProject.ServerID = &serverID
localProject.SyncStatus = "synced"
now := time.Now()
localProject.SyncedAt = &now
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
}
return nil
}
modelProject := localdb.LocalToProject(localProject)
if modelProject.OwnerUsername == "" {
modelProject.OwnerUsername = cfg.OwnerUsername
}
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
return createErr
}
if modelProject.ID > 0 {
serverID := modelProject.ID
localProject.ServerID = &serverID
localProject.SyncStatus = "synced"
now := time.Now()
localProject.SyncedAt = &now
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
}
return nil
}
systemProject := &models.Project{}

View File

@@ -100,5 +100,10 @@ func (w *Worker) runSync() {
return
}
// Pull partnumber books together with pricelists
if _, err := w.service.PullPartnumberBooks(); err != nil {
w.logger.Warn("background sync: failed to pull partnumber books", "error", err)
}
w.logger.Info("background sync cycle completed")
}

View File

@@ -135,6 +135,8 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
case IsInspurBOM(data):
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
case IsNxBOM(data):
workspace, err = parseNxBOM(data, filepath.Base(sourceFileName))
case IsTextBOM(data):
workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
default:
@@ -683,6 +685,93 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
}, nil
}
// nxBOMItemLine matches a quantity-first BOM line: "<qty>x <description>"
// where the quantity prefix is digits followed immediately by "x" (case-insensitive).
// Parentheses, commas, and hyphens inside the description are preserved.
var nxBOMItemLine = regexp.MustCompile(`(?i)^(\d+)[xX]\s+(.+\S)\s*$`)
// IsNxBOM reports whether data looks like a quantity-first "Nx" BOM where each
// item line begins with "<qty>x <description>" (e.g. "2x Intel Xeon 8570 ...").
func IsNxBOM(data []byte) bool {
for _, raw := range strings.Split(string(data), "\n") {
if nxBOMItemLine.MatchString(strings.TrimSpace(raw)) {
return true
}
}
return false
}
// parseNxBOM parses a quantity-first "Nx" BOM into a single configuration.
// An optional header line ending with ", в составе:" supplies server_model and name.
// Each "<qty>x <description>" line becomes one vendor spec row; description is stored
// as both vendor_partnumber and description so rows resolve through the active
// partnumber book when matched and otherwise stay unresolved and editable in the UI.
func parseNxBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
lines := strings.Split(string(data), "\n")
rows := make([]localdb.VendorSpecItem, 0, len(lines))
sortOrder := 10
serverModel := ""
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
if fields := strings.Fields(m[1]); len(fields) > 0 {
serverModel = fields[len(fields)-1]
}
continue
}
m := nxBOMItemLine.FindStringSubmatch(line)
if m == nil {
continue
}
qty, err := strconv.Atoi(m[1])
if err != nil || qty <= 0 {
continue
}
description := strings.TrimSpace(m[2])
if description == "" {
continue
}
rows = append(rows, localdb.VendorSpecItem{
SortOrder: sortOrder,
VendorPartnumber: description,
Quantity: qty,
Description: description,
})
sortOrder += 10
}
if len(rows) == 0 {
return nil, fmt.Errorf("Nx BOM has no importable rows")
}
name := serverModel
if name == "" {
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
}
if name == "" {
name = "Nx BOM Import"
}
return &importedWorkspace{
SourceFormat: "Nx",
SourceFileName: sourceFileName,
Configurations: []importedConfiguration{
{
GroupID: "nx-0",
Name: name,
Line: 10,
ServerCount: 1,
ServerModel: serverModel,
Rows: rows,
},
},
}, nil
}
// textBOMItemLine matches a human-readable BOM line of the form
// "<description> - <quantity> шт." where the separator may be a hyphen,
// en-dash or em-dash and the quantity may have an optional space before "шт".
@@ -709,6 +798,8 @@ func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) {
switch {
case IsInspurBOM(data):
ws, err = parseInspurBOM(data, "")
case IsNxBOM(data):
ws, err = parseNxBOM(data, "")
case IsTextBOM(data):
ws, err = parseTextBOM(data, "")
default:

View File

@@ -0,0 +1,20 @@
# QuoteForge v1.16
Дата релиза: 2026-06-16
Тег: `v1.16`
Предыдущий релиз: `v1.15`
## Ключевые изменения
- self-heal застрявших pending changes: конфигурации со ссылкой на удалённый проект теперь автоматически переназначаются на «Без проекта» вместо вечной ошибки;
- авторемонт очереди (`RepairPendingChanges`) запускается автоматически перед каждым push-циклом;
- после 20 неудачных попыток неисправимые записи удаляются из очереди (логируются как ERROR);
- неизвестные `entity_type` и `operation` в очереди дропаются с предупреждением вместо блокировки;
- детальная диагностика в `qt_client_schema_state.last_sync_error_text`: теперь JSON-массив с `uuid`/`op`/`attempts`/`error` по каждому застрявшему изменению;
- книги партномеров синхронизируются автоматически вместе с прайслистами.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -0,0 +1,15 @@
# QuoteForge v1.17
Дата релиза: 2026-06-16
Тег: `v1.17`
Предыдущий релиз: `v1.16`
## Ключевые изменения
- исправлен поиск в разделе Партномера по LOT-имени и описанию — `lots_json` хранится как BLOB, `modernc.org/sqlite` не коерсит BLOB→TEXT при LIKE, исправлено через `CAST(lots_json AS TEXT) LIKE`;
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -0,0 +1,20 @@
# QuoteForge v1.18
Дата релиза: 2026-06-18
Тег: `v1.18`
Предыдущий релиз: `v1.17`
## Ключевые изменения
- BOM: поддержка формата `<qty>x <description>` при импорте Nx-спецификаций;
- BOM: приоритет cart-LOT в дропдауне, корректный qtyMismatch при lot_qty_per_pn > 1;
- CSV экспорт: bundle (1 PN → N LOT) разворачивается в отдельные строки;
- ценообразование: ручная цена (buy/sale) сохраняется и экспортируется в CSV;
- ценообразование: таблица использует qty из корзины как источник истины;
- ценообразование: правильный порядок строк (MB→CPU→MEM→…) в pricing CSV и вкладке Ценообразование при отсутствии BOM;
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -0,0 +1,35 @@
# QuoteForge v2.19
Дата релиза: 2026-06-23
Тег: `v2.19`
## Что нового
### Серверно-управляемые настройки конфигуратора
Типы устройств, структура вкладок и фильтры категорий теперь приезжают с сервера вместо жёстко заданных JS-констант.
- новая таблица `qt_settings` на стороне сервера (контракт в `bible-local/server-contract-qt-settings.md`);
- QF синхронизирует `qt_settings``local_qt_settings` (SQLite) после каждой синхронизации компонентов;
- новый endpoint `GET /api/configurator-settings` отдаёт четыре настройки: `config_types`, `tab_config`, `always_visible_tabs`, `required_categories`;
- при недоступности сервера или отсутствии таблицы QF автоматически использует прежние захардкоженные значения — поведение не меняется.
### Динамический выбор типа оборудования
- модальное окно «Новая конфигурация» загружает типы устройств с сервера: названия и количество кнопок определяются в `qt_settings.config_types`;
- добавление новых типов устройств не требует обновления QF.
### Серверно-управляемая фильтрация категорий
- конфигуратор фильтрует LOT-категории по списку из `qt_settings.config_types[].categories`;
- структура вкладок обновляется из `qt_settings.tab_config` (порядок вкладок, подразделы, single-select режим);
- бейдж на вкладке при незаполненных обязательных категориях (`qt_settings.required_categories`).
### Прочее
- тайтлы страниц переименованы с OFS на QFS.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -0,0 +1,29 @@
# QuoteForge v2.21
Дата релиза: 2026-06-25
Тег: `v2.21`
## Что нового
### Короткие ссылки на проекты и варианты
- `GET /:code` — редирект на проект по коду опти (регистронезависимо);
- `GET /:code/:variant` — редирект на конкретный вариант проекта;
- валидация кода опти и имени варианта: только URL-безопасные символы `[A-Za-z0-9._-]` — проверка на бэкенде и в форме с подсказкой `«Используется в URL: /КОД/Вариант»`.
### Ревизия «до обновления цен»
При нажатии «Обновить цены» автоматически создаётся ревизия текущего состояния конфигурации до применения новых цен, после чего сохраняется ревизия с обновлёнными ценами. История изменений теперь полная.
### Исправления
- Старая цена в итоге конфигурации больше не зачёркивается, если цены фактически не изменились.
- Устранён race condition: `SyncPricelists()` теперь защищена мьютексом — параллельный запуск фонового тикера и ручной синхронизации больше не приводит к `UNIQUE constraint failed`.
- Дублирующиеся `lot_name` в серверном прайслисте пропускаются при загрузке вместо аварийного завершения синхронизации.
- Ошибки отправки конфигураций и проектов на сервер теперь видны в диалоге «Информация о синхронизации» и в support bundle (`sync_log`, тип `changes`).
- Состояние клиента (`last_sync_error_code` и др.) отправляется на сервер по завершении синхронизации независимо от её результата.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -53,45 +53,34 @@ mkdir -p "${RELEASE_DIR}"
# Create release notes template only when missing.
ensure_release_notes "${RELEASE_DIR}/RELEASE_NOTES.md"
# Build for all platforms
# Build binaries
echo -e "${YELLOW}→ Building binaries...${NC}"
make build-all
LDFLAGS="-s -w -X main.Version=${VERSION}"
echo "Building qfs for macOS (Apple Silicon)..."
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o bin/qfs-darwin-arm64 ./cmd/qfs
echo "✓ Built: bin/qfs-darwin-arm64"
echo "Building qfs for Windows..."
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o bin/qfs-windows-amd64.exe ./cmd/qfs
echo "✓ Built: bin/qfs-windows-amd64.exe"
# Package binaries with checksums
echo ""
echo -e "${YELLOW}→ Creating release packages...${NC}"
# Linux AMD64
if [ -f "bin/qfs-linux-amd64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-linux-amd64.tar.gz" qfs-linux-amd64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-linux-amd64.tar.gz${NC}"
fi
# macOS Intel
if [ -f "bin/qfs-darwin-amd64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-amd64.tar.gz" qfs-darwin-amd64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-amd64.tar.gz${NC}"
fi
# macOS Apple Silicon
if [ -f "bin/qfs-darwin-arm64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
fi
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
# Windows AMD64
if [ -f "bin/qfs-windows-amd64.exe" ]; then
cd bin
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
fi
cd bin
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
# Generate checksums
echo ""

View File

@@ -629,11 +629,13 @@
const totalColor = totalDelta > 0 ? 'text-red-600' : totalDelta < 0 ? 'text-green-600' : 'text-gray-600';
const totalArrow = _fmtArrow(r.prevTotal || 0, r.newTotal || 0);
const totalPrevHtml = totalDelta !== 0
? `<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span>`
: '';
html += `<div class="flex justify-between items-center text-sm bg-gray-50 rounded px-3 py-2 mb-1">
<span class="text-gray-600 font-medium">Итог конфигурации</span>
<span>
<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span>
<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
${totalPrevHtml}<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
</span>
</div>`;
}

View File

@@ -1,4 +1,4 @@
{{define "title"}}Ревизии - OFS{{end}}
{{define "title"}}QFS Ревизии{{end}}
{{define "content"}}
<div class="space-y-4">

View File

@@ -1,4 +1,4 @@
{{define "title"}}Мои конфигурации - OFS{{end}}
{{define "title"}}QFS Мои конфигурации{{end}}
{{define "content"}}
<div class="space-y-4">
@@ -55,12 +55,12 @@
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label>
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
<button type="button" id="type-server-btn" onclick="setCreateType('server')"
<div id="config-type-buttons" class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
<button type="button" data-type="server" onclick="setCreateType('server')"
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
Сервер
</button>
<button type="button" id="type-storage-btn" onclick="setCreateType('storage')"
<button type="button" data-type="storage" onclick="setCreateType('storage')"
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
СХД
</button>
@@ -532,18 +532,51 @@ async function cloneConfig() {
}
let createConfigType = 'server';
let _cfgSettings = null;
async function loadCfgSettings() {
if (_cfgSettings) return _cfgSettings;
try {
const r = await fetch('/api/configurator-settings');
if (r.ok) _cfgSettings = await r.json();
} catch(e) { /* use hardcoded fallback */ }
return _cfgSettings;
}
function renderConfigTypeButtons(types) {
if (!types || !types.length) return;
const el = document.getElementById('config-type-buttons');
if (!el) return;
el.innerHTML = types
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0))
.map((t, i) => {
const borderClass = i > 0 ? 'border-l border-gray-200' : '';
return `<button type="button" data-type="${t.code}" onclick="setCreateType('${t.code}')"
class="flex-1 py-2 text-sm font-medium ${borderClass} bg-white text-gray-700 hover:bg-gray-50">
${t.name_ru || t.code}
</button>`;
}).join('');
// activate first type
const firstCode = types[0].code;
createConfigType = firstCode;
setCreateType(firstCode);
}
function setCreateType(type) {
createConfigType = type;
document.getElementById('type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' +
(type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200');
document.getElementById('type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' +
(type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
document.querySelectorAll('#config-type-buttons button').forEach(btn => {
const active = btn.dataset.type === type;
btn.className = 'flex-1 py-2 text-sm font-medium ' +
(active
? 'bg-blue-600 text-white border-l border-gray-200'
: 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200');
});
}
function openCreateModal() {
createConfigType = 'server';
setCreateType('server');
loadCfgSettings().then(s => renderConfigTypeButtons(s && s.config_types));
document.getElementById('opportunity-number').value = '';
document.getElementById('create-project-input').value = '';
document.getElementById('create-modal').classList.remove('hidden');

View File

@@ -1,4 +1,4 @@
{{define "title"}}OFS - Конфигуратор{{end}}
{{define "title"}}QFS Конфигуратор{{end}}
{{define "content"}}
<div class="space-y-4">
@@ -488,7 +488,7 @@ function updateConfigBreadcrumbs() {
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
configEl.title = fullConfigName;
versionEl.textContent = 'main';
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — OFS';
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — QFS';
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
if (configNameLinkEl && configUUID) {
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
@@ -504,6 +504,9 @@ let currentTab = 'base';
let allComponents = [];
let cart = [];
let categoryOrderMap = {}; // Category code -> display_order mapping
let configTypeCategoryMap = {}; // configTypeCode → Set<UPPER_CODE> of allowed categories (from server)
let alwaysVisibleTabsSet = null; // Set<tabKey> — null means use hardcoded fallback
let requiredCategoriesMap = {}; // configTypeCode → Set<UPPER_CODE> of required categories
let autoSaveTimeout = null; // Timeout for debounced autosave
let hasUnsavedChanges = false;
let exitSaveStarted = false;
@@ -783,6 +786,95 @@ async function loadCategoriesFromAPI() {
}
}
async function loadCfgSettings() {
if (typeof _cfgSettings !== 'undefined' && _cfgSettings) return _cfgSettings;
try {
const r = await fetch('/api/configurator-settings');
if (r.ok) {
window._cfgSettings = await r.json();
return window._cfgSettings;
}
} catch(e) { /* fallback to hardcoded */ }
return null;
}
function applyServerSettings(settings) {
if (!settings) return;
// config_types → category allowlist map
if (Array.isArray(settings.config_types) && settings.config_types.length) {
configTypeCategoryMap = {};
settings.config_types.forEach(ct => {
if (ct.code && Array.isArray(ct.categories)) {
configTypeCategoryMap[ct.code] = new Set(ct.categories.map(c => c.toUpperCase()));
}
});
}
// tab_config → update TAB_CONFIG (preserve .other)
if (Array.isArray(settings.tab_config) && settings.tab_config.length) {
const otherTab = TAB_CONFIG.other;
TAB_CONFIG = {};
settings.tab_config.forEach(tab => {
TAB_CONFIG[tab.key] = {
categories: Array.isArray(tab.categories) ? tab.categories : [],
singleSelect: !!tab.single_select,
label: tab.label || tab.key,
sections: tab.sections || undefined
};
});
TAB_CONFIG.other = otherTab || { categories: [], singleSelect: false, label: 'Other' };
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG).flatMap(t => t.categories).map(c => c.toUpperCase());
}
// always_visible_tabs
if (Array.isArray(settings.always_visible_tabs) && settings.always_visible_tabs.length) {
alwaysVisibleTabsSet = new Set(settings.always_visible_tabs);
}
// required_categories
if (settings.required_categories && typeof settings.required_categories === 'object') {
requiredCategoriesMap = {};
Object.entries(settings.required_categories).forEach(([ct, codes]) => {
if (Array.isArray(codes)) {
requiredCategoriesMap[ct] = new Set(codes.map(c => c.toUpperCase()));
}
});
}
applyConfigTypeToTabs();
updateTabVisibility();
updateRequiredCategoryBadges();
}
function updateRequiredCategoryBadges() {
const required = requiredCategoriesMap[configType];
if (!required || !required.size) return;
// Build set of categories that have at least one cart item
const filledCategories = new Set(
cart.map(item => (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase())
);
// For each tab, check if it contains any required-but-unfilled category
Object.entries(TAB_CONFIG).forEach(([tabKey, tabCfg]) => {
const btn = document.querySelector(`[data-tab="${tabKey}"]`);
if (!btn) return;
const tabCategories = (tabCfg.categories || []).map(c => c.toUpperCase());
const hasUnfilled = tabCategories.some(cat => required.has(cat) && !filledCategories.has(cat));
const badge = btn.querySelector('.required-badge');
if (hasUnfilled) {
if (!badge) {
const dot = document.createElement('span');
dot.className = 'required-badge inline-block w-1.5 h-1.5 bg-orange-400 rounded-full ml-1 align-middle';
btn.appendChild(dot);
}
} else if (badge) {
badge.remove();
}
});
}
// Initialize
document.addEventListener('DOMContentLoaded', async function() {
// RBAC disabled - no token check required
@@ -791,8 +883,9 @@ document.addEventListener('DOMContentLoaded', async function() {
return;
}
// Load categories in background (defaults are usable immediately).
// Load categories and configurator settings in background (defaults are usable immediately).
const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
loadCfgSettings().then(s => applyServerSettings(s)).catch(() => {});
try {
const resp = await fetch('/api/configs/' + configUUID);
@@ -879,6 +972,12 @@ document.addEventListener('DOMContentLoaded', async function() {
}
});
// Save pricing state (ручная цена) on page exit so it survives navigation
window.addEventListener('pagehide', saveConfigOnExit);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') saveConfigOnExit();
});
// Load vendor spec BOM for this configuration
if (configUUID) {
loadVendorSpec(configUUID);
@@ -1154,60 +1253,60 @@ function switchTab(tab) {
renderTab();
}
// Hardcoded fallback constants — used only when server has not provided config_types data
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
// Storage-only categories — hidden for server configs
const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC'];
// Server-only categories — hidden for storage configs
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID'];
const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU'];
const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU'];
function isCategoryVisibleForConfigType(code, cfgType) {
const allowed = configTypeCategoryMap[cfgType];
if (!allowed || allowed.size === 0) return _hardcodedCategoryVisible(code, cfgType);
return allowed.has(code.toUpperCase());
}
function _hardcodedCategoryVisible(code, cfgType) {
if (cfgType === 'storage') {
if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return true;
if (SERVER_ONLY_BASE_CATEGORIES.includes(code)) return false;
if (STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(code)) return false;
if (STORAGE_HIDDEN_PCI_CATEGORIES.includes(code)) return false;
if (STORAGE_HIDDEN_POWER_CATEGORIES.includes(code)) return false;
} else {
if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return false;
}
return true;
}
function _effectiveAlwaysVisibleTabs() {
return alwaysVisibleTabsSet || ALWAYS_VISIBLE_TABS;
}
function applyConfigTypeToTabs() {
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
const storageSections = [
{ title: 'RAID Контроллеры', categories: ['RAID'] },
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
];
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
const pciSections = [
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
{ title: 'HBA', categories: ['HBA'] },
{ title: 'HIC', categories: ['HIC'] }
];
const powerCategories = ['PS', 'PSU'];
// Filter each tab's categories by visibility for current configType.
// Uses server-driven allowlists when available; falls back to hardcoded constants.
Object.keys(TAB_CONFIG).forEach(tabKey => {
if (tabKey === 'other') return;
const tab = TAB_CONFIG[tabKey];
if (!tab || !Array.isArray(tab.categories)) return;
TAB_CONFIG.base.categories = baseCategories.filter(c => {
if (configType === 'storage') {
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
}
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
});
// Snapshot the full category list for this tab (stored in _allCategories if not yet saved)
if (!tab._allCategories) tab._allCategories = [...tab.categories];
TAB_CONFIG.storage.categories = storageCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
});
TAB_CONFIG.storage.sections = storageSections.filter(section => {
if (configType === 'storage') {
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
}
return true;
});
tab.categories = tab._allCategories.filter(c => isCategoryVisibleForConfigType(c, configType));
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC';
});
TAB_CONFIG.pci.sections = pciSections.filter(section => {
if (configType === 'storage') {
return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat));
if (Array.isArray(tab._allSections || tab.sections)) {
const allSections = tab._allSections || tab.sections;
if (!tab._allSections) tab._allSections = allSections.map(s => ({ ...s, categories: [...s.categories] }));
tab.sections = tab._allSections
.map(section => ({
...section,
categories: section.categories.filter(c => isCategoryVisibleForConfigType(c, configType))
}))
.filter(section => section.categories.length > 0);
}
return section.title !== 'HIC';
});
TAB_CONFIG.power.categories = powerCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true;
});
// Rebuild assigned categories index
@@ -1217,8 +1316,9 @@ function applyConfigTypeToTabs() {
}
function updateTabVisibility() {
const visibleTabs = _effectiveAlwaysVisibleTabs();
for (const tabId of Object.keys(TAB_CONFIG)) {
if (ALWAYS_VISIBLE_TABS.has(tabId)) continue;
if (visibleTabs.has(tabId)) continue;
const btn = document.querySelector(`[data-tab="${tabId}"]`);
if (!btn) continue;
const hasComponents = getComponentsForTab(tabId).length > 0;
@@ -1656,6 +1756,10 @@ function renderAutocomplete() {
// Build autocomplete items based on mode
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => {
if (comp.isDivider) {
return `<div class="px-3 py-1 text-xs text-gray-400 border-t border-gray-200 select-none cursor-default" style="pointer-events:none">── прочие ──</div>`;
}
let onmousedown;
if (autocompleteMode === 'section') {
@@ -2039,14 +2143,24 @@ function showAutocompleteBOM(rowIdx, input) {
function filterAutocompleteBOM(rowIdx, search) {
const searchLower = (search || '').toLowerCase();
autocompleteFiltered = (window._bomAllComponents || allComponents).filter(c => {
const cartLots = new Set(cart.map(i => i.lot_name));
const all = (window._bomAllComponents || allComponents).filter(c => {
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
}).sort((a, b) => {
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
const inCart = all.filter(c => cartLots.has(c.lot_name))
.sort((a, b) => a.lot_name.localeCompare(b.lot_name));
const notInCart = all.filter(c => !cartLots.has(c.lot_name))
.sort((a, b) => {
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
if (inCart.length && notInCart.length) {
autocompleteFiltered = [...inCart, {isDivider: true}, ...notInCart];
} else {
autocompleteFiltered = [...inCart, ...notInCart];
}
renderAutocomplete();
}
@@ -2071,7 +2185,7 @@ function handleAutocompleteKeyBOM(event, rowIdx) {
function selectAutocompleteItemBOM(index, rowIdx) {
const comp = autocompleteFiltered[index];
if (!comp) return;
if (!comp || comp.isDivider) return;
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
if (!row) return;
row.manual_lot = comp.lot_name;
@@ -2131,6 +2245,7 @@ function removeFromCart(lotName) {
function updateCartUI() {
updateTabVisibility();
updateRequiredCategoryBadges();
window._currentCart = cart; // expose for BOM/Pricing tabs
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
document.getElementById('cart-total').textContent = formatMoney(total);
@@ -2426,6 +2541,9 @@ function restoreAutosaveDraftIfAny() {
customPriceInput.value = '';
}
}
if (payload.notes) {
restorePricingStateFromNotes(payload.notes);
}
hasUnsavedChanges = true;
} catch (_) {
// ignore invalid draft
@@ -2857,6 +2975,15 @@ async function refreshPrices() {
}
beforeTotal *= serverCount;
// Create a revision of the current state before prices are updated
if (configUUID) {
try {
await fetch('/api/configs/' + configUUID + '/snapshot', { method: 'POST' });
} catch (e) {
console.warn('pre-refresh snapshot failed', e);
}
}
await saveConfig(false);
await refreshPriceLevels({ force: true, noCache: true });
renderTab();
@@ -3209,7 +3336,7 @@ function _bomRawLotCell(rowIdx) {
cart.forEach(item => { cartMap[item.lot_name] = item.quantity; });
const isUnresolved = !map.resolved_lot || map.resolution_source === 'unresolved';
const cartQty = map.resolved_lot ? (cartMap[map.resolved_lot] ?? null) : null;
const qtyMismatch = cartQty !== null && cartQty !== map.quantity;
const qtyMismatch = cartQty !== null && cartQty !== map.quantity * _getRowLotQtyPerPN(map);
const notInCart = map.resolved_lot && cartQty === null;
if (isUnresolved) {
@@ -3595,7 +3722,7 @@ function _renderBOMParsedTable() {
const tr = document.createElement('tr');
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
const cartQty = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null;
const qtyMismatch = cartQty !== null && cartQty !== row.quantity;
const qtyMismatch = cartQty !== null && cartQty !== row.quantity * _getRowLotQtyPerPN(row);
const notInCart = row.resolved_lot && cartQty === null;
if (isUnresolved) unresolved++;
if (qtyMismatch || notInCart) mismatches++;
@@ -3662,7 +3789,7 @@ function _renderBOMRawTable() {
else if (parsed) {
const isUnresolved = !parsed.resolved_lot || parsed.resolution_source === 'unresolved';
const cartQty = parsed.resolved_lot ? (cartMap[parsed.resolved_lot] ?? null) : null;
const qtyMismatch = cartQty !== null && cartQty !== parsed.quantity;
const qtyMismatch = cartQty !== null && cartQty !== parsed.quantity * _getRowLotQtyPerPN(parsed);
const notInCart = parsed.resolved_lot && cartQty === null;
if (isUnresolved) unresolved++;
if (qtyMismatch || notInCart) mismatches++;
@@ -3930,6 +4057,9 @@ async function renderPricingTab() {
};
// Collect LOTs to price: from BOM rows (resolved) or from cart
// Use cart quantity when available (source of truth); fall back to BOM-computed quantity.
const _cartQtyMap = {};
cart.forEach(item => { if (item?.lot_name) _cartQtyMap[item.lot_name] = item.quantity; });
let itemsForPriceLevels = [];
if (bomRows.length) {
const seen = new Set();
@@ -3938,13 +4068,13 @@ async function renderPricingTab() {
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
if (baseLot && !seen.has(baseLot)) {
seen.add(baseLot);
itemsForPriceLevels.push({ lot_name: baseLot, quantity: row.quantity * _getRowLotQtyPerPN(row) });
itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[baseLot] ?? (row.quantity * _getRowLotQtyPerPN(row)) });
}
if (allocs.length) {
allocs.forEach(a => {
if (!seen.has(a.lot_name)) {
seen.add(a.lot_name);
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: row.quantity * a.quantity });
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity) });
}
});
}
@@ -3997,6 +4127,8 @@ async function renderPricingTab() {
// ─── Build shared row data (unit prices for display, totals for math) ────
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
const cartQtyMap = {};
cart.forEach(item => { if (item?.lot_name) cartQtyMap[item.lot_name] = item.quantity; });
const _buildRows = () => {
const result = [];
const coveredLots = new Set();
@@ -4020,7 +4152,12 @@ async function renderPricingTab() {
};
if (!bomRows.length) {
cart.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
const sortedByCategory = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999);
});
sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
return { result, coveredLots };
}
@@ -4041,7 +4178,7 @@ async function renderPricingTab() {
if (baseLot) {
const u = _getUnitPrices(priceMap[baseLot]);
const lotQty = _getRowLotQtyPerPN(row);
const qty = row.quantity * lotQty;
const qty = cartQtyMap[baseLot] ?? (row.quantity * lotQty);
subRows.push({
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0,
@@ -4053,7 +4190,7 @@ async function renderPricingTab() {
}
allocs.forEach(a => {
const u = _getUnitPrices(priceMap[a.lot_name]);
const qty = row.quantity * a.quantity;
const qty = cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity);
subRows.push({
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0,
@@ -4374,6 +4511,8 @@ function setPricingCustomPriceFromVendor() {
async function exportPricingCSV(table) {
if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; }
const basis = table === 'sale' ? 'ddp' : 'fob';
const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy';
const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || '');
try {
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
method: 'POST',
@@ -4385,6 +4524,7 @@ async function exportPricingCSV(table) {
include_stock: true,
include_competitor: true,
basis: basis,
manual_price: manualPrice > 0 ? manualPrice : null,
}),
});
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }

View File

@@ -1,4 +1,4 @@
{{define "title"}}OFS - Партномера{{end}}
{{define "title"}}QFS Партномера{{end}}
{{define "content"}}
<div class="space-y-4">
@@ -22,20 +22,17 @@
</div>
<div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера.
Нет активного листа сопоставлений. Книги загружаются автоматически вместе с прайслистами.
</div>
<!-- All books list (collapsed by default) -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<!-- Header row — always visible -->
<div class="px-4 py-3 flex items-center justify-between">
<div class="px-4 py-3">
<button onclick="toggleBooksSection()" class="flex items-center gap-2 text-sm font-semibold text-gray-800 hover:text-gray-600 select-none">
<svg id="books-chevron" class="w-4 h-4 text-gray-400 transition-transform duration-150" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
Снимки сопоставлений (Partnumber Books)
</button>
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 text-sm font-medium">
Синхронизировать
</button>
</div>
<!-- Collapsible body -->
<div id="books-section-body" class="hidden border-t">
@@ -69,7 +66,7 @@
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
<input type="text" id="pn-search" placeholder="Поиск по PN, LOT или описанию..."
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
oninput="onItemsSearchInput(this.value)">
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>

View File

@@ -1,4 +1,4 @@
{{define "title"}}Прайслист - OFS{{end}}
{{define "title"}}QFS Прайслист{{end}}
{{define "content"}}
<div class="space-y-6">

View File

@@ -1,4 +1,4 @@
{{define "title"}}Прайслисты - OFS{{end}}
{{define "title"}}QFS Прайслисты{{end}}
{{define "content"}}
<div class="space-y-6">

View File

@@ -1,4 +1,4 @@
{{define "title"}}Проект - OFS{{end}}
{{define "title"}}QFS Проект{{end}}
{{define "content"}}
<div class="space-y-4">
@@ -207,9 +207,11 @@
</div>
<div>
<label for="new-variant-value" class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
<input id="new-variant-value" type="text" placeholder="Например: Lenovo"
<input id="new-variant-value" type="text" placeholder="Например: B200"
pattern="[A-Za-z0-9._-]+"
title="Только буквы, цифры, дефис, точка, подчёркивание"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<div class="text-xs text-gray-500 mt-1">Оставьте пустым для main нельзя — нужно уникальное значение.</div>
<div class="text-xs text-gray-500 mt-1">Буквы, цифры, дефис, точка, подчёркивание. Используется в URL: /КОД/Вариант.</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
@@ -842,6 +844,10 @@ async function createNewVariant() {
showToast('Укажите вариант', 'error');
return;
}
if (!/^[A-Za-z0-9._-]+$/.test(variant)) {
showToast('Имя варианта содержит недопустимые символы. Разрешены: буквы, цифры, дефис, точка, подчёркивание.', 'error');
return;
}
const payload = {
code: code,
variant: variant,

View File

@@ -1,4 +1,4 @@
{{define "title"}}Мои проекты - OFS{{end}}
{{define "title"}}QFS Мои проекты{{end}}
{{define "content"}}
<div class="space-y-4">
@@ -39,12 +39,18 @@
<div>
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
pattern="[A-Za-z0-9._-]+"
title="Только буквы, цифры, дефис, точка, подчёркивание"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="text-xs text-gray-400 mt-1">Буквы, цифры, дефис, точка, подчёркивание. Код используется в URL.</p>
</div>
<div>
<label for="create-project-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
<input id="create-project-variant" type="text" placeholder="Например: Lenovo"
<input id="create-project-variant" type="text" placeholder="Например: B200"
pattern="[A-Za-z0-9._-]*"
title="Только буквы, цифры, дефис, точка, подчёркивание"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="text-xs text-gray-400 mt-1">Используется в URL: /КОД/Вариант</p>
</div>
<div>
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
@@ -396,6 +402,14 @@ async function createProject() {
alert('Введите код проекта');
return;
}
if (!/^[A-Za-z0-9._-]+$/.test(code)) {
alert('Код проекта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
return;
}
if (variant && !/^[A-Za-z0-9._-]+$/.test(variant)) {
alert('Имя варианта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
return;
}
const resp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@@ -411,6 +425,11 @@ async function createProject() {
alert('Проект с таким кодом и вариантом уже существует');
return;
}
if (resp.status === 400) {
const body = await resp.json().catch(() => ({}));
alert(body.error || 'Некорректный запрос');
return;
}
alert('Не удалось создать проект');
return;
}