Compare commits

...

27 Commits
v1.11 ... v2.19

Author SHA1 Message Date
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
Mikhail Chusavitin
24c34eb0e1 fix: текстовый BOM работает в пасте конфигуратора через единый серверный парсер
Паста BOM на странице конфигурирования теперь распознаёт текстовый и
Inspur-форматы: вместо дублирования парсера на JS добавлен stateless
эндпоинт POST /api/vendor-spec/parse-text, который использует те же
детекторы и парсеры, что и импорт файла (KISS — один парсер на оба
входа). JS-копии _parseInspurBOMText/_isInspurBOMText удалены.

Заголовок конфигурации определяется по маркеру ", в составе:" с любым
префиксом ("Сервер X3" и "Вычислительный GPU сервер X3" → модель X3);
строки тримятся, пробел в начале не попадает в P/N; запятые и дефисы
внутри описания сохраняются (RAID0,1,10; 8-GPU-2304GB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:16:55 +03:00
Mikhail Chusavitin
6f2c261350 chore: обновить сабмодуль bible до 5244435
Включает контракты build-version-display, local-first-recovery и
автоматизацию резервных копий миграций.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:07:48 +03:00
Mikhail Chusavitin
7233a0780f feat: импорт человекочитаемого текстового BOM (формат "<описание> - N шт.")
Новый формат vendor-import: опциональный заголовок "Сервер <модель>,
в составе:" и строки вида "<описание> - <кол-во> шт." (дефис/тире,
пробел перед "шт" и точка опциональны). Количество якорится в конце
строки, поэтому дефисы и цифры внутри описания (8-GPU-2304GB) сохраняются.

Описание пишется и в vendor_partnumber, и в description: строки
резолвятся через активную книгу партномеров, иначе остаются
нерезолвленными и редактируемыми. Весь файл — одна конфигурация.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:06:26 +03:00
Mikhail Chusavitin
360c754952 refactor: удалить мёртвые таблицы qt_price_overrides, qt_pricing_alerts, qt_component_usage_stats
Удалены модели, репозитории и авто-миграции для трёх таблиц, которые
никогда не использовались в продакшн-коде. Убраны StatsRepository и
RecordUsage из сервисов, сигнатуры конструкторов упрощены.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:54:42 +03:00
184f54b663 refactor: привести кодовую базу в соответствие с канонами bible
- 400 → 422 для всех ошибок валидации входных данных (handlers: export, quote, sync, vendor_spec, partnumber_books, pricelist)
- SQL-запросы вынесены из handlers в localdb (partnumber_books, pricelist, support_bundle); ValidateMariaDBConnection перенесён в internal/db/validate.go
- List-ответы унифицированы: ключ items, поля total_count/page/per_page/total_pages (component, pricelist, partnumber_books); шаблоны обновлены
- Молчаливые ошибки заменены на slog.Warn/Error (support_bundle, vendor_spec, component, configuration, local_configuration, localdb)
- N+1 запросы устранены: batch-запросы в export.go и vendor_workspace_import.go
- fmt.Println → slog в cmd/ (qfs, migrate, migrate_ops_projects, migrate_project_updated_at)
- Заголовки recovery/verify добавлены во все 28 SQL-миграций
- Добавлены bible-local/runtime-flows.md и bible-local/decisions/
- Обновлён субмодуль bible до v0.2.0-13

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:38:01 +03:00
e548305396 fix: экспорт конфига через GetByUUIDNoAuth, формат чисел с запятой как разделителем
- ExportConfigCSV и ExportConfigPricingCSV: GetByUUID → GetByUUIDNoAuth,
  чтобы не падать с ErrConfigForbidden на конфигах с чужим OriginalUsername
- ConfigurationGetter: добавлен GetByUUIDNoAuth в интерфейс
- Шаблоны: toLocaleString('en-US') → 'ru-RU' во всех местах отображения цен;
  процентные значения: .toFixed(1) → .toFixed(1).replace('.', ',')

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 05:00:37 +03:00
09d694234d feat: кнопка "Обновить цены" использует последний скачанный прайслист без синхронизации и показывает diff
- убрать вызовы /api/sync/components и /api/sync/pricelists из обеих кнопок
- брать самый свежий прайслист из уже скачанных (active_only)
- проверять галочку disable_price_refresh (пропускать конфиг если включена)
- показывать модальное окно diff: компонент / цена за шт. / сумма (было → стало) + итог конфиги
- общие утилиты (fetchLatestEstimatePricelistId, showPriceDiffModal) вынесены в base.html
- обе кнопки вызывают refreshPrices() без дублирования кода

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 04:26:25 +03:00
56782fa718 refactor: удалить неиспользуемые модели StockLog, StockIgnoreRule, Supplier
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:56:55 +03:00
2bd57591ea docs: убрать stock_log и stock_ignore_rules из списка прав БД
Таблицы не используются в коде (только объявлены модели), stock_log не существует в БД.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:56:05 +03:00
a81947b852 docs: убрать qt_pricelist_sync_status из списка прав БД
Таблица удалена в 3992dbf, грант на неё больше не требуется.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:46:24 +03:00
6146f6aec7 fix: галочка "Создать копию" снята по умолчанию (программный checked не триггерил change-обработчик имени)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:46:23 +03:00
91 changed files with 3302 additions and 920 deletions

View File

@@ -116,6 +116,28 @@ Rules:
- storage configurations use the same vendor_spec + PN→LOT + pricing flow as server configurations; - 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. - 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` = дисковая полка без контроллера. - `DKC` = контроллерная полка (модель СХД + тип дисков + кол-во слотов + кол-во контроллеров); `CTL` = контроллер (кэш + встроенные порты); `ENC` = дисковая полка без контроллера.
- the available config types and their localized names flow from `qt_settings.config_types` on the server;
QF falls back to hardcoded "server/Сервер" and "storage/СХД" when the setting is absent.
## Server-driven configurator settings (`qt_settings`)
QF reads four settings from `qt_settings` (MariaDB) and caches them in `local_qt_settings` (SQLite).
They are synced during every component sync. See `bible-local/server-contract-qt-settings.md` for the
full contract and JSON schemas.
| Setting key | Effect in QF |
|-------------|-------------|
| `config_types` | New-config modal buttons; category allowlist per config type |
| `tab_config` | Configurator tab structure, sections, singleSelect |
| `always_visible_tabs` | Which tabs are shown even when empty |
| `required_categories` | Per-config-type badge on tabs with unfilled required categories |
Rules:
- sync runs after `SyncComponents`; failure is non-fatal (Warn log only);
- `local_qt_settings` is a read-only cache — never written by user actions;
- absent or unparseable settings: QF uses hardcoded fallbacks for that key only;
- `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere;
- `qt_categories.name` and `qt_categories.name_ru` are not used by QF runtime; do not depend on them.
## Vendor BOM contract ## Vendor BOM contract

View File

@@ -20,6 +20,7 @@ Main tables:
| `connection_settings` | encrypted MariaDB connection settings | | `connection_settings` | encrypted MariaDB connection settings |
| `app_settings` | local app state | | `app_settings` | local app state |
| `local_schema_migrations` | applied local migration markers | | `local_schema_migrations` | applied local migration markers |
| `local_qt_settings` | server-pushed configurator settings cache (from `qt_settings`) |
Rules: Rules:
- cache tables may be rebuilt if local migration recovery requires it; - cache tables may be rebuilt if local migration recovery requires it;
@@ -34,12 +35,13 @@ MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
### QuoteForge tables (qt_*) ### QuoteForge tables (qt_*)
Runtime read: 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_lot_metadata` — component metadata, price settings
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor) - `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
- `qt_pricelist_items` — pricelist rows - `qt_pricelist_items` — pricelist rows
- `qt_partnumber_books` — partnumber book headers - `qt_partnumber_books` — partnumber book headers
- `qt_partnumber_book_items` — PN→LOT catalog payload - `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: Runtime read/write:
- `qt_projects` — projects - `qt_projects` — projects
@@ -48,7 +50,7 @@ Runtime read/write:
- `qt_pricelist_sync_status` — pricelist sync timestamps per user - `qt_pricelist_sync_status` — pricelist sync timestamps per user
Insert-only tracking: 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): Server-side only (not queried by client runtime):
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs) - `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 | | | id | bigint UNSIGNED PK AUTO_INCREMENT | |
| code | varchar(20) UNIQUE NOT NULL | | | code | varchar(20) UNIQUE NOT NULL | |
| name | varchar(100) NOT NULL | | | name | varchar(100) NOT NULL | being removed; QF does not use at runtime |
| name_ru | varchar(100) | | | name_ru | varchar(100) | being removed; QF does not use at runtime |
| display_order | bigint DEFAULT 0 | | | display_order | bigint DEFAULT 0 | |
| is_required | tinyint(1) 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 ### qt_client_schema_state
PK: (username, hostname) PK: (username, hostname)
| Column | Type | Notes | | Column | Type | Notes |
@@ -312,6 +329,7 @@ PK: job_name
| ignored_by | varchar(100) | | | ignored_by | varchar(100) | |
| created_at | datetime(3) | | | created_at | datetime(3) | |
| updated_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 ### stock_ignore_rules
| Column | Type | Notes | | Column | Type | Notes |
@@ -370,8 +388,6 @@ GRANT SELECT ON RFQ_LOG.qt_categories TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'qfs_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'qfs_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'qfs_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.stock_log TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.stock_ignore_rules TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'qfs_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'qfs_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%'; GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
@@ -380,7 +396,6 @@ GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_projects TO 'qfs_user'@'%'; GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_projects TO 'qfs_user'@'%';
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'qfs_user'@'%'; GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'qfs_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'qfs_user'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'qfs_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'qfs_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;

View File

@@ -80,3 +80,81 @@ Rules:
- configuration `name` is derived from the uploaded filename (without extension); - configuration `name` is derived from the uploaded filename (without extension);
- lines that do not contain `*<digits>` are skipped; - lines that do not contain `*<digits>` are skipped;
- no price data is present in the format; `unit_price` and `total_price` are left nil. - no price data is present in the format; `unit_price` and `total_price` are left nil.
## Text BOM import
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a human-readable Russian text BOM.
Format: an optional header line ending with `, в составе:` followed by one component per line as
`<description> - <quantity> шт.`. The separator may be a hyphen, en-dash, or em-dash; the space before
`шт` is optional; a trailing `.` after `шт` is optional. Quantities are anchored to the end of the line,
so hyphens, commas, and digits inside the description (e.g. `8-GPU-2304GB`, `RAID0,1,10`) are preserved.
Example:
```
Вычислительный GPU сервер G5500V7, в составе:
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
NVIDIA twin port transceiver, 800Gbps, OSFP - 8шт.
```
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 (so both `Сервер X3` and `Вычислительный GPU сервер X3`
resolve to `X3`);
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
- each line is trimmed, so leading/trailing whitespace never enters `vendor_partnumber`;
- 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 `<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
(Inspur and Russian text BOM) into rows. Request body: `{"text": "..."}`. Response:
`{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`.
This shares the exact detectors and parsers used by the file-import path
(`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,563 @@
# 10 - Agent API Guide: Pricing Servers from a TZ
This guide is written for an AI agent that needs to price a server configuration
(техническое задание, ТЗ) using the QuoteForge HTTP API.
## Runtime assumptions
- QuoteForge runs locally, binds to `127.0.0.1:8080` by default.
- No authentication is required — the app is single-user, loopback-only.
- All responses are JSON. All request bodies are JSON unless stated otherwise.
- The port can be overridden with the `QF_SERVER_PORT` environment variable.
Base URL for all examples: `http://127.0.0.1:8080`
---
## Configuration composition rules
These rules are mandatory and must be respected before saving any configuration.
### 1. Every configuration must belong to a project
Configurations cannot be created in isolation. The correct sequence is:
1. Create a project (`POST /api/projects`) and save the returned `uuid`.
2. Create the configuration inside that project by passing `project_uuid` in the
config body, or by using `POST /api/projects/:uuid/configs`.
If the project for a given TZ already exists, retrieve its `uuid` first:
```
GET /api/projects?page=1&per_page=100
```
then pass the matching `uuid` in `project_uuid`.
### 2. Every server configuration must contain all four required component groups
A configuration is not valid for pricing unless items from all four of the
following category groups are present:
| Category code | Meaning | Notes |
|---------------|------------------|---------------------------------------------------|
| `MB` | Motherboard | exactly one MB per configuration |
| `CPU` | Processor | one or more CPUs |
| `MEM` | Memory / RAM | one or more memory modules |
| `PS` / `PSU` | Power supply | `PSU` is the current code; `PS` is legacy — both are accepted |
Before saving, verify the assembled BOM with `POST /api/quote/validate`:
the response `errors` array will contain `"Component not found: …"` entries
for unknown lot names, and `warnings` will list lots without a price.
Reject the configuration and report back to the user if any of the four
required categories is missing.
### 3. Category codes to use when searching
Use `category=<code>` in `GET /api/components` to narrow results:
```
GET /api/components?category=MB&search=X13&has_price=true
GET /api/components?category=CPU&search=Xeon+Gold&has_price=true
GET /api/components?category=MEM&search=32GB+DDR5&has_price=true
GET /api/components?category=PSU&search=800W&has_price=true
```
Retrieve the full list of active categories at any time:
```
GET /api/categories
```
---
## Typical workflow for pricing a server
```
1. Check the app is up GET /api/ping
2. Find or create a project GET /api/projects → POST /api/projects
3. Find the latest pricelist GET /api/pricelists/latest?source=estimate
4. Look up lot names for MB GET /api/components?category=MB&search=…
5. Look up lot names for CPU GET /api/components?category=CPU&search=…
6. Look up lot names for MEM GET /api/components?category=MEM&search=…
7. Look up lot names for PSU GET /api/components?category=PSU&search=…
8. (Repeat for other components) GET /api/components?category=…&search=…
9. Validate and calculate the quote POST /api/quote/validate
10. (Optional) Compare price tiers POST /api/quote/price-levels
11. Save configuration in the project POST /api/projects/:uuid/configs
```
---
## Step 1 — Verify the app is running
```
GET /api/ping
```
Response `200 OK`:
```json
{"status": "ok"}
```
---
## Step 2 — Find or create a project
Each TZ maps to one project. Use the TZ identifier as the `code` field.
### Find an existing project
```
GET /api/projects?page=1&per_page=100
```
Response `200 OK`:
```json
{
"projects": [
{
"uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"code": "TZ-123",
"variant": "",
"name": "Проект по ТЗ №123",
"tracker_url": "",
"is_active": true,
"created_at": "2026-06-01T00:00:00Z"
}
],
"total": 1,
"page": 1,
"per_page": 100
}
```
### Create a new project
```
POST /api/projects
Content-Type: application/json
```
Request body:
```json
{
"code": "TZ-123",
"name": "Проект по ТЗ №123",
"tracker_url": ""
}
```
Fields:
| field | type | required | description |
|---------------|--------|----------|--------------------------------------------------------------------|
| `code` | string | yes | short identifier, unique per variant; use the TZ number or ticket |
| `variant` | string | no | variant label within the same `code`; default is empty string |
| `name` | string | no | human-readable title |
| `tracker_url` | string | no | link to a ticket or issue tracker |
Response `201 Created`:
```json
{
"uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"code": "TZ-123",
"variant": "",
"name": "Проект по ТЗ №123",
"is_active": true,
"created_at": "2026-06-11T10:00:00Z"
}
```
Save the `uuid` — it is required to create configurations inside this project.
---
## Step 3 — Find the latest pricelist
QuoteForge maintains three pricing tiers. The `source` values are:
| source | meaning |
|--------------|-----------------------------|
| `estimate` | list / catalogue price |
| `warehouse` | stock price (purchase cost) |
| `competitor` | competitor reference price |
```
GET /api/pricelists/latest?source=estimate
```
Response `200 OK`:
```json
{
"id": 42,
"source": "estimate",
"version": "2026-05-28",
"item_count": 12500,
"is_active": true,
"created_at": "2026-05-28T06:00:00Z"
}
```
The `id` field is a numeric pricelist identifier. Pass it as `pricelist_id`
when calculating a quote to pin pricing to a specific pricelist.
To list all available pricelists:
```
GET /api/pricelists?source=estimate&active_only=true
```
---
## Steps 48 — Look up component lot names
Each component is identified by a `lot_name` (internal SKU). The TZ typically
contains model names or descriptions; use the search endpoint to resolve them.
```
GET /api/components?search=Xeon+Gold+6342&category=CPU&has_price=true&page=1&per_page=20
```
Query parameters:
| parameter | default | description |
|------------------|---------|---------------------------------------------------|
| `search` | — | free-text search in lot name and description |
| `category` | — | filter by category code (`MB`, `CPU`, `MEM`, `PSU`, …) |
| `has_price` | false | return only components that have a price |
| `include_hidden` | false | include hidden/retired components |
| `page` | 1 | page number |
| `per_page` | 20 | page size |
Response `200 OK`:
```json
{
"components": [
{
"lot_name": "CPU-XEON-6342",
"description": "Intel Xeon Gold 6342, 24C/48T, 2.8 GHz, LGA4189",
"category": "CPU",
"category_name": "CPU",
"model": "Xeon Gold 6342"
}
],
"total": 1,
"page": 1,
"per_page": 20
}
```
To look up a single component by exact lot name:
```
GET /api/components/CPU-XEON-6342
```
To list all known categories:
```
GET /api/categories
```
---
## Step 9 — Validate and calculate the quote
Before saving, validate the assembled BOM. This catches unknown lot names and
missing prices, and also confirms that all required categories are covered.
```
POST /api/quote/validate
Content-Type: application/json
```
Request body:
```json
{
"items": [
{"lot_name": "MB-X13DAI-N", "quantity": 1},
{"lot_name": "CPU-XEON-6342", "quantity": 2},
{"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8},
{"lot_name": "SSD-480GB-SATA", "quantity": 2},
{"lot_name": "PSU-800W-TITANIUM", "quantity": 2}
],
"pricelist_id": 42
}
```
Response `200 OK`:
```json
{
"valid": true,
"items": [
{
"lot_name": "MB-X13DAI-N",
"quantity": 1,
"unit_price": 95000.00,
"total_price": 95000.00,
"description": "Supermicro X13DAi-N dual-socket server board",
"category": "MB",
"has_price": true
},
{
"lot_name": "CPU-XEON-6342",
"quantity": 2,
"unit_price": 87500.00,
"total_price": 175000.00,
"description": "Intel Xeon Gold 6342, 24C/48T, 2.8 GHz",
"category": "CPU",
"has_price": true
},
{
"lot_name": "RAM-32GB-DDR4-3200",
"quantity": 8,
"unit_price": 12000.00,
"total_price": 96000.00,
"description": "32 GB DDR4-3200 ECC RDIMM",
"category": "MEM",
"has_price": true
},
{
"lot_name": "PSU-800W-TITANIUM",
"quantity": 2,
"unit_price": 18500.00,
"total_price": 37000.00,
"description": "800W 80+ Titanium redundant PSU",
"category": "PSU",
"has_price": true
}
],
"errors": [],
"warnings": [],
"total": 403000.00
}
```
**Agent check after validation:**
1. `valid` must be `true` — all lot names resolved.
2. `errors` must be empty — no unknown components.
3. The returned `items` array must contain at least one entry from each required
category: `MB`, `CPU`, `MEM`, and `PS` or `PSU`.
4. Items with `has_price: false` are allowed but should be flagged to the user.
If any check fails, do not save the configuration. Report the issue and ask the
user to clarify or replace the problematic component.
For simple price totals without validation metadata use `POST /api/quote/calculate`
— identical request body, response contains only `items` and `total`.
---
## Step 10 (optional) — Compare price tiers
To see estimate, warehouse, and competitor prices side-by-side for a BOM:
```
POST /api/quote/price-levels
Content-Type: application/json
```
Request body:
```json
{
"items": [
{"lot_name": "CPU-XEON-6342", "quantity": 2},
{"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8}
],
"pricelist_ids": {
"estimate": 42,
"warehouse": 31,
"competitor": 15
},
"no_cache": false
}
```
`pricelist_ids` is optional. When omitted the latest pricelist for each source
is used automatically.
Response `200 OK`:
```json
{
"items": [
{
"lot_name": "CPU-XEON-6342",
"quantity": 2,
"estimate_price": 87500.00,
"warehouse_price": 71000.00,
"competitor_price": 85000.00,
"delta_wh_estimate_abs": -16500.00,
"delta_wh_estimate_pct": -18.86,
"delta_comp_estimate_abs": -2500.00,
"delta_comp_estimate_pct": -2.86,
"delta_comp_wh_abs": 14000.00,
"delta_comp_wh_pct": 19.72,
"price_missing": []
}
],
"resolved_pricelist_ids": {
"estimate": 42,
"warehouse": 31,
"competitor": 15
}
}
```
`price_missing` lists the source names for which no price was found for that lot.
Delta fields are `null` when either operand price is missing.
---
## Step 11 — Save a configuration inside the project
Use the project-scoped endpoint so the configuration is immediately linked to
the correct project without a separate move operation.
```
POST /api/projects/:project_uuid/configs
Content-Type: application/json
```
The request body is identical to `POST /api/configs` — the `project_uuid` field
in the body is ignored when using the project-scoped route; the URL parameter
takes precedence.
Request body:
```json
{
"name": "Сервер по ТЗ №123 — вариант А",
"items": [
{"lot_name": "MB-X13DAI-N", "quantity": 1, "unit_price": 95000.00},
{"lot_name": "CPU-XEON-6342", "quantity": 2, "unit_price": 87500.00},
{"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8, "unit_price": 12000.00},
{"lot_name": "SSD-480GB-SATA", "quantity": 2, "unit_price": 8500.00},
{"lot_name": "PSU-800W-TITANIUM", "quantity": 2, "unit_price": 18500.00}
],
"server_model": "2U",
"support_code": "NBD",
"server_count": 1,
"pricelist_id": 42,
"warehouse_pricelist_id": 31,
"competitor_pricelist_id": 15,
"config_type": "server",
"notes": "Автоматически создано агентом на основании ТЗ №123"
}
```
Key fields:
| field | type | required | description |
|--------------------------|--------|----------|-----------------------------------------------------|
| `name` | string | yes | human-readable name |
| `items` | array | yes | `{lot_name, quantity, unit_price}` from validate |
| `server_model` | string | no | chassis/form-factor code; used for article generation |
| `support_code` | string | no | support tier code; used for article generation |
| `server_count` | int | no | number of identical servers; total is multiplied |
| `pricelist_id` | uint | no | estimate pricelist to attach |
| `warehouse_pricelist_id` | uint | no | warehouse pricelist to attach |
| `competitor_pricelist_id`| uint | no | competitor pricelist to attach |
| `config_type` | string | no | `"server"` (default) or `"storage"` |
| `notes` | string | no | free text |
| `custom_price` | float | no | override total price |
| `disable_price_refresh` | bool | no | prevent automatic price refresh on open |
| `only_in_stock` | bool | no | filter to in-stock components only |
Response `201 Created`:
```json
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "Сервер по ТЗ №123 — вариант А",
"items": [...],
"total_price": 403000.00,
"server_count": 1,
"config_type": "server",
"article": "2U-6342x2-32GBx8-NBD",
"project_uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"created_at": "2026-06-11T10:00:00Z"
}
```
The `uuid` can be used for all subsequent operations on this configuration.
---
## Working with saved configurations
```
GET /api/configs/:uuid — retrieve a saved configuration
PUT /api/configs/:uuid — full update (same body as create)
POST /api/configs/:uuid/refresh-prices — re-price from latest pricelist
POST /api/configs/:uuid/clone — duplicate: body {"name": "clone name"}
GET /api/configs/:uuid/versions — revision history
GET /api/configs?page=1&per_page=20 — list all configurations
```
---
## Error responses
All error responses follow the same shape:
```json
{"error": "human-readable message"}
```
Common status codes:
| code | meaning |
|------|-------------------------------------------------------|
| 400 | invalid request body or validation failure |
| 404 | entity (component, pricelist, config) not found |
| 423 | sync readiness is blocked; retry after sync completes |
| 500 | internal server error |
---
## Minimal end-to-end example
```bash
BASE=http://127.0.0.1:8080
# 1. Verify the app is up
curl -s $BASE/api/ping
# 2. Create a project for this TZ
PROJECT_UUID=$(curl -s -X POST $BASE/api/projects \
-H "Content-Type: application/json" \
-d '{"code": "TZ-123", "name": "Проект по ТЗ №123"}' | jq -r .uuid)
# 3. Get latest estimate pricelist
PRICELIST_ID=$(curl -s "$BASE/api/pricelists/latest?source=estimate" | jq .id)
# 4. Find lot names for required categories
curl -s "$BASE/api/components?category=MB&search=X13&has_price=true" | jq '.components[].lot_name'
curl -s "$BASE/api/components?category=CPU&search=Xeon&has_price=true" | jq '.components[].lot_name'
curl -s "$BASE/api/components?category=MEM&search=32GB&has_price=true" | jq '.components[].lot_name'
curl -s "$BASE/api/components?category=PSU&search=800W&has_price=true" | jq '.components[].lot_name'
# 5. Validate the BOM (must contain MB, CPU, MEM, PSU/PS)
curl -s -X POST $BASE/api/quote/validate \
-H "Content-Type: application/json" \
-d "{
\"pricelist_id\": $PRICELIST_ID,
\"items\": [
{\"lot_name\": \"MB-X13DAI-N\", \"quantity\": 1},
{\"lot_name\": \"CPU-XEON-6342\", \"quantity\": 2},
{\"lot_name\": \"RAM-32GB-DDR4-3200\", \"quantity\": 8},
{\"lot_name\": \"PSU-800W-TITANIUM\", \"quantity\": 2}
]
}" | jq '{valid, errors, warnings, total}'
# 6. Save the configuration inside the project
curl -s -X POST "$BASE/api/projects/$PROJECT_UUID/configs" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Сервер по ТЗ №123\",
\"pricelist_id\": $PRICELIST_ID,
\"server_model\": \"2U\",
\"server_count\": 1,
\"config_type\": \"server\",
\"items\": [
{\"lot_name\": \"MB-X13DAI-N\", \"quantity\": 1, \"unit_price\": 95000},
{\"lot_name\": \"CPU-XEON-6342\", \"quantity\": 2, \"unit_price\": 87500},
{\"lot_name\": \"RAM-32GB-DDR4-3200\", \"quantity\": 8, \"unit_price\": 12000},
{\"lot_name\": \"PSU-800W-TITANIUM\", \"quantity\": 2, \"unit_price\": 18500}
]
}" | jq '{uuid, total_price, article}'
```

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

@@ -14,6 +14,8 @@ Project-specific architecture and operational contracts.
| [06-backup.md](06-backup.md) | Backup contract and restore workflow | | [06-backup.md](06-backup.md) | Backup contract and restore workflow |
| [07-dev.md](07-dev.md) | Development commands and guardrails | | [07-dev.md](07-dev.md) | Development commands and guardrails |
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract | | [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 ## Rules

View File

@@ -0,0 +1,31 @@
# Architectural Decision Log
One file per decision, named `YYYY-MM-DD-short-topic.md`.
Write a new entry when:
- Choosing between non-obvious implementation approaches.
- Intentionally rejecting a feature or pattern.
- A bug causes a rule change.
- Freezing or deprecating something.
Format:
```markdown
# Decision: <short title>
**Date:** YYYY-MM-DD
**Status:** active | superseded by YYYY-MM-DD-topic.md
## Context
Situation making this decision necessary.
## Decision
What was decided, stated clearly.
## Consequences
What this means going forward; what is forbidden or required.
```
When a decision is superseded: add "superseded by" to the old file and create the new one.
Do NOT delete old entries.
Record the decision in the SAME COMMIT as the implementation code.

View File

@@ -0,0 +1,86 @@
# Runtime Flows
Critical mutation paths, deduplication logic, and cross-entity side effects.
Update this file in the same commit as any change to the flows below.
---
## 1. Configuration save (create/update)
1. Handler receives JSON body; validates via `ShouldBindJSON`.
2. `LocalConfigurationService.Create` or `Update` is called.
3. Service computes `total_price` from `req.Items.Total()` (sum of `unit_price * quantity` per item).
4. A new revision snapshot is created via `createWithVersion`; revision number increments.
5. `quoteService.RecordUsage` is called best-effort (warn on failure, do not abort save).
6. Configuration row written to SQLite (`local_configurations`); version row appended to `local_configuration_versions`.
7. Pending change queued in `pending_changes` for later sync push.
**DO NOT** read prices from `local_components` during save - prices must already be on items.
**DO NOT** skip version creation on rename/reorder/project-move - those operations call different paths that must NOT call `createWithVersion`.
---
## 2. Refresh prices (POST /api/configs/:uuid/refresh-prices)
1. Handler calls `LocalConfigurationService.RefreshPricesNoAuth(uuid, pricelistServerID)`.
2. If online, `SyncPricelistsIfNeeded` runs best-effort (warn on failure, do not block).
3. Resolves target pricelist in order:
a. Explicitly requested pricelist (`pricelistServerID` param).
b. Pricelist stored in configuration row (`localCfg.PricelistID`).
c. Latest local pricelist as fallback.
4. For each item in the config, looks up price from `local_pricelist_items` via `GetLocalPricesForLots` (batch, single query).
5. Items with matching prices are updated; items with no price keep their existing `unit_price`.
6. Updated configuration saved as a new version (same flow as §1 from step 4 onward).
**DO NOT** read prices from `qt_pricelist_items` (MariaDB) directly - prices come from SQLite cache only.
---
## 3. Pricelist sync (POST /api/sync/pricelists)
1. Readiness guard checked; returns 423 if guard blocks sync.
2. `SyncService.SyncPricelists` pulls from `qt_pricelists` and `qt_pricelist_items` (MariaDB).
3. For each pricelist: header upserted first, then items replaced atomically via `ReplaceLocalPricelistItems`.
4. After all pricelists: `RecalculateAllLocalPricelistUsage` marks which pricelists are referenced by active configurations.
5. Sync result (status, error, timestamp) written to `app_settings` via `SetPricelistSyncResult`.
**DO NOT** write pricelist header without items in the same transaction - must be atomic.
**DO NOT** query MariaDB from runtime handlers outside sync/setup flows.
---
## 4. Vendor spec apply (POST /api/configs/:uuid/vendor-spec/apply)
1. Incoming `items[]` (lot_name, quantity, unit_price) replace the configuration's `items` entirely.
2. New item list saved through `LocalConfigurationService.UpdateItemsNoAuth`.
3. A new revision is created reflecting the BOM-derived item state.
**DO NOT** apply vendor spec without going through the service layer - handler must not write items directly to DB.
---
## 5. Configuration versioning invariants
- `local_configuration_versions` is append-only; rows are never updated or deleted.
- Version deduplication: if new snapshot hash equals current head, no new version is created.
- Rollback = create new HEAD revision from old snapshot data (does not restore version pointer to old row).
- UI must always show "main" (implicit head) as the active state; never point to a numbered revision after save.
- Operations that do NOT create a new version: rename, reorder within project, project move, pricelist selector change only.
---
## 6. Pending changes queue
- Every local write (create/update/delete) appends a row to `pending_changes`.
- `POST /api/sync/push` drains the queue by writing to MariaDB.
- If a push fails, `increment_attempts` and `last_error` are updated; row stays in queue.
- `RepairPendingChanges` reconciles orphaned changes (configuration/project deleted locally).
---
## 7. Error handling boundary rules
- Handlers: log 500 responses with `slog.Error`; surface error message via `RespondError`.
- Services: wrap errors with `fmt.Errorf("context: %w", err)`; do NOT log inside service.
- Repositories: return raw errors; no logging.
- Best-effort operations (usage stats, background sync): log `slog.Warn` and continue.

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

@@ -2,8 +2,8 @@ package main
import ( import (
"flag" "flag"
"fmt"
"log" "log"
"log/slog"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appstate" "git.mchus.pro/mchus/quoteforge/internal/appstate"
@@ -153,7 +153,7 @@ func main() {
log.Printf(" Skipped: %d", skipped) log.Printf(" Skipped: %d", skipped)
log.Printf(" Errors: %d", errors) log.Printf(" Errors: %d", errors)
fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs") slog.Info("Done! You can now run the server with: go run ./cmd/qfs")
} }
func derefUint(v *uint) uint { func derefUint(v *uint) uint {

View File

@@ -5,6 +5,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"log" "log"
"log/slog"
"os" "os"
"regexp" "regexp"
"sort" "sort"
@@ -79,12 +80,12 @@ func main() {
printPlan(actions) printPlan(actions)
if len(actions) == 0 { if len(actions) == 0 {
fmt.Println("Nothing to migrate.") slog.Info("Nothing to migrate.")
return return
} }
if !*apply { if !*apply {
fmt.Println("\nPreview complete. Re-run with -apply to execute.") slog.Info("Preview complete. Re-run with -apply to execute.")
return return
} }
@@ -94,7 +95,7 @@ func main() {
log.Fatalf("confirmation failed: %v", confirmErr) log.Fatalf("confirmation failed: %v", confirmErr)
} }
if !ok { if !ok {
fmt.Println("Aborted.") slog.Info("Aborted.")
return return
} }
} }
@@ -103,7 +104,7 @@ func main() {
log.Fatalf("migration failed: %v", err) log.Fatalf("migration failed: %v", err)
} }
fmt.Println("Migration completed successfully.") slog.Info("Migration completed successfully.")
} }
func ensureProjectsTable(db *gorm.DB) error { func ensureProjectsTable(db *gorm.DB) error {
@@ -212,10 +213,8 @@ func printPlan(actions []migrationAction) {
} }
} }
fmt.Printf("Planned actions: %d\n", len(actions)) slog.Info("Plan summary", "actions", len(actions), "create", createCount, "reactivate", reactivateCount)
fmt.Printf("Projects to create: %d\n", createCount) slog.Info("Details:")
fmt.Printf("Projects to reactivate: %d\n", reactivateCount)
fmt.Println("\nDetails:")
for _, a := range actions { for _, a := range actions {
extra := "" extra := ""

View File

@@ -2,8 +2,8 @@ package main
import ( import (
"flag" "flag"
"fmt"
"log" "log"
"log/slog"
"sort" "sort"
"time" "time"
@@ -161,7 +161,7 @@ func printPlan(plan []updatePlanRow, apply bool) {
log.Printf("%s [%s] local=%s server=%s", row.Code, variant, formatStamp(row.LocalUpdatedAt), formatStamp(row.ServerUpdatedAt)) log.Printf("%s [%s] local=%s server=%s", row.Code, variant, formatStamp(row.LocalUpdatedAt), formatStamp(row.ServerUpdatedAt))
} }
if !apply { if !apply {
fmt.Println("Re-run with -apply to write server updated_at into local SQLite.") slog.Info("Re-run with -apply to write server updated_at into local SQLite.")
} }
} }

View File

@@ -289,8 +289,7 @@ func main() {
} }
func showStartupConsoleWarning() { func showStartupConsoleWarning() {
// Visible in console output. slog.Warn(startupConsoleWarning)
fmt.Println(startupConsoleWarning)
// Keep the warning always visible in the console window title when supported. // Keep the warning always visible in the console window title when supported.
fmt.Printf("\033]0;%s\007", startupConsoleWarning) fmt.Printf("\033]0;%s\007", startupConsoleWarning)
} }
@@ -678,8 +677,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
var projectService *services.ProjectService var projectService *services.ProjectService
syncService = sync.NewService(connMgr, local) syncService = sync.NewService(connMgr, local)
componentService := services.NewComponentService(nil, nil, nil) componentService := services.NewComponentService(nil, nil)
quoteService := services.NewQuoteService(nil, nil, nil, local, nil) quoteService := services.NewQuoteService(nil, nil, local, nil)
exportService := services.NewExportService(cfg.Export, local) exportService := services.NewExportService(cfg.Export, local)
// isOnline function for local-first architecture // isOnline function for local-first architecture
@@ -780,7 +779,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
quoteHandler := handlers.NewQuoteHandler(quoteService) quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername) exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
pricelistHandler := handlers.NewPricelistHandler(local) pricelistHandler := handlers.NewPricelistHandler(local)
vendorSpecHandler := handlers.NewVendorSpecHandler(local) vendorSpecHandler := handlers.NewVendorSpecHandler(local, syncService)
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local) partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
respondError := handlers.RespondError respondError := handlers.RespondError
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
@@ -920,6 +919,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Categories (public) // Categories (public)
api.GET("/categories", componentHandler.GetCategories) api.GET("/categories", componentHandler.GetCategories)
api.GET("/configurator-settings", componentHandler.GetConfiguratorSettings)
// Quote (public) // Quote (public)
quote := api.Group("/quote") quote := api.Group("/quote")
@@ -952,6 +952,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pnBooks.GET("/:id", partnumberBooksHandler.GetItems) pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
} }
// Stateless BOM text parsing shared by paste and file-import paths.
api.POST("/vendor-spec/parse-text", vendorSpecHandler.ParseText)
// Configurations (public - RBAC disabled) // Configurations (public - RBAC disabled)
configs := api.Group("/configs") configs := api.Group("/configs")
{ {
@@ -1726,7 +1729,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge) respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return return
} }
if !services.IsCFXMLWorkspace(data) && !services.IsQuoteForgeCSV(data) && !services.IsInspurBOM(data) { if !services.IsCFXMLWorkspace(data) && !services.IsQuoteForgeCSV(data) && !services.IsInspurBOM(data) && !services.IsTextBOM(data) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"}) c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
return return
} }

60
internal/db/validate.go Normal file
View File

@@ -0,0 +1,60 @@
package db
import (
"errors"
"fmt"
"time"
gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var errPermissionProbeRollback = errors.New("permission probe rollback")
// ValidateMariaDBConnection opens a one-off connection using dsn, pings, checks
// the required lot table exists, and probes write access to qt_client_schema_state.
// Returns (lot row count, canWrite, error).
func ValidateMariaDBConnection(dsn string) (int64, bool, error) {
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return 0, false, fmt.Errorf("open MariaDB connection: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return 0, false, fmt.Errorf("get database handle: %w", err)
}
defer sqlDB.Close()
if err := sqlDB.Ping(); err != nil {
return 0, false, fmt.Errorf("ping MariaDB: %w", err)
}
var lotCount int64
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
return 0, false, fmt.Errorf("check required table lot: %w", err)
}
return lotCount, testSyncWritePermission(db), nil
}
func testSyncWritePermission(db *gorm.DB) bool {
sentinel := fmt.Sprintf("quoteforge-permission-check-%d", time.Now().UnixNano())
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`
INSERT INTO qt_client_schema_state (username, hostname, last_checked_at, updated_at)
VALUES (?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at)
`, sentinel, "setup-check").Error; err != nil {
return err
}
return errPermissionProbeRollback
})
return errors.Is(err, errPermissionProbeRollback)
}

View File

@@ -64,11 +64,16 @@ func (h *ComponentHandler) List(c *gin.Context) {
} }
} }
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
if totalPages < 1 {
totalPages = 1
}
c.JSON(http.StatusOK, &services.ComponentListResult{ c.JSON(http.StatusOK, &services.ComponentListResult{
Components: components, Items: components,
Total: total, TotalCount: total,
Page: page, Page: page,
PerPage: perPage, PerPage: perPage,
TotalPages: totalPages,
}) })
} }
@@ -120,3 +125,102 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
c.JSON(http.StatusOK, models.DefaultCategories) 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

@@ -60,7 +60,7 @@ type ProjectExportOptionsRequest struct {
func (h *ExportHandler) ExportCSV(c *gin.Context) { func (h *ExportHandler) ExportCSV(c *gin.Context) {
var req ExportRequest var req ExportRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
@@ -68,7 +68,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
// Validate before streaming (can return JSON error) // Validate before streaming (can return JSON error)
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 { if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"}) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no items to export"})
return return
} }
@@ -150,7 +150,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
uuid := c.Param("uuid") uuid := c.Param("uuid")
// Get config before streaming (can return JSON error) // Get config before streaming (can return JSON error)
config, err := h.configService.GetByUUID(uuid, h.dbUsername) config, err := h.configService.GetByUUIDNoAuth(uuid)
if err != nil { if err != nil {
RespondError(c, http.StatusNotFound, "resource not found", err) RespondError(c, http.StatusNotFound, "resource not found", err)
return return
@@ -160,7 +160,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
// Validate before streaming (can return JSON error) // Validate before streaming (can return JSON error)
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 { if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"}) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no items to export"})
return return
} }
@@ -206,7 +206,7 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
} }
if len(result.Configs) == 0 { if len(result.Configs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"}) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"})
return return
} }
@@ -228,11 +228,11 @@ func (h *ExportHandler) ExportConfigPricingCSV(c *gin.Context) {
var req ProjectExportOptionsRequest var req ProjectExportOptionsRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
config, err := h.configService.GetByUUID(uuid, h.dbUsername) config, err := h.configService.GetByUUIDNoAuth(uuid)
if err != nil { if err != nil {
RespondError(c, http.StatusNotFound, "resource not found", err) RespondError(c, http.StatusNotFound, "resource not found", err)
return return
@@ -285,7 +285,7 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
var req ProjectExportOptionsRequest var req ProjectExportOptionsRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
@@ -301,7 +301,7 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
return return
} }
if len(result.Configs) == 0 { if len(result.Configs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"}) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"})
return return
} }

View File

@@ -26,6 +26,10 @@ func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*model
return m.config, m.err return m.config, m.err
} }
func (m *mockConfigService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
return m.config, m.err
}
func TestExportCSV_Success(t *testing.T) { func TestExportCSV_Success(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
@@ -124,8 +128,8 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
handler.ExportCSV(c) handler.ExportCSV(c)
// Should return 400 Bad Request // Should return 400 Bad Request
if w.Code != http.StatusBadRequest { if w.Code != http.StatusUnprocessableEntity {
t.Errorf("Expected status 400, got %d", w.Code) t.Errorf("Expected status 422, got %d", w.Code)
} }
// Should return JSON error // Should return JSON error
@@ -158,8 +162,8 @@ func TestExportCSV_EmptyItems(t *testing.T) {
handler.ExportCSV(c) handler.ExportCSV(c)
// Should return 400 Bad Request (validation error from gin binding) // Should return 400 Bad Request (validation error from gin binding)
if w.Code != http.StatusBadRequest { if w.Code != http.StatusUnprocessableEntity {
t.Logf("Status code: %d (expected 400 for empty items)", w.Code) t.Logf("Status code: %d (expected 422 for empty items)", w.Code)
} }
} }
@@ -290,8 +294,8 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
handler.ExportConfigCSV(c) handler.ExportConfigCSV(c)
// Should return 400 Bad Request // Should return 400 Bad Request
if w.Code != http.StatusBadRequest { if w.Code != http.StatusUnprocessableEntity {
t.Errorf("Expected status 400, got %d", w.Code) t.Errorf("Expected status 422, got %d", w.Code)
} }
// Should return JSON error // Should return JSON error

View File

@@ -51,8 +51,11 @@ func (h *PartnumberBooksHandler) List(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"books": summaries, "items": summaries,
"total": len(summaries), "total_count": len(summaries),
"page": 1,
"per_page": len(summaries),
"total_pages": 1,
}) })
} }
@@ -62,7 +65,7 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64) id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"}) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid book ID"})
return return
} }
@@ -77,9 +80,8 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
perPage = 100 perPage = 100
} }
// Find local book by server_id book, err := h.localDB.GetLocalPartnumberBookByServerID(uint(id))
var book localdb.LocalPartnumberBook if err != nil {
if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
return return
} }
@@ -90,15 +92,20 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
return return
} }
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
if totalPages < 1 {
totalPages = 1
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"book_id": book.ServerID, "book_id": book.ServerID,
"version": book.Version, "version": book.Version,
"is_active": book.IsActive, "is_active": book.IsActive,
"partnumbers": book.PartnumbersJSON, "partnumbers": book.PartnumbersJSON,
"items": items, "items": items,
"total": total, "total_count": total,
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
"total_pages": totalPages,
"search": search, "search": search,
"book_total": bookRepo.CountBookItems(book.ID), "book_total": bookRepo.CountBookItems(book.ID),
"lot_count": bookRepo.CountDistinctLots(book.ID), "lot_count": bookRepo.CountDistinctLots(book.ID),

View File

@@ -106,11 +106,16 @@ func (h *PricelistHandler) List(c *gin.Context) {
}) })
} }
totalPages := (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"pricelists": summaries, "items": summaries,
"total": total, "total_count": total,
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
"total_pages": totalPages,
}) })
} }
@@ -119,7 +124,7 @@ func (h *PricelistHandler) Get(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32) id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
return return
} }
@@ -146,7 +151,7 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32) id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
return return
} }
@@ -165,40 +170,21 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
if perPage < 1 { if perPage < 1 {
perPage = 50 perPage = 50
} }
var items []localdb.LocalPricelistItem
dbq := h.localDB.DB().Model(&localdb.LocalPricelistItem{}).Where("pricelist_id = ?", localPL.ID)
if strings.TrimSpace(search) != "" {
dbq = dbq.Where("lot_name LIKE ?", "%"+strings.TrimSpace(search)+"%")
}
var total int64
if err := dbq.Count(&total).Error; err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
offset := (page - 1) * perPage
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil { items, total, err := h.localDB.GetLocalPricelistItemsPage(localPL.ID, strings.TrimSpace(search), page, perPage)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
lotNames := make([]string, len(items)) lotNames := make([]string, len(items))
for i, item := range items { for i, item := range items {
lotNames[i] = item.LotName lotNames[i] = item.LotName
} }
type compRow struct { descMap, err := h.localDB.GetLocalComponentDescriptionsByLotNames(lotNames)
LotName string if err != nil {
LotDescription string RespondError(c, http.StatusInternalServerError, "internal server error", err)
} return
var comps []compRow
if len(lotNames) > 0 {
h.localDB.DB().Table("local_components").
Select("lot_name, lot_description").
Where("lot_name IN ?", lotNames).
Scan(&comps)
}
descMap := make(map[string]string, len(comps))
for _, c := range comps {
descMap[c.LotName] = c.LotDescription
} }
resultItems := make([]gin.H, 0, len(items)) resultItems := make([]gin.H, 0, len(items))
@@ -217,12 +203,14 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
}) })
} }
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"source": localPL.Source, "source": localPL.Source,
"items": resultItems, "items": resultItems,
"total": total, "total_count": total,
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
"total_pages": totalPages,
}) })
} }
@@ -230,7 +218,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32) id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
return return
} }

View File

@@ -141,21 +141,21 @@ func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
} }
var resp struct { var resp struct {
Pricelists []struct { Items []struct {
ID uint `json:"id"` ID uint `json:"id"`
} `json:"pricelists"` } `json:"items"`
Total int `json:"total"` TotalCount int `json:"total_count"`
} }
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err) t.Fatalf("unmarshal response: %v", err)
} }
if resp.Total != 1 { if resp.TotalCount != 1 {
t.Fatalf("expected total=1, got %d", resp.Total) t.Fatalf("expected total=1, got %d", resp.TotalCount)
} }
if len(resp.Pricelists) != 1 { if len(resp.Items) != 1 {
t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists)) t.Fatalf("expected 1 pricelist, got %d", len(resp.Items))
} }
if resp.Pricelists[0].ID != 10 { if resp.Items[0].ID != 10 {
t.Fatalf("expected pricelist id=10, got %d", resp.Pricelists[0].ID) t.Fatalf("expected pricelist id=10, got %d", resp.Items[0].ID)
} }
} }

View File

@@ -18,13 +18,13 @@ func NewQuoteHandler(quoteService *services.QuoteService) *QuoteHandler {
func (h *QuoteHandler) Validate(c *gin.Context) { func (h *QuoteHandler) Validate(c *gin.Context) {
var req services.QuoteRequest var req services.QuoteRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
result, err := h.quoteService.ValidateAndCalculate(&req) result, err := h.quoteService.ValidateAndCalculate(&req)
if err != nil { if err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
@@ -34,13 +34,13 @@ func (h *QuoteHandler) Validate(c *gin.Context) {
func (h *QuoteHandler) Calculate(c *gin.Context) { func (h *QuoteHandler) Calculate(c *gin.Context) {
var req services.QuoteRequest var req services.QuoteRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
result, err := h.quoteService.ValidateAndCalculate(&req) result, err := h.quoteService.ValidateAndCalculate(&req)
if err != nil { if err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
@@ -53,13 +53,13 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
func (h *QuoteHandler) PriceLevels(c *gin.Context) { func (h *QuoteHandler) PriceLevels(c *gin.Context) {
var req services.PriceLevelsRequest var req services.PriceLevelsRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
result, err := h.quoteService.CalculatePriceLevels(&req) result, err := h.quoteService.CalculatePriceLevels(&req)
if err != nil { if err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }

View File

@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"errors"
"fmt" "fmt"
"html/template" "html/template"
"log/slog" "log/slog"
@@ -15,9 +14,6 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
mysqlDriver "github.com/go-sql-driver/mysql" mysqlDriver "github.com/go-sql-driver/mysql"
gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
) )
type SetupHandler struct { type SetupHandler struct {
@@ -27,8 +23,6 @@ type SetupHandler struct {
restartSig chan struct{} restartSig chan struct{}
} }
var errPermissionProbeRollback = errors.New("permission probe rollback")
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) { func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b }, "sub": func(a, b int) int { return a - b },
@@ -93,7 +87,7 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
} }
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
lotCount, canWrite, err := validateMariaDBConnection(dsn) lotCount, canWrite, err := db.ValidateMariaDBConnection(dsn)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -135,7 +129,7 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
// Test connection first // Test connection first
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
if _, _, err := validateMariaDBConnection(dsn); err != nil { if _, _, err := db.ValidateMariaDBConnection(dsn); err != nil {
_ = c.Error(err) _ = c.Error(err)
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"success": false, "success": false,
@@ -214,46 +208,3 @@ func buildMySQLDSN(host string, port int, database, user, password string, timeo
return cfg.FormatDSN() return cfg.FormatDSN()
} }
func validateMariaDBConnection(dsn string) (int64, bool, error) {
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return 0, false, fmt.Errorf("open MariaDB connection: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return 0, false, fmt.Errorf("get database handle: %w", err)
}
defer sqlDB.Close()
if err := sqlDB.Ping(); err != nil {
return 0, false, fmt.Errorf("ping MariaDB: %w", err)
}
var lotCount int64
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
return 0, false, fmt.Errorf("check required table lot: %w", err)
}
return lotCount, testSyncWritePermission(db), nil
}
func testSyncWritePermission(db *gorm.DB) bool {
sentinel := fmt.Sprintf("quoteforge-permission-check-%d", time.Now().UnixNano())
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`
INSERT INTO qt_client_schema_state (username, hostname, last_checked_at, updated_at)
VALUES (?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at)
`, sentinel, "setup-check").Error; err != nil {
return err
}
return errPermissionProbeRollback
})
return errors.Is(err, errPermissionProbeRollback)
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -39,7 +40,10 @@ func NewSupportBundleHandler(local *localdb.LocalDB, connMgr *db.ConnectionManag
// GET /api/support-bundle // GET /api/support-bundle
func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) { func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
now := time.Now().UTC() now := time.Now().UTC()
hostname, _ := os.Hostname() hostname, err := os.Hostname()
if err != nil {
slog.Warn("support bundle: could not get hostname", "err", err)
}
c.Header("Content-Type", "application/zip") c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="qfs-bundle-%s.zip"`, now.Format("20060102-150405"))) c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="qfs-bundle-%s.zip"`, now.Format("20060102-150405")))
@@ -153,8 +157,10 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
} }
// schema_migrations.json // schema_migrations.json
var migrations []localdb.LocalSchemaMigration migrations, err := h.localDB.GetSchemaMigrations()
_ = h.localDB.DB().Order("applied_at ASC").Find(&migrations).Error if err != nil {
slog.Warn("support bundle: could not load schema migrations", "err", err)
}
writeJSON("schema_migrations.json", migrations) writeJSON("schema_migrations.json", migrations)
// app.log (tail 5 MiB) // app.log (tail 5 MiB)
@@ -169,7 +175,9 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
} }
if _, err := f.Seek(offset, io.SeekStart); err == nil { if _, err := f.Seek(offset, io.SeekStart); err == nil {
if w, err := zw.Create("app.log"); err == nil { if w, err := zw.Create("app.log"); err == nil {
_, _ = io.Copy(w, f) if _, err := io.Copy(w, f); err != nil {
slog.Warn("support bundle: error copying log file", "err", err)
}
} }
} }
} }

View File

@@ -203,6 +203,10 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
_ = h.localDB.SetComponentSyncResult("ok", "", now) _ = h.localDB.SetComponentSyncResult("ok", "", now)
h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds()) h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds())
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
slog.Warn("qt_settings sync failed", "error", err)
}
c.JSON(http.StatusOK, SyncResultResponse{ c.JSON(http.StatusOK, SyncResultResponse{
Success: true, Success: true,
Message: "Components synced successfully", Message: "Components synced successfully",
@@ -232,6 +236,10 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
} }
h.localDB.AppendSyncLog("pricelists", "ok", "", synced, startTime, time.Since(startTime).Milliseconds()) 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{ c.JSON(http.StatusOK, SyncResultResponse{
Success: true, Success: true,
Message: "Pricelists synced successfully", Message: "Pricelists synced successfully",
@@ -335,6 +343,10 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds()) h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds())
componentsSynced = compResult.TotalSynced componentsSynced = compResult.TotalSynced
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
slog.Warn("qt_settings sync failed", "error", err)
}
// Sync pricelists // Sync pricelists
plNow := time.Now() plNow := time.Now()
pricelistsSynced, err = h.syncService.SyncPricelists() pricelistsSynced, err = h.syncService.SyncPricelists()
@@ -352,6 +364,10 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
} }
h.localDB.AppendSyncLog("pricelists", "ok", "", pricelistsSynced, plNow, time.Since(plNow).Milliseconds()) 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() projectsResult, err := h.syncService.ImportProjectsToLocal()
if err != nil { if err != nil {
slog.Error("project import failed during full sync", "error", err) slog.Error("project import failed during full sync", "error", err)
@@ -739,7 +755,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
} `json:"items"` } `json:"items"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }

View File

@@ -2,12 +2,14 @@ package handlers
import ( import (
"errors" "errors"
"log/slog"
"net/http" "net/http"
"strings" "strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -15,12 +17,14 @@ import (
type VendorSpecHandler struct { type VendorSpecHandler struct {
localDB *localdb.LocalDB localDB *localdb.LocalDB
configService *services.LocalConfigurationService 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{ return &VendorSpecHandler{
localDB: localDB, localDB: localDB,
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }), configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
syncService: syncService,
} }
} }
@@ -36,6 +40,28 @@ func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfigurati
return cfg, nil return cfg, nil
} }
// ParseText parses a pasted single-column text BOM (Inspur or Russian text BOM)
// using the same parsers as the vendor file-import path. It is stateless: no
// configuration is required. Returns the parsed rows and the detected format, or
// an empty result when the text is not a recognized single-column format (the
// client then falls back to manual column mapping).
// POST /api/vendor-spec/parse-text
func (h *VendorSpecHandler) ParseText(c *gin.Context) {
var body struct {
Text string `json:"text"`
}
if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}
rows, format := services.ParsePastedBOMText(body.Text)
if rows == nil {
rows = []localdb.VendorSpecItem{}
}
c.JSON(http.StatusOK, gin.H{"rows": rows, "format": format})
}
// GetVendorSpec returns the vendor spec (BOM) for a configuration. // GetVendorSpec returns the vendor spec (BOM) for a configuration.
// GET /api/configs/:uuid/vendor-spec // GET /api/configs/:uuid/vendor-spec
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) { func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
@@ -65,7 +91,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
@@ -88,9 +114,57 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
return 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}) 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 { func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(in) == 0 { if len(in) == 0 {
return nil return nil
@@ -136,7 +210,7 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }
@@ -149,7 +223,11 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
return return
} }
book, _ := bookRepo.GetActiveBook() book, err := bookRepo.GetActiveBook()
if err != nil {
slog.Warn("vendor spec resolve: no active partnumber book", "err", err)
book = nil
}
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo) aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) RespondError(c, http.StatusInternalServerError, "internal server error", err)
@@ -179,7 +257,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
} `json:"items"` } `json:"items"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err) RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return return
} }

View File

@@ -230,6 +230,7 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
&PendingChange{}, &PendingChange{},
&LocalPartnumberBook{}, &LocalPartnumberBook{},
&SyncLogEntry{}, &SyncLogEntry{},
&LocalQtSetting{},
) )
} }
@@ -497,7 +498,10 @@ func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string
// HasSettings returns true if connection settings exist // HasSettings returns true if connection settings exist
func (l *LocalDB) HasSettings() bool { func (l *LocalDB) HasSettings() bool {
var count int64 var count int64
l.db.Model(&ConnectionSettings{}).Count(&count) if err := l.db.Model(&ConnectionSettings{}).Count(&count).Error; err != nil {
slog.Error("localdb: HasSettings count failed", "err", err)
return false
}
return count > 0 return count > 0
} }
@@ -1044,14 +1048,18 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
// CountConfigurations returns the number of local configurations // CountConfigurations returns the number of local configurations
func (l *LocalDB) CountConfigurations() int64 { func (l *LocalDB) CountConfigurations() int64 {
var count int64 var count int64
l.db.Model(&LocalConfiguration{}).Count(&count) if err := l.db.Model(&LocalConfiguration{}).Count(&count).Error; err != nil {
slog.Error("localdb: CountConfigurations failed", "err", err)
}
return count return count
} }
// CountProjects returns the number of local projects // CountProjects returns the number of local projects
func (l *LocalDB) CountProjects() int64 { func (l *LocalDB) CountProjects() int64 {
var count int64 var count int64
l.db.Model(&LocalProject{}).Count(&count) if err := l.db.Model(&LocalProject{}).Count(&count).Error; err != nil {
slog.Error("localdb: CountProjects failed", "err", err)
}
return count return count
} }
@@ -1819,3 +1827,62 @@ func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requi
}), }),
}).Create(state).Error }).Create(state).Error
} }
// GetLocalPartnumberBookByServerID returns a local partnumber book by its server-side ID.
func (l *LocalDB) GetLocalPartnumberBookByServerID(serverID uint) (*LocalPartnumberBook, error) {
var book LocalPartnumberBook
if err := l.db.Where("server_id = ?", serverID).First(&book).Error; err != nil {
return nil, err
}
return &book, nil
}
// GetLocalPricelistItemsPage returns a paginated, searchable list of items for a pricelist.
func (l *LocalDB) GetLocalPricelistItemsPage(pricelistID uint, search string, page, perPage int) ([]LocalPricelistItem, int64, error) {
dbq := l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID)
if search != "" {
dbq = dbq.Where("lot_name LIKE ?", "%"+search+"%")
}
var total int64
if err := dbq.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("count pricelist items: %w", err)
}
offset := (page - 1) * perPage
var items []LocalPricelistItem
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
return nil, 0, fmt.Errorf("fetch pricelist items: %w", err)
}
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) {
var migrations []LocalSchemaMigration
if err := l.db.Order("applied_at ASC").Find(&migrations).Error; err != nil {
return nil, fmt.Errorf("fetch schema migrations: %w", err)
}
return migrations, nil
}

View File

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

View File

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

View File

@@ -1,93 +0,0 @@
package models
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
)
type AlertType string
const (
AlertHighDemandStalePrice AlertType = "high_demand_stale_price"
AlertPriceSpike AlertType = "price_spike"
AlertPriceDrop AlertType = "price_drop"
AlertNoRecentQuotes AlertType = "no_recent_quotes"
AlertTrendingNoPrice AlertType = "trending_no_price"
)
type AlertSeverity string
const (
SeverityLow AlertSeverity = "low"
SeverityMedium AlertSeverity = "medium"
SeverityHigh AlertSeverity = "high"
SeverityCritical AlertSeverity = "critical"
)
type AlertStatus string
const (
AlertStatusNew AlertStatus = "new"
AlertStatusAcknowledged AlertStatus = "acknowledged"
AlertStatusResolved AlertStatus = "resolved"
AlertStatusIgnored AlertStatus = "ignored"
)
type AlertDetails map[string]interface{}
func (d AlertDetails) Value() (driver.Value, error) {
return json.Marshal(d)
}
func (d *AlertDetails) Scan(value interface{}) error {
if value == nil {
*d = make(AlertDetails)
return nil
}
bytes, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(bytes, d)
}
type PricingAlert struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
LotName string `gorm:"size:255;not null" json:"lot_name"`
AlertType AlertType `gorm:"type:enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price');not null" json:"alert_type"`
Severity AlertSeverity `gorm:"type:enum('low','medium','high','critical');default:'medium'" json:"severity"`
Message string `gorm:"type:text;not null" json:"message"`
Details AlertDetails `gorm:"type:json" json:"details"`
Status AlertStatus `gorm:"type:enum('new','acknowledged','resolved','ignored');default:'new'" json:"status"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (PricingAlert) TableName() string {
return "qt_pricing_alerts"
}
type TrendDirection string
const (
TrendUp TrendDirection = "up"
TrendStable TrendDirection = "stable"
TrendDown TrendDirection = "down"
)
type ComponentUsageStats struct {
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
QuotesTotal int `gorm:"default:0" json:"quotes_total"`
QuotesLast30d int `gorm:"default:0" json:"quotes_last_30d"`
QuotesLast7d int `gorm:"default:0" json:"quotes_last_7d"`
TotalQuantity int `gorm:"default:0" json:"total_quantity"`
TotalRevenue float64 `gorm:"type:decimal(14,2);default:0" json:"total_revenue"`
TrendDirection TrendDirection `gorm:"type:enum('up','stable','down');default:'stable'" json:"trend_direction"`
TrendPercent float64 `gorm:"type:decimal(5,2);default:0" json:"trend_percent"`
LastUsedAt *time.Time `json:"last_used_at"`
}
func (ComponentUsageStats) TableName() string {
return "qt_component_usage_stats"
}

View File

@@ -124,16 +124,3 @@ func (Configuration) TableName() string {
return "qt_configurations" return "qt_configurations"
} }
type PriceOverride struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
LotName string `gorm:"size:255;not null" json:"lot_name"`
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
ValidFrom time.Time `gorm:"type:date;not null" json:"valid_from"`
ValidUntil *time.Time `gorm:"type:date" json:"valid_until"`
Reason string `gorm:"type:text" json:"reason"`
CreatedBy uint `gorm:"not null" json:"created_by"`
}
func (PriceOverride) TableName() string {
return "qt_price_overrides"
}

View File

@@ -1,7 +1,5 @@
package models package models
import "time"
// Lot represents existing lot table // Lot represents existing lot table
type Lot struct { type Lot struct {
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"` LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
@@ -12,43 +10,3 @@ type Lot struct {
func (Lot) TableName() string { func (Lot) TableName() string {
return "lot" return "lot"
} }
// Supplier represents existing supplier table (READ-ONLY)
type Supplier struct {
SupplierName string `gorm:"column:supplier_name;primaryKey;size:255"`
SupplierComment string `gorm:"column:supplier_comment;size:10000"`
}
func (Supplier) TableName() string {
return "supplier"
}
// StockLog stores warehouse stock snapshots imported from external files.
type StockLog struct {
StockLogID uint `gorm:"column:stock_log_id;primaryKey;autoIncrement"`
Partnumber string `gorm:"column:partnumber;size:255;not null"`
Supplier *string `gorm:"column:supplier;size:255"`
Date time.Time `gorm:"column:date;type:date;not null"`
Price float64 `gorm:"column:price;not null"`
Quality *string `gorm:"column:quality;size:255"`
Comments *string `gorm:"column:comments;size:15000"`
Vendor *string `gorm:"column:vendor;size:255"`
Qty *float64 `gorm:"column:qty"`
}
func (StockLog) TableName() string {
return "stock_log"
}
// StockIgnoreRule contains import ignore pattern rules.
type StockIgnoreRule struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Target string `gorm:"column:target;size:20;not null" json:"target"` // partnumber|description
MatchType string `gorm:"column:match_type;size:20;not null" json:"match_type"` // exact|prefix|suffix
Pattern string `gorm:"column:pattern;size:500;not null" json:"pattern"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
}
func (StockIgnoreRule) TableName() string {
return "stock_ignore_rules"
}

View File

@@ -14,9 +14,6 @@ func AllModels() []interface{} {
&LotMetadata{}, &LotMetadata{},
&Project{}, &Project{},
&Configuration{}, &Configuration{},
&PriceOverride{},
&PricingAlert{},
&ComponentUsageStats{},
&Pricelist{}, &Pricelist{},
&PricelistItem{}, &PricelistItem{},
} }

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

@@ -1,91 +0,0 @@
package repository
import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type AlertRepository struct {
db *gorm.DB
}
func NewAlertRepository(db *gorm.DB) *AlertRepository {
return &AlertRepository{db: db}
}
func (r *AlertRepository) Create(alert *models.PricingAlert) error {
return r.db.Create(alert).Error
}
func (r *AlertRepository) GetByID(id uint) (*models.PricingAlert, error) {
var alert models.PricingAlert
err := r.db.First(&alert, id).Error
if err != nil {
return nil, err
}
return &alert, nil
}
func (r *AlertRepository) Update(alert *models.PricingAlert) error {
return r.db.Save(alert).Error
}
type AlertFilter struct {
Status models.AlertStatus
Severity models.AlertSeverity
Type models.AlertType
LotName string
}
func (r *AlertRepository) List(filter AlertFilter, offset, limit int) ([]models.PricingAlert, int64, error) {
var alerts []models.PricingAlert
var total int64
query := r.db.Model(&models.PricingAlert{})
if filter.Status != "" {
query = query.Where("status = ?", filter.Status)
}
if filter.Severity != "" {
query = query.Where("severity = ?", filter.Severity)
}
if filter.Type != "" {
query = query.Where("alert_type = ?", filter.Type)
}
if filter.LotName != "" {
query = query.Where("lot_name = ?", filter.LotName)
}
query.Count(&total)
err := query.
Order("FIELD(severity, 'critical', 'high', 'medium', 'low')").
Order("created_at DESC").
Offset(offset).
Limit(limit).
Find(&alerts).Error
return alerts, total, err
}
func (r *AlertRepository) CountByStatus(status models.AlertStatus) (int64, error) {
var count int64
err := r.db.Model(&models.PricingAlert{}).
Where("status = ?", status).
Count(&count).Error
return count, err
}
func (r *AlertRepository) UpdateStatus(id uint, status models.AlertStatus) error {
return r.db.Model(&models.PricingAlert{}).
Where("id = ?", id).
Update("status", status).Error
}
func (r *AlertRepository) ExistsByLotAndType(lotName string, alertType models.AlertType) (bool, error) {
var count int64
err := r.db.Model(&models.PricingAlert{}).
Where("lot_name = ? AND alert_type = ? AND status IN ('new', 'acknowledged')", lotName, alertType).
Count(&count).Error
return count > 0, err
}

View File

@@ -157,7 +157,7 @@ func (r *PartnumberBookRepository) listCatalogItems(partnumbers localdb.LocalStr
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers)) query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers))
if search != "" { if search != "" {
trimmedSearch := "%" + 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 var total int64

View File

@@ -177,7 +177,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
if err != nil { if err != nil {
t.Fatalf("open sqlite: %v", err) t.Fatalf("open sqlite: %v", err)
} }
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.StockLog{}); err != nil { if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}); err != nil {
t.Fatalf("migrate: %v", err) t.Fatalf("migrate: %v", err)
} }
return NewPricelistRepository(db) return NewPricelistRepository(db)

View File

@@ -1,93 +0,0 @@
package repository
import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type StatsRepository struct {
db *gorm.DB
}
func NewStatsRepository(db *gorm.DB) *StatsRepository {
return &StatsRepository{db: db}
}
func (r *StatsRepository) GetByLotName(lotName string) (*models.ComponentUsageStats, error) {
var stats models.ComponentUsageStats
err := r.db.Where("lot_name = ?", lotName).First(&stats).Error
if err != nil {
return nil, err
}
return &stats, nil
}
func (r *StatsRepository) Upsert(stats *models.ComponentUsageStats) error {
return r.db.Save(stats).Error
}
func (r *StatsRepository) IncrementUsage(lotName string, quantity int, revenue float64) error {
now := time.Now()
result := r.db.Model(&models.ComponentUsageStats{}).
Where("lot_name = ?", lotName).
Updates(map[string]interface{}{
"quotes_total": gorm.Expr("quotes_total + 1"),
"quotes_last_30d": gorm.Expr("quotes_last_30d + 1"),
"quotes_last_7d": gorm.Expr("quotes_last_7d + 1"),
"total_quantity": gorm.Expr("total_quantity + ?", quantity),
"total_revenue": gorm.Expr("total_revenue + ?", revenue),
"last_used_at": now,
})
if result.RowsAffected == 0 {
stats := &models.ComponentUsageStats{
LotName: lotName,
QuotesTotal: 1,
QuotesLast30d: 1,
QuotesLast7d: 1,
TotalQuantity: quantity,
TotalRevenue: revenue,
LastUsedAt: &now,
}
return r.db.Create(stats).Error
}
return result.Error
}
func (r *StatsRepository) GetTopComponents(limit int) ([]models.ComponentUsageStats, error) {
var stats []models.ComponentUsageStats
err := r.db.
Order("quotes_last_30d DESC").
Limit(limit).
Find(&stats).Error
return stats, err
}
func (r *StatsRepository) GetTrendingComponents(limit int) ([]models.ComponentUsageStats, error) {
var stats []models.ComponentUsageStats
err := r.db.
Where("trend_direction = ? AND trend_percent > ?", models.TrendUp, 20).
Order("trend_percent DESC").
Limit(limit).
Find(&stats).Error
return stats, err
}
// ResetWeeklyCounters resets quotes_last_7d (run weekly via cron)
func (r *StatsRepository) ResetWeeklyCounters() error {
return r.db.Model(&models.ComponentUsageStats{}).
Where("1 = 1").
Update("quotes_last_7d", 0).Error
}
// ResetMonthlyCounters resets quotes_last_30d (run monthly via cron)
func (r *StatsRepository) ResetMonthlyCounters() error {
return r.db.Model(&models.ComponentUsageStats{}).
Where("1 = 1").
Update("quotes_last_30d", 0).Error
}

View File

@@ -2,6 +2,7 @@ package services
import ( import (
"fmt" "fmt"
"log/slog"
"strings" "strings"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
@@ -11,18 +12,15 @@ import (
type ComponentService struct { type ComponentService struct {
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository
categoryRepo *repository.CategoryRepository categoryRepo *repository.CategoryRepository
statsRepo *repository.StatsRepository
} }
func NewComponentService( func NewComponentService(
componentRepo *repository.ComponentRepository, componentRepo *repository.ComponentRepository,
categoryRepo *repository.CategoryRepository, categoryRepo *repository.CategoryRepository,
statsRepo *repository.StatsRepository,
) *ComponentService { ) *ComponentService {
return &ComponentService{ return &ComponentService{
componentRepo: componentRepo, componentRepo: componentRepo,
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
statsRepo: statsRepo,
} }
} }
@@ -41,10 +39,11 @@ func ParsePartNumber(lotName string) (category, model string) {
} }
type ComponentListResult struct { type ComponentListResult struct {
Components []ComponentView `json:"components"` Items []ComponentView `json:"items"`
Total int64 `json:"total"` TotalCount int64 `json:"total_count"`
Page int `json:"page"` Page int `json:"page"`
PerPage int `json:"per_page"` PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
} }
type ComponentView struct { type ComponentView struct {
@@ -63,10 +62,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
// Components should be loaded via /api/sync/components first // Components should be loaded via /api/sync/components first
if s.componentRepo == nil { if s.componentRepo == nil {
return &ComponentListResult{ return &ComponentListResult{
Components: []ComponentView{}, Items: []ComponentView{},
Total: 0, TotalCount: 0,
Page: page, Page: page,
PerPage: perPage, PerPage: perPage,
TotalPages: 1,
}, nil }, nil
} }
@@ -107,11 +107,16 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
views[i] = view views[i] = view
} }
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
if totalPages < 1 {
totalPages = 1
}
return &ComponentListResult{ return &ComponentListResult{
Components: views, Items: views,
Total: total, TotalCount: total,
Page: page, Page: page,
PerPage: perPage, PerPage: perPage,
TotalPages: totalPages,
}, nil }, nil
} }
@@ -126,8 +131,10 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
return nil, err return nil, err
} }
// Track usage // Track usage (best-effort)
_ = s.componentRepo.IncrementRequestCount(lotName) if err := s.componentRepo.IncrementRequestCount(lotName); err != nil {
slog.Warn("component: could not increment request count", "lot", lotName, "err", err)
}
view := &ComponentView{ view := &ComponentView{
LotName: c.LotName, LotName: c.LotName,

View File

@@ -18,6 +18,7 @@ var (
// Used by handlers to work with both ConfigurationService and LocalConfigurationService // Used by handlers to work with both ConfigurationService and LocalConfigurationService
type ConfigurationGetter interface { type ConfigurationGetter interface {
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
GetByUUIDNoAuth(uuid string) (*models.Configuration, error)
} }
type ConfigurationService struct { type ConfigurationService struct {
@@ -116,9 +117,6 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
return nil, err return nil, err
} }
// Record usage stats
_ = s.quoteService.RecordUsage(req.Items)
return config, nil return config, nil
} }

View File

@@ -60,6 +60,7 @@ type ProjectPricingExportOptions struct {
IncludeCompetitor bool `json:"include_competitor"` IncludeCompetitor bool `json:"include_competitor"`
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob" Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3 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 { func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
@@ -95,6 +96,7 @@ type ProjectPricingExportRow struct {
Estimate *float64 Estimate *float64
Stock *float64 Stock *float64
Competitor *float64 Competitor *float64
ManualPrice *float64 // proportional share of the user-defined total price
} }
// ToCSV writes project export data in the new structured CSV format. // 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] description = componentDescriptions[rowMappings[0].LotName]
} }
pricingRow := ProjectPricingExportRow{ if len(rowMappings) == 0 {
LotDisplay: formatLotDisplay(rowMappings), block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: "н/д",
VendorPN: row.VendorPartnumber, VendorPN: row.VendorPartnumber,
Description: description, Description: description,
Quantity: exportPositiveInt(row.Quantity, 1), Quantity: exportPositiveInt(row.Quantity, 1),
BOMTotal: vendorRowTotal(row), 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 }), continue
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }), }
// 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 { for _, item := range cfg.Items {
@@ -422,10 +444,22 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
if opts.isDDP() { if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor()) applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
distributeManualPrice(block.Rows, *opts.ManualPrice)
}
return block, nil return block, nil
} }
catOrder := defaultCategoryOrder()
lotNames := make([]string, 0, len(cfg.Items))
for _, item := range 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 == "" { if item.LotName == "" {
continue continue
} }
@@ -444,10 +478,29 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
if opts.isDDP() { if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor()) applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
distributeManualPrice(block.Rows, *opts.ManualPrice)
}
return block, nil 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) { func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
for i := range rows { for i := range rows {
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor) rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
@@ -567,45 +620,52 @@ func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg
} }
} }
estimatePrices := s.batchLookupPrices(estimateID, lots)
stockPrices := s.batchLookupPrices(warehouseID, lots)
competitorPrices := s.batchLookupPrices(competitorID, lots)
for _, lot := range lots { for _, lot := range lots {
level := pricingLevels{} level := pricingLevels{}
level.Estimate = s.lookupPricePointer(estimateID, lot) if p, ok := estimatePrices[lot]; ok {
level.Stock = s.lookupPricePointer(warehouseID, lot) level.Estimate = floatPtr(p)
level.Competitor = s.lookupPricePointer(competitorID, lot) }
if p, ok := stockPrices[lot]; ok {
level.Stock = floatPtr(p)
}
if p, ok := competitorPrices[lot]; ok {
level.Competitor = floatPtr(p)
}
result[lot] = level result[lot] = level
} }
return result return result
} }
func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 { // batchLookupPrices fetches prices for all lots from a pricelist in a single query.
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" { func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string) map[string]float64 {
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || len(lots) == 0 {
return nil return nil
} }
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID) localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
if err != nil { if err != nil {
return nil return nil
} }
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName) prices, err := s.localDB.GetLocalPricesForLots(localPL.ID, lots)
if err != nil || price <= 0 { if err != nil {
return nil return nil
} }
return floatPtr(price) return prices
} }
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string { func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
lots := collectPricingLots(cfg, localCfg, true) lots := collectPricingLots(cfg, localCfg, true)
result := make(map[string]string, len(lots)) if s.localDB == nil || len(lots) == 0 {
if s.localDB == nil { return map[string]string{}
return result
} }
for _, lot := range lots { descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots)
component, err := s.localDB.GetLocalComponent(lot)
if err != nil { if err != nil {
continue return map[string]string{}
} }
result[lot] = component.LotDescription return descriptions
}
return result
} }
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string { func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
@@ -689,6 +749,52 @@ func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.V
return floatPtr(total) 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 { func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
if unitPrice == nil || *unitPrice <= 0 { if unitPrice == nil || *unitPrice <= 0 {
return nil return nil
@@ -709,7 +815,7 @@ func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quanti
} }
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string { func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
headers := make([]string, 0, 8) headers := make([]string, 0, 9)
headers = append(headers, "Line Item") headers = append(headers, "Line Item")
if opts.IncludeLOT { if opts.IncludeLOT {
headers = append(headers, "LOT") headers = append(headers, "LOT")
@@ -727,11 +833,14 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
if opts.IncludeCompetitor { if opts.IncludeCompetitor {
headers = append(headers, "Конкуренты") headers = append(headers, "Конкуренты")
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
headers = append(headers, "Ручная цена")
}
return headers return headers
} }
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string { func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
record := make([]string, 0, 8) record := make([]string, 0, 9)
record = append(record, "") record = append(record, "")
if opts.IncludeLOT { if opts.IncludeLOT {
record = append(record, emptyDash(row.LotDisplay)) record = append(record, emptyDash(row.LotDisplay))
@@ -753,11 +862,14 @@ func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions
if opts.IncludeCompetitor { if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(row.Competitor)) record = append(record, formatMoneyValue(row.Competitor))
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
record = append(record, formatMoneyValue(row.ManualPrice))
}
return record return record
} }
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string { 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)) record = append(record, fmt.Sprintf("%d", cfg.Line))
if opts.IncludeLOT { if opts.IncludeLOT {
record = append(record, "") record = append(record, "")
@@ -779,19 +891,12 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
if opts.IncludeCompetitor { if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor }))) 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 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 { func formatMoneyValue(value *float64) string {
if value == nil { if value == nil {

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"strings" "strings"
"time" "time"
@@ -118,9 +119,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
} }
cfg.Line = localCfg.Line cfg.Line = localCfg.Line
// Record usage stats
_ = s.quoteService.RecordUsage(req.Items)
return cfg, nil return cfg, nil
} }
@@ -407,7 +405,9 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
// Refresh local pricelists when online. // Refresh local pricelists when online.
if s.isOnline() { if s.isOnline() {
_ = s.syncService.SyncPricelistsIfNeeded() if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
slog.Warn("local configuration: background pricelist sync failed", "err", err)
}
} }
// Use the pricelist stored in the config; fall back to latest if unavailable. // Use the pricelist stored in the config; fall back to latest if unavailable.
@@ -791,7 +791,9 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
} }
if s.isOnline() { if s.isOnline() {
_ = s.syncService.SyncPricelistsIfNeeded() if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
slog.Warn("local configuration: background pricelist sync failed", "err", err)
}
} }
// Resolve which pricelist to use: // Resolve which pricelist to use:

View File

@@ -19,7 +19,6 @@ var (
type QuoteService struct { type QuoteService struct {
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository
statsRepo *repository.StatsRepository
pricelistRepo *repository.PricelistRepository pricelistRepo *repository.PricelistRepository
localDB *localdb.LocalDB localDB *localdb.LocalDB
pricingService priceResolver pricingService priceResolver
@@ -34,14 +33,12 @@ type priceResolver interface {
func NewQuoteService( func NewQuoteService(
componentRepo *repository.ComponentRepository, componentRepo *repository.ComponentRepository,
statsRepo *repository.StatsRepository,
pricelistRepo *repository.PricelistRepository, pricelistRepo *repository.PricelistRepository,
localDB *localdb.LocalDB, localDB *localdb.LocalDB,
pricingService priceResolver, pricingService priceResolver,
) *QuoteService { ) *QuoteService {
return &QuoteService{ return &QuoteService{
componentRepo: componentRepo, componentRepo: componentRepo,
statsRepo: statsRepo,
pricelistRepo: pricelistRepo, pricelistRepo: pricelistRepo,
localDB: localDB, localDB: localDB,
pricingService: pricingService, pricingService: pricingService,
@@ -504,18 +501,3 @@ func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string
return 0, false return 0, false
} }
// RecordUsage records that components were used in a quote
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
if s.statsRepo == nil {
// Offline mode: usage stats are unavailable and should not block config saves.
return nil
}
for _, item := range items {
revenue := item.UnitPrice * float64(item.Quantity)
if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil {
return err
}
}
return nil
}

View File

@@ -13,7 +13,7 @@ import (
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) { func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
db := newPriceLevelsTestDB(t) db := newPriceLevelsTestDB(t)
repo := repository.NewPricelistRepository(db) repo := repository.NewPricelistRepository(db)
service := NewQuoteService(nil, nil, repo, nil, nil) service := NewQuoteService(nil, repo, nil, nil)
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100) estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
_ = estimate _ = estimate
@@ -57,7 +57,7 @@ func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) { func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
db := newPriceLevelsTestDB(t) db := newPriceLevelsTestDB(t)
repo := repository.NewPricelistRepository(db) repo := repository.NewPricelistRepository(db)
service := NewQuoteService(nil, nil, repo, nil, nil) service := NewQuoteService(nil, repo, nil, nil)
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80) olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90) seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)

View File

@@ -1,8 +1,10 @@
package sync package sync
import ( import (
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
) )
@@ -11,10 +13,19 @@ type SeenPartnumber struct {
Partnumber string Partnumber string
Description string Description string
Ignored bool 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. // 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 { func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
@@ -30,7 +41,43 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
if item.Partnumber == "" { if item.Partnumber == "" {
continue 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 INSERT INTO qt_vendor_partnumber_seen
(source_type, vendor, partnumber, description, is_ignored, last_seen_at) (source_type, vendor, partnumber, description, is_ignored, last_seen_at)
VALUES VALUES
@@ -40,10 +87,18 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
`, item.Partnumber, item.Description, item.Ignored, now).Error `, item.Partnumber, item.Description, item.Ignored, now).Error
if err != nil { if err != nil {
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err) 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)) slog.Info("partnumber_seen pushed to server", "count", len(items))
return nil 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 package sync
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -320,16 +321,39 @@ func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText)) return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
} }
var pending localdb.PendingChange var errored []localdb.PendingChange
if err := local.DB(). if err := local.DB().
Where("TRIM(COALESCE(last_error, '')) <> ''"). Where("TRIM(COALESCE(last_error, '')) <> ''").
Order("id DESC"). Order("id DESC").
First(&pending).Error; err == nil { Limit(20).
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError)) 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 { func optionalString(value string) *string {
if strings.TrimSpace(value) == "" { if strings.TrimSpace(value) == "" {
return nil return nil

View File

@@ -851,6 +851,11 @@ func (s *Service) SyncPricelistsIfNeeded() error {
return nil 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 // PushPendingChanges pushes all pending changes to the server
func (s *Service) PushPendingChanges() (int, error) { func (s *Service) PushPendingChanges() (int, error) {
if _, err := s.EnsureReadinessForSync(); err != nil { if _, err := s.EnsureReadinessForSync(); err != nil {
@@ -864,6 +869,14 @@ func (s *Service) PushPendingChanges() (int, error) {
slog.Info("purged orphan configuration pending changes", "removed", removed) 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() changes, err := s.localDB.GetPendingChanges()
if err != nil { if err != nil {
return 0, fmt.Errorf("getting pending changes: %w", err) return 0, fmt.Errorf("getting pending changes: %w", err)
@@ -884,8 +897,14 @@ func (s *Service) PushPendingChanges() (int, error) {
if err != nil { if err != nil {
s.markConnectionBroken(err) s.markConnectionBroken(err)
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", 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()) s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
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 continue
} }
@@ -912,7 +931,11 @@ func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
case "configuration": case "configuration":
return s.pushConfigurationChange(change) return s.pushConfigurationChange(change)
default: 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 +1068,10 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
case "delete": case "delete":
return s.pushConfigurationDelete(change) return s.pushConfigurationDelete(change)
default: 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,8 +1271,13 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID) localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
if localErr != nil { 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) modelProject := localdb.LocalToProject(localProject)
if modelProject.OwnerUsername == "" { if modelProject.OwnerUsername == "" {
modelProject.OwnerUsername = cfg.OwnerUsername modelProject.OwnerUsername = cfg.OwnerUsername
@@ -1264,6 +1295,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
} }
return nil return nil
} }
}
systemProject := &models.Project{} systemProject := &models.Project{}
err := mariaDB. err := mariaDB.

View File

@@ -100,5 +100,10 @@ func (w *Worker) runSync() {
return 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") w.logger.Info("background sync cycle completed")
} }

View File

@@ -6,6 +6,7 @@ import (
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -134,6 +135,10 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName)) workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
case IsInspurBOM(data): case IsInspurBOM(data):
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName)) 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: default:
return nil, fmt.Errorf("unsupported vendor export format") return nil, fmt.Errorf("unsupported vendor export format")
} }
@@ -269,13 +274,17 @@ func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *loca
} }
sort.Strings(order) sort.Strings(order)
var priceMap map[string]float64
if estimatePricelist != nil && local != nil && len(order) > 0 {
priceMap, _ = local.GetLocalPricesForLots(estimatePricelist.ID, order)
}
items := make(localdb.LocalConfigItems, 0, len(order)) items := make(localdb.LocalConfigItems, 0, len(order))
for _, lotName := range order { for _, lotName := range order {
unitPrice := 0.0 unitPrice := 0.0
if estimatePricelist != nil && local != nil { if priceMap != nil {
if price, err := local.GetLocalPriceForLot(estimatePricelist.ID, lotName); err == nil && price > 0 { unitPrice = priceMap[lotName]
unitPrice = price
}
} }
items = append(items, localdb.LocalConfigItem{ items = append(items, localdb.LocalConfigItem{
LotName: lotName, LotName: lotName,
@@ -676,6 +685,211 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
}, nil }, 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 "шт".
// The quantity anchor at the end keeps internal hyphens/digits in the
// description (e.g. "8-GPU-2304GB") from being mistaken for the separator.
var textBOMItemLine = regexp.MustCompile(`(?i)^(.*\S)\s*[-–—]\s*(\d+)\s*шт\.?\s*$`)
// textBOMHeaderLine matches a configuration header ending with ", в составе:"
// regardless of the leading words (e.g. "Сервер <model>" or
// "Вычислительный GPU сервер <model>"). The captured group is everything before
// the comma; the model is its last whitespace-separated token.
var textBOMHeaderLine = regexp.MustCompile(`(?i)^(.*?)\s*,\s*в\s+составе`)
// ParsePastedBOMText detects and parses a single-column text BOM (Inspur or
// Russian text BOM) pasted into the configurator. It shares the same detectors
// and parsers as the vendor file-import path, so paste and upload behave
// identically. It returns the parsed vendor spec rows and the detected format,
// or (nil, "") when the text is not a recognized single-column format and the
// caller should fall back to manual column mapping.
func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) {
data := []byte(text)
var ws *importedWorkspace
var err error
switch {
case IsInspurBOM(data):
ws, err = parseInspurBOM(data, "")
case IsNxBOM(data):
ws, err = parseNxBOM(data, "")
case IsTextBOM(data):
ws, err = parseTextBOM(data, "")
default:
return nil, ""
}
if err != nil || ws == nil || len(ws.Configurations) == 0 {
return nil, ""
}
return ws.Configurations[0].Rows, ws.SourceFormat
}
// IsTextBOM reports whether data looks like a human-readable Russian text BOM,
// i.e. it contains at least one "<description> - <quantity> шт." line.
func IsTextBOM(data []byte) bool {
for _, raw := range strings.Split(string(data), "\n") {
if textBOMItemLine.MatchString(strings.TrimSpace(raw)) {
return true
}
}
return false
}
// parseTextBOM parses a human-readable Russian text BOM into a single configuration.
// The optional "Сервер <model>, в составе:" header provides the configuration name and
// server model. Each "<description> - <quantity> шт." line becomes one vendor spec row.
// The format carries no partnumbers, so rows stay unresolved and editable in the UI
// until mapped through the active partnumber book.
func parseTextBOM(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 := textBOMItemLine.FindStringSubmatch(line)
if m == nil {
continue
}
description := strings.TrimSpace(m[1])
qty, err := strconv.Atoi(m[2])
if err != nil || qty <= 0 || 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("text BOM has no importable rows")
}
name := serverModel
if name == "" {
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
}
if name == "" {
name = "Text BOM Import"
}
return &importedWorkspace{
SourceFormat: "Text",
SourceFileName: sourceFileName,
Configurations: []importedConfiguration{
{
GroupID: "text-0",
Name: name,
Line: 10,
ServerCount: 1,
ServerModel: serverModel,
Rows: rows,
},
},
}, nil
}
// IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export. // IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export.
// The file starts (after optional UTF-8 BOM) with the header line: // The file starts (after optional UTF-8 BOM) with the header line:
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..." // "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."

View File

@@ -2,6 +2,7 @@ package services
import ( import (
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
@@ -588,3 +589,232 @@ func TestIsInspurBOM(t *testing.T) {
} }
} }
} }
func TestIsTextBOM(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"CPU Intel 6760P - 2 шт.", true},
{"Fan 18Krpm 8086 - 20 шт.\nRail L-Type 665mm - 1 шт.", true},
{"NVIDIA transceiver - 8шт.", true}, // no space before шт
{"Сервер KR9288X3, в составе:\nFan - 4 шт.", true},
{"|CPU_AMD*1\n|PSU*2", false}, // Inspur
{"<CFXML>\n</CFXML>", false},
{"just text\nno quantities", false},
{"CPU - 2 pcs.", false}, // not Russian шт
{"", false},
}
for _, tc := range cases {
got := IsTextBOM([]byte(tc.input))
if got != tc.want {
t.Errorf("IsTextBOM(%q) = %v, want %v", tc.input, got, tc.want)
}
}
}
func TestParseTextBOM(t *testing.T) {
const sample = `Сервер KR9288X3, в составе:
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
incl. onboard 800G XDR - 8 шт.
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
SSD 960G U.2 16GTps 2.5in RAID_1 - 2 шт.
SSD 3.84T U.2 16GTps 2.5in R-Standard - 2 шт.
NIC 25Gbps 2Port LC Nvidia CX6LX PCIe MM GEN4 - 1 шт.
PowerSupply 3200W Titanium 220VACor240VDC - 2 шт.
PowerSupply 3300W Titanium 220VACor240VDC - 6 шт.
PowerCord 1.9M C20 C19 - 14 шт.
Rail L-Type 665mm - 1 шт.
Chassis 2.5x12 gpu - 1 шт.
Fan 18Krpm 8086 - 20 шт.
NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up to 50m, flat top - 8шт.`
workspace, err := parseTextBOM([]byte(sample), "spec.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if workspace.SourceFormat != "Text" {
t.Fatalf("expected SourceFormat Text, got %q", workspace.SourceFormat)
}
if len(workspace.Configurations) != 1 {
t.Fatalf("expected 1 configuration, got %d", len(workspace.Configurations))
}
cfg := workspace.Configurations[0]
if cfg.Name != "KR9288X3" {
t.Fatalf("expected name KR9288X3 (from header), got %q", cfg.Name)
}
if cfg.ServerModel != "KR9288X3" {
t.Fatalf("expected ServerModel KR9288X3, got %q", cfg.ServerModel)
}
if cfg.ServerCount != 1 {
t.Fatalf("expected ServerCount 1, got %d", cfg.ServerCount)
}
const wantRows = 14
if len(cfg.Rows) != wantRows {
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
}
rowsByDesc := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
for _, r := range cfg.Rows {
rowsByDesc[r.Description] = r
if r.VendorPartnumber != r.Description {
t.Fatalf("expected VendorPartnumber to mirror Description, got pn=%q desc=%q", r.VendorPartnumber, r.Description)
}
}
// Description with internal hyphens and digits must not be split early.
gpu, ok := rowsByDesc["GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E"]
if !ok {
t.Fatal("expected GPU row not found (check hyphen handling)")
}
if gpu.Quantity != 1 {
t.Fatalf("GPU: expected qty 1, got %d", gpu.Quantity)
}
mem, ok := rowsByDesc["Mem 128G DDR5-6400MHz ECC-RDIMM"]
if !ok {
t.Fatal("expected Mem row not found")
}
if mem.Quantity != 16 {
t.Fatalf("Mem: expected qty 16, got %d", mem.Quantity)
}
// Quantity with no space before "шт" and commas/hyphens in description.
xcvr, ok := rowsByDesc["NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up to 50m, flat top"]
if !ok {
t.Fatal("expected transceiver row not found (check no-space quantity)")
}
if xcvr.Quantity != 8 {
t.Fatalf("transceiver: expected qty 8, got %d", xcvr.Quantity)
}
}
func TestParseTextBOMVariantHeaderAndLeadingSpace(t *testing.T) {
// Header does not start with "Сервер"; some lines have leading/trailing spaces;
// descriptions contain commas and internal hyphens.
const sample = `Вычислительный GPU сервер G5500V7, в составе:
Серверное шасси G5500 V7 (12NVMe + 8SAS/SATA) - 1 шт.
Процессор Intel 8558P 48C 2.7G 260MB 350W - 2 шт.
Модуль оперативной памяти Mem 128G DDR5-5600MHz ECC-RDIMM - 16 шт.
Накопитель SSD 2.5" NVMe 3.84TB - 8 шт.
Накопитель SSD 2.5" SATA 3.84TB - 2 шт.
Адаптер SAS/SATA RAID0,1,10 12Gb/s-no Cache - 1 шт.
Адаптер 25GE(CX6-Lx)-Dual Port SFP28 - 1 шт.
Сетевая карта 4 x 1G, Base-T - 1 шт.
Адаптер HBA Emulex LPe32002 2 Port 32GFC - 1 шт.
Крепежный комплект Ball Bearing Rail Kit - 1 шт.
Кабельный органайзер Cable Management Arm - 1 шт.
Кабель питания PowerCord 3m C20 C19 - 4 шт.
Видеокарта (видеоускоритель) NVIDIA RTX PRO 6000 Server Edition 96GB GDDR7 - 8 шт.
Блок питания 3000W Titanium AC Power Supply - 4 шт.`
workspace, err := parseTextBOM([]byte(sample), "spec.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := workspace.Configurations[0]
if cfg.ServerModel != "G5500V7" {
t.Fatalf("expected ServerModel G5500V7 (last token before comma), got %q", cfg.ServerModel)
}
if cfg.Name != "G5500V7" {
t.Fatalf("expected name G5500V7, got %q", cfg.Name)
}
const wantRows = 14
if len(cfg.Rows) != wantRows {
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
}
for _, r := range cfg.Rows {
if r.VendorPartnumber != strings.TrimSpace(r.VendorPartnumber) {
t.Fatalf("vendor_partnumber has surrounding whitespace: %q", r.VendorPartnumber)
}
if r.Description != strings.TrimSpace(r.Description) {
t.Fatalf("description has surrounding whitespace: %q", r.Description)
}
if r.VendorPartnumber == "" {
t.Fatal("empty vendor_partnumber")
}
}
rowsByDesc := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
for _, r := range cfg.Rows {
rowsByDesc[r.VendorPartnumber] = r
}
// Leading-space line must yield a trimmed P/N.
sata, ok := rowsByDesc[`Накопитель SSD 2.5" SATA 3.84TB`]
if !ok {
t.Fatal("expected SATA SSD row not found (check leading-space trimming)")
}
if sata.Quantity != 2 {
t.Fatalf("SATA SSD: expected qty 2, got %d", sata.Quantity)
}
// Commas inside the description must not break parsing.
raid, ok := rowsByDesc["Адаптер SAS/SATA RAID0,1,10 12Gb/s-no Cache"]
if !ok {
t.Fatal("expected RAID adapter row not found (check commas in description)")
}
if raid.Quantity != 1 {
t.Fatalf("RAID adapter: expected qty 1, got %d", raid.Quantity)
}
gpu, ok := rowsByDesc["Видеокарта (видеоускоритель) NVIDIA RTX PRO 6000 Server Edition 96GB GDDR7"]
if !ok {
t.Fatal("expected GPU row not found")
}
if gpu.Quantity != 8 {
t.Fatalf("GPU: expected qty 8, got %d", gpu.Quantity)
}
}
func TestParsePastedBOMText(t *testing.T) {
t.Run("text BOM", func(t *testing.T) {
rows, format := ParsePastedBOMText("Сервер X1, в составе:\nCPU Intel 6760P - 2 шт.\nMem 128G - 16 шт.")
if format != "Text" {
t.Fatalf("expected format Text, got %q", format)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
if rows[0].VendorPartnumber != "CPU Intel 6760P" || rows[0].Quantity != 2 {
t.Fatalf("unexpected first row: %+v", rows[0])
}
})
t.Run("inspur BOM", func(t *testing.T) {
rows, format := ParsePastedBOMText("|CPU_AMD*1\n|PSU*2")
if format != "Inspur" {
t.Fatalf("expected format Inspur, got %q", format)
}
if len(rows) != 2 || rows[1].Quantity != 2 {
t.Fatalf("unexpected rows: %+v", rows)
}
})
t.Run("unrecognized falls through", func(t *testing.T) {
rows, format := ParsePastedBOMText("col a\tcol b\nfoo\tbar")
if rows != nil || format != "" {
t.Fatalf("expected nil/empty for unrecognized text, got rows=%+v format=%q", rows, format)
}
})
}
func TestParseTextBOMNameFromFilename(t *testing.T) {
const sample = "CPU Intel 6760P - 2 шт.\nMem 128G - 16 шт."
workspace, err := parseTextBOM([]byte(sample), "my-config.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := workspace.Configurations[0]
if cfg.Name != "my-config" {
t.Fatalf("expected name my-config (from filename), got %q", cfg.Name)
}
if cfg.ServerModel != "" {
t.Fatalf("expected empty ServerModel without header, got %q", cfg.ServerModel)
}
if len(cfg.Rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(cfg.Rows))
}
}

View File

@@ -1,3 +1,9 @@
-- Tables affected: lot
-- recovery.not-started: check first; ADD COLUMN fails if lot_category already exists
-- recovery.partial: DROP INDEX IF EXISTS idx_lot_category ON lot; ALTER TABLE lot DROP COLUMN lot_category;
-- recovery.completed: no action needed
-- verify: lot_category column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='lot' AND column_name='lot_category' HAVING COUNT(*)=0
-- Migration: Add lot_category column to lot table -- Migration: Add lot_category column to lot table
-- Run this migration manually on the database -- Run this migration manually on the database

View File

@@ -1,2 +1,8 @@
-- Tables affected: qt_configurations
-- recovery.not-started: check first; ADD COLUMN fails if custom_price already exists
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN custom_price;
-- recovery.completed: no action needed
-- verify: custom_price column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='custom_price' HAVING COUNT(*)=0
-- Add custom_price column to qt_configurations table -- Add custom_price column to qt_configurations table
ALTER TABLE qt_configurations ADD COLUMN custom_price DECIMAL(12,2) NULL COMMENT 'User-defined custom total price'; ALTER TABLE qt_configurations ADD COLUMN custom_price DECIMAL(12,2) NULL COMMENT 'User-defined custom total price';

View File

@@ -1,2 +1,8 @@
-- Tables affected: qt_lot_metadata
-- recovery.not-started: check first; ADD COLUMN fails if is_hidden already exists
-- recovery.partial: ALTER TABLE qt_lot_metadata DROP COLUMN is_hidden;
-- recovery.completed: no action needed
-- verify: is_hidden column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_lot_metadata' AND column_name='is_hidden' HAVING COUNT(*)=0
-- Add is_hidden column to qt_lot_metadata table -- Add is_hidden column to qt_lot_metadata table
ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator'; ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator';

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_configurations
-- recovery.not-started: check first; ADD COLUMN fails if price_updated_at already exists
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN price_updated_at;
-- recovery.completed: no action needed
-- verify: price_updated_at column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='price_updated_at' HAVING COUNT(*)=0
-- Add price_updated_at column to qt_configurations table -- Add price_updated_at column to qt_configurations table
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_configurations
-- recovery.not-started: check first; ADD COLUMN fails if owner_username already exists
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN owner_username;
-- recovery.completed: no action needed
-- verify: owner_username column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='owner_username' HAVING COUNT(*)=0
-- Store configuration owner as username (instead of relying on numeric user_id) -- Store configuration owner as username (instead of relying on numeric user_id)
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id, ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id,

View File

@@ -1,3 +1,9 @@
-- Tables affected: local_configuration_versions (SQLite), local_configurations (SQLite)
-- recovery.not-started: safe to re-run only if table does not exist; fails if table or column already present
-- recovery.partial: roll back: DROP TABLE IF EXISTS local_configuration_versions; run SQLite migration recovery
-- recovery.completed: no action needed
-- verify: local_configuration_versions table missing | SELECT 1 FROM sqlite_master WHERE type='table' AND name='local_configuration_versions' HAVING COUNT(*)=0
-- Add full-snapshot versioning for local configurations (SQLite) -- Add full-snapshot versioning for local configurations (SQLite)
-- 1) Create local_configuration_versions -- 1) Create local_configuration_versions
-- 2) Add current_version_id to local_configurations -- 2) Add current_version_id to local_configurations

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; uses INFORMATION_SCHEMA check before DROP FOREIGN KEY
-- recovery.partial: no rollback needed; FK was dropped intentionally
-- recovery.completed: no action needed
-- verify: user_id column is still NOT NULL | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='user_id' AND is_nullable='NO' HAVING COUNT(*)>0
-- Detach qt_configurations from qt_users (ownership is owner_username text) -- Detach qt_configurations from qt_users (ownership is owner_username text)
-- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks. -- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks.

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_configurations
-- recovery.not-started: check first; ADD COLUMN fails if app_version already exists
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN app_version;
-- recovery.completed: no action needed
-- verify: app_version column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='app_version' HAVING COUNT(*)=0
-- Track application version used for configuration writes (create/update via sync) -- Track application version used for configuration writes (create/update via sync)
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN app_version VARCHAR(64) NULL DEFAULT NULL AFTER owner_username; ADD COLUMN app_version VARCHAR(64) NULL DEFAULT NULL AFTER owner_username;

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_projects, qt_configurations
-- recovery.not-started: check first; CREATE TABLE and ADD COLUMN fail if already exist
-- recovery.partial: ALTER TABLE qt_configurations DROP FOREIGN KEY fk_qt_configurations_project_uuid; ALTER TABLE qt_configurations DROP COLUMN project_uuid; DROP TABLE IF EXISTS qt_projects;
-- recovery.completed: no action needed
-- verify: qt_projects table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='qt_projects' HAVING COUNT(*)=0
-- Add projects and attach configurations to projects -- Add projects and attach configurations to projects
CREATE TABLE qt_projects ( CREATE TABLE qt_projects (

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_pricelist_sync_status
-- recovery.not-started: safe to re-run; CREATE TABLE IF NOT EXISTS
-- recovery.partial: DROP TABLE IF EXISTS qt_pricelist_sync_status;
-- recovery.completed: no action needed
-- verify: qt_pricelist_sync_status table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='qt_pricelist_sync_status' HAVING COUNT(*)=0
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status ( CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
username VARCHAR(100) NOT NULL, username VARCHAR(100) NOT NULL,
last_sync_at DATETIME NOT NULL, last_sync_at DATETIME NOT NULL,

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_configurations
-- recovery.not-started: check first; ADD COLUMN fails if pricelist_id already exists
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN pricelist_id;
-- recovery.completed: no action needed
-- verify: pricelist_id column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='pricelist_id' HAVING COUNT(*)=0
-- Add pricelist binding to configurations -- Add pricelist binding to configurations
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN pricelist_id BIGINT UNSIGNED NULL AFTER server_count; ADD COLUMN pricelist_id BIGINT UNSIGNED NULL AFTER server_count;

View File

@@ -1,2 +1,8 @@
-- Tables affected: qt_pricelist_sync_status
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_pricelist_sync_status DROP COLUMN app_version;
-- recovery.completed: no action needed
-- verify: app_version column in qt_pricelist_sync_status missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_pricelist_sync_status' AND column_name='app_version' HAVING COUNT(*)=0
ALTER TABLE qt_pricelist_sync_status ALTER TABLE qt_pricelist_sync_status
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL; ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL;

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_projects
-- recovery.not-started: check first; ADD COLUMN fails if tracker_url already exists
-- recovery.partial: ALTER TABLE qt_projects DROP COLUMN tracker_url;
-- recovery.completed: no action needed
-- verify: tracker_url column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='tracker_url' HAVING COUNT(*)=0
ALTER TABLE qt_projects ALTER TABLE qt_projects
ADD COLUMN tracker_url VARCHAR(500) NULL AFTER name; ADD COLUMN tracker_url VARCHAR(500) NULL AFTER name;

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_pricelists
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_pricelists DROP COLUMN source;
-- recovery.completed: no action needed
-- verify: source column in qt_pricelists missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_pricelists' AND column_name='source' HAVING COUNT(*)=0
ALTER TABLE qt_pricelists ALTER TABLE qt_pricelists
ADD COLUMN IF NOT EXISTS source ENUM('estimate', 'warehouse', 'competitor') NOT NULL DEFAULT 'estimate' AFTER id; ADD COLUMN IF NOT EXISTS source ENUM('estimate', 'warehouse', 'competitor') NOT NULL DEFAULT 'estimate' AFTER id;

View File

@@ -1,3 +1,9 @@
-- Tables affected: stock_log
-- recovery.not-started: safe to re-run; CREATE TABLE IF NOT EXISTS
-- recovery.partial: DROP TABLE IF EXISTS stock_log;
-- recovery.completed: no action needed
-- verify: stock_log table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='stock_log' HAVING COUNT(*)=0
CREATE TABLE IF NOT EXISTS stock_log ( CREATE TABLE IF NOT EXISTS stock_log (
stock_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, stock_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
lot VARCHAR(255) NOT NULL, lot VARCHAR(255) NOT NULL,

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN warehouse_pricelist_id, DROP COLUMN competitor_pricelist_id;
-- recovery.completed: no action needed
-- verify: warehouse_pricelist_id column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='warehouse_pricelist_id' HAVING COUNT(*)=0
-- Add per-source pricelist bindings for configurations -- Add per-source pricelist bindings for configurations
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS warehouse_pricelist_id BIGINT UNSIGNED NULL AFTER pricelist_id, ADD COLUMN IF NOT EXISTS warehouse_pricelist_id BIGINT UNSIGNED NULL AFTER pricelist_id,

View File

@@ -1,3 +1,9 @@
-- Tables affected: stock_ignore_rules
-- recovery.not-started: safe to re-run; CREATE TABLE IF NOT EXISTS
-- recovery.partial: DROP TABLE IF EXISTS stock_ignore_rules;
-- recovery.completed: no action needed
-- verify: stock_ignore_rules table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='stock_ignore_rules' HAVING COUNT(*)=0
CREATE TABLE IF NOT EXISTS stock_ignore_rules ( CREATE TABLE IF NOT EXISTS stock_ignore_rules (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
target VARCHAR(20) NOT NULL, target VARCHAR(20) NOT NULL,

View File

@@ -1,2 +1,8 @@
-- Tables affected: stock_log
-- recovery.not-started: check first; CHANGE COLUMN fails if partnumber already exists
-- recovery.partial: ALTER TABLE stock_log CHANGE COLUMN partnumber lot VARCHAR(255) NOT NULL;
-- recovery.completed: no action needed
-- verify: partnumber column in stock_log missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='stock_log' AND column_name='partnumber' HAVING COUNT(*)=0
ALTER TABLE stock_log ALTER TABLE stock_log
CHANGE COLUMN lot partnumber VARCHAR(255) NOT NULL; CHANGE COLUMN lot partnumber VARCHAR(255) NOT NULL;

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN only_in_stock;
-- recovery.completed: no action needed
-- verify: only_in_stock column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='only_in_stock' HAVING COUNT(*)=0
-- Add only_in_stock toggle to configuration settings persisted in MariaDB. -- Add only_in_stock toggle to configuration settings persisted in MariaDB.
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS only_in_stock BOOLEAN NOT NULL DEFAULT FALSE AFTER disable_price_refresh; ADD COLUMN IF NOT EXISTS only_in_stock BOOLEAN NOT NULL DEFAULT FALSE AFTER disable_price_refresh;

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_pricelist_items
-- recovery.not-started: safe to re-run; uses INFORMATION_SCHEMA check before adding index
-- recovery.partial: DROP INDEX IF EXISTS idx_qt_pricelist_items_pricelist_lot ON qt_pricelist_items;
-- recovery.completed: no action needed
-- verify: composite index on qt_pricelist_items missing | SELECT 1 FROM information_schema.STATISTICS WHERE table_schema=DATABASE() AND table_name='qt_pricelist_items' AND index_name='idx_qt_pricelist_items_pricelist_lot' HAVING COUNT(*)=0
-- Ensure fast lookup for /api/quote/price-levels batched queries: -- Ensure fast lookup for /api/quote/price-levels batched queries:
-- SELECT ... FROM qt_pricelist_items WHERE pricelist_id = ? AND lot_name IN (...) -- SELECT ... FROM qt_pricelist_items WHERE pricelist_id = ? AND lot_name IN (...)
SET @has_idx := ( SET @has_idx := (

View File

@@ -1,2 +1,8 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN article;
-- recovery.completed: no action needed
-- verify: article column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='article' HAVING COUNT(*)=0
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count; ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count;

View File

@@ -1,2 +1,8 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN server_model;
-- recovery.completed: no action needed
-- verify: server_model column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='server_model' HAVING COUNT(*)=0
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count; ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count;

View File

@@ -1,2 +1,8 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN support_code;
-- recovery.completed: no action needed
-- verify: support_code column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='support_code' HAVING COUNT(*)=0
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model; ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model;

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_projects
-- recovery.not-started: check first; idempotent backfill but ADD COLUMN fails if code already exists
-- recovery.partial: ALTER TABLE qt_projects DROP INDEX idx_qt_projects_code; ALTER TABLE qt_projects DROP COLUMN code;
-- recovery.completed: no action needed
-- verify: code column in qt_projects missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='code' HAVING COUNT(*)=0
-- Add project code and enforce uniqueness -- Add project code and enforce uniqueness
ALTER TABLE qt_projects ALTER TABLE qt_projects

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_projects
-- recovery.not-started: check first; ADD COLUMN fails if variant already exists
-- recovery.partial: DROP INDEX IF EXISTS idx_qt_projects_code_variant ON qt_projects; ALTER TABLE qt_projects DROP COLUMN variant;
-- recovery.completed: no action needed
-- verify: variant column in qt_projects missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='variant' HAVING COUNT(*)=0
-- Add project variant and reset codes from project names -- Add project variant and reset codes from project names
ALTER TABLE qt_projects ALTER TABLE qt_projects

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_projects
-- recovery.not-started: safe to re-run; MODIFY COLUMN is idempotent
-- recovery.partial: ALTER TABLE qt_projects MODIFY COLUMN name VARCHAR(200) NOT NULL;
-- recovery.completed: no action needed
-- verify: name column in qt_projects is still NOT NULL | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='name' AND is_nullable='NO' HAVING COUNT(*)>0
-- Allow NULL project names -- Allow NULL project names
ALTER TABLE qt_projects ALTER TABLE qt_projects

View File

@@ -1,3 +1,9 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN line_no;
-- recovery.completed: no action needed
-- verify: line_no column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='line_no' HAVING COUNT(*)=0
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS line_no INT NULL AFTER only_in_stock; ADD COLUMN IF NOT EXISTS line_no INT NULL AFTER only_in_stock;

View File

@@ -1,2 +1,8 @@
-- Tables affected: qt_configurations
-- recovery.not-started: check first; ADD COLUMN fails if config_type already exists
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN config_type;
-- recovery.completed: no action needed
-- verify: config_type column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='config_type' HAVING COUNT(*)=0
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN config_type VARCHAR(20) NOT NULL DEFAULT 'server'; ADD COLUMN config_type VARCHAR(20) NOT NULL DEFAULT 'server';

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

@@ -53,45 +53,34 @@ mkdir -p "${RELEASE_DIR}"
# Create release notes template only when missing. # Create release notes template only when missing.
ensure_release_notes "${RELEASE_DIR}/RELEASE_NOTES.md" ensure_release_notes "${RELEASE_DIR}/RELEASE_NOTES.md"
# Build for all platforms # Build binaries
echo -e "${YELLOW}→ Building binaries...${NC}" 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 # Package binaries with checksums
echo "" echo ""
echo -e "${YELLOW}→ Creating release packages...${NC}" 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 # macOS Apple Silicon
if [ -f "bin/qfs-darwin-arm64" ]; then
cd bin cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64 tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
cd .. cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}" echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
fi
# Windows AMD64 # Windows AMD64
if [ -f "bin/qfs-windows-amd64.exe" ]; then
cd bin cd bin
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
cd .. cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}" echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
fi
# Generate checksums # Generate checksums
echo "" echo ""

View File

@@ -79,6 +79,24 @@
</div> </div>
</div> </div>
<!-- Price Diff Modal -->
<div id="price-diff-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col">
<div class="p-5 border-b border-gray-200 flex-shrink-0 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">Изменение цен</h3>
<button onclick="closePriceDiffModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="price-diff-modal-body" class="overflow-y-auto flex-1 p-5 space-y-5"></div>
<div class="p-4 border-t border-gray-200 flex-shrink-0 flex justify-end">
<button onclick="closePriceDiffModal()" class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 text-sm">Закрыть</button>
</div>
</div>
</div>
<!-- Sync Info Modal --> <!-- Sync Info Modal -->
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4"> <div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col"> <div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
@@ -517,6 +535,121 @@
// } // }
// } // }
// ==================== SHARED PRICE REFRESH UTILITIES ====================
async function fetchLatestEstimatePricelistId() {
try {
const resp = await fetch('/api/pricelists?active_only=true&source=estimate&per_page=1');
if (!resp.ok) return null;
const data = await resp.json();
const list = data.pricelists || data.items || data;
if (Array.isArray(list) && list.length > 0) return Number(list[0].id);
} catch(_) {}
return null;
}
function _fmtMoneyDiff(value) {
if (!Number.isFinite(Number(value))) return 'N/A';
return '$ ' + Math.round(Number(value)).toLocaleString('ru-RU');
}
function _fmtArrow(prev, next) {
const diff = next - prev;
if (Math.abs(diff) < 0.5) return '';
const pct = prev > 0 ? Math.round((diff / prev) * 100) : 0;
const sign = diff > 0 ? '+' : '';
const color = diff > 0 ? 'text-red-600' : 'text-green-600';
return ` <span class="${color} text-xs font-medium">(${sign}${pct}%)</span>`;
}
function _buildDiffRow(lot, qty, prev, next) {
const prevLine = prev * qty;
const nextLine = next * qty;
const delta = next - prev;
const arrowColor = delta > 0 ? 'text-red-600' : 'text-green-600';
return `<tr class="border-b border-gray-100 last:border-0">
<td class="py-1.5 pr-3 text-sm text-gray-700 font-mono">${lot}</td>
<td class="py-1.5 px-2 text-sm text-right text-gray-500">${qty}</td>
<td class="py-1.5 px-2 text-sm text-right whitespace-nowrap">
<span class="text-gray-400 line-through text-xs">${_fmtMoneyDiff(prev)}</span>
<span class="${arrowColor} font-medium ml-1">${_fmtMoneyDiff(next)}</span>
</td>
<td class="py-1.5 pl-2 text-sm text-right whitespace-nowrap">
<span class="text-gray-400 line-through text-xs">${_fmtMoneyDiff(prevLine)}</span>
<span class="${arrowColor} font-medium ml-1">${_fmtMoneyDiff(nextLine)}</span>
</td>
</tr>`;
}
function showPriceDiffModal(results) {
const body = document.getElementById('price-diff-modal-body');
if (!body) return;
const sections = results.filter(r => !r.skipped);
if (sections.length === 0) {
body.innerHTML = '<p class="text-gray-500 text-sm text-center py-4">Обновление цен отключено для всех конфигураций</p>';
document.getElementById('price-diff-modal').classList.remove('hidden');
return;
}
let html = '';
let anyChanges = false;
for (const r of sections) {
if (r.error) {
html += `<div class="text-sm text-red-600 bg-red-50 rounded px-3 py-2">${r.configName ? `<span class="font-medium">${r.configName}:</span> ` : ''}Ошибка обновления цен</div>`;
continue;
}
const diffs = (r.itemDiffs || []).filter(d => Math.abs(d.prevPrice - d.newPrice) > 0.01);
const totalDelta = (r.newTotal || 0) - (r.prevTotal || 0);
if (results.length > 1) {
html += `<div class="text-sm font-semibold text-gray-800 mb-1">${r.configName || '—'}</div>`;
}
if (diffs.length === 0) {
html += `<div class="text-sm text-gray-500 bg-gray-50 rounded px-3 py-2 mb-2">Изменений нет</div>`;
} else {
anyChanges = true;
html += `<div class="overflow-x-auto mb-2">
<table class="w-full text-left">
<thead>
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase">
<th class="pb-1 pr-3 font-medium">Компонент</th>
<th class="pb-1 px-2 text-right font-medium">Кол.</th>
<th class="pb-1 px-2 text-right font-medium">Цена / шт.</th>
<th class="pb-1 pl-2 text-right font-medium">Сумма</th>
</tr>
</thead>
<tbody>${diffs.map(d => _buildDiffRow(d.lot_name, d.quantity, d.prevPrice, d.newPrice)).join('')}</tbody>
</table>
</div>`;
}
const totalColor = totalDelta > 0 ? 'text-red-600' : totalDelta < 0 ? 'text-green-600' : 'text-gray-600';
const totalArrow = _fmtArrow(r.prevTotal || 0, r.newTotal || 0);
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}
</span>
</div>`;
}
if (!anyChanges && sections.every(r => !r.error)) {
html = '<div class="text-sm text-gray-500 text-center py-6">Цены актуальны — изменений нет</div>' + html;
}
body.innerHTML = html;
document.getElementById('price-diff-modal').classList.remove('hidden');
}
function closePriceDiffModal() {
document.getElementById('price-diff-modal')?.classList.add('hidden');
}
// Call functions immediately to ensure they run even before DOMContentLoaded // Call functions immediately to ensure they run even before DOMContentLoaded
// This ensures username and admin link are visible ASAP // This ensures username and admin link are visible ASAP
loadDBUser(); loadDBUser();

View File

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

View File

@@ -1,4 +1,4 @@
{{define "title"}}Мои конфигурации - OFS{{end}} {{define "title"}}QFS Мои конфигурации{{end}}
{{define "content"}} {{define "content"}}
<div class="space-y-4"> <div class="space-y-4">
@@ -55,12 +55,12 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label> <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"> <div id="config-type-buttons" class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
<button type="button" id="type-server-btn" onclick="setCreateType('server')" <button type="button" data-type="server" onclick="setCreateType('server')"
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white"> class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
Сервер Сервер
</button> </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"> class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
СХД СХД
</button> </button>
@@ -247,7 +247,7 @@ function renderConfigs(configs) {
configs.forEach(c => { configs.forEach(c => {
const date = new Date(c.created_at).toLocaleDateString('ru-RU'); const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—'; const total = c.total_price ? '$' + c.total_price.toLocaleString('ru-RU', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1; const serverCount = c.server_count ? c.server_count : 1;
const author = c.owner_username || (c.user && c.user.username) || '—'; const author = c.owner_username || (c.user && c.user.username) || '—';
const projectName = c.project_uuid && projectNameByUUID[c.project_uuid] const projectName = c.project_uuid && projectNameByUUID[c.project_uuid]
@@ -258,7 +258,7 @@ function renderConfigs(configs) {
let pricePerUnit = '—'; let pricePerUnit = '—';
if (c.total_price && serverCount > 0) { if (c.total_price && serverCount > 0) {
const unitPrice = c.total_price / serverCount; const unitPrice = c.total_price / serverCount;
pricePerUnit = '$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}); pricePerUnit = '$' + unitPrice.toLocaleString('ru-RU', {minimumFractionDigits: 2});
} }
html += '<tr class="hover:bg-gray-50">'; html += '<tr class="hover:bg-gray-50">';
@@ -532,18 +532,51 @@ async function cloneConfig() {
} }
let createConfigType = 'server'; 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) { function setCreateType(type) {
createConfigType = type; createConfigType = type;
document.getElementById('type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' + document.querySelectorAll('#config-type-buttons button').forEach(btn => {
(type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200'); const active = btn.dataset.type === type;
document.getElementById('type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' + btn.className = 'flex-1 py-2 text-sm font-medium ' +
(type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'); (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() { function openCreateModal() {
createConfigType = 'server'; createConfigType = 'server';
setCreateType('server'); setCreateType('server');
loadCfgSettings().then(s => renderConfigTypeButtons(s && s.config_types));
document.getElementById('opportunity-number').value = ''; document.getElementById('opportunity-number').value = '';
document.getElementById('create-project-input').value = ''; document.getElementById('create-project-input').value = '';
document.getElementById('create-modal').classList.remove('hidden'); document.getElementById('create-modal').classList.remove('hidden');

View File

@@ -1,4 +1,4 @@
{{define "title"}}OFS - Конфигуратор{{end}} {{define "title"}}QFS Конфигуратор{{end}}
{{define "content"}} {{define "content"}}
<div class="space-y-4"> <div class="space-y-4">
@@ -488,7 +488,7 @@ function updateConfigBreadcrumbs() {
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName); configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
configEl.title = fullConfigName; configEl.title = fullConfigName;
versionEl.textContent = 'main'; versionEl.textContent = 'main';
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — OFS'; document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — QFS';
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link'); const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
if (configNameLinkEl && configUUID) { if (configNameLinkEl && configUUID) {
configNameLinkEl.href = '/configs/' + configUUID + '/revisions'; configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
@@ -504,6 +504,9 @@ let currentTab = 'base';
let allComponents = []; let allComponents = [];
let cart = []; let cart = [];
let categoryOrderMap = {}; // Category code -> display_order mapping 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 autoSaveTimeout = null; // Timeout for debounced autosave
let hasUnsavedChanges = false; let hasUnsavedChanges = false;
let exitSaveStarted = false; let exitSaveStarted = false;
@@ -783,6 +786,95 @@ async function loadCategoriesFromAPI() {
} }
} }
async function loadCfgSettings() {
if (typeof _cfgSettings !== 'undefined' && _cfgSettings) return _cfgSettings;
try {
const r = await fetch('/api/configurator-settings');
if (r.ok) {
window._cfgSettings = await r.json();
return window._cfgSettings;
}
} catch(e) { /* fallback to hardcoded */ }
return null;
}
function applyServerSettings(settings) {
if (!settings) return;
// config_types → category allowlist map
if (Array.isArray(settings.config_types) && settings.config_types.length) {
configTypeCategoryMap = {};
settings.config_types.forEach(ct => {
if (ct.code && Array.isArray(ct.categories)) {
configTypeCategoryMap[ct.code] = new Set(ct.categories.map(c => c.toUpperCase()));
}
});
}
// tab_config → update TAB_CONFIG (preserve .other)
if (Array.isArray(settings.tab_config) && settings.tab_config.length) {
const otherTab = TAB_CONFIG.other;
TAB_CONFIG = {};
settings.tab_config.forEach(tab => {
TAB_CONFIG[tab.key] = {
categories: Array.isArray(tab.categories) ? tab.categories : [],
singleSelect: !!tab.single_select,
label: tab.label || tab.key,
sections: tab.sections || undefined
};
});
TAB_CONFIG.other = otherTab || { categories: [], singleSelect: false, label: 'Other' };
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG).flatMap(t => t.categories).map(c => c.toUpperCase());
}
// always_visible_tabs
if (Array.isArray(settings.always_visible_tabs) && settings.always_visible_tabs.length) {
alwaysVisibleTabsSet = new Set(settings.always_visible_tabs);
}
// required_categories
if (settings.required_categories && typeof settings.required_categories === 'object') {
requiredCategoriesMap = {};
Object.entries(settings.required_categories).forEach(([ct, codes]) => {
if (Array.isArray(codes)) {
requiredCategoriesMap[ct] = new Set(codes.map(c => c.toUpperCase()));
}
});
}
applyConfigTypeToTabs();
updateTabVisibility();
updateRequiredCategoryBadges();
}
function updateRequiredCategoryBadges() {
const required = requiredCategoriesMap[configType];
if (!required || !required.size) return;
// Build set of categories that have at least one cart item
const filledCategories = new Set(
cart.map(item => (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase())
);
// For each tab, check if it contains any required-but-unfilled category
Object.entries(TAB_CONFIG).forEach(([tabKey, tabCfg]) => {
const btn = document.querySelector(`[data-tab="${tabKey}"]`);
if (!btn) return;
const tabCategories = (tabCfg.categories || []).map(c => c.toUpperCase());
const hasUnfilled = tabCategories.some(cat => required.has(cat) && !filledCategories.has(cat));
const badge = btn.querySelector('.required-badge');
if (hasUnfilled) {
if (!badge) {
const dot = document.createElement('span');
dot.className = 'required-badge inline-block w-1.5 h-1.5 bg-orange-400 rounded-full ml-1 align-middle';
btn.appendChild(dot);
}
} else if (badge) {
badge.remove();
}
});
}
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('DOMContentLoaded', async function() {
// RBAC disabled - no token check required // RBAC disabled - no token check required
@@ -791,8 +883,9 @@ document.addEventListener('DOMContentLoaded', async function() {
return; return;
} }
// Load categories in background (defaults are usable immediately). // Load categories and configurator settings in background (defaults are usable immediately).
const categoriesPromise = loadCategoriesFromAPI().catch(() => {}); const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
loadCfgSettings().then(s => applyServerSettings(s)).catch(() => {});
try { try {
const resp = await fetch('/api/configs/' + configUUID); const resp = await fetch('/api/configs/' + configUUID);
@@ -879,6 +972,12 @@ document.addEventListener('DOMContentLoaded', async function() {
} }
}); });
// Save pricing state (ручная цена) on page exit so it survives navigation
window.addEventListener('pagehide', saveConfigOnExit);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') saveConfigOnExit();
});
// Load vendor spec BOM for this configuration // Load vendor spec BOM for this configuration
if (configUUID) { if (configUUID) {
loadVendorSpec(configUUID); loadVendorSpec(configUUID);
@@ -889,7 +988,7 @@ async function loadAllComponents() {
try { try {
const resp = await fetch('/api/components?per_page=5000'); const resp = await fetch('/api/components?per_page=5000');
const data = await resp.json(); const data = await resp.json();
allComponents = data.components || []; allComponents = data.items || [];
window._bomAllComponents = allComponents; window._bomAllComponents = allComponents;
} catch(e) { } catch(e) {
console.error('Failed to load components', e); console.error('Failed to load components', e);
@@ -940,7 +1039,7 @@ async function loadActivePricelists(force = false) {
try { try {
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`); const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
const data = await resp.json(); const data = await resp.json();
activePricelistsBySource[source] = data.pricelists || []; activePricelistsBySource[source] = data.items || [];
// Do not reset the stored pricelist — it may be inactive but must be preserved // Do not reset the stored pricelist — it may be inactive but must be preserved
} catch (e) { } catch (e) {
activePricelistsBySource[source] = []; activePricelistsBySource[source] = [];
@@ -1154,60 +1253,60 @@ function switchTab(tab) {
renderTab(); renderTab();
} }
// Hardcoded fallback constants — used only when server has not provided config_types data
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']); const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
// Storage-only categories — hidden for server configs
const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC']; 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_STORAGE_CATEGORIES = ['RAID'];
const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU']; const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU'];
const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU']; const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU'];
function applyConfigTypeToTabs() { function isCategoryVisibleForConfigType(code, cfgType) {
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC']; const allowed = configTypeCategoryMap[cfgType];
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL']; if (!allowed || allowed.size === 0) return _hardcodedCategoryVisible(code, cfgType);
const storageSections = [ return allowed.has(code.toUpperCase());
{ 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'];
TAB_CONFIG.base.categories = baseCategories.filter(c => {
if (configType === 'storage') {
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
} }
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
});
TAB_CONFIG.storage.categories = storageCategories.filter(c => { function _hardcodedCategoryVisible(code, cfgType) {
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true; if (cfgType === 'storage') {
}); if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return true;
TAB_CONFIG.storage.sections = storageSections.filter(section => { if (SERVER_ONLY_BASE_CATEGORIES.includes(code)) return false;
if (configType === 'storage') { if (STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(code)) return false;
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat)); 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; 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';
}); function _effectiveAlwaysVisibleTabs() {
TAB_CONFIG.power.categories = powerCategories.filter(c => { return alwaysVisibleTabsSet || ALWAYS_VISIBLE_TABS;
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true; }
function applyConfigTypeToTabs() {
// 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;
// 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);
}
}); });
// Rebuild assigned categories index // Rebuild assigned categories index
@@ -1217,8 +1316,9 @@ function applyConfigTypeToTabs() {
} }
function updateTabVisibility() { function updateTabVisibility() {
const visibleTabs = _effectiveAlwaysVisibleTabs();
for (const tabId of Object.keys(TAB_CONFIG)) { 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}"]`); const btn = document.querySelector(`[data-tab="${tabId}"]`);
if (!btn) continue; if (!btn) continue;
const hasComponents = getComponentsForTab(tabId).length > 0; const hasComponents = getComponentsForTab(tabId).length > 0;
@@ -1656,6 +1756,10 @@ function renderAutocomplete() {
// Build autocomplete items based on mode // Build autocomplete items based on mode
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => { 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; let onmousedown;
if (autocompleteMode === 'section') { if (autocompleteMode === 'section') {
@@ -2039,14 +2143,24 @@ function showAutocompleteBOM(rowIdx, input) {
function filterAutocompleteBOM(rowIdx, search) { function filterAutocompleteBOM(rowIdx, search) {
const searchLower = (search || '').toLowerCase(); const searchLower = (search || '').toLowerCase();
autocompleteFiltered = (window._bomAllComponents || allComponents).filter(c => { const cartLots = new Set(cart.map(i => i.lot_name));
const all = (window._bomAllComponents || allComponents).filter(c => {
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase(); const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower); return text.includes(searchLower);
}).sort((a, b) => { });
const inCart = all.filter(c => cartLots.has(c.lot_name))
.sort((a, b) => a.lot_name.localeCompare(b.lot_name));
const notInCart = all.filter(c => !cartLots.has(c.lot_name))
.sort((a, b) => {
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0); const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff; if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name); return a.lot_name.localeCompare(b.lot_name);
}); });
if (inCart.length && notInCart.length) {
autocompleteFiltered = [...inCart, {isDivider: true}, ...notInCart];
} else {
autocompleteFiltered = [...inCart, ...notInCart];
}
renderAutocomplete(); renderAutocomplete();
} }
@@ -2071,7 +2185,7 @@ function handleAutocompleteKeyBOM(event, rowIdx) {
function selectAutocompleteItemBOM(index, rowIdx) { function selectAutocompleteItemBOM(index, rowIdx) {
const comp = autocompleteFiltered[index]; const comp = autocompleteFiltered[index];
if (!comp) return; if (!comp || comp.isDivider) return;
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx]; const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
if (!row) return; if (!row) return;
row.manual_lot = comp.lot_name; row.manual_lot = comp.lot_name;
@@ -2131,6 +2245,7 @@ function removeFromCart(lotName) {
function updateCartUI() { function updateCartUI() {
updateTabVisibility(); updateTabVisibility();
updateRequiredCategoryBadges();
window._currentCart = cart; // expose for BOM/Pricing tabs window._currentCart = cart; // expose for BOM/Pricing tabs
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0); const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
document.getElementById('cart-total').textContent = formatMoney(total); document.getElementById('cart-total').textContent = formatMoney(total);
@@ -2426,6 +2541,9 @@ function restoreAutosaveDraftIfAny() {
customPriceInput.value = ''; customPriceInput.value = '';
} }
} }
if (payload.notes) {
restorePricingStateFromNotes(payload.notes);
}
hasUnsavedChanges = true; hasUnsavedChanges = true;
} catch (_) { } catch (_) {
// ignore invalid draft // ignore invalid draft
@@ -2584,7 +2702,7 @@ function formatDiffPercent(baseTotal, compareTotal, compareLabel) {
} }
const pct = ((baseTotal - compareTotal) / compareTotal) * 100; const pct = ((baseTotal - compareTotal) / compareTotal) * 100;
const sign = pct > 0 ? '+' : ''; const sign = pct > 0 ? '+' : '';
return `${sign}${pct.toFixed(1)}% от ${compareLabel}`; return `${sign}${pct.toFixed(1).replace('.', ',')}% от ${compareLabel}`;
} }
function getTotalClass(current, references) { function getTotalClass(current, references) {
@@ -2709,7 +2827,7 @@ function calculateCustomPrice() {
// Show discount info // Show discount info
discountInfoEl.classList.remove('hidden'); discountInfoEl.classList.remove('hidden');
discountPercentEl.textContent = discountPercent.toFixed(1) + '%'; discountPercentEl.textContent = discountPercent.toFixed(1).replace('.', ',') + '%';
// Update discount color based on value // Update discount color based on value
const discountEl = discountPercentEl; const discountEl = discountPercentEl;
@@ -2817,7 +2935,6 @@ async function exportCSVWithCustomPrice() {
} }
async function refreshPrices() { async function refreshPrices() {
// RBAC disabled - no token check required
if (!configUUID) return; if (!configUUID) return;
if (disablePriceRefresh) { if (disablePriceRefresh) {
showToast('Обновление цен отключено в настройках', 'error'); showToast('Обновление цен отключено в настройках', 'error');
@@ -2834,30 +2951,7 @@ async function refreshPrices() {
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed'; refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
} }
let serverSyncSkipped = false; await loadActivePricelists(true);
try {
const statusResp = await fetch('/api/sync/status');
const statusData = statusResp.ok ? await statusResp.json() : null;
if (statusData && statusData.is_online) {
const componentSyncResp = await fetch('/api/sync/components', { method: 'POST' });
if (!componentSyncResp.ok) throw new Error('component sync failed');
const pricelistSyncResp = await fetch('/api/sync/pricelists', { method: 'POST' });
if (!pricelistSyncResp.ok) throw new Error('pricelist sync failed');
} else {
serverSyncSkipped = true;
}
} catch(syncErr) {
if (syncErr.message === 'component sync failed' || syncErr.message === 'pricelist sync failed') {
throw syncErr;
}
serverSyncSkipped = true;
}
await Promise.all([
loadActivePricelists(true),
loadAllComponents()
]);
['estimate', 'warehouse', 'competitor'].forEach(source => { ['estimate', 'warehouse', 'competitor'].forEach(source => {
const latest = activePricelistsBySource[source]?.[0]; const latest = activePricelistsBySource[source]?.[0];
@@ -2871,22 +2965,44 @@ async function refreshPrices() {
renderPricelistSettingsSummary(); renderPricelistSettingsSummary();
persistLocalPriceSettings(); persistLocalPriceSettings();
// Snapshot prices before refresh for diff
const beforePricesMap = {};
let beforeTotal = 0;
for (const item of cart) {
const p = getDisplayPrice(item);
beforePricesMap[item.lot_name] = { price: p, qty: item.quantity };
beforeTotal += p * item.quantity;
}
beforeTotal *= serverCount;
await saveConfig(false); await saveConfig(false);
await refreshPriceLevels({ force: true, noCache: true }); await refreshPriceLevels({ force: true, noCache: true });
renderTab(); renderTab();
updateCartUI(); updateCartUI();
// Compute diff after refresh
const itemDiffs = [];
let afterTotal = 0;
for (const item of cart) {
const newPrice = getDisplayPrice(item);
afterTotal += newPrice * item.quantity;
const before = beforePricesMap[item.lot_name];
if (before && Math.abs(before.price - newPrice) > 0.01) {
itemDiffs.push({ lot_name: item.lot_name, quantity: item.quantity, prevPrice: before.price, newPrice });
}
}
afterTotal *= serverCount;
if (configUUID) { if (configUUID) {
const configResp = await fetch('/api/configs/' + configUUID); const configResp = await fetch('/api/configs/' + configUUID);
if (configResp.ok) { if (configResp.ok) {
const config = await configResp.json(); const config = await configResp.json();
if (config.price_updated_at) { if (config.price_updated_at) updatePriceUpdateDate(config.price_updated_at);
updatePriceUpdateDate(config.price_updated_at);
}
} }
} }
showToast(serverSyncSkipped ? 'Цены обновлены (без связи с сервером)' : 'Цены обновлены', 'success'); showToast('Цены обновлены', 'success');
showPriceDiffModal([{ configName: configName || 'Конфигурация', prevTotal: beforeTotal, newTotal: afterTotal, serverCount, itemDiffs }]);
} catch(e) { } catch(e) {
showToast('Ошибка обновления цен', 'error'); showToast('Ошибка обновления цен', 'error');
} finally { } finally {
@@ -3056,47 +3172,8 @@ function _normalizeBomRawRows(rows) {
}); });
} }
function _parseInspurBOMText(text) { function _applyParsedBOMRows(parsed) {
const lines = text.split(/\r?\n/); if (!Array.isArray(parsed) || !parsed.length) return false;
const result = [];
for (const raw of lines) {
const line = raw.trim();
if (!line) continue;
const clean = line.startsWith('|') ? line.slice(1).trim() : line;
if (!clean) continue;
const starIdx = clean.lastIndexOf('*');
if (starIdx > 0) {
const suffix = clean.slice(starIdx + 1).trim();
if (/^\d+$/.test(suffix)) {
result.push([clean.slice(0, starIdx).trim(), suffix]);
continue;
}
}
result.push([clean, '1']);
}
return result;
}
function _isInspurBOMText(text) {
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
if (!lines.length) return false;
let matches = 0;
for (const line of lines) {
const t = line.trim();
const idx = t.lastIndexOf('*');
if (idx > 0 && /^\d+$/.test(t.slice(idx + 1).trim())) matches++;
}
return matches > 0 && matches >= Math.ceil(lines.length * 0.5);
}
function handleBOMPaste(event) {
event.preventDefault();
const text = event.clipboardData.getData('text/plain');
if (!text || !text.trim()) return;
if (_isInspurBOMText(text)) {
const parsed = _parseInspurBOMText(text);
if (!parsed.length) return;
bomImportRaw = { bomImportRaw = {
mode: 'raw', mode: 'raw',
rows: parsed, rows: parsed,
@@ -3109,9 +3186,30 @@ function handleBOMPaste(event) {
_setBomUIError(''); _setBomUIError('');
rebuildBOMRowsFromRaw(); rebuildBOMRowsFromRaw();
renderBOMTable(); renderBOMTable();
return; return true;
} }
// Detection and parsing of known single-column text BOM formats (Inspur, Russian
// text BOM) lives only on the server (internal/services/vendor_workspace_import.go),
// shared with the vendor file-import path. The paste handler asks the server to
// parse; an unrecognized payload falls back to the generic Excel column grid below.
async function _serverParseBOMText(text) {
try {
const resp = await fetch('/api/vendor-spec/parse-text', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text})
});
if (!resp.ok) return null;
const data = await resp.json();
if (!Array.isArray(data.rows) || !data.rows.length) return null;
return data.rows.map(r => [r.vendor_partnumber || '', String(r.quantity || '')]);
} catch (e) {
return null;
}
}
function _applyGenericBOMPaste(text) {
const lines = text.split(/\r?\n/).filter(l => l.length > 0); const lines = text.split(/\r?\n/).filter(l => l.length > 0);
if (!lines.length) return; if (!lines.length) return;
const rows = lines.map(l => l.split('\t').map(c => c.trim())); const rows = lines.map(l => l.split('\t').map(c => c.trim()));
@@ -3131,6 +3229,20 @@ function handleBOMPaste(event) {
renderBOMTable(); renderBOMTable();
} }
async function handleBOMPaste(event) {
event.preventDefault();
const text = event.clipboardData.getData('text/plain');
if (!text || !text.trim()) return;
// Tabs mean a real spreadsheet table — go straight to the column grid.
if (!text.includes('\t')) {
const parsed = await _serverParseBOMText(text);
if (_applyParsedBOMRows(parsed)) return;
}
_applyGenericBOMPaste(text);
}
function _getBomColumnTypeIndexes() { function _getBomColumnTypeIndexes() {
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null; if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null;
const idx = { ignore: [], pn: [], qty: [], price: [], description: [] }; const idx = { ignore: [], pn: [], qty: [], price: [], description: [] };
@@ -3215,7 +3327,7 @@ function _bomRawLotCell(rowIdx) {
cart.forEach(item => { cartMap[item.lot_name] = item.quantity; }); cart.forEach(item => { cartMap[item.lot_name] = item.quantity; });
const isUnresolved = !map.resolved_lot || map.resolution_source === 'unresolved'; const isUnresolved = !map.resolved_lot || map.resolution_source === 'unresolved';
const cartQty = map.resolved_lot ? (cartMap[map.resolved_lot] ?? null) : null; 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; const notInCart = map.resolved_lot && cartQty === null;
if (isUnresolved) { if (isUnresolved) {
@@ -3601,7 +3713,7 @@ function _renderBOMParsedTable() {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved'; const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
const cartQty = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null; 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; const notInCart = row.resolved_lot && cartQty === null;
if (isUnresolved) unresolved++; if (isUnresolved) unresolved++;
if (qtyMismatch || notInCart) mismatches++; if (qtyMismatch || notInCart) mismatches++;
@@ -3668,7 +3780,7 @@ function _renderBOMRawTable() {
else if (parsed) { else if (parsed) {
const isUnresolved = !parsed.resolved_lot || parsed.resolution_source === 'unresolved'; const isUnresolved = !parsed.resolved_lot || parsed.resolution_source === 'unresolved';
const cartQty = parsed.resolved_lot ? (cartMap[parsed.resolved_lot] ?? null) : null; 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; const notInCart = parsed.resolved_lot && cartQty === null;
if (isUnresolved) unresolved++; if (isUnresolved) unresolved++;
if (qtyMismatch || notInCart) mismatches++; if (qtyMismatch || notInCart) mismatches++;
@@ -3936,6 +4048,9 @@ async function renderPricingTab() {
}; };
// Collect LOTs to price: from BOM rows (resolved) or from cart // 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 = []; let itemsForPriceLevels = [];
if (bomRows.length) { if (bomRows.length) {
const seen = new Set(); const seen = new Set();
@@ -3944,13 +4059,13 @@ async function renderPricingTab() {
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1); const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
if (baseLot && !seen.has(baseLot)) { if (baseLot && !seen.has(baseLot)) {
seen.add(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) { if (allocs.length) {
allocs.forEach(a => { allocs.forEach(a => {
if (!seen.has(a.lot_name)) { if (!seen.has(a.lot_name)) {
seen.add(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) });
} }
}); });
} }
@@ -4003,6 +4118,8 @@ async function renderPricingTab() {
// ─── Build shared row data (unit prices for display, totals for math) ──── // ─── 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. // 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 _buildRows = () => {
const result = []; const result = [];
const coveredLots = new Set(); const coveredLots = new Set();
@@ -4026,7 +4143,12 @@ async function renderPricingTab() {
}; };
if (!bomRows.length) { if (!bomRows.length) {
cart.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); }); const sortedByCategory = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999);
});
sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
return { result, coveredLots }; return { result, coveredLots };
} }
@@ -4047,7 +4169,7 @@ async function renderPricingTab() {
if (baseLot) { if (baseLot) {
const u = _getUnitPrices(priceMap[baseLot]); const u = _getUnitPrices(priceMap[baseLot]);
const lotQty = _getRowLotQtyPerPN(row); const lotQty = _getRowLotQtyPerPN(row);
const qty = row.quantity * lotQty; const qty = cartQtyMap[baseLot] ?? (row.quantity * lotQty);
subRows.push({ subRows.push({
lotCell: escapeHtml(baseLot), lotText: baseLot, qty, lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0, estUnit: u.estUnit > 0 ? u.estUnit : 0,
@@ -4059,7 +4181,7 @@ async function renderPricingTab() {
} }
allocs.forEach(a => { allocs.forEach(a => {
const u = _getUnitPrices(priceMap[a.lot_name]); 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({ subRows.push({
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty, lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0, estUnit: u.estUnit > 0 ? u.estUnit : 0,
@@ -4274,7 +4396,7 @@ function applyCustomPrice(table) {
if (est <= 0) return ''; if (est <= 0) return '';
const pct = ((est - custom) / est * 100); const pct = ((est - custom) / est * 100);
const sign = pct >= 0 ? '-' : '+'; const sign = pct >= 0 ? '-' : '+';
return ` (${sign}${Math.abs(pct).toFixed(1)}%)`; return ` (${sign}${Math.abs(pct).toFixed(1).replace('.', ',')}%)`;
}; };
const _pctClass = (custom, est) => custom <= est ? 'text-green-600' : 'text-red-600'; const _pctClass = (custom, est) => custom <= est ? 'text-green-600' : 'text-red-600';
@@ -4367,7 +4489,7 @@ function setPricingCustomPriceFromVendor() {
const totalEl = document.getElementById('pricing-total-buy-vendor'); const totalEl = document.getElementById('pricing-total-buy-vendor');
if (hasAny) { if (hasAny) {
document.getElementById('pricing-custom-price-buy').value = total.toFixed(2); document.getElementById('pricing-custom-price-buy').value = total.toFixed(2);
const pct = estimateTotal > 0 ? ` (-${((estimateTotal - total) / estimateTotal * 100).toFixed(1)}%)` : ''; const pct = estimateTotal > 0 ? ` (-${((estimateTotal - total) / estimateTotal * 100).toFixed(1).replace('.', ',')}%)` : '';
totalEl.textContent = formatCurrency(total) + pct; totalEl.textContent = formatCurrency(total) + pct;
totalEl.className = totalEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim(); totalEl.className = totalEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
totalEl.classList.add(total <= estimateTotal ? 'text-green-600' : 'text-red-600'); totalEl.classList.add(total <= estimateTotal ? 'text-green-600' : 'text-red-600');
@@ -4380,6 +4502,8 @@ function setPricingCustomPriceFromVendor() {
async function exportPricingCSV(table) { async function exportPricingCSV(table) {
if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; } if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; }
const basis = table === 'sale' ? 'ddp' : 'fob'; 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 { try {
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, { const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
method: 'POST', method: 'POST',
@@ -4391,6 +4515,7 @@ async function exportPricingCSV(table) {
include_stock: true, include_stock: true,
include_competitor: true, include_competitor: true,
basis: basis, basis: basis,
manual_price: manualPrice > 0 ? manualPrice : null,
}), }),
}); });
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; } if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }

View File

@@ -1,4 +1,4 @@
{{define "title"}}OFS - Партномера{{end}} {{define "title"}}QFS Партномера{{end}}
{{define "content"}} {{define "content"}}
<div class="space-y-4"> <div class="space-y-4">
@@ -22,20 +22,17 @@
</div> </div>
<div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800"> <div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера. Нет активного листа сопоставлений. Книги загружаются автоматически вместе с прайслистами.
</div> </div>
<!-- All books list (collapsed by default) --> <!-- All books list (collapsed by default) -->
<div class="bg-white rounded-lg shadow overflow-hidden"> <div class="bg-white rounded-lg shadow overflow-hidden">
<!-- Header row — always visible --> <!-- 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"> <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> <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) Снимки сопоставлений (Partnumber Books)
</button> </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> </div>
<!-- Collapsible body --> <!-- Collapsible body -->
<div id="books-section-body" class="hidden border-t"> <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 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"> <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> <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" 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)"> oninput="onItemsSearchInput(this.value)">
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span> <span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
@@ -127,7 +124,7 @@ async function loadBooks() {
return; return;
} }
allBooks = data.books || []; allBooks = data.items || [];
document.getElementById('books-list-loading').classList.add('hidden'); document.getElementById('books-list-loading').classList.add('hidden');
if (!allBooks.length) { if (!allBooks.length) {
@@ -213,7 +210,7 @@ async function loadActiveBookItemsPage(page = 1, search = '', book = null) {
activeItems = data.items || []; activeItems = data.items || [];
itemsPage = data.page || page; itemsPage = data.page || page;
itemsTotal = Number(data.total || 0); itemsTotal = Number(data.total_count || 0);
itemsSearch = data.search || search || ''; itemsSearch = data.search || search || '';
document.getElementById('card-version').textContent = targetBook.version; document.getElementById('card-version').textContent = targetBook.version;

View File

@@ -1,4 +1,4 @@
{{define "title"}}Прайслист - OFS{{end}} {{define "title"}}QFS Прайслист{{end}}
{{define "content"}} {{define "content"}}
<div class="space-y-6"> <div class="space-y-6">
@@ -137,7 +137,7 @@
toggleWarehouseColumns(); toggleWarehouseColumns();
renderItems(data.items || []); renderItems(data.items || []);
renderItemsPagination(data.total, data.page, data.per_page); renderItemsPagination(data.total_count, data.page, data.per_page);
} catch (e) { } catch (e) {
document.getElementById('items-body').innerHTML = ` document.getElementById('items-body').innerHTML = `
<tr> <tr>
@@ -243,7 +243,7 @@
const descMax = stock ? 30 : 60; const descMax = stock ? 30 : 60;
const html = items.map(item => { const html = items.map(item => {
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const price = item.price.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const description = item.lot_description || '-'; const description = item.lot_description || '-';
const truncatedDesc = description.length > descMax ? description.substring(0, descMax) + '...' : description; const truncatedDesc = description.length > descMax ? description.substring(0, descMax) + '...' : description;

View File

@@ -1,4 +1,4 @@
{{define "title"}}Прайслисты - OFS{{end}} {{define "title"}}QFS Прайслисты{{end}}
{{define "content"}} {{define "content"}}
<div class="space-y-6"> <div class="space-y-6">
@@ -83,8 +83,8 @@
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`); const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
const data = await resp.json(); const data = await resp.json();
renderPricelists(data.pricelists || []); renderPricelists(data.items || []);
renderPagination(data.total, data.page, data.per_page); renderPagination(data.total_count, data.page, data.per_page);
} catch (e) { } catch (e) {
document.getElementById('pricelists-body').innerHTML = ` document.getElementById('pricelists-body').innerHTML = `
<tr> <tr>

View File

@@ -1,4 +1,4 @@
{{define "title"}}Проект - OFS{{end}} {{define "title"}}QFS Проект{{end}}
{{define "content"}} {{define "content"}}
<div class="space-y-4"> <div class="space-y-4">
@@ -40,7 +40,7 @@
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"> <button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Конфигурация + Конфигурация
</button> </button>
<button id="refresh-all-prices-btn" onclick="refreshAllPrices()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"> <button id="refresh-all-prices-btn" onclick="refreshPrices()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
Обновить цены Обновить цены
</button> </button>
<button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium"> <button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium">
@@ -229,7 +229,7 @@
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div> </div>
<label class="flex items-center gap-2 text-sm text-gray-700"> <label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" id="variant-action-copy" class="rounded border-gray-300" checked> <input type="checkbox" id="variant-action-copy" class="rounded border-gray-300">
Создать копию Создать копию
</label> </label>
<div> <div>
@@ -257,7 +257,7 @@
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div> </div>
<label class="flex items-center gap-2 text-sm text-gray-700"> <label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" id="config-action-copy" class="rounded border-gray-300" checked> <input type="checkbox" id="config-action-copy" class="rounded border-gray-300">
Создать копию Создать копию
</label> </label>
<div> <div>
@@ -339,7 +339,7 @@ function escapeHtml(text) {
function formatMoneyNoDecimals(value) { function formatMoneyNoDecimals(value) {
const safe = Number.isFinite(Number(value)) ? Number(value) : 0; const safe = Number.isFinite(Number(value)) ? Number(value) : 0;
return '$' + Math.round(safe).toLocaleString('en-US'); return '$' + Math.round(safe).toLocaleString('ru-RU');
} }
function resolveProjectTrackerURL(projectData) { function resolveProjectTrackerURL(projectData) {
@@ -634,7 +634,7 @@ function openVariantActionModal() {
document.getElementById('variant-action-current-code').value = currentCode; document.getElementById('variant-action-current-code').value = currentCode;
document.getElementById('variant-action-name').value = currentName; document.getElementById('variant-action-name').value = currentName;
document.getElementById('variant-action-code').value = currentCode; document.getElementById('variant-action-code').value = currentCode;
document.getElementById('variant-action-copy').checked = true; document.getElementById('variant-action-copy').checked = false;
document.getElementById('variant-action-modal').classList.remove('hidden'); document.getElementById('variant-action-modal').classList.remove('hidden');
document.getElementById('variant-action-modal').classList.add('flex'); document.getElementById('variant-action-modal').classList.add('flex');
const nameInput = document.getElementById('variant-action-name'); const nameInput = document.getElementById('variant-action-name');
@@ -1105,7 +1105,7 @@ async function openConfigActionModal(uuid, currentName, currentProjectUUID) {
document.getElementById('config-action-current-name').value = currentName; document.getElementById('config-action-current-name').value = currentName;
document.getElementById('config-action-current-project').value = currentProjectUUID || projectUUID; document.getElementById('config-action-current-project').value = currentProjectUUID || projectUUID;
document.getElementById('config-action-name').value = currentName; document.getElementById('config-action-name').value = currentName;
document.getElementById('config-action-copy').checked = true; document.getElementById('config-action-copy').checked = false;
populateProjectAutocomplete(); populateProjectAutocomplete();
const currentProject = projectsCatalog.find(p => p.uuid === (currentProjectUUID || projectUUID)); const currentProject = projectsCatalog.find(p => p.uuid === (currentProjectUUID || projectUUID));
if (currentProject) { if (currentProject) {
@@ -1572,10 +1572,10 @@ async function exportProject() {
} }
} }
async function refreshAllPrices() { async function refreshPrices() {
const configs = (allConfigs || []).filter(c => c.is_active !== false); const configs = (allConfigs || []).filter(c => c.is_active !== false);
if (!configs.length) { if (!configs.length) {
if (typeof showToast === 'function') showToast('Нет активных конфигураций', 'error'); showToast('Нет активных конфигураций', 'error');
return; return;
} }
@@ -1586,48 +1586,22 @@ async function refreshAllPrices() {
btn.className = 'py-2 bg-gray-300 text-gray-500 rounded-lg cursor-not-allowed font-medium'; btn.className = 'py-2 bg-gray-300 text-gray-500 rounded-lg cursor-not-allowed font-medium';
} }
let serverSyncSkipped = false;
try { try {
const statusResp = await fetch('/api/sync/status'); const latestEstimatePricelistId = await fetchLatestEstimatePricelistId();
const statusData = statusResp.ok ? await statusResp.json() : null;
if (statusData && statusData.is_online) {
const componentSyncResp = await fetch('/api/sync/components', { method: 'POST' });
if (!componentSyncResp.ok) throw new Error('component sync failed');
const pricelistSyncResp = await fetch('/api/sync/pricelists', { method: 'POST' });
if (!pricelistSyncResp.ok) throw new Error('pricelist sync failed');
} else {
serverSyncSkipped = true;
}
} catch (syncErr) {
if (syncErr.message === 'component sync failed' || syncErr.message === 'pricelist sync failed') {
if (btn) {
btn.disabled = false;
btn.textContent = 'Обновить цены';
btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium';
}
if (typeof showToast === 'function') showToast('Ошибка синхронизации прайс-листов', 'error');
return;
}
serverSyncSkipped = true;
}
// Resolve latest estimate pricelist ID to pass explicitly, so each config const diffResults = [];
// is updated to the newest pricelist rather than the one stored in the config.
let latestEstimatePricelistId = null;
try {
const plResp = await fetch('/api/pricelists?active_only=true&source=estimate&per_page=1');
if (plResp.ok) {
const plData = await plResp.json();
const list = plData.pricelists || plData.items || plData;
if (Array.isArray(list) && list.length > 0 && list[0].id) {
latestEstimatePricelistId = Number(list[0].id);
}
}
} catch (_) {}
let failed = 0;
let newTotalSum = 0;
for (const cfg of configs) { for (const cfg of configs) {
if (cfg.disable_price_refresh) {
diffResults.push({ configName: cfg.name, skipped: true });
continue;
}
const prevTotal = cfg.total_price || 0;
const prevItemsMap = {};
if (cfg.items) {
for (const item of cfg.items) prevItemsMap[item.lot_name] = item.unit_price;
}
try { try {
const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined; const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined;
const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', { const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
@@ -1635,38 +1609,53 @@ async function refreshAllPrices() {
headers: body ? { 'Content-Type': 'application/json' } : {}, headers: body ? { 'Content-Type': 'application/json' } : {},
body, body,
}); });
if (!resp.ok) { failed++; continue; } if (!resp.ok) {
diffResults.push({ configName: cfg.name, error: true });
continue;
}
const updated = await resp.json(); const updated = await resp.json();
if (updated && updated.total_price != null) {
cfg.total_price = updated.total_price; cfg.total_price = updated.total_price ?? cfg.total_price;
if (updated.items) cfg.items = updated.items;
const totalCell = document.querySelector('[data-total-uuid="' + cfg.uuid + '"]'); const totalCell = document.querySelector('[data-total-uuid="' + cfg.uuid + '"]');
if (totalCell) totalCell.textContent = formatMoneyNoDecimals(updated.total_price); if (totalCell && updated.total_price != null) {
const serverCount = cfg.server_count || 1; totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
const unitPrice = serverCount > 0 ? (updated.total_price / serverCount) : 0; const sc = cfg.server_count || 1;
const row = totalCell && totalCell.closest('tr'); const row = totalCell.closest('tr');
if (row) { if (row) {
const cells = row.querySelectorAll('td'); const cells = row.querySelectorAll('td');
if (cells[4]) cells[4].textContent = formatMoneyNoDecimals(unitPrice); if (cells[4]) cells[4].textContent = formatMoneyNoDecimals(sc > 0 ? updated.total_price / sc : 0);
} }
} }
newTotalSum += cfg.total_price || 0;
} catch { failed++; }
}
const footerTotal = document.querySelector('[data-footer-total="1"]'); const itemDiffs = [];
if (footerTotal) footerTotal.textContent = formatMoneyNoDecimals(newTotalSum); if (updated.items) {
for (const item of updated.items) {
const prevPrice = prevItemsMap[item.lot_name];
if (prevPrice !== undefined && Math.abs(prevPrice - item.unit_price) > 0.01) {
itemDiffs.push({ lot_name: item.lot_name, quantity: item.quantity, prevPrice, newPrice: item.unit_price });
}
}
}
diffResults.push({ configName: cfg.name, prevTotal, newTotal: updated.total_price || 0, serverCount: cfg.server_count || 1, itemDiffs });
} catch(_) {
diffResults.push({ configName: cfg.name, error: true });
}
}
updateFooterTotal();
showToast('Цены обновлены', 'success');
showPriceDiffModal(diffResults);
} catch(e) {
showToast('Ошибка обновления цен', 'error');
} finally {
if (btn) { if (btn) {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Обновить цены'; btn.textContent = 'Обновить цены';
btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium'; btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium';
} }
if (failed > 0) {
if (typeof showToast === 'function') showToast('Часть конфигураций не обновилась (' + failed + ')', 'error');
} else {
const msg = serverSyncSkipped ? 'Цены обновлены (без связи с сервером)' : 'Цены обновлены';
if (typeof showToast === 'function') showToast(msg, 'success');
} }
} }

View File

@@ -1,4 +1,4 @@
{{define "title"}}Мои проекты - OFS{{end}} {{define "title"}}QFS Мои проекты{{end}}
{{define "content"}} {{define "content"}}
<div class="space-y-4"> <div class="space-y-4">
@@ -81,7 +81,7 @@ function escapeHtml(text) {
} }
function formatMoney(v) { function formatMoney(v) {
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2}); return '$' + (v || 0).toLocaleString('ru-RU', {minimumFractionDigits: 2});
} }
function formatDateTime(value) { function formatDateTime(value) {