Compare commits

...

30 Commits
v1.14 ... v2.23

Author SHA1 Message Date
Mikhail Chusavitin
f7d26a28f8 chore: обновление субмодуля bible
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:30:31 +03:00
Mikhail Chusavitin
bb742d2f38 fix: DKC/CTL/ENC попадали в Other при режиме server
ASSIGNED_CATEGORIES пересобирался из отфильтрованного tab.categories,
поэтому категории скрытые для текущего типа конфигурации переставали
считаться «назначенными» и уходили в Other. Теперь используется
tab._allCategories (полный статический список) — категория принадлежит
вкладке независимо от видимости в текущем режиме.

Также убрал лишний .toUpperCase() в updateTabVisibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:29:32 +03:00
Mikhail Chusavitin
f70cc680f7 fix: конфигуратор зависал на «Загрузка...», infinite retry при sync, UpsertByUUID
1. JS-конфигуратор: при загрузке сохранённой конфигурации item.category
   всегда undefined (в config.items хранится только lot_name/quantity/unit_price).
   Добавлено обогащение cart из allComponents после загрузки, все сравнения
   категорий переведены на ciStr() вместо .toUpperCase(), исправлены все 4
   точки построения ASSIGNED_CATEGORIES — устраняет TypeError и таб «Other»
   показывал компоненты с известными категориями.

2. RepairPendingChanges: repair-функции теперь возвращают (bool, error);
   attempts/last_error сбрасываются только при modified=true — устраняет
   бесконечный retry когда ошибка на стороне сервера, а не локальных данных.

3. UpsertByUUID: сброс project.ID=0 перед INSERT … ON DUPLICATE KEY UPDATE,
   чтобы конфликт шёл по уникальному uuid, а не по PK чужой строки —
   устраняет «record not found» при разрешении изменений проекта.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:27:29 +03:00
64c9c4e862 docs: bible-local — удаление local_components, правило регистра lot_name, категория из прайслиста 2026-06-26 08:56:32 +03:00
cc91ca10fc docs: release notes v2.22 2026-06-26 08:54:23 +03:00
7d190cc7a8 fix: регистронезависимый поиск lot_name и удаление мёртвого кода
- SQLite-запросы по lot_name теперь используют UPPER(lot_name) IN/= для
  совместимости с легаси-данными, синхронизированными до нормализации регистра
- Удалена таблица local_components и весь связанный код синхронизации;
  источник данных для компонентов — local_pricelist_items
- Удалена функция getCategoryFromLotName из JS: категория берётся только
  из прайслиста, без инференса из имени лота
- Регистронезависимые сравнения lot_name в JS (warehouse stock set,
  addedLots, cartLots, allComponents.find, _bomLotValid)
- В support bundle добавлены: latest_pricelist_items.json, local.db,
  autocomplete_lots.json для диагностики

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 08:52:22 +03:00
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
Mikhail Chusavitin
d204e337b5 feat: сохранять ручные PN→LOT маппинги как lot_suggestion в qt_vendor_partnumber_seen
При сохранении vendor-spec строки с заполненным lot_mappings автоматически
отправляются на сервер и пишутся в новый столбец lot_suggestion. Столбец
хранит JSON-массив [{lot_name, qty}] — тот же формат, что qt_partnumber_book_items.lots_json.

Если миграция ещё не прошла (столбец отсутствует), приложение логирует WARN
и записывает строку без столбца; сбоя нет.

Контракт для инструмента создания partnumber-books описан в bible-local/11-lot-suggestions.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 15:39:53 +03:00
Mikhail Chusavitin
d340bf80af docs: release notes v1.14
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:19:58 +03:00
50 changed files with 2330 additions and 818 deletions

View File

@@ -40,14 +40,25 @@ Readiness guard:
## Pricing contract
Prices come only from `local_pricelist_items`.
`local_pricelist_items` is the single source of truth for both prices and component catalog (lot_name + lot_category). There is no separate component catalog table.
Rules:
- `local_components` is metadata-only;
- quote calculation must not read prices from components;
- `local_components` table has been removed; do not recreate it;
- component list for the configurator autocomplete comes from `local_pricelist_items` via `ListComponents`;
- quote calculation reads prices from `local_pricelist_items` only;
- latest pricelist selection ignores snapshots without items;
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
## lot_name case handling
lot_names in `local_pricelist_items` may be stored in mixed case in databases synced before normalization was enforced. `NormalizeLotName` (uppercase + trim) is applied at sync time via `PricelistItemToLocal`, but existing rows are not retroactively updated.
Rules:
- all SQLite queries that filter by `lot_name` must use `UPPER(lot_name) IN ?` or `UPPER(lot_name) = ?` with an uppercased input — never a bare `=` or `IN` on a string that may have been sourced from user input or a legacy row;
- result map keys must preserve the original case passed by the caller (build a `uppercase → original` index before the query);
- `GetLocalPricesForLots` is the canonical pattern: it uppercases the input list, queries with `UPPER(lot_name) IN ?`, and returns keys that match the input lot_names;
- frontend JS must never infer a component category from the lot_name prefix; `lot_category` from `local_pricelist_items` is the only valid source; items without a category fall into the "Other" tab.
## Pricing tab layout
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
@@ -116,6 +127,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 as part of the pricelist pull; failure is non-fatal (Warn log only);
- `local_qt_settings` is a read-only cache — never written by user actions;
- absent or unparseable settings: QF uses hardcoded fallbacks for that key only;
- `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere;
- `qt_categories.name` and `qt_categories.name_ru` are not used by QF runtime; do not depend on them.
## Vendor BOM contract

View File

@@ -8,9 +8,8 @@ Main tables:
| Table | Purpose |
| --- | --- |
| `local_components` | synced component metadata |
| `local_pricelists` | local pricelist headers |
| `local_pricelist_items` | local pricelist rows, the only runtime price source |
| `local_pricelist_items` | pricelist rows; the only runtime source of prices and component catalog |
| `local_projects` | user projects |
| `local_configurations` | user configurations |
| `local_configuration_versions` | immutable revision snapshots |
@@ -20,12 +19,14 @@ 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;
- user-authored tables must not be dropped as a recovery shortcut;
- `local_pricelist_items` is the only valid runtime source of prices;
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows.
- `local_pricelist_items` is the only valid runtime source of prices and component catalog; do not add a separate component cache table;
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows;
- `local_components` table has been removed; any reference to it is dead code.
## MariaDB
@@ -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
@@ -48,7 +50,7 @@ Runtime read/write:
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
Insert-only tracking:
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
Server-side only (not queried by client runtime):
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
@@ -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 |
@@ -312,6 +329,7 @@ PK: job_name
| ignored_by | varchar(100) | |
| created_at | datetime(3) | |
| updated_at | datetime(3) | |
| lot_suggestion | longtext (JSON) | nullable; set when user manually maps PN → LOT in vendor-spec UI; same format as `qt_partnumber_book_items.lots_json`; see [11-lot-suggestions.md](11-lot-suggestions.md) |
### stock_ignore_rules
| 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,161 @@
# 11 - Lot Suggestions (qt_vendor_partnumber_seen)
## Purpose
`qt_vendor_partnumber_seen` records vendor partnumbers encountered during import
that have no mapping in the active partnumber book. When a user manually maps
such a partnumber to one or more LOT names in the QuoteForge UI, those mappings
are written back to the server as **lot suggestions** — hints for the team that
maintains `qt_partnumber_book_items`.
## Schema Extension
Add one nullable column to `qt_vendor_partnumber_seen`:
```sql
ALTER TABLE `qt_vendor_partnumber_seen`
ADD COLUMN `lot_suggestion` longtext DEFAULT NULL
COMMENT 'JSON array [{lot_name, qty}] — user-entered LOT mappings from the UI';
```
### Updated table contract (relevant columns only)
| Column | Type | Notes |
|--------|------|-------|
| `partnumber` | varchar(255) UNIQUE NOT NULL | natural key |
| `lot_suggestion` | longtext (JSON) | nullable; set when user maps the PN manually |
`lot_suggestion` contains the same JSON shape as `qt_partnumber_book_items.lots_json`:
```json
[
{ "lot_name": "LOT_A", "qty": 1 },
{ "lot_name": "LOT_B", "qty": 2 }
]
```
Rules:
- `null` or absent means no suggestion has been entered yet;
- an empty array `[]` is not a valid value — use `null` instead;
- a single PN may map to multiple lots (`lot_name` entries), each with its own `qty`;
- the array is ordered — the order reflects the order of `lot_mappings[]` in the
vendor spec row at the time of last user save;
- `qty` must be a positive integer (≥ 1).
## Write Contract (QuoteForge → MariaDB)
QuoteForge writes `lot_suggestion` when all of the following are true:
1. The user saves a vendor BOM via `PUT /api/configs/:uuid/vendor-spec`.
2. At least one `vendor_spec` row has a non-empty `lot_mappings[]` array (manually
entered or confirmed by the user — not auto-resolved from a partnumber book).
3. The MariaDB connection is available at the time of save.
For each such row:
```sql
INSERT INTO qt_vendor_partnumber_seen
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
VALUES
('manual', '', ?, ?, 0, NOW(3), ?)
ON DUPLICATE KEY UPDATE
lot_suggestion = VALUES(lot_suggestion),
last_seen_at = IF(lot_suggestion IS NULL, last_seen_at, NOW(3))
```
- `lot_suggestion` value = JSON-marshalled `lot_mappings[]` from the vendor spec item,
reusing the same `{lot_name, qty}` shape.
- If the PN row already exists and `lot_suggestion` is already set, it is **overwritten**
with the latest user input (the user is assumed to have corrected it).
- If the user **clears** all lot_mappings for a PN (sets to empty), no update is sent —
the existing `lot_suggestion` on the server is left untouched.
- Rows where `lot_mappings[]` is empty or nil are skipped entirely (no insert, no update).
- Writes are best-effort: a MariaDB error for one row is logged and skipped; remaining
rows continue. A write failure does not fail the vendor-spec save.
## Read Contract (Partnumber-Book Creation Tool → MariaDB)
The tool that maintains `qt_partnumber_book_items` reads `qt_vendor_partnumber_seen`
to discover new partnumbers and their suggested mappings.
### Discovery query
```sql
SELECT
s.id,
s.partnumber,
s.description,
s.vendor,
s.lot_suggestion,
s.last_seen_at,
b.lots_json AS book_lots_json
FROM qt_vendor_partnumber_seen s
LEFT JOIN qt_partnumber_book_items b ON b.partnumber = s.partnumber
WHERE s.is_ignored = 0
AND s.lot_suggestion IS NOT NULL
ORDER BY s.last_seen_at DESC;
```
### Interpretation rules
| Condition | Meaning | Suggested action |
|-----------|---------|-----------------|
| `book_lots_json IS NULL` AND `lot_suggestion IS NOT NULL` | No book entry yet; user suggested mapping | Create new `qt_partnumber_book_items` row with `lots_json = lot_suggestion` |
| `book_lots_json IS NOT NULL` AND `lot_suggestion IS NOT NULL` AND they differ | User corrected or extended the existing mapping | Review diff and decide whether to update `qt_partnumber_book_items` |
| `book_lots_json IS NOT NULL` AND `lot_suggestion IS NOT NULL` AND they match | Suggestion already applied | No action needed |
### Suggestion format
`lot_suggestion` is valid JSON (or `null`). Parse it as an array of objects:
```json
[
{ "lot_name": "LOT_A", "qty": 1 },
{ "lot_name": "LOT_B", "qty": 2 }
]
```
Map directly to `qt_partnumber_book_items.lots_json` — the formats are identical.
### Multiple lots per PN
One PN may have multiple suggestion entries (e.g., a bundle). The array carries
all of them. The book-creation tool must preserve the full array when writing
`lots_json`, not just the first element.
### Qty semantics
`qty` in a lot suggestion means "how many of this LOT per one occurrence of the
vendor PN". This matches `qt_partnumber_book_items.lots_json` exactly. Example:
a server platform that comes with 4 PSUs would produce
`[{"lot_name": "PS_1300W_Titanium", "qty": 4}]`.
## Permissions
The existing `qfs_user` grant covers this column — no new permission is required:
```sql
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
```
The book-creation tool connects with its own credentials and needs at minimum:
```sql
GRANT SELECT ON RFQ_LOG.qt_vendor_partnumber_seen TO '<book_tool_user>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_partnumber_book_items TO '<book_tool_user>'@'%';
```
## Migration
Migration is applied outside this repo (server-side DDL):
```sql
ALTER TABLE `qt_vendor_partnumber_seen`
ADD COLUMN IF NOT EXISTS `lot_suggestion` longtext DEFAULT NULL
COMMENT 'JSON [{lot_name, qty}] — user LOT suggestions from QuoteForge UI';
```
QuoteForge handles a missing column gracefully: if the migration has not run yet,
the write with `lot_suggestion` fails with "Unknown column" (MariaDB 1054), a warning
is logged, and the row is re-inserted without the column. The app never crashes on
migration lag.

View File

@@ -15,6 +15,7 @@ Project-specific architecture and operational contracts.
| [07-dev.md](07-dev.md) | Development commands and guardrails |
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
| [10-agent-api-guide.md](10-agent-api-guide.md) | End-to-end API guide for agents pricing servers from a TZ |
| [11-lot-suggestions.md](11-lot-suggestions.md) | lot_suggestion column in qt_vendor_partnumber_seen — write/read contract for manual UI mappings |
## Rules

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

@@ -779,7 +779,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
pricelistHandler := handlers.NewPricelistHandler(local)
vendorSpecHandler := handlers.NewVendorSpecHandler(local)
vendorSpecHandler := handlers.NewVendorSpecHandler(local, syncService)
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
respondError := handlers.RespondError
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
@@ -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)
@@ -1776,7 +1811,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
syncAPI.GET("/readiness", syncHandler.GetReadiness)
syncAPI.GET("/info", syncHandler.GetInfo)
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks)
syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen)

View File

@@ -45,38 +45,55 @@ func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
}
}
func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) {
func TestResolveLotCategoriesStrict_FallbackToLatestPricelist(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
// Older pricelist used by the configuration — CPU_B has no category here
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 2,
Source: "estimate",
Version: "S-2026-02-11-002",
Name: "test",
Name: "old",
IsActive: false,
CreatedAt: time.Now().Add(-time.Hour),
SyncedAt: time.Now().Add(-time.Hour),
}); err != nil {
t.Fatalf("save old pricelist: %v", err)
}
oldPL, err := local.GetLocalPricelistByServerID(2)
if err != nil {
t.Fatalf("get old pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: oldPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
}); err != nil {
t.Fatalf("save old pricelist items: %v", err)
}
// Newer active pricelist — CPU_B has category set
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 3,
Source: "estimate",
Version: "S-2026-02-11-003",
Name: "latest",
IsActive: true,
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save local pricelist: %v", err)
t.Fatalf("save latest pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(2)
latestPL, err := local.GetLocalPricelistByServerID(3)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
t.Fatalf("get latest pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: localPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
{PricelistID: latestPL.ID, LotName: "CPU_B", LotCategory: "CPU", Price: 10},
}); err != nil {
t.Fatalf("save local items: %v", err)
}
if err := local.DB().Create(&localdb.LocalComponent{
LotName: "CPU_B",
Category: "CPU",
LotDescription: "cpu",
}).Error; err != nil {
t.Fatalf("save local components: %v", err)
t.Fatalf("save latest pricelist items: %v", err)
}
cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"})

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

@@ -177,22 +177,12 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
return
}
lotNames := make([]string, len(items))
for i, item := range items {
lotNames[i] = item.LotName
}
descMap, err := h.localDB.GetLocalComponentDescriptionsByLotNames(lotNames)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
resultItems := make([]gin.H, 0, len(items))
for _, item := range items {
resultItems = append(resultItems, gin.H{
"id": item.ID,
"lot_name": item.LotName,
"lot_description": descMap[item.LotName],
"lot_description": "",
"price": item.Price,
"category": item.LotCategory,
"available_qty": item.AvailableQty,

View File

@@ -74,7 +74,7 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
// local_db_stats.json
writeJSON("local_db_stats.json", map[string]any{
"components": h.localDB.CountLocalComponents(),
"components": h.localDB.CountComponents(),
"configurations": h.localDB.CountConfigurations(),
"projects": h.localDB.CountProjects(),
"pricelists": h.localDB.CountLocalPricelists(),
@@ -139,6 +139,7 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
CreatedAt time.Time `json:"created_at"`
SyncedAt time.Time `json:"synced_at"`
IsUsed bool `json:"is_used"`
IsActive bool `json:"is_active"`
}
bySource := map[string][]plEntry{}
for _, pl := range pricelists {
@@ -150,12 +151,78 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
CreatedAt: pl.CreatedAt,
SyncedAt: pl.SyncedAt,
IsUsed: pl.IsUsed,
IsActive: pl.IsActive,
}
bySource[pl.Source] = append(bySource[pl.Source], e)
}
writeJSON("pricelists.json", bySource)
}
// pricelist_coverage.json — for each local estimate pricelist: item count by lot_category
if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil {
type catRow struct {
Category string `json:"category"`
Count int64 `json:"count"`
}
type plCoverage struct {
Version string `json:"version"`
ServerID uint `json:"server_id"`
TotalItems int64 `json:"total_items"`
Categories []catRow `json:"categories"`
}
rows, total, catErr := h.localDB.GetLocalPricelistCoverageByCategory(pl.ID)
if catErr == nil {
cats := make([]catRow, 0, len(rows))
for cat, cnt := range rows {
cats = append(cats, catRow{Category: cat, Count: cnt})
}
writeJSON("pricelist_coverage.json", plCoverage{
Version: pl.Version,
ServerID: pl.ServerID,
TotalItems: total,
Categories: cats,
})
}
}
// configurator_settings.json — what /api/configurator-settings actually returns
if cfgSettings, err := h.localDB.GetConfiguratorSettings(); err == nil {
writeJSON("configurator_settings.json", cfgSettings)
} else {
writeJSON("configurator_settings.json", map[string]any{"error": err.Error()})
}
// component_categories.json — distinct categories in active estimate pricelist
if cats, err := h.localDB.GetLocalComponentCategories(); err == nil {
writeJSON("component_categories.json", cats)
}
// autocomplete_lots.json — per-category breakdown of lots with their prices
// Mirrors what filterAutocomplete() works with: lot_name + estimate_price per category.
if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil {
if items, err := h.localDB.GetLocalPricelistItems(pl.ID); err == nil {
type lotEntry struct {
LotName string `json:"lot_name"`
Price float64 `json:"price"`
HasPrice bool `json:"has_price"`
}
byCategory := map[string][]lotEntry{}
for _, it := range items {
entry := lotEntry{
LotName: it.LotName,
Price: it.Price,
HasPrice: it.Price > 0,
}
byCategory[it.LotCategory] = append(byCategory[it.LotCategory], entry)
}
writeJSON("autocomplete_lots.json", map[string]any{
"pricelist_version": pl.Version,
"pricelist_id": pl.ServerID,
"by_category": byCategory,
})
}
}
// schema_migrations.json
migrations, err := h.localDB.GetSchemaMigrations()
if err != nil {
@@ -163,6 +230,44 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
}
writeJSON("schema_migrations.json", migrations)
// latest_pricelist_items.json — all items from the most recent active estimate pricelist
if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil {
if items, err := h.localDB.GetLocalPricelistItems(pl.ID); err == nil {
type plItem struct {
LotName string `json:"lot_name"`
LotCategory string `json:"lot_category"`
Price float64 `json:"price"`
}
out := make([]plItem, len(items))
for i, it := range items {
out[i] = plItem{
LotName: it.LotName,
LotCategory: it.LotCategory,
Price: it.Price,
}
}
writeJSON("latest_pricelist_items.json", map[string]any{
"pricelist_version": pl.Version,
"pricelist_id": pl.ServerID,
"source": pl.Source,
"item_count": len(out),
"items": out,
})
}
}
// local.db — full SQLite database file (for deep diagnostics)
if dbPath := h.localDB.DBFilePath(); dbPath != "" {
if f, err := os.Open(dbPath); err == nil {
defer f.Close()
if w, err := zw.Create("local.db"); err == nil {
if _, err := io.Copy(w, f); err != nil {
slog.Warn("support bundle: error copying local.db", "err", err)
}
}
}
}
// app.log (tail 5 MiB)
if h.logFilePath != "" {
if f, err := os.Open(h.logFilePath); err == nil {

View File

@@ -50,7 +50,6 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
// SyncStatusResponse represents the sync status
type SyncStatusResponse struct {
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
@@ -61,7 +60,6 @@ type SyncStatusResponse struct {
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
}
@@ -80,19 +78,16 @@ type SyncReadinessResponse struct {
func (h *SyncHandler) GetStatus(c *gin.Context) {
connStatus := h.connMgr.GetStatus()
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
lastComponentSync := h.localDB.GetComponentSyncTime()
lastPricelistSync := h.localDB.GetLastSyncTime()
componentsCount := h.localDB.CountLocalComponents()
componentsCount := h.localDB.CountComponents()
pricelistsCount := h.localDB.CountLocalPricelists()
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
needComponentSync := h.localDB.NeedComponentSync(24)
readiness := h.getReadinessLocal()
c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync,
LastPricelistAttemptAt: lastPricelistAttemptAt,
LastPricelistSyncStatus: lastPricelistSyncStatus,
@@ -103,7 +98,6 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount,
ServerPricelists: 0,
NeedComponentSync: needComponentSync,
NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
Readiness: readiness,
})
@@ -169,48 +163,6 @@ type SyncResultResponse struct {
Duration string `json:"duration"`
}
// SyncComponents syncs components from MariaDB to local SQLite
// POST /api/sync/components
func (h *SyncHandler) SyncComponents(c *gin.Context) {
if !h.ensureSyncReadiness(c) {
return
}
// Get database connection from ConnectionManager
mariaDB, err := h.connMgr.GetDB()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "database connection failed",
})
_ = c.Error(err)
return
}
now := time.Now()
result, err := h.localDB.SyncComponents(mariaDB)
if err != nil {
_ = h.localDB.SetComponentSyncResult("error", err.Error(), now)
h.localDB.AppendSyncLog("components", "error", err.Error(), 0, now, time.Since(now).Milliseconds())
slog.Error("component sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "component sync failed",
})
_ = c.Error(err)
return
}
_ = h.localDB.SetComponentSyncResult("ok", "", now)
h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds())
c.JSON(http.StatusOK, SyncResultResponse{
Success: true,
Message: "Components synced successfully",
Synced: result.TotalSynced,
Duration: result.Duration.String(),
})
}
// SyncPricelists syncs pricelists from MariaDB to local SQLite
// POST /api/sync/pricelists
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
@@ -232,6 +184,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",
@@ -272,7 +228,6 @@ type SyncAllResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
PendingPushed int `json:"pending_pushed"`
ComponentsSynced int `json:"components_synced"`
PricelistsSynced int `json:"pricelists_synced"`
ProjectsImported int `json:"projects_imported"`
ProjectsUpdated int `json:"projects_updated"`
@@ -293,7 +248,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
}
startTime := time.Now()
var pendingPushed, componentsSynced, pricelistsSynced int
var pricelistsSynced int
// Push local pending changes first (projects/configurations)
pendingPushed, err := h.syncService.PushPendingChanges()
@@ -307,34 +262,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
return
}
// Sync components
mariaDB, err := h.connMgr.GetDB()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "database connection failed",
})
_ = c.Error(err)
return
}
compNow := time.Now()
compResult, err := h.localDB.SyncComponents(mariaDB)
if err != nil {
_ = h.localDB.SetComponentSyncResult("error", err.Error(), compNow)
h.localDB.AppendSyncLog("components", "error", err.Error(), 0, compNow, time.Since(compNow).Milliseconds())
slog.Error("component sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "component sync failed",
})
_ = c.Error(err)
return
}
_ = h.localDB.SetComponentSyncResult("ok", "", compNow)
h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds())
componentsSynced = compResult.TotalSynced
// Sync pricelists
plNow := time.Now()
pricelistsSynced, err = h.syncService.SyncPricelists()
@@ -342,16 +269,19 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, plNow, time.Since(plNow).Milliseconds())
slog.Error("pricelist sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "pricelist sync failed",
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
"success": false,
"error": "pricelist sync failed",
"pending_pushed": pendingPushed,
})
_ = c.Error(err)
return
}
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)
@@ -359,7 +289,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
"success": false,
"error": "project import failed",
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced,
})
_ = c.Error(err)
@@ -373,7 +302,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
"success": false,
"error": "configuration import failed",
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced,
"projects_imported": projectsResult.Imported,
"projects_updated": projectsResult.Updated,
@@ -387,7 +315,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
Success: true,
Message: "Full sync completed successfully",
PendingPushed: pendingPushed,
ComponentsSynced: componentsSynced,
PricelistsSynced: pricelistsSynced,
ProjectsImported: projectsResult.Imported,
ProjectsUpdated: projectsResult.Updated,
@@ -548,7 +475,7 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
// Get local counts
configCount := h.localDB.CountConfigurations()
projectCount := h.localDB.CountProjects()
componentCount := h.localDB.CountLocalComponents()
componentCount := h.localDB.CountComponents()
pricelistCount := h.localDB.CountLocalPricelists()
// Get error count (only changes with LastError != "")

View File

@@ -4,11 +4,12 @@ import (
"errors"
"log/slog"
"net/http"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
)
@@ -16,12 +17,14 @@ import (
type VendorSpecHandler struct {
localDB *localdb.LocalDB
configService *services.LocalConfigurationService
syncService *syncsvc.Service // optional; nil = no server push
}
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
func NewVendorSpecHandler(localDB *localdb.LocalDB, syncService *syncsvc.Service) *VendorSpecHandler {
return &VendorSpecHandler{
localDB: localDB,
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
syncService: syncService,
}
}
@@ -111,9 +114,57 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
return
}
// Push manual lot mappings as suggestions to the server (best-effort, non-blocking).
h.pushLotSuggestions(body.VendorSpec)
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
}
// pushLotSuggestions sends manual PN→LOT mappings to qt_vendor_partnumber_seen.lot_suggestion.
// Errors are logged and silently dropped — they must not affect the HTTP response.
func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
if h.syncService == nil {
return
}
var items []syncsvc.SeenPartnumber
for _, row := range spec {
if row.VendorPartnumber == "" || len(row.LotMappings) == 0 {
continue
}
suggestion := make([]syncsvc.LotSuggestionEntry, 0, len(row.LotMappings))
for _, m := range row.LotMappings {
if m.LotName == "" {
continue
}
qty := m.QuantityPerPN
if qty < 1 {
qty = 1
}
suggestion = append(suggestion, syncsvc.LotSuggestionEntry{
LotName: m.LotName,
Qty: qty,
})
}
if len(suggestion) == 0 {
continue
}
items = append(items, syncsvc.SeenPartnumber{
Partnumber: row.VendorPartnumber,
Description: row.Description,
LotSuggestion: suggestion,
})
}
if len(items) == 0 {
return
}
if err := h.syncService.PushPartnumberSeen(items); err != nil {
slog.Warn("vendor_spec: failed to push lot suggestions to server", "error", err)
}
}
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(in) == 0 {
return nil
@@ -121,7 +172,7 @@ func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpe
merged := make(map[string]int, len(in))
order := make([]string, 0, len(in))
for _, m := range in {
lot := strings.TrimSpace(m.LotName)
lot := models.NormalizeLotName(m.LotName)
if lot == "" {
continue
}

View File

@@ -2,11 +2,8 @@ package localdb
import (
"fmt"
"log/slog"
"strings"
"time"
"gorm.io/gorm"
)
// ComponentFilter for searching with filters
@@ -24,344 +21,213 @@ type ComponentSyncResult struct {
Duration time.Duration
}
// SyncComponents loads components from MariaDB (lot + qt_lot_metadata) into local_components
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
startTime := time.Now()
// Build the component catalog from every runtime source of LOT names.
// Storage lots may exist in qt_lot_metadata / qt_pricelist_items before they appear in lot,
// so the sync cannot start from lot alone.
type componentRow struct {
LotName string
LotDescription string
Category *string
Model *string
}
var rows []componentRow
err := mariaDB.Raw(`
SELECT
src.lot_name,
COALESCE(MAX(NULLIF(TRIM(l.lot_description), '')), '') AS lot_description,
COALESCE(
MAX(NULLIF(TRIM(c.code), '')),
MAX(NULLIF(TRIM(l.lot_category), '')),
SUBSTRING_INDEX(src.lot_name, '_', 1)
) AS category,
MAX(NULLIF(TRIM(m.model), '')) AS model
FROM (
SELECT lot_name FROM lot
UNION
SELECT lot_name FROM qt_lot_metadata
WHERE is_hidden = FALSE OR is_hidden IS NULL
UNION
SELECT lot_name FROM qt_pricelist_items
) src
LEFT JOIN lot l ON l.lot_name = src.lot_name
LEFT JOIN qt_lot_metadata m
ON m.lot_name = src.lot_name
AND (m.is_hidden = FALSE OR m.is_hidden IS NULL)
LEFT JOIN qt_categories c ON m.category_id = c.id
GROUP BY src.lot_name
ORDER BY src.lot_name
`).Scan(&rows).Error
// latestActivePricelistID returns the local DB id of the most recently created
// active pricelist for the given source ("estimate", "warehouse", etc.).
func (l *LocalDB) latestActivePricelistID(source string) (uint, error) {
var id uint
err := l.db.Table("local_pricelists").
Select("id").
Where("is_active = ? AND source = ?", true, source).
Order("created_at DESC, id DESC").
Limit(1).
Scan(&id).Error
if err != nil {
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
return 0, err
}
if len(rows) == 0 {
slog.Warn("no components found in MariaDB")
return &ComponentSyncResult{
Duration: time.Since(startTime),
}, nil
if id == 0 {
return 0, fmt.Errorf("no active %s pricelist", source)
}
// Get existing local components for comparison
existingMap := make(map[string]bool)
var existing []LocalComponent
if err := l.db.Find(&existing).Error; err != nil {
return nil, fmt.Errorf("reading existing local components: %w", err)
}
for _, c := range existing {
existingMap[c.LotName] = true
}
// Prepare components for batch insert/update.
// Source joins may duplicate the same lot_name, so collapse them before insert.
syncTime := time.Now()
components := make([]LocalComponent, 0, len(rows))
componentIndex := make(map[string]int, len(rows))
newCount := 0
for _, row := range rows {
lotName := strings.TrimSpace(row.LotName)
if lotName == "" {
continue
}
category := ""
if row.Category != nil {
category = strings.TrimSpace(*row.Category)
} else {
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
parts := strings.SplitN(lotName, "_", 2)
if len(parts) >= 1 {
category = parts[0]
}
}
model := ""
if row.Model != nil {
model = strings.TrimSpace(*row.Model)
}
comp := LocalComponent{
LotName: lotName,
LotDescription: strings.TrimSpace(row.LotDescription),
Category: category,
Model: model,
}
if idx, exists := componentIndex[lotName]; exists {
// Keep the first row, but fill any missing metadata from duplicates.
if components[idx].LotDescription == "" && comp.LotDescription != "" {
components[idx].LotDescription = comp.LotDescription
}
if components[idx].Category == "" && comp.Category != "" {
components[idx].Category = comp.Category
}
if components[idx].Model == "" && comp.Model != "" {
components[idx].Model = comp.Model
}
continue
}
componentIndex[lotName] = len(components)
components = append(components, comp)
if !existingMap[lotName] {
newCount++
}
}
// Use transaction for bulk upsert
err = l.db.Transaction(func(tx *gorm.DB) error {
// Delete all existing and insert new (simpler than upsert for SQLite)
if err := tx.Where("1=1").Delete(&LocalComponent{}).Error; err != nil {
return fmt.Errorf("clearing local components: %w", err)
}
// Batch insert
batchSize := 500
for i := 0; i < len(components); i += batchSize {
end := i + batchSize
if end > len(components) {
end = len(components)
}
if err := tx.CreateInBatches(components[i:end], batchSize).Error; err != nil {
return fmt.Errorf("inserting components batch: %w", err)
}
}
return nil
})
if err != nil {
return nil, err
}
// Update last sync time
if err := l.SetComponentSyncTime(syncTime); err != nil {
slog.Warn("failed to update component sync time", "error", err)
}
result := &ComponentSyncResult{
TotalSynced: len(components),
NewCount: newCount,
UpdateCount: len(components) - newCount,
Duration: time.Since(startTime),
}
slog.Info("components synced",
"total", result.TotalSynced,
"new", result.NewCount,
"updated", result.UpdateCount,
"duration", result.Duration)
return result, nil
return id, nil
}
// SearchLocalComponents searches components in local cache by query string
// Searches in lot_name, lot_description, category, and model fields
// pricelistItemRow is used for scanning rows from local_pricelist_items.
type pricelistItemRow struct {
LotName string `gorm:"column:lot_name"`
Category string `gorm:"column:lot_category"`
}
func (r pricelistItemRow) toLocalComponent() LocalComponent {
return LocalComponent{
LotName: r.LotName,
Category: r.Category,
}
}
// SearchLocalComponents searches components in the latest active estimate
// pricelist by lot_name.
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
if limit <= 0 {
limit = 50
}
var components []LocalComponent
if query == "" {
// Return all components with limit
err := l.db.Order("lot_name").Limit(limit).Find(&components).Error
return components, err
}
// Search with LIKE on multiple fields
searchPattern := "%" + strings.ToLower(query) + "%"
err := l.db.Where(
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
searchPattern, searchPattern, searchPattern, searchPattern,
).Order("lot_name").Limit(limit).Find(&components).Error
return components, err
}
// SearchLocalComponentsByCategory searches components by category and optional query
func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string, limit int) ([]LocalComponent, error) {
if limit <= 0 {
limit = 50
}
var components []LocalComponent
db := l.db.Where("LOWER(category) = ?", strings.ToLower(category))
if query != "" {
searchPattern := "%" + strings.ToLower(query) + "%"
db = db.Where(
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(model) LIKE ?",
searchPattern, searchPattern, searchPattern,
)
}
err := db.Order("lot_name").Limit(limit).Find(&components).Error
return components, err
}
// ListComponents returns components with filtering and pagination
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
db := l.db
// Apply category filter
if filter.Category != "" {
db = db.Where("LOWER(category) = ?", strings.ToLower(filter.Category))
}
// Apply search filter
if filter.Search != "" {
searchPattern := "%" + strings.ToLower(filter.Search) + "%"
db = db.Where(
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
searchPattern, searchPattern, searchPattern, searchPattern,
)
}
// Get total count
var total int64
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// Apply pagination and get results
var components []LocalComponent
if err := db.Order("lot_name").Offset(offset).Limit(limit).Find(&components).Error; err != nil {
return nil, 0, err
}
return components, total, nil
}
// GetLocalComponent returns a single component by lot_name
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
var component LocalComponent
err := l.db.Where("lot_name = ?", lotName).First(&component).Error
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return nil, err
}
return &component, nil
db := l.db.Table("local_pricelist_items").
Where("pricelist_id = ?", pricelistID)
if query != "" {
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
}
var rows []pricelistItemRow
if err := db.Select("lot_name, lot_category").Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
return nil, err
}
components := make([]LocalComponent, len(rows))
for i, r := range rows {
components[i] = r.toLocalComponent()
}
return components, nil
}
// GetLocalComponentCategoriesByLotNames returns category for each lot_name in the local component cache.
// Missing lots are not included in the map; caller is responsible for strict validation.
// SearchLocalComponentsByCategory searches components in the latest active
// estimate pricelist filtered by category.
func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit int) ([]LocalComponent, error) {
if limit <= 0 {
limit = 50
}
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return nil, err
}
db := l.db.Table("local_pricelist_items").
Where("pricelist_id = ? AND UPPER(lot_category) = ?", pricelistID, strings.ToUpper(category))
if query != "" {
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
}
var rows []pricelistItemRow
if err := db.Select("lot_name, lot_category").Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
return nil, err
}
components := make([]LocalComponent, len(rows))
for i, r := range rows {
components[i] = r.toLocalComponent()
}
return components, nil
}
// ListComponents returns components from the latest active estimate pricelist
// with optional category/search filtering and pagination.
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return nil, 0, err
}
db := l.db.Table("local_pricelist_items").
Where("pricelist_id = ?", pricelistID)
if filter.Category != "" {
db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(filter.Category))
}
if filter.Search != "" {
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(filter.Search)+"%")
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []pricelistItemRow
if err := db.Select("lot_name, lot_category").Order("lot_name").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil {
return nil, 0, err
}
components := make([]LocalComponent, len(rows))
for i, r := range rows {
components[i] = r.toLocalComponent()
}
return components, total, nil
}
// GetLocalComponent returns a single component by lot_name from the latest
// active estimate pricelist.
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return nil, err
}
var row pricelistItemRow
if err := l.db.Table("local_pricelist_items").
Select("lot_name, lot_category").
Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)).
First(&row).Error; err != nil {
return nil, err
}
c := row.toLocalComponent()
return &c, nil
}
// GetLocalComponentCategoriesByLotNames returns category for each lot_name
// from the latest active estimate pricelist.
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
result := make(map[string]string, len(lotNames))
if len(lotNames) == 0 {
return result, nil
}
type row struct {
LotName string `gorm:"column:lot_name"`
Category string `gorm:"column:category"`
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return result, nil
}
var rows []row
if err := l.db.Model(&LocalComponent{}).
Select("lot_name, category").
Where("lot_name IN ?", lotNames).
Find(&rows).Error; err != nil {
// Build uppercase → original mapping so result keys match what the caller passed.
upperToOrig := make(map[string]string, len(lotNames))
upper := make([]string, len(lotNames))
for i, n := range lotNames {
u := strings.ToUpper(n)
upper[i] = u
upperToOrig[u] = n
}
var rows []pricelistItemRow
if err := l.db.Table("local_pricelist_items").
Select("lot_name, lot_category").
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, upper).
Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.LotName] = r.Category
orig := upperToOrig[strings.ToUpper(r.LotName)]
if orig == "" {
orig = r.LotName
}
result[orig] = r.Category
}
return result, nil
}
// GetLocalComponentCategories returns distinct categories from local components
// GetLocalComponentCategories returns distinct categories from the latest
// active estimate pricelist.
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
var categories []string
err := l.db.Model(&LocalComponent{}).
Distinct("category").
Where("category != ''").
Order("category").
Pluck("category", &categories).Error
return categories, err
}
// CountLocalComponents returns the total number of local components
func (l *LocalDB) CountLocalComponents() int64 {
var count int64
l.db.Model(&LocalComponent{}).Count(&count)
return count
}
// CountLocalComponentsByCategory returns component count by category
func (l *LocalDB) CountLocalComponentsByCategory(category string) int64 {
var count int64
l.db.Model(&LocalComponent{}).Where("LOWER(category) = ?", strings.ToLower(category)).Count(&count)
return count
}
// GetComponentSyncTime returns the last component sync timestamp
func (l *LocalDB) GetComponentSyncTime() *time.Time {
var setting struct {
Value string
}
if err := l.db.Table("app_settings").
Where("key = ?", "last_component_sync").
First(&setting).Error; err != nil {
return nil
}
t, err := time.Parse(time.RFC3339, setting.Value)
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return nil
return nil, err
}
return &t
var categories []string
if err := l.db.Table("local_pricelist_items").
Where("pricelist_id = ? AND lot_category != ''", pricelistID).
Distinct("lot_category").
Order("lot_category").
Pluck("lot_category", &categories).Error; err != nil {
return nil, err
}
return categories, nil
}
// SetComponentSyncTime sets the last component sync timestamp
func (l *LocalDB) SetComponentSyncTime(t time.Time) error {
return l.db.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, "last_component_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
// CountComponents returns the number of distinct lot names in the latest
// active estimate pricelist (used to check if data is available).
func (l *LocalDB) CountComponents() int64 {
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return 0
}
var count int64
l.db.Table("local_pricelist_items").Where("pricelist_id = ?", pricelistID).Count(&count)
return count
}
// NeedComponentSync checks if component sync is needed (older than specified hours)
func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
syncTime := l.GetComponentSyncTime()
if syncTime == nil {
return true
}
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
}

View File

@@ -11,7 +11,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
items := make(LocalConfigItems, len(cfg.Items))
for i, item := range cfg.Items {
items[i] = LocalConfigItem{
LotName: item.LotName,
LotName: models.NormalizeLotName(item.LotName),
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
@@ -271,7 +271,7 @@ func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *Lo
partnumbers = append(partnumbers, item.Partnumbers...)
return &LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
LotName: models.NormalizeLotName(item.LotName),
LotCategory: item.LotCategory,
Price: item.Price,
AvailableQty: item.AvailableQty,

View File

@@ -46,7 +46,6 @@ type LocalDB struct {
var localReadOnlyCacheTables = []string{
"local_pricelist_items",
"local_pricelists",
"local_components",
"local_partnumber_book_items",
"local_partnumber_books",
}
@@ -78,7 +77,6 @@ func ResetData(dbPath string) error {
"local_configuration_versions",
"local_pricelists",
"local_pricelist_items",
"local_components",
"local_sync_guard_state",
"pending_changes",
"app_settings",
@@ -224,12 +222,12 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
&LocalConfigurationVersion{},
&LocalPricelist{},
&LocalPricelistItem{},
&LocalComponent{},
&AppSetting{},
&LocalSyncGuardState{},
&PendingChange{},
&LocalPartnumberBook{},
&SyncLogEntry{},
&LocalQtSetting{},
)
}
@@ -691,6 +689,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 {
@@ -1220,25 +1234,6 @@ func (l *LocalDB) GetLastComponentSyncError() string {
return strings.TrimSpace(value)
}
func (l *LocalDB) SetComponentSyncResult(status, errorText string, attemptedAt time.Time) error {
status = strings.TrimSpace(status)
errorText = strings.TrimSpace(errorText)
if status == "" {
status = "unknown"
}
return l.db.Transaction(func(tx *gorm.DB) error {
if err := l.upsertAppSetting(tx, "last_component_sync_status", status, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_component_sync_error", errorText, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_component_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
return err
}
return nil
})
}
// CountLocalPricelists returns the number of local pricelists
func (l *LocalDB) CountLocalPricelists() int64 {
@@ -1254,11 +1249,10 @@ func (l *LocalDB) CountAllPricelistItems() int64 {
return count
}
// CountComponents returns the number of rows in local_components.
func (l *LocalDB) CountComponents() int64 {
var count int64
l.db.Model(&LocalComponent{}).Count(&count)
return count
// DBFilePath returns the path to the SQLite database file.
func (l *LocalDB) DBFilePath() string {
return l.path
}
// DBFileSizeBytes returns the size of the SQLite database file in bytes.
@@ -1270,11 +1264,11 @@ func (l *LocalDB) DBFileSizeBytes() int64 {
return info.Size()
}
// GetLatestLocalPricelist returns the most recently synced pricelist
// GetLatestLocalPricelist returns the most recently synced active estimate pricelist.
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.
Where("source = ?", "estimate").
Where("source = ? AND is_active = ?", "estimate", true).
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
Order("created_at DESC, id DESC").
First(&pricelist).Error; err != nil {
@@ -1283,11 +1277,11 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
return &pricelist, nil
}
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
// GetLatestLocalPricelistBySource returns the most recently synced active pricelist for a source.
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.
Where("source = ?", source).
Where("source = ? AND is_active = ?", source, true).
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
Order("created_at DESC, id DESC").
First(&pricelist).Error; err != nil {
@@ -1296,6 +1290,17 @@ func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelis
return &pricelist, nil
}
// DeactivateLocalPricelistsNotIn marks all local pricelists with is_active=true whose
// server_id is not in activeServerIDs as inactive. Used after each pricelist sync to
// mirror server-side deactivations locally.
func (l *LocalDB) DeactivateLocalPricelistsNotIn(activeServerIDs []uint) error {
q := l.db.Model(&LocalPricelist{}).Where("is_active = ?", true)
if len(activeServerIDs) > 0 {
q = q.Where("server_id NOT IN ?", activeServerIDs)
}
return q.Update("is_active", false).Error
}
// GetLocalPricelistByServerID returns a local pricelist by its server ID
func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) {
var pricelist LocalPricelist
@@ -1363,6 +1368,30 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
return count
}
// GetLocalPricelistCoverageByCategory returns item count per lot_category and the total
// for the given local pricelist ID. Only items with price > 0 are counted.
func (l *LocalDB) GetLocalPricelistCoverageByCategory(pricelistID uint) (map[string]int64, int64, error) {
type row struct {
Category string `gorm:"column:lot_category"`
Count int64 `gorm:"column:cnt"`
}
var rows []row
if err := l.db.Model(&LocalPricelistItem{}).
Select("COALESCE(NULLIF(TRIM(lot_category),''), '?') AS lot_category, COUNT(*) AS cnt").
Where("pricelist_id = ? AND price > 0", pricelistID).
Group("lot_category").
Scan(&rows).Error; err != nil {
return nil, 0, err
}
result := make(map[string]int64, len(rows))
var total int64
for _, r := range rows {
result[r.Category] = r.Count
total += r.Count
}
return result, total, nil
}
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
var count int64
@@ -1427,10 +1456,11 @@ func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem
return items, nil
}
// GetLocalPriceForLot returns the price for a lot from a local pricelist
// GetLocalPriceForLot returns the price for a lot from a local pricelist.
// Matching is case-insensitive via UPPER(lot_name) to handle legacy mixed-case rows.
func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) {
var item LocalPricelistItem
if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).
if err := l.db.Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)).
First(&item).Error; err != nil {
return 0, err
}
@@ -1438,26 +1468,32 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
}
// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query.
// Uses the composite index (pricelist_id, lot_name). Missing lots are omitted from the result.
// Missing lots are omitted from the result.
// lotNames must already be normalized (uppercased); matching is done via UPPER(lot_name) to handle
// legacy rows that were stored in mixed case before normalization was enforced at sync time.
// Keys in the returned map are uppercased (matching the input lotNames).
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
result := make(map[string]float64, len(lotNames))
if len(lotNames) == 0 {
return result, nil
}
type row struct {
LotName string `gorm:"column:lot_name"`
Price float64 `gorm:"column:price"`
}
var rows []row
// Use UPPER(lot_name) so rows synced before normalization (mixed-case) are still matched.
if err := l.db.Model(&LocalPricelistItem{}).
Select("lot_name, price").
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, lotNames).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
if r.Price > 0 {
result[r.LotName] = r.Price
// Key must be uppercase to match callers that normalise lot names before lookup.
result[strings.ToUpper(r.LotName)] = r.Price
}
}
return result, nil
@@ -1480,15 +1516,27 @@ func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uin
LotName string `gorm:"column:lot_name"`
LotCategory string `gorm:"column:lot_category"`
}
// Build uppercase → original mapping so result keys match what the caller passed.
upperToOrig := make(map[string]string, len(lotNames))
upper := make([]string, len(lotNames))
for i, n := range lotNames {
u := strings.ToUpper(n)
upper[i] = u
upperToOrig[u] = n
}
var rows []row
if err := l.db.Model(&LocalPricelistItem{}).
Select("lot_name, lot_category").
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", localPL.ID, upper).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.LotName] = r.LotCategory
orig := upperToOrig[strings.ToUpper(r.LotName)]
if orig == "" {
orig = r.LotName
}
result[orig] = r.LotCategory
}
return result, nil
}
@@ -1672,12 +1720,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
var remainingErrors []string
for _, change := range erroredChanges {
var modified bool
var repairErr error
switch change.EntityType {
case "project":
repairErr = l.repairProjectChange(&change)
modified, repairErr = l.repairProjectChange(&change)
case "configuration":
repairErr = l.repairConfigurationChange(&change)
modified, repairErr = l.repairConfigurationChange(&change)
default:
repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
}
@@ -1688,7 +1737,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
continue
}
// Clear error and reset attempts
// Only reset attempts when the repair actually changed local data.
// If nothing was modified, the error is server-side; leaving attempts
// intact lets maxPendingChangeAttempts eventually abandon the change.
if !modified {
continue
}
if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{
"last_error": "",
"attempts": 0,
@@ -1704,12 +1759,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
}
// repairProjectChange validates and fixes project data.
// Returns (modified, err): modified=true only when local data was actually changed.
// Note: This only validates local data. Server-side conflicts (like duplicate code+variant)
// are handled by sync service layer with deduplication logic.
func (l *LocalDB) repairProjectChange(change *PendingChange) error {
func (l *LocalDB) repairProjectChange(change *PendingChange) (bool, error) {
project, err := l.GetProjectByUUID(change.EntityUUID)
if err != nil {
return fmt.Errorf("project not found locally: %w", err)
return false, fmt.Errorf("project not found locally: %w", err)
}
modified := false
@@ -1735,7 +1791,7 @@ func (l *LocalDB) repairProjectChange(change *PendingChange) error {
if strings.TrimSpace(project.OwnerUsername) == "" {
project.OwnerUsername = l.GetDBUser()
if project.OwnerUsername == "" {
return fmt.Errorf("cannot determine owner username")
return false, fmt.Errorf("cannot determine owner username")
}
modified = true
}
@@ -1756,18 +1812,19 @@ func (l *LocalDB) repairProjectChange(change *PendingChange) error {
if modified {
if err := l.SaveProject(project); err != nil {
return fmt.Errorf("saving repaired project: %w", err)
return false, fmt.Errorf("saving repaired project: %w", err)
}
}
return nil
return modified, nil
}
// repairConfigurationChange validates and fixes configuration data
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
// repairConfigurationChange validates and fixes configuration data.
// Returns (modified, err): modified=true only when local data was actually changed.
func (l *LocalDB) repairConfigurationChange(change *PendingChange) (bool, error) {
config, err := l.GetConfigurationByUUID(change.EntityUUID)
if err != nil {
return fmt.Errorf("configuration not found locally: %w", err)
return false, fmt.Errorf("configuration not found locally: %w", err)
}
modified := false
@@ -1779,7 +1836,7 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
// Project doesn't exist locally - use default system project
systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
if sysErr != nil {
return fmt.Errorf("getting system project: %w", sysErr)
return false, fmt.Errorf("getting system project: %w", sysErr)
}
config.ProjectUUID = &systemProject.UUID
modified = true
@@ -1788,11 +1845,11 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
if modified {
if err := l.SaveConfiguration(config); err != nil {
return fmt.Errorf("saving repaired configuration: %w", err)
return false, fmt.Errorf("saving repaired configuration: %w", err)
}
}
return nil
return modified, nil
}
// GetSyncGuardState returns the latest readiness guard state.
@@ -1854,28 +1911,6 @@ func (l *LocalDB) GetLocalPricelistItemsPage(pricelistID uint, search string, pa
return items, total, nil
}
// GetLocalComponentDescriptionsByLotNames returns a map of lot_name → lot_description for the given lots.
func (l *LocalDB) GetLocalComponentDescriptionsByLotNames(lotNames []string) (map[string]string, error) {
if len(lotNames) == 0 {
return map[string]string{}, nil
}
type row struct {
LotName string
LotDescription string
}
var rows []row
if err := l.db.Table("local_components").
Select("lot_name, lot_description").
Where("lot_name IN ?", lotNames).
Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("fetch component descriptions: %w", err)
}
m := make(map[string]string, len(rows))
for _, r := range rows {
m[r.LotName] = r.LotDescription
}
return m, nil
}
// GetSchemaMigrations returns all applied local schema migrations ordered by applied_at.
func (l *LocalDB) GetSchemaMigrations() ([]LocalSchemaMigration, error) {

View File

@@ -1120,3 +1120,4 @@ func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error {
slog.Info("deduplicated local_pricelist_items and added unique index")
return nil
}

View File

@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
// AppSetting stores application settings in local SQLite
@@ -46,7 +48,13 @@ func (c *LocalConfigItems) Scan(value interface{}) error {
default:
return errors.New("type assertion failed for LocalConfigItems")
}
return json.Unmarshal(bytes, c)
if err := json.Unmarshal(bytes, c); err != nil {
return err
}
for i := range *c {
(*c)[i].LotName = models.NormalizeLotName((*c)[i].LotName)
}
return nil
}
func (c LocalConfigItems) Total() float64 {
@@ -169,7 +177,8 @@ type LocalPricelist struct {
Name string `json:"name"`
CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
SyncedAt time.Time `json:"synced_at"`
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
IsActive bool `gorm:"not null;default:true;index" json:"is_active"` // Mirrors qt_pricelists.is_active
}
func (LocalPricelist) TableName() string {
@@ -356,3 +365,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,126 @@
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.
// If the read fails (no connection, table missing on old server) or the server
// returns an empty table, the existing local_qt_settings are preserved so the
// configurator keeps working offline or against old server versions.
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 {
slog.Warn("qt_settings: read from MariaDB failed, keeping existing local cache", "error", err)
return fmt.Errorf("reading qt_settings from MariaDB: %w", err)
}
if len(rows) == 0 {
slog.Warn("qt_settings: server returned empty table, keeping existing local cache")
return nil
}
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 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

@@ -1,5 +1,13 @@
package models
import "strings"
// NormalizeLotName returns the canonical form of a lot name: trimmed and uppercased.
// Apply at every point where a lot name enters the system (sync, API input, config load).
func NormalizeLotName(s string) string {
return strings.ToUpper(strings.TrimSpace(s))
}
// Lot represents existing lot table
type Lot struct {
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`

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

@@ -269,12 +269,21 @@ func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (
}
// GetPricesForLots returns price map for given lots within a pricelist.
// Keys in the returned map match the requested lot names (case-preserving) so that
// callers using Go map lookups are not confused by case differences between the
// requested name and the stored value (e.g. pricelist renamed lots to UPPERCASE).
func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
result := make(map[string]float64, len(lotNames))
if pricelistID == 0 || len(lotNames) == 0 {
return result, nil
}
// Build case-insensitive index: lowercase → original requested name.
lotIndex := make(map[string]string, len(lotNames))
for _, n := range lotNames {
lotIndex[strings.ToLower(n)] = n
}
var rows []models.PricelistItem
if err := r.db.Select("lot_name, price").
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
@@ -284,7 +293,11 @@ func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []stri
for _, row := range rows {
if row.Price > 0 {
result[row.LotName] = row.Price
key := row.LotName
if requested, ok := lotIndex[strings.ToLower(row.LotName)]; ok {
key = requested
}
result[key] = row.Price
}
}
return result, nil

View File

@@ -23,6 +23,11 @@ func (r *ProjectRepository) Update(project *models.Project) error {
}
func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
// Clear the client-side primary key so the upsert is driven purely by the
// uuid unique constraint. Passing a non-zero ID can trigger ON DUPLICATE KEY
// on the primary key of an unrelated row, leaving uuid unchanged and causing
// the follow-up SELECT to return ErrRecordNotFound.
project.ID = 0
if err := r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.AssignmentColumns([]string{

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)
@@ -603,16 +656,8 @@ func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string
return prices
}
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
lots := collectPricingLots(cfg, localCfg, true)
if s.localDB == nil || len(lots) == 0 {
return map[string]string{}
}
descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots)
if err != nil {
return map[string]string{}
}
return descriptions
func (s *ExportService) resolveLotDescriptions(_ *models.Configuration, _ *localdb.LocalConfiguration) map[string]string {
return map[string]string{}
}
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
@@ -696,6 +741,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 +807,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 +825,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 +854,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 +883,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

@@ -111,6 +111,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
if len(req.Items) == 0 {
return nil, ErrEmptyQuote
}
for i := range req.Items {
req.Items[i].LotName = models.NormalizeLotName(req.Items[i].LotName)
}
// Strict local-first path: calculations use local SQLite snapshot regardless of online status.
if s.localDB != nil {
@@ -245,6 +248,16 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
if len(req.Items) == 0 {
return nil, ErrEmptyQuote
}
// Keep original lot names so the response mirrors what the caller sent.
// Normalization is applied only for internal DB lookups.
originalLotNames := make(map[string]string, len(req.Items))
for i := range req.Items {
upper := models.NormalizeLotName(req.Items[i].LotName)
if _, exists := originalLotNames[upper]; !exists {
originalLotNames[upper] = req.Items[i].LotName
}
req.Items[i].LotName = upper
}
lotNames := make([]string, 0, len(req.Items))
seenLots := make(map[string]struct{}, len(req.Items))
@@ -303,8 +316,12 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
}
for _, reqItem := range req.Items {
responseLotName := originalLotNames[reqItem.LotName]
if responseLotName == "" {
responseLotName = reqItem.LotName
}
item := PriceLevelsItem{
LotName: reqItem.LotName,
LotName: responseLotName,
Quantity: reqItem.Quantity,
PriceMissing: make([]string, 0, 3),
}

View File

@@ -1,20 +1,31 @@
package sync
import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
)
// SeenPartnumber represents an unresolved vendor partnumber to report.
type SeenPartnumber struct {
Partnumber string
Description string
Ignored bool
Partnumber string
Description string
Ignored bool
LotSuggestion []LotSuggestionEntry // optional; set when user manually mapped PN → LOT in UI
}
// LotSuggestionEntry is one suggested LOT mapping for a vendor partnumber.
// JSON shape mirrors qt_partnumber_book_items.lots_json: {"lot_name", "qty"}.
type LotSuggestionEntry struct {
LotName string `json:"lot_name"`
Qty int `json:"qty"`
}
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
// Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
// When LotSuggestion is provided the column is updated too; if the column does not exist yet
// (migration pending) the write is retried without it and a warning is logged — the app never panics.
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
if len(items) == 0 {
return nil
@@ -30,7 +41,43 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
if item.Partnumber == "" {
continue
}
err := mariaDB.Exec(`
if len(item.LotSuggestion) > 0 {
suggJSON, marshalErr := json.Marshal(item.LotSuggestion)
if marshalErr != nil {
slog.Error("partnumber_seen: failed to marshal lot_suggestion, skipping suggestion",
"partnumber", item.Partnumber, "error", marshalErr)
suggJSON = nil
}
if suggJSON != nil {
err = mariaDB.Exec(`
INSERT INTO qt_vendor_partnumber_seen
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
VALUES
('manual', '', ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
lot_suggestion = VALUES(lot_suggestion),
last_seen_at = NOW(3)
`, item.Partnumber, item.Description, item.Ignored, now, string(suggJSON)).Error
if err == nil {
continue
}
// Column not yet migrated — fall through to insert without lot_suggestion.
if !isUnknownColumnError(err) {
slog.Error("partnumber_seen: failed to upsert with lot_suggestion",
"partnumber", item.Partnumber, "error", err)
continue
}
slog.Warn("partnumber_seen: lot_suggestion column missing (migration pending), inserting without it",
"partnumber", item.Partnumber)
}
}
// Insert without lot_suggestion (baseline behaviour or fallback).
err = mariaDB.Exec(`
INSERT INTO qt_vendor_partnumber_seen
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
VALUES
@@ -40,10 +87,18 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
`, item.Partnumber, item.Description, item.Ignored, now).Error
if err != nil {
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
// Continue with remaining items
}
}
slog.Info("partnumber_seen pushed to server", "count", len(items))
return nil
}
// isUnknownColumnError returns true when MariaDB reports that a column does not exist.
func isUnknownColumnError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unknown column") || strings.Contains(msg, "1054")
}

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)
@@ -392,6 +404,7 @@ func (s *Service) SyncPricelists() (int, error) {
CreatedAt: pl.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
IsActive: true,
}
itemCount, err := s.syncNewPricelistSnapshot(localPL)
@@ -414,6 +427,12 @@ func (s *Service) SyncPricelists() (int, error) {
slog.Info("deleted stale local pricelists", "deleted", removed)
}
// Mirror server-side deactivations: any local pricelist not in the current active set
// is marked is_active=false so offline lookups skip it.
if err := s.localDB.DeactivateLocalPricelistsNotIn(serverPricelistIDs); err != nil {
slog.Warn("failed to deactivate stale local pricelists", "error", err)
}
// Backfill lot_category for used pricelists (older local caches may miss the column values).
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
@@ -764,9 +783,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 +869,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 +877,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 +895,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 +914,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 +926,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 +952,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 +970,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 +1107,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 +1310,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{}
@@ -1574,24 +1645,3 @@ func (s *Service) getConnectionStatus() db.ConnectionStatus {
return s.connMgr.GetStatus()
}
// SyncComponentsIfEmpty syncs components from MariaDB when local_components is empty.
// Used by the background worker on first run to populate the catalog for new users.
func (s *Service) SyncComponentsIfEmpty() error {
if s.localDB.CountComponents() > 0 {
return nil
}
mariaDB, err := s.getDB()
if err != nil {
_ = s.localDB.SetComponentSyncResult("error", err.Error(), time.Now())
return err
}
result, err := s.localDB.SyncComponents(mariaDB)
now := time.Now()
if err != nil {
_ = s.localDB.SetComponentSyncResult("error", err.Error(), now)
return err
}
_ = s.localDB.SetComponentSyncResult("ok", "", now)
slog.Info("background sync: initial component sync completed", "synced", result.TotalSynced)
return nil
}

View File

@@ -80,11 +80,6 @@ func (w *Worker) runSync() {
return
}
// Populate component catalog on first run (empty local_components)
if err := w.service.SyncComponentsIfEmpty(); err != nil {
w.logger.Warn("background sync: initial component sync failed", "error", err)
}
// Push pending changes first
pushed, err := w.service.PushPendingChanges()
if err != nil {
@@ -100,5 +95,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,25 @@
# QuoteForge v1.14
Дата релиза: 2026-06-16
Тег: `v1.14`
Предыдущий релиз: `v1.13`
## Ключевые изменения
- добавлен импорт человекочитаемого текстового BOM формата `<описание> - <кол-во> шт.`
(с необязательным заголовком, оканчивающимся на `, в составе:`) — как при загрузке файла
через `POST /api/projects/:uuid/vendor-import`, так и при вставке в конфигураторе;
- заголовок конфигурации определяется по маркеру `, в составе:` с любым префиксом
(`Сервер X3` и `Вычислительный GPU сервер X3` → модель `X3`);
- парсинг устойчив к пробелам в начале/конце строки (в P/N не попадает лишний пробел),
а также к запятым и дефисам внутри описания (`RAID0,1,10`, `8-GPU-2304GB`);
- вставка BOM в конфигураторе и импорт файла используют единый серверный парсер
(`POST /api/vendor-spec/parse-text`) — дублирующая логика разбора на фронтенде удалена;
- сабмодуль `bible` обновлён до актуальных контрактов (build-version-display,
local-first-recovery, резервные копии миграций).
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

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

@@ -0,0 +1,23 @@
# QuoteForge v2.22
Дата релиза: 2026-06-26
Тег: `v2.22`
## Что нового
### Исправления
- **MB-автокомплит в конфигураторе теперь работает в offline-режиме.** Корневая причина: прайслист мог быть синхронизирован до введения нормализации имён лотов, из-за чего SQLite хранил их в исходном регистре (`MB_AMD_2.Rome_...`). Запрос на поиск цены отправлял уже нормализованное имя (`MB_AMD_2.ROME_...`), `IN`-сравнение в SQLite регистрозависимо — совпадений не было, цена возвращалась как null, и автокомплит показывал пустой список. Все запросы к `local_pricelist_items` по `lot_name` переведены на `UPPER(lot_name)`.
- **Удалён мёртвый код инференса категории из имени лота.** Функция `getCategoryFromLotName` на фронтенде выводила категорию из префикса лота (`DKC_AFF_A1K``DKC`) как fallback. Категория всегда приходит из прайслиста; функция удалена. Позиции без категории корректно попадают во вкладку «Other».
- **Удалена таблица `local_components` и весь связанный с ней код.** Источник данных для компонентов — только `local_pricelist_items`. Убраны маршрут `POST /api/sync/components`, поля `ComponentsSynced` и `LastComponentSync` в ответах синхронизации.
- **Support bundle расширен диагностическими файлами:** `latest_pricelist_items.json` (все позиции активного estimate-прайслиста), `autocomplete_lots.json` (позиции по категориям с флагом `has_price`), `local.db` (полная копия SQLite-базы).
- **Регистронезависимые сравнения lot_name на фронтенде:** Set-коллекции для склада, добавленных позиций и корзины BOM теперь нормализуют ключи через `.toUpperCase()`.
## Запуск на 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">
@@ -417,7 +417,7 @@ let TAB_CONFIG = {
let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
.map(c => ciStr(c));
// State
let configUUID = '{{.ConfigUUID}}';
@@ -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;
@@ -710,7 +713,7 @@ async function loadWarehouseInStockLots() {
const lotNames = Array.isArray(data.lot_names) ? data.lot_names : [];
lotNames.forEach(lot => {
if (typeof lot === 'string' && lot.trim() !== '') {
result.add(lot);
result.add(lot.toUpperCase());
}
});
@@ -745,7 +748,7 @@ function isComponentAllowedByStockFilter(comp) {
const availableLots = warehouseStockLotsByPricelist.get(pricelistID);
// Don't block UI while stock set is being loaded.
if (!availableLots) return true;
return availableLots.has(comp.lot_name);
return availableLots.has((comp.lot_name || '').toUpperCase());
}
// Load categories from API and update tab configuration
@@ -757,16 +760,16 @@ async function loadCategoriesFromAPI() {
// Build category order map
categoryOrderMap = {};
cats.forEach(cat => {
categoryOrderMap[cat.code.toUpperCase()] = cat.display_order;
categoryOrderMap[ciStr(cat.code)] = cat.display_order;
});
// Build list of unassigned categories
const knownCodes = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
.map(c => ciStr(c));
const unassignedCategories = cats
.filter(cat => !knownCodes.includes(cat.code.toUpperCase()))
.filter(cat => !knownCodes.includes(ciStr(cat.code)))
.sort((a, b) => a.display_order - b.display_order)
.map(cat => cat.code);
@@ -776,13 +779,102 @@ async function loadCategoriesFromAPI() {
// Rebuild ASSIGNED_CATEGORIES
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
.map(c => ciStr(c));
} catch(e) {
console.error('Failed to load categories, using defaults', e);
// Will use default configuration if API fails
}
}
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 => ciStr(c));
}
// 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 || '').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);
@@ -832,8 +925,7 @@ document.addEventListener('DOMContentLoaded', async function() {
warehouse_price: null,
competitor_price: null,
description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name)
}));
category: item.category }));
}
serverModelForQuote = config.server_model || '';
supportCode = config.support_code || '';
@@ -861,6 +953,10 @@ document.addEventListener('DOMContentLoaded', async function() {
loadAllComponents(),
categoriesPromise,
]);
cart = cart.map(item => ({
...item,
category: item.category || allComponents.find(c => c.lot_name.toUpperCase() === (item.lot_name || '').toUpperCase())?.category || ''
}));
syncPriceSettingsControls();
renderPricelistSettingsSummary();
updateRefreshPricesButtonState();
@@ -879,6 +975,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);
@@ -904,7 +1006,7 @@ const BOM_LOT_DATALIST_DIVIDER = '────────';
function _bomLotValid(v) {
const lot = (v || '').trim();
if (!lot || lot === BOM_LOT_DATALIST_DIVIDER) return false;
return (window._bomAllComponents || allComponents).some(c => c.lot_name === lot);
return (window._bomAllComponents || allComponents).some(c => c.lot_name.toUpperCase() === lot.toUpperCase());
}
function updateServerCount() {
@@ -1120,19 +1222,16 @@ function applyPriceSettings() {
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true });
}
function getCategoryFromLotName(lotName) {
const parts = lotName.split('_');
return parts[0] || '';
}
function ciStr(s) { return (s || '').toLowerCase(); }
function getComponentCategory(comp) {
return (comp.category || getCategoryFromLotName(comp.lot_name)).toUpperCase();
return comp.category || '';
}
function getTabForCategory(category) {
const cat = category.toUpperCase();
const cat = ciStr(category);
for (const [tabKey, tabConfig] of Object.entries(TAB_CONFIG)) {
if (tabConfig.categories.map(c => c.toUpperCase()).includes(cat)) {
if (tabConfig.categories.some(c => ciStr(c) === cat)) {
return tabKey;
}
}
@@ -1154,77 +1253,78 @@ 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);
// Snapshot the full category list for this tab (stored in _allCategories if not yet saved)
if (!tab._allCategories) tab._allCategories = [...tab.categories];
tab.categories = tab._allCategories.filter(c => isCategoryVisibleForConfigType(c, configType));
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 !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
});
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_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));
}
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
// Rebuild assigned categories index using the full static list (_allCategories),
// not the filtered one — hidden categories still belong to their tab, not to Other.
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
.flatMap(t => t._allCategories || t.categories)
.map(c => ciStr(c));
}
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;
const hasCartItems = cart.some(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase();
return getTabForCategory(cat) === tabId;
return getTabForCategory(item.category) === tabId;
});
const visible = hasComponents || hasCartItems;
btn.classList.toggle('hidden', !visible);
@@ -1240,15 +1340,15 @@ function getComponentsForTab(tab) {
return allComponents.filter(comp => {
const category = getComponentCategory(comp);
if (tab === 'other') {
return !ASSIGNED_CATEGORIES.includes(category);
return !ASSIGNED_CATEGORIES.includes(ciStr(category));
}
return config.categories.map(c => c.toUpperCase()).includes(category);
return config.categories.some(c => ciStr(c) === ciStr(category));
});
}
function getComponentsForCategory(category) {
return allComponents.filter(comp => {
return getComponentCategory(comp) === category.toUpperCase();
return ciStr(getComponentCategory(comp)) === ciStr(category);
});
}
@@ -1310,10 +1410,10 @@ function renderSingleSelectTab(categories) {
categories.forEach(cat => {
const catLabel = cat === 'MB' ? 'MB' : cat === 'CPU' ? 'CPU' : cat === 'MEM' ? 'MEM' : cat;
const selectedItem = cart.find(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() === cat.toUpperCase()
ciStr(item.category) === ciStr(cat)
);
const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null;
const comp = selectedItem ? allComponents.find(c => c.lot_name.toUpperCase() === (selectedItem.lot_name || '').toUpperCase()) : null;
const price = comp?.current_price || 0;
const estimate = selectedItem?.estimate_price ?? price;
const qty = selectedItem?.quantity || 1;
@@ -1363,9 +1463,7 @@ function renderSingleSelectTab(categories) {
function renderMultiSelectTab(components) {
// Get cart items for this tab
const tabItems = cart.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
const tab = getTabForCategory(cat);
return tab === currentTab;
return getTabForCategory(item.category) === currentTab;
});
let html = `
@@ -1385,7 +1483,7 @@ function renderMultiSelectTab(components) {
// Render existing cart items for this tab
tabItems.forEach((item, idx) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name);
const comp = allComponents.find(c => c.lot_name.toUpperCase() === (item.lot_name || '').toUpperCase());
const total = getDisplayPrice(item) * item.quantity;
html += `
@@ -1452,9 +1550,7 @@ function renderMultiSelectTab(components) {
function renderMultiSelectTabWithSections(sections) {
// Get cart items for this tab
const tabItems = cart.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
const tab = getTabForCategory(cat);
return tab === currentTab;
return getTabForCategory(item.category) === currentTab;
});
let html = '';
@@ -1462,17 +1558,14 @@ function renderMultiSelectTabWithSections(sections) {
sections.forEach((section, sectionIdx) => {
// Get components for this section's categories
const sectionCategories = section.categories.map(c => c.toUpperCase());
const sectionComponents = allComponents.filter(comp => {
const category = getComponentCategory(comp);
return sectionCategories.includes(category);
return section.categories.some(c => ciStr(c) === ciStr(getComponentCategory(comp)));
});
totalComponents += sectionComponents.length;
// Get cart items for this section
const sectionItems = tabItems.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
return sectionCategories.includes(cat);
return sectionCategories.some(c => ciStr(c) === ciStr(item.category));
});
// Section header
@@ -1499,7 +1592,7 @@ function renderMultiSelectTabWithSections(sections) {
// Render existing cart items for this section
sectionItems.forEach((item) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name);
const comp = allComponents.find(c => c.lot_name.toUpperCase() === (item.lot_name || '').toUpperCase());
const total = getDisplayPrice(item) * item.quantity;
html += `
@@ -1656,6 +1749,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') {
@@ -1708,7 +1805,7 @@ function selectAutocompleteItem(index) {
// Remove existing item of this category
cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== autocompleteCategory.toUpperCase()
ciStr(item.category) !== ciStr(autocompleteCategory)
);
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
@@ -1764,11 +1861,11 @@ function filterAutocompleteMulti(search) {
const searchLower = search.toLowerCase();
// Filter out already added items
const addedLots = new Set(cart.map(i => i.lot_name));
const addedLots = new Set(cart.map(i => (i.lot_name || '').toUpperCase()));
autocompleteFiltered = components.filter(c => {
if (!hasComponentPrice(c.lot_name)) return false;
if (addedLots.has(c.lot_name)) return false;
if (addedLots.has((c.lot_name || '').toUpperCase())) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
@@ -1869,11 +1966,11 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
});
// Filter out already added items
const addedLots = new Set(cart.map(i => i.lot_name));
const addedLots = new Set(cart.map(i => (i.lot_name || '').toUpperCase()));
autocompleteFiltered = sectionComponents.filter(c => {
if (!hasComponentPrice(c.lot_name)) return false;
if (addedLots.has(c.lot_name)) return false;
if (addedLots.has((c.lot_name || '').toUpperCase())) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
@@ -2039,14 +2136,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 || '').toUpperCase()));
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 || '').toUpperCase()))
.sort((a, b) => a.lot_name.localeCompare(b.lot_name));
const notInCart = all.filter(c => !cartLots.has((c.lot_name || '').toUpperCase()))
.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 +2178,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;
@@ -2081,7 +2188,7 @@ function selectAutocompleteItemBOM(index, rowIdx) {
function clearSingleSelect(category) {
cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
ciStr(item.category) !== ciStr(category)
);
renderTab();
updateCartUI();
@@ -2091,7 +2198,7 @@ function clearSingleSelect(category) {
function updateSingleQuantity(category, value) {
const qty = parseInt(value) || 1;
const item = cart.find(i =>
(i.category || getCategoryFromLotName(i.lot_name)).toUpperCase() === category.toUpperCase()
ciStr(i.category) === ciStr(category)
);
if (item) {
@@ -2131,6 +2238,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);
@@ -2149,8 +2257,8 @@ function updateCartUI() {
// Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
const catA = ciStr(a.category);
const catB = ciStr(b.category);
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
@@ -2158,8 +2266,7 @@ function updateCartUI() {
const grouped = {};
sortedCart.forEach(item => {
const cat = item.category || getCategoryFromLotName(item.lot_name);
const tab = getTabForCategory(cat);
const tab = getTabForCategory(item.category);
if (!grouped[tab]) grouped[tab] = [];
grouped[tab].push(item);
});
@@ -2167,11 +2274,11 @@ function updateCartUI() {
// Sort tabs by minimum display order of their categories
const sortedTabs = Object.entries(grouped).sort((a, b) => {
const minOrderA = Math.min(...a[1].map(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
const cat = ciStr(item.category);
return categoryOrderMap[cat] || 9999;
}));
const minOrderB = Math.min(...b[1].map(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
const cat = ciStr(item.category);
return categoryOrderMap[cat] || 9999;
}));
return minOrderA - minOrderB;
@@ -2402,8 +2509,7 @@ function restoreAutosaveDraftIfAny() {
warehouse_price: null,
competitor_price: null,
description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name)
}));
category: item.category }));
}
if (typeof payload.server_count === 'number' && payload.server_count > 0) {
serverCount = payload.server_count;
@@ -2426,6 +2532,9 @@ function restoreAutosaveDraftIfAny() {
customPriceInput.value = '';
}
}
if (payload.notes) {
restorePricingStateFromNotes(payload.notes);
}
hasUnsavedChanges = true;
} catch (_) {
// ignore invalid draft
@@ -2620,8 +2729,8 @@ function renderSalePriceTable() {
}
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
const catA = ciStr(a.category);
const catB = ciStr(b.category);
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
@@ -2724,8 +2833,8 @@ function calculateCustomPrice() {
// Build adjusted prices table
// Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
const catA = ciStr(a.category);
const catB = ciStr(b.category);
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
@@ -2857,6 +2966,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 +3327,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 +3713,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 +3780,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 +4048,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 +4059,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 +4118,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 +4143,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 = ciStr(a.category);
const catB = ciStr(b.category);
return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999);
});
sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
return { result, coveredLots };
}
@@ -4041,7 +4169,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 +4181,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 +4502,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 +4515,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;
}