Compare commits

..

36 Commits
v1.11 ... v2.22

Author SHA1 Message Date
7d190cc7a8 fix: регистронезависимый поиск lot_name и удаление мёртвого кода
- SQLite-запросы по lot_name теперь используют UPPER(lot_name) IN/= для
  совместимости с легаси-данными, синхронизированными до нормализации регистра
- Удалена таблица local_components и весь связанный код синхронизации;
  источник данных для компонентов — local_pricelist_items
- Удалена функция getCategoryFromLotName из JS: категория берётся только
  из прайслиста, без инференса из имени лота
- Регистронезависимые сравнения lot_name в JS (warehouse stock set,
  addedLots, cartLots, allComponents.find, _bomLotValid)
- В support bundle добавлены: latest_pricelist_items.json, local.db,
  autocomplete_lots.json для диагностики

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 15:39:53 +03:00
Mikhail Chusavitin
d340bf80af docs: release notes v1.14
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:19:58 +03:00
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
100 changed files with 4070 additions and 1468 deletions

2
bible

Submodule bible updated: 52444350c1...1977730d93

View File

@@ -116,6 +116,28 @@ Rules:
- storage configurations use the same vendor_spec + PN→LOT + pricing flow as server configurations; - storage 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)
@@ -895,6 +894,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/partnumber-books", webHandler.PartnumberBooks) router.GET("/partnumber-books", webHandler.PartnumberBooks)
// Short project URLs: /:code → main variant, /:code/:variant → named variant
router.GET("/:code", func(c *gin.Context) {
code := c.Param("code")
project, err := projectService.GetByCode(code)
if err != nil {
c.Redirect(http.StatusFound, "/projects")
return
}
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
})
router.GET("/:code/:variant", func(c *gin.Context) {
code := c.Param("code")
variant := c.Param("variant")
project, err := projectService.GetByCodeAndVariant(code, variant)
if err != nil {
c.Redirect(http.StatusFound, "/projects")
return
}
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
})
// htmx partials // htmx partials
partials := router.Group("/partials") partials := router.Group("/partials")
{ {
@@ -920,6 +940,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 +973,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")
{ {
@@ -1145,6 +1169,15 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusOK, config) c.JSON(http.StatusOK, config)
}) })
configs.POST("/:uuid/snapshot", func(c *gin.Context) {
uuid := c.Param("uuid")
if err := configService.SnapshotCurrentState(uuid); err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
configs.PATCH("/:uuid/project", func(c *gin.Context) { configs.PATCH("/:uuid/project", func(c *gin.Context) {
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req struct { var req struct {
@@ -1514,7 +1547,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
project, err := projectService.Create(dbUsername, &req) project, err := projectService.Create(dbUsername, &req)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrReservedMainVariant): case errors.Is(err, services.ErrReservedMainVariant),
errors.Is(err, services.ErrProjectCodeInvalidChars),
errors.Is(err, services.ErrProjectVariantInvalidChars):
respondError(c, http.StatusBadRequest, "invalid request", err) respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectCodeExists): case errors.Is(err, services.ErrProjectCodeExists):
respondError(c, http.StatusConflict, "conflict detected", err) respondError(c, http.StatusConflict, "conflict detected", err)
@@ -1552,7 +1587,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrReservedMainVariant), case errors.Is(err, services.ErrReservedMainVariant),
errors.Is(err, services.ErrCannotRenameMainVariant): errors.Is(err, services.ErrCannotRenameMainVariant),
errors.Is(err, services.ErrProjectCodeInvalidChars),
errors.Is(err, services.ErrProjectVariantInvalidChars):
respondError(c, http.StatusBadRequest, "invalid request", err) respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectCodeExists): case errors.Is(err, services.ErrProjectCodeExists):
respondError(c, http.StatusConflict, "conflict detected", err) respondError(c, http.StatusConflict, "conflict detected", err)
@@ -1726,7 +1763,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
} }
@@ -1774,7 +1811,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
syncAPI.GET("/readiness", syncHandler.GetReadiness) syncAPI.GET("/readiness", syncHandler.GetReadiness)
syncAPI.GET("/info", syncHandler.GetInfo) syncAPI.GET("/info", syncHandler.GetInfo)
syncAPI.GET("/users-status", syncHandler.GetUsersStatus) syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists) syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks) syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks)
syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen) syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen)

View File

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

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

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

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

View File

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

View File

@@ -46,7 +46,6 @@ type LocalDB struct {
var localReadOnlyCacheTables = []string{ var localReadOnlyCacheTables = []string{
"local_pricelist_items", "local_pricelist_items",
"local_pricelists", "local_pricelists",
"local_components",
"local_partnumber_book_items", "local_partnumber_book_items",
"local_partnumber_books", "local_partnumber_books",
} }
@@ -78,7 +77,6 @@ func ResetData(dbPath string) error {
"local_configuration_versions", "local_configuration_versions",
"local_pricelists", "local_pricelists",
"local_pricelist_items", "local_pricelist_items",
"local_components",
"local_sync_guard_state", "local_sync_guard_state",
"pending_changes", "pending_changes",
"app_settings", "app_settings",
@@ -224,12 +222,12 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
&LocalConfigurationVersion{}, &LocalConfigurationVersion{},
&LocalPricelist{}, &LocalPricelist{},
&LocalPricelistItem{}, &LocalPricelistItem{},
&LocalComponent{},
&AppSetting{}, &AppSetting{},
&LocalSyncGuardState{}, &LocalSyncGuardState{},
&PendingChange{}, &PendingChange{},
&LocalPartnumberBook{}, &LocalPartnumberBook{},
&SyncLogEntry{}, &SyncLogEntry{},
&LocalQtSetting{},
) )
} }
@@ -497,7 +495,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
} }
@@ -688,6 +689,22 @@ func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) {
return &project, nil return &project, nil
} }
func (l *LocalDB) GetProjectByCode(code string) (*LocalProject, error) {
var project LocalProject
if err := l.db.Where("LOWER(code) = LOWER(?) AND variant = ''", code).First(&project).Error; err != nil {
return nil, err
}
return &project, nil
}
func (l *LocalDB) GetProjectByCodeAndVariant(code, variant string) (*LocalProject, error) {
var project LocalProject
if err := l.db.Where("LOWER(code) = LOWER(?) AND LOWER(variant) = LOWER(?)", code, variant).First(&project).Error; err != nil {
return nil, err
}
return &project, nil
}
func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) { func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) {
var project LocalProject var project LocalProject
if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil { if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil {
@@ -1044,14 +1061,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
} }
@@ -1213,25 +1234,6 @@ func (l *LocalDB) GetLastComponentSyncError() string {
return strings.TrimSpace(value) return strings.TrimSpace(value)
} }
func (l *LocalDB) SetComponentSyncResult(status, errorText string, attemptedAt time.Time) error {
status = strings.TrimSpace(status)
errorText = strings.TrimSpace(errorText)
if status == "" {
status = "unknown"
}
return l.db.Transaction(func(tx *gorm.DB) error {
if err := l.upsertAppSetting(tx, "last_component_sync_status", status, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_component_sync_error", errorText, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_component_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
return err
}
return nil
})
}
// CountLocalPricelists returns the number of local pricelists // CountLocalPricelists returns the number of local pricelists
func (l *LocalDB) CountLocalPricelists() int64 { func (l *LocalDB) CountLocalPricelists() int64 {
@@ -1247,11 +1249,10 @@ func (l *LocalDB) CountAllPricelistItems() int64 {
return count return count
} }
// CountComponents returns the number of rows in local_components.
func (l *LocalDB) CountComponents() int64 { // DBFilePath returns the path to the SQLite database file.
var count int64 func (l *LocalDB) DBFilePath() string {
l.db.Model(&LocalComponent{}).Count(&count) return l.path
return count
} }
// DBFileSizeBytes returns the size of the SQLite database file in bytes. // DBFileSizeBytes returns the size of the SQLite database file in bytes.
@@ -1263,11 +1264,11 @@ func (l *LocalDB) DBFileSizeBytes() int64 {
return info.Size() return info.Size()
} }
// GetLatestLocalPricelist returns the most recently synced pricelist // GetLatestLocalPricelist returns the most recently synced active estimate pricelist.
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) { func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
var pricelist LocalPricelist var pricelist LocalPricelist
if err := l.db. if err := l.db.
Where("source = ?", "estimate"). Where("source = ? AND is_active = ?", "estimate", true).
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)"). Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
Order("created_at DESC, id DESC"). Order("created_at DESC, id DESC").
First(&pricelist).Error; err != nil { First(&pricelist).Error; err != nil {
@@ -1276,11 +1277,11 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
return &pricelist, nil return &pricelist, nil
} }
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source. // GetLatestLocalPricelistBySource returns the most recently synced active pricelist for a source.
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) { func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
var pricelist LocalPricelist var pricelist LocalPricelist
if err := l.db. if err := l.db.
Where("source = ?", source). Where("source = ? AND is_active = ?", source, true).
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)"). Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
Order("created_at DESC, id DESC"). Order("created_at DESC, id DESC").
First(&pricelist).Error; err != nil { First(&pricelist).Error; err != nil {
@@ -1289,6 +1290,17 @@ func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelis
return &pricelist, nil return &pricelist, nil
} }
// DeactivateLocalPricelistsNotIn marks all local pricelists with is_active=true whose
// server_id is not in activeServerIDs as inactive. Used after each pricelist sync to
// mirror server-side deactivations locally.
func (l *LocalDB) DeactivateLocalPricelistsNotIn(activeServerIDs []uint) error {
q := l.db.Model(&LocalPricelist{}).Where("is_active = ?", true)
if len(activeServerIDs) > 0 {
q = q.Where("server_id NOT IN ?", activeServerIDs)
}
return q.Update("is_active", false).Error
}
// GetLocalPricelistByServerID returns a local pricelist by its server ID // GetLocalPricelistByServerID returns a local pricelist by its server ID
func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) { func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) {
var pricelist LocalPricelist var pricelist LocalPricelist
@@ -1356,6 +1368,30 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
return count return count
} }
// GetLocalPricelistCoverageByCategory returns item count per lot_category and the total
// for the given local pricelist ID. Only items with price > 0 are counted.
func (l *LocalDB) GetLocalPricelistCoverageByCategory(pricelistID uint) (map[string]int64, int64, error) {
type row struct {
Category string `gorm:"column:lot_category"`
Count int64 `gorm:"column:cnt"`
}
var rows []row
if err := l.db.Model(&LocalPricelistItem{}).
Select("COALESCE(NULLIF(TRIM(lot_category),''), '?') AS lot_category, COUNT(*) AS cnt").
Where("pricelist_id = ? AND price > 0", pricelistID).
Group("lot_category").
Scan(&rows).Error; err != nil {
return nil, 0, err
}
result := make(map[string]int64, len(rows))
var total int64
for _, r := range rows {
result[r.Category] = r.Count
total += r.Count
}
return result, total, nil
}
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category. // CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) { func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
var count int64 var count int64
@@ -1420,10 +1456,11 @@ func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem
return items, nil return items, nil
} }
// GetLocalPriceForLot returns the price for a lot from a local pricelist // GetLocalPriceForLot returns the price for a lot from a local pricelist.
// Matching is case-insensitive via UPPER(lot_name) to handle legacy mixed-case rows.
func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) { func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) {
var item LocalPricelistItem var item LocalPricelistItem
if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName). if err := l.db.Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)).
First(&item).Error; err != nil { First(&item).Error; err != nil {
return 0, err return 0, err
} }
@@ -1431,26 +1468,32 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
} }
// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query. // GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query.
// Uses the composite index (pricelist_id, lot_name). Missing lots are omitted from the result. // Missing lots are omitted from the result.
// lotNames must already be normalized (uppercased); matching is done via UPPER(lot_name) to handle
// legacy rows that were stored in mixed case before normalization was enforced at sync time.
// Keys in the returned map are uppercased (matching the input lotNames).
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) { func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
result := make(map[string]float64, len(lotNames)) result := make(map[string]float64, len(lotNames))
if len(lotNames) == 0 { if len(lotNames) == 0 {
return result, nil return result, nil
} }
type row struct { type row struct {
LotName string `gorm:"column:lot_name"` LotName string `gorm:"column:lot_name"`
Price float64 `gorm:"column:price"` Price float64 `gorm:"column:price"`
} }
var rows []row var rows []row
// Use UPPER(lot_name) so rows synced before normalization (mixed-case) are still matched.
if err := l.db.Model(&LocalPricelistItem{}). if err := l.db.Model(&LocalPricelistItem{}).
Select("lot_name, price"). Select("lot_name, price").
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames). Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, lotNames).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
for _, r := range rows { for _, r := range rows {
if r.Price > 0 { if r.Price > 0 {
result[r.LotName] = r.Price // Key must be uppercase to match callers that normalise lot names before lookup.
result[strings.ToUpper(r.LotName)] = r.Price
} }
} }
return result, nil return result, nil
@@ -1473,15 +1516,27 @@ func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uin
LotName string `gorm:"column:lot_name"` LotName string `gorm:"column:lot_name"`
LotCategory string `gorm:"column:lot_category"` LotCategory string `gorm:"column:lot_category"`
} }
// Build uppercase → original mapping so result keys match what the caller passed.
upperToOrig := make(map[string]string, len(lotNames))
upper := make([]string, len(lotNames))
for i, n := range lotNames {
u := strings.ToUpper(n)
upper[i] = u
upperToOrig[u] = n
}
var rows []row var rows []row
if err := l.db.Model(&LocalPricelistItem{}). if err := l.db.Model(&LocalPricelistItem{}).
Select("lot_name, lot_category"). Select("lot_name, lot_category").
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames). Where("pricelist_id = ? AND UPPER(lot_name) IN ?", localPL.ID, upper).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
for _, r := range rows { for _, r := range rows {
result[r.LotName] = r.LotCategory orig := upperToOrig[strings.ToUpper(r.LotName)]
if orig == "" {
orig = r.LotName
}
result[orig] = r.LotCategory
} }
return result, nil return result, nil
} }
@@ -1819,3 +1874,40 @@ 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
}
// 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

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

View File

@@ -5,6 +5,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/models"
) )
// AppSetting stores application settings in local SQLite // AppSetting stores application settings in local SQLite
@@ -46,7 +48,13 @@ func (c *LocalConfigItems) Scan(value interface{}) error {
default: default:
return errors.New("type assertion failed for LocalConfigItems") return errors.New("type assertion failed for LocalConfigItems")
} }
return json.Unmarshal(bytes, c) if err := json.Unmarshal(bytes, c); err != nil {
return err
}
for i := range *c {
(*c)[i].LotName = models.NormalizeLotName((*c)[i].LotName)
}
return nil
} }
func (c LocalConfigItems) Total() float64 { func (c LocalConfigItems) Total() float64 {
@@ -169,7 +177,8 @@ type LocalPricelist struct {
Name string `json:"name"` Name string `json:"name"`
CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"` CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
SyncedAt time.Time `json:"synced_at"` SyncedAt time.Time `json:"synced_at"`
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
IsActive bool `gorm:"not null;default:true;index" json:"is_active"` // Mirrors qt_pricelists.is_active
} }
func (LocalPricelist) TableName() string { func (LocalPricelist) TableName() string {
@@ -356,3 +365,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,126 @@
package localdb
import (
"encoding/json"
"fmt"
"log/slog"
"gorm.io/gorm"
)
// ConfigTypeDef describes one device configuration type as synced from qt_settings.
type ConfigTypeDef struct {
Code string `json:"code"`
NameRu string `json:"name_ru"`
DisplayOrder int `json:"display_order"`
Categories []string `json:"categories"`
}
// TabSection is a named sub-group of categories within a configurator tab.
type TabSection struct {
Title string `json:"title"`
Categories []string `json:"categories"`
}
// TabDef describes one tab in the configurator as synced from qt_settings.
type TabDef struct {
Key string `json:"key"`
Label string `json:"label"`
SingleSelect bool `json:"single_select"`
Categories []string `json:"categories"`
Sections []TabSection `json:"sections,omitempty"`
}
// ConfiguratorSettings holds all four server-driven settings consumed by the configurator.
// Fields are nil/empty when the corresponding qt_settings key is absent or unparseable;
// callers are expected to apply hardcoded fallbacks in that case.
type ConfiguratorSettings struct {
ConfigTypes []ConfigTypeDef `json:"config_types"`
TabConfig []TabDef `json:"tab_config"`
AlwaysVisibleTabs []string `json:"always_visible_tabs"`
RequiredCategories map[string][]string `json:"required_categories"`
}
// SyncQtSettings reads all rows from qt_settings on MariaDB and replaces the
// local_qt_settings cache in a single SQLite transaction.
// If the read fails (no connection, table missing on old server) or the server
// returns an empty table, the existing local_qt_settings are preserved so the
// configurator keeps working offline or against old server versions.
func (l *LocalDB) SyncQtSettings(mariaDB *gorm.DB) error {
var rows []LocalQtSetting
if err := mariaDB.
Table("qt_settings").
Select("name, value").
Find(&rows).Error; err != nil {
slog.Warn("qt_settings: read from MariaDB failed, keeping existing local cache", "error", err)
return fmt.Errorf("reading qt_settings from MariaDB: %w", err)
}
if len(rows) == 0 {
slog.Warn("qt_settings: server returned empty table, keeping existing local cache")
return nil
}
return l.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("DELETE FROM local_qt_settings").Error; err != nil {
return fmt.Errorf("clearing local_qt_settings: %w", err)
}
if err := tx.Create(&rows).Error; err != nil {
return fmt.Errorf("inserting local_qt_settings: %w", err)
}
slog.Info("qt_settings synced", "count", len(rows))
return nil
})
}
// GetQtSetting returns the raw JSON value for a named setting.
// found is false when the key does not exist.
func (l *LocalDB) GetQtSetting(name string) (value string, found bool, err error) {
var row LocalQtSetting
res := l.db.Where("name = ?", name).First(&row)
if res.Error != nil {
if res.Error == gorm.ErrRecordNotFound {
return "", false, nil
}
return "", false, res.Error
}
return row.Value, true, nil
}
// GetConfiguratorSettings reads all four known settings from local_qt_settings and
// parses them. Any missing or unparseable key is left as nil/zero in the result;
// the caller must apply fallbacks.
func (l *LocalDB) GetConfiguratorSettings() (*ConfiguratorSettings, error) {
out := &ConfiguratorSettings{}
keys := []string{"config_types", "tab_config", "always_visible_tabs", "required_categories"}
for _, key := range keys {
raw, found, err := l.GetQtSetting(key)
if err != nil {
return out, fmt.Errorf("reading setting %q: %w", key, err)
}
if !found || raw == "" {
continue
}
switch key {
case "config_types":
if err := json.Unmarshal([]byte(raw), &out.ConfigTypes); err != nil {
slog.Warn("failed to parse config_types setting", "error", err)
}
case "tab_config":
if err := json.Unmarshal([]byte(raw), &out.TabConfig); err != nil {
slog.Warn("failed to parse tab_config setting", "error", err)
}
case "always_visible_tabs":
if err := json.Unmarshal([]byte(raw), &out.AlwaysVisibleTabs); err != nil {
slog.Warn("failed to parse always_visible_tabs setting", "error", err)
}
case "required_categories":
if err := json.Unmarshal([]byte(raw), &out.RequiredCategories); err != nil {
slog.Warn("failed to parse required_categories setting", "error", err)
}
}
}
return out, nil
}

View File

@@ -1,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,6 +1,12 @@
package models package models
import "time" import "strings"
// NormalizeLotName returns the canonical form of a lot name: trimmed and uppercased.
// Apply at every point where a lot name enters the system (sync, API input, config load).
func NormalizeLotName(s string) string {
return strings.ToUpper(strings.TrimSpace(s))
}
// Lot represents existing lot table // Lot represents existing lot table
type Lot struct { type Lot struct {
@@ -12,43 +18,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

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

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

@@ -53,13 +53,14 @@ type ProjectExportData struct {
} }
type ProjectPricingExportOptions struct { type ProjectPricingExportOptions struct {
IncludeLOT bool `json:"include_lot"` IncludeLOT bool `json:"include_lot"`
IncludeBOM bool `json:"include_bom"` IncludeBOM bool `json:"include_bom"`
IncludeEstimate bool `json:"include_estimate"` IncludeEstimate bool `json:"include_estimate"`
IncludeStock bool `json:"include_stock"` IncludeStock bool `json:"include_stock"`
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 {
@@ -87,14 +88,15 @@ type ProjectPricingExportConfig struct {
} }
type ProjectPricingExportRow struct { type ProjectPricingExportRow struct {
LotDisplay string LotDisplay string
VendorPN string VendorPN string
Description string Description string
Quantity int Quantity int
BOMTotal *float64 BOMTotal *float64
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{
VendorPN: row.VendorPartnumber, LotDisplay: "н/д",
Description: description, VendorPN: row.VendorPartnumber,
Quantity: exportPositiveInt(row.Quantity, 1), Description: description,
BOMTotal: vendorRowTotal(row), Quantity: exportPositiveInt(row.Quantity, 1),
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }), BOMTotal: vendorRowTotal(row),
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }), })
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }), continue
}
// One export row per LOT mapping so that bundles (1 PN → N LOTs) appear
// as separate lines, matching the frontend pricing table layout.
pnQty := exportPositiveInt(row.Quantity, 1)
for i, mapping := range rowMappings {
lotQty := pnQty * mapping.QuantityPerPN
var bomTotal *float64
if i == 0 {
bomTotal = vendorRowTotal(row)
}
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: mapping.LotName,
VendorPN: row.VendorPartnumber,
Description: description,
Quantity: lotQty,
BOMTotal: bomTotal,
Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }),
Stock: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Stock }),
Competitor: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Competitor }),
})
} }
block.Rows = append(block.Rows, pricingRow)
} }
for _, item := range cfg.Items { 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,44 @@ 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(_ *models.Configuration, _ *localdb.LocalConfiguration) map[string]string {
lots := collectPricingLots(cfg, localCfg, true) return map[string]string{}
result := make(map[string]string, len(lots))
if s.localDB == nil {
return result
}
for _, lot := range lots {
component, err := s.localDB.GetLocalComponent(lot)
if err != nil {
continue
}
result[lot] = component.LotDescription
}
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 +741,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 +807,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 +825,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 +854,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 +883,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.
@@ -423,6 +423,13 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
} }
} }
// Capture fingerprint of the current state before any mutations.
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
}
preRefreshCfg := *localCfg
// Update prices for all items from pricelist // Update prices for all items from pricelist
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items { for i, item := range localCfg.Items {
@@ -462,6 +469,18 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
localCfg.UpdatedAt = now localCfg.UpdatedAt = now
localCfg.SyncStatus = "pending" localCfg.SyncStatus = "pending"
// Before saving the new prices, snapshot the pre-refresh state so the revision
// history shows a clear before/after for every price update.
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
}
if preRefreshFP != postRefreshFP {
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
}
}
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername) cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
if err != nil { if err != nil {
return nil, fmt.Errorf("refresh prices with version: %w", err) return nil, fmt.Errorf("refresh prices with version: %w", err)
@@ -791,7 +810,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:
@@ -818,6 +839,13 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
} }
} }
// Capture fingerprint of the current state before any mutations.
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
}
preRefreshCfg := *localCfg
// Update prices for all items from pricelist // Update prices for all items from pricelist
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items { for i, item := range localCfg.Items {
@@ -857,6 +885,18 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
localCfg.UpdatedAt = now localCfg.UpdatedAt = now
localCfg.SyncStatus = "pending" localCfg.SyncStatus = "pending"
// Before saving the new prices, snapshot the pre-refresh state so the revision
// history shows a clear before/after for every price update.
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
}
if preRefreshFP != postRefreshFP {
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ""); err != nil {
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
}
}
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "") cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil { if err != nil {
return nil, fmt.Errorf("refresh prices without auth with version: %w", err) return nil, fmt.Errorf("refresh prices without auth with version: %w", err)
@@ -864,6 +904,16 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
return cfg, nil return cfg, nil
} }
// SnapshotCurrentState creates a revision of the current configuration state without modifying it.
// Called before a client-side price refresh so the revision history has a clear before/after.
func (s *LocalConfigurationService) SnapshotCurrentState(uuid string) error {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
return s.snapshotPreRefreshTx(localCfg, "")
}
// UpdateServerCount updates server count and recalculates total price without creating a new version. // UpdateServerCount updates server count and recalculates total price without creating a new version.
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) { func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
if serverCount < 1 { if serverCount < 1 {
@@ -1430,12 +1480,25 @@ func (s *LocalConfigurationService) appendVersionTx(
localCfg *localdb.LocalConfiguration, localCfg *localdb.LocalConfiguration,
operation string, operation string,
createdBy string, createdBy string,
) (*localdb.LocalConfigurationVersion, error) {
return s.appendVersionTxNote(tx, localCfg, operation, createdBy, "")
}
func (s *LocalConfigurationService) appendVersionTxNote(
tx *gorm.DB,
localCfg *localdb.LocalConfiguration,
operation string,
createdBy string,
noteOverride string,
) (*localdb.LocalConfigurationVersion, error) { ) (*localdb.LocalConfigurationVersion, error) {
snapshot, err := s.buildConfigurationSnapshot(localCfg) snapshot, err := s.buildConfigurationSnapshot(localCfg)
if err != nil { if err != nil {
return nil, fmt.Errorf("build snapshot: %w", err) return nil, fmt.Errorf("build snapshot: %w", err)
} }
changeNote := fmt.Sprintf("%s via local-first flow", operation) changeNote := fmt.Sprintf("%s via local-first flow", operation)
if noteOverride != "" {
changeNote = noteOverride
}
var createdByPtr *string var createdByPtr *string
if createdBy != "" { if createdBy != "" {
@@ -1476,6 +1539,35 @@ func (s *LocalConfigurationService) appendVersionTx(
return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID) return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID)
} }
// snapshotPreRefreshTx creates a revision of the current configuration state before a price
// refresh so the history clearly shows what existed before prices were updated.
// Called only when prices are about to change (fingerprints differ).
func (s *LocalConfigurationService) snapshotPreRefreshTx(localCfg *localdb.LocalConfiguration, createdBy string) error {
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
var locked localdb.LocalConfiguration
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("uuid = ?", localCfg.UUID).
First(&locked).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConfigNotFound
}
return fmt.Errorf("lock row for pre-refresh snapshot: %w", err)
}
version, err := s.appendVersionTxNote(tx, localCfg, "update", createdBy, "до обновления цен")
if err != nil {
return fmt.Errorf("append pre-refresh version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current_version_id for pre-refresh snapshot: %w", err)
}
return nil
})
}
func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) { func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) {
return localdb.BuildConfigurationSnapshot(localCfg) return localdb.BuildConfigurationSnapshot(localCfg)
} }

View File

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

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,
@@ -114,6 +111,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
if len(req.Items) == 0 { if len(req.Items) == 0 {
return nil, ErrEmptyQuote return nil, ErrEmptyQuote
} }
for i := range req.Items {
req.Items[i].LotName = models.NormalizeLotName(req.Items[i].LotName)
}
// Strict local-first path: calculations use local SQLite snapshot regardless of online status. // Strict local-first path: calculations use local SQLite snapshot regardless of online status.
if s.localDB != nil { if s.localDB != nil {
@@ -248,6 +248,16 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
if len(req.Items) == 0 { if len(req.Items) == 0 {
return nil, ErrEmptyQuote return nil, ErrEmptyQuote
} }
// Keep original lot names so the response mirrors what the caller sent.
// Normalization is applied only for internal DB lookups.
originalLotNames := make(map[string]string, len(req.Items))
for i := range req.Items {
upper := models.NormalizeLotName(req.Items[i].LotName)
if _, exists := originalLotNames[upper]; !exists {
originalLotNames[upper] = req.Items[i].LotName
}
req.Items[i].LotName = upper
}
lotNames := make([]string, 0, len(req.Items)) lotNames := make([]string, 0, len(req.Items))
seenLots := make(map[string]struct{}, len(req.Items)) seenLots := make(map[string]struct{}, len(req.Items))
@@ -306,8 +316,12 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
} }
for _, reqItem := range req.Items { for _, reqItem := range req.Items {
responseLotName := originalLotNames[reqItem.LotName]
if responseLotName == "" {
responseLotName = reqItem.LotName
}
item := PriceLevelsItem{ item := PriceLevelsItem{
LotName: reqItem.LotName, LotName: responseLotName,
Quantity: reqItem.Quantity, Quantity: reqItem.Quantity,
PriceMissing: make([]string, 0, 3), PriceMissing: make([]string, 0, 3),
} }
@@ -504,18 +518,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,20 +1,31 @@
package sync package sync
import ( import (
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
) )
// SeenPartnumber represents an unresolved vendor partnumber to report. // SeenPartnumber represents an unresolved vendor partnumber to report.
type SeenPartnumber struct { 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,14 +321,37 @@ 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 {

View File

@@ -322,6 +322,12 @@ func (s *Service) NeedSync() (bool, error) {
// SyncPricelists synchronizes all active pricelists from server to local SQLite // SyncPricelists synchronizes all active pricelists from server to local SQLite
func (s *Service) SyncPricelists() (int, error) { func (s *Service) SyncPricelists() (int, error) {
s.pricelistMu.Lock()
defer s.pricelistMu.Unlock()
return s.syncPricelists()
}
func (s *Service) syncPricelists() (int, error) {
slog.Info("starting pricelist sync") slog.Info("starting pricelist sync")
plSyncStart := time.Now() plSyncStart := time.Now()
if _, err := s.EnsureReadinessForSync(); err != nil { if _, err := s.EnsureReadinessForSync(); err != nil {
@@ -336,6 +342,12 @@ func (s *Service) SyncPricelists() (int, error) {
return 0, fmt.Errorf("database not available: %w", err) return 0, fmt.Errorf("database not available: %w", err)
} }
defer func() {
if reportErr := s.reportClientSchemaState(mariaDB, time.Now().UTC()); reportErr != nil {
slog.Warn("failed to report client state after pricelist sync", "error", reportErr)
}
}()
// Create repository // Create repository
pricelistRepo := repository.NewPricelistRepository(mariaDB) pricelistRepo := repository.NewPricelistRepository(mariaDB)
@@ -392,6 +404,7 @@ func (s *Service) SyncPricelists() (int, error) {
CreatedAt: pl.CreatedAt, CreatedAt: pl.CreatedAt,
SyncedAt: time.Now(), SyncedAt: time.Now(),
IsUsed: false, IsUsed: false,
IsActive: true,
} }
itemCount, err := s.syncNewPricelistSnapshot(localPL) itemCount, err := s.syncNewPricelistSnapshot(localPL)
@@ -414,6 +427,12 @@ func (s *Service) SyncPricelists() (int, error) {
slog.Info("deleted stale local pricelists", "deleted", removed) slog.Info("deleted stale local pricelists", "deleted", removed)
} }
// Mirror server-side deactivations: any local pricelist not in the current active set
// is marked is_active=false so offline lookups skip it.
if err := s.localDB.DeactivateLocalPricelistsNotIn(serverPricelistIDs); err != nil {
slog.Warn("failed to deactivate stale local pricelists", "error", err)
}
// Backfill lot_category for used pricelists (older local caches may miss the column values). // Backfill lot_category for used pricelists (older local caches may miss the column values).
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs) s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
@@ -764,9 +783,16 @@ func (s *Service) fetchServerPricelistItems(serverPricelistID uint) ([]localdb.L
return nil, fmt.Errorf("getting server pricelist items: %w", err) return nil, fmt.Errorf("getting server pricelist items: %w", err)
} }
localItems := make([]localdb.LocalPricelistItem, len(serverItems)) seen := make(map[string]struct{}, len(serverItems))
for i, item := range serverItems { localItems := make([]localdb.LocalPricelistItem, 0, len(serverItems))
localItems[i] = *localdb.PricelistItemToLocal(&item, 0) for i := range serverItems {
lotName := serverItems[i].LotName
if _, dup := seen[lotName]; dup {
slog.Warn("duplicate lot_name in server pricelist, skipping", "pricelist_id", serverPricelistID, "lot_name", lotName)
continue
}
seen[lotName] = struct{}{}
localItems = append(localItems, *localdb.PricelistItemToLocal(&serverItems[i], 0))
} }
return localItems, nil return localItems, nil
@@ -843,7 +869,7 @@ func (s *Service) SyncPricelistsIfNeeded() error {
} }
slog.Info("new pricelists detected, syncing...") slog.Info("new pricelists detected, syncing...")
_, err = s.SyncPricelists() _, err = s.syncPricelists()
if err != nil { if err != nil {
return fmt.Errorf("syncing pricelists: %w", err) return fmt.Errorf("syncing pricelists: %w", err)
} }
@@ -851,6 +877,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 +895,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)
@@ -875,7 +914,10 @@ func (s *Service) PushPendingChanges() (int, error) {
} }
slog.Info("pushing pending changes", "count", len(changes)) slog.Info("pushing pending changes", "count", len(changes))
pushStart := time.Now()
pushed := 0 pushed := 0
failed := 0
var firstErr string
var syncedIDs []int64 var syncedIDs []int64
sortedChanges := prioritizeProjectChanges(changes) sortedChanges := prioritizeProjectChanges(changes)
@@ -884,8 +926,18 @@ 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 firstErr == "" {
firstErr = err.Error()
}
failed++
if newAttempts >= maxPendingChangeAttempts {
slog.Error("abandoning pending change after max attempts",
"id", change.ID, "type", change.EntityType, "op", change.Operation,
"attempts", newAttempts, "last_error", err.Error())
syncedIDs = append(syncedIDs, change.ID)
}
continue continue
} }
@@ -900,7 +952,13 @@ func (s *Service) PushPendingChanges() (int, error) {
} }
} }
slog.Info("pending changes pushed", "pushed", pushed, "failed", len(changes)-pushed) if failed > 0 {
s.localDB.AppendSyncLog("changes", "error", firstErr, pushed, pushStart, time.Since(pushStart).Milliseconds())
} else {
s.localDB.AppendSyncLog("changes", "ok", "", pushed, pushStart, time.Since(pushStart).Milliseconds())
}
slog.Info("pending changes pushed", "pushed", pushed, "failed", failed)
return pushed, nil return pushed, nil
} }
@@ -912,7 +970,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 +1107,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,24 +1310,30 @@ 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)
if modelProject.OwnerUsername == "" {
modelProject.OwnerUsername = cfg.OwnerUsername
}
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
return createErr
}
if modelProject.ID > 0 {
serverID := modelProject.ID
localProject.ServerID = &serverID
localProject.SyncStatus = "synced"
now := time.Now()
localProject.SyncedAt = &now
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
}
return nil
} }
modelProject := localdb.LocalToProject(localProject)
if modelProject.OwnerUsername == "" {
modelProject.OwnerUsername = cfg.OwnerUsername
}
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
return createErr
}
if modelProject.ID > 0 {
serverID := modelProject.ID
localProject.ServerID = &serverID
localProject.SyncStatus = "synced"
now := time.Now()
localProject.SyncedAt = &now
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
}
return nil
} }
systemProject := &models.Project{} systemProject := &models.Project{}
@@ -1574,24 +1645,3 @@ func (s *Service) getConnectionStatus() db.ConnectionStatus {
return s.connMgr.GetStatus() return s.connMgr.GetStatus()
} }
// SyncComponentsIfEmpty syncs components from MariaDB when local_components is empty.
// Used by the background worker on first run to populate the catalog for new users.
func (s *Service) SyncComponentsIfEmpty() error {
if s.localDB.CountComponents() > 0 {
return nil
}
mariaDB, err := s.getDB()
if err != nil {
_ = s.localDB.SetComponentSyncResult("error", err.Error(), time.Now())
return err
}
result, err := s.localDB.SyncComponents(mariaDB)
now := time.Now()
if err != nil {
_ = s.localDB.SetComponentSyncResult("error", err.Error(), now)
return err
}
_ = s.localDB.SetComponentSyncResult("ok", "", now)
slog.Info("background sync: initial component sync completed", "synced", result.TotalSynced)
return nil
}

View File

@@ -80,11 +80,6 @@ func (w *Worker) runSync() {
return return
} }
// Populate component catalog on first run (empty local_components)
if err := w.service.SyncComponentsIfEmpty(); err != nil {
w.logger.Warn("background sync: initial component sync failed", "error", err)
}
// Push pending changes first // Push pending changes first
pushed, err := w.service.PushPendingChanges() pushed, err := w.service.PushPendingChanges()
if err != nil { if err != nil {
@@ -100,5 +95,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

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

View File

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

View File

@@ -53,45 +53,34 @@ mkdir -p "${RELEASE_DIR}"
# Create release notes template only when missing. # 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,123 @@
// } // }
// } // }
// ==================== 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);
const totalPrevHtml = totalDelta !== 0
? `<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span>`
: '';
html += `<div class="flex justify-between items-center text-sm bg-gray-50 rounded px-3 py-2 mb-1">
<span class="text-gray-600 font-medium">Итог конфигурации</span>
<span>
${totalPrevHtml}<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;
@@ -710,7 +713,7 @@ async function loadWarehouseInStockLots() {
const lotNames = Array.isArray(data.lot_names) ? data.lot_names : []; const lotNames = Array.isArray(data.lot_names) ? data.lot_names : [];
lotNames.forEach(lot => { lotNames.forEach(lot => {
if (typeof lot === 'string' && lot.trim() !== '') { if (typeof lot === 'string' && lot.trim() !== '') {
result.add(lot); result.add(lot.toUpperCase());
} }
}); });
@@ -745,7 +748,7 @@ function isComponentAllowedByStockFilter(comp) {
const availableLots = warehouseStockLotsByPricelist.get(pricelistID); const availableLots = warehouseStockLotsByPricelist.get(pricelistID);
// Don't block UI while stock set is being loaded. // Don't block UI while stock set is being loaded.
if (!availableLots) return true; if (!availableLots) return true;
return availableLots.has(comp.lot_name); return availableLots.has((comp.lot_name || '').toUpperCase());
} }
// Load categories from API and update tab configuration // Load categories from API and update tab configuration
@@ -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 || '').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);
@@ -832,8 +925,7 @@ document.addEventListener('DOMContentLoaded', async function() {
warehouse_price: null, warehouse_price: null,
competitor_price: null, competitor_price: null,
description: item.description || '', description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name) category: item.category }));
}));
} }
serverModelForQuote = config.server_model || ''; serverModelForQuote = config.server_model || '';
supportCode = config.support_code || ''; supportCode = config.support_code || '';
@@ -879,6 +971,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 +987,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);
@@ -904,7 +1002,7 @@ const BOM_LOT_DATALIST_DIVIDER = '────────';
function _bomLotValid(v) { function _bomLotValid(v) {
const lot = (v || '').trim(); const lot = (v || '').trim();
if (!lot || lot === BOM_LOT_DATALIST_DIVIDER) return false; if (!lot || lot === BOM_LOT_DATALIST_DIVIDER) return false;
return (window._bomAllComponents || allComponents).some(c => c.lot_name === lot); return (window._bomAllComponents || allComponents).some(c => c.lot_name.toUpperCase() === lot.toUpperCase());
} }
function updateServerCount() { function updateServerCount() {
@@ -940,7 +1038,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] = [];
@@ -1120,13 +1218,8 @@ function applyPriceSettings() {
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true }); schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true });
} }
function getCategoryFromLotName(lotName) {
const parts = lotName.split('_');
return parts[0] || '';
}
function getComponentCategory(comp) { function getComponentCategory(comp) {
return (comp.category || getCategoryFromLotName(comp.lot_name)).toUpperCase(); return (comp.category || '').toUpperCase();
} }
function getTabForCategory(category) { function getTabForCategory(category) {
@@ -1154,60 +1247,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 isCategoryVisibleForConfigType(code, cfgType) {
const allowed = configTypeCategoryMap[cfgType];
if (!allowed || allowed.size === 0) return _hardcodedCategoryVisible(code, cfgType);
return allowed.has(code.toUpperCase());
}
function _hardcodedCategoryVisible(code, cfgType) {
if (cfgType === 'storage') {
if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return true;
if (SERVER_ONLY_BASE_CATEGORIES.includes(code)) return false;
if (STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(code)) return false;
if (STORAGE_HIDDEN_PCI_CATEGORIES.includes(code)) return false;
if (STORAGE_HIDDEN_POWER_CATEGORIES.includes(code)) return false;
} else {
if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return false;
}
return true;
}
function _effectiveAlwaysVisibleTabs() {
return alwaysVisibleTabsSet || ALWAYS_VISIBLE_TABS;
}
function applyConfigTypeToTabs() { function applyConfigTypeToTabs() {
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC']; // Filter each tab's categories by visibility for current configType.
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL']; // Uses server-driven allowlists when available; falls back to hardcoded constants.
const storageSections = [ Object.keys(TAB_CONFIG).forEach(tabKey => {
{ title: 'RAID Контроллеры', categories: ['RAID'] }, if (tabKey === 'other') return;
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] } const tab = TAB_CONFIG[tabKey];
]; if (!tab || !Array.isArray(tab.categories)) return;
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 => { // Snapshot the full category list for this tab (stored in _allCategories if not yet saved)
if (configType === 'storage') { if (!tab._allCategories) tab._allCategories = [...tab.categories];
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
}
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
});
TAB_CONFIG.storage.categories = storageCategories.filter(c => { tab.categories = tab._allCategories.filter(c => isCategoryVisibleForConfigType(c, configType));
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
});
TAB_CONFIG.storage.sections = storageSections.filter(section => {
if (configType === 'storage') {
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
}
return true;
});
TAB_CONFIG.pci.categories = pciCategories.filter(c => { if (Array.isArray(tab._allSections || tab.sections)) {
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC'; const allSections = tab._allSections || tab.sections;
}); if (!tab._allSections) tab._allSections = allSections.map(s => ({ ...s, categories: [...s.categories] }));
TAB_CONFIG.pci.sections = pciSections.filter(section => { tab.sections = tab._allSections
if (configType === 'storage') { .map(section => ({
return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat)); ...section,
categories: section.categories.filter(c => isCategoryVisibleForConfigType(c, configType))
}))
.filter(section => section.categories.length > 0);
} }
return section.title !== 'HIC';
});
TAB_CONFIG.power.categories = powerCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true;
}); });
// Rebuild assigned categories index // Rebuild assigned categories index
@@ -1217,13 +1310,14 @@ 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;
const hasCartItems = cart.some(item => { const hasCartItems = cart.some(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase(); const cat = (item.category || '').toUpperCase();
return getTabForCategory(cat) === tabId; return getTabForCategory(cat) === tabId;
}); });
const visible = hasComponents || hasCartItems; const visible = hasComponents || hasCartItems;
@@ -1310,10 +1404,10 @@ function renderSingleSelectTab(categories) {
categories.forEach(cat => { categories.forEach(cat => {
const catLabel = cat === 'MB' ? 'MB' : cat === 'CPU' ? 'CPU' : cat === 'MEM' ? 'MEM' : cat; const catLabel = cat === 'MB' ? 'MB' : cat === 'CPU' ? 'CPU' : cat === 'MEM' ? 'MEM' : cat;
const selectedItem = cart.find(item => const selectedItem = cart.find(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() === cat.toUpperCase() (item.category).toUpperCase() === cat.toUpperCase()
); );
const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null; const comp = selectedItem ? allComponents.find(c => c.lot_name.toUpperCase() === (selectedItem.lot_name || '').toUpperCase()) : null;
const price = comp?.current_price || 0; const price = comp?.current_price || 0;
const estimate = selectedItem?.estimate_price ?? price; const estimate = selectedItem?.estimate_price ?? price;
const qty = selectedItem?.quantity || 1; const qty = selectedItem?.quantity || 1;
@@ -1363,7 +1457,7 @@ function renderSingleSelectTab(categories) {
function renderMultiSelectTab(components) { function renderMultiSelectTab(components) {
// Get cart items for this tab // Get cart items for this tab
const tabItems = cart.filter(item => { const tabItems = cart.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase(); const cat = (item.category).toUpperCase();
const tab = getTabForCategory(cat); const tab = getTabForCategory(cat);
return tab === currentTab; return tab === currentTab;
}); });
@@ -1385,7 +1479,7 @@ function renderMultiSelectTab(components) {
// Render existing cart items for this tab // Render existing cart items for this tab
tabItems.forEach((item, idx) => { tabItems.forEach((item, idx) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name); const comp = allComponents.find(c => c.lot_name.toUpperCase() === (item.lot_name || '').toUpperCase());
const total = getDisplayPrice(item) * item.quantity; const total = getDisplayPrice(item) * item.quantity;
html += ` html += `
@@ -1452,7 +1546,7 @@ function renderMultiSelectTab(components) {
function renderMultiSelectTabWithSections(sections) { function renderMultiSelectTabWithSections(sections) {
// Get cart items for this tab // Get cart items for this tab
const tabItems = cart.filter(item => { const tabItems = cart.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase(); const cat = (item.category).toUpperCase();
const tab = getTabForCategory(cat); const tab = getTabForCategory(cat);
return tab === currentTab; return tab === currentTab;
}); });
@@ -1471,7 +1565,7 @@ function renderMultiSelectTabWithSections(sections) {
// Get cart items for this section // Get cart items for this section
const sectionItems = tabItems.filter(item => { const sectionItems = tabItems.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase(); const cat = (item.category).toUpperCase();
return sectionCategories.includes(cat); return sectionCategories.includes(cat);
}); });
@@ -1499,7 +1593,7 @@ function renderMultiSelectTabWithSections(sections) {
// Render existing cart items for this section // Render existing cart items for this section
sectionItems.forEach((item) => { sectionItems.forEach((item) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name); const comp = allComponents.find(c => c.lot_name.toUpperCase() === (item.lot_name || '').toUpperCase());
const total = getDisplayPrice(item) * item.quantity; const total = getDisplayPrice(item) * item.quantity;
html += ` html += `
@@ -1656,6 +1750,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') {
@@ -1708,7 +1806,7 @@ function selectAutocompleteItem(index) {
// Remove existing item of this category // Remove existing item of this category
cart = cart.filter(item => cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== autocompleteCategory.toUpperCase() (item.category).toUpperCase() !== autocompleteCategory.toUpperCase()
); );
const qtyInput = document.getElementById('qty-' + autocompleteCategory); const qtyInput = document.getElementById('qty-' + autocompleteCategory);
@@ -1764,11 +1862,11 @@ function filterAutocompleteMulti(search) {
const searchLower = search.toLowerCase(); const searchLower = search.toLowerCase();
// Filter out already added items // Filter out already added items
const addedLots = new Set(cart.map(i => i.lot_name)); const addedLots = new Set(cart.map(i => (i.lot_name || '').toUpperCase()));
autocompleteFiltered = components.filter(c => { autocompleteFiltered = components.filter(c => {
if (!hasComponentPrice(c.lot_name)) return false; if (!hasComponentPrice(c.lot_name)) return false;
if (addedLots.has(c.lot_name)) return false; if (addedLots.has((c.lot_name || '').toUpperCase())) return false;
if (!isComponentAllowedByStockFilter(c)) return false; if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase(); const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower); return text.includes(searchLower);
@@ -1869,11 +1967,11 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
}); });
// Filter out already added items // Filter out already added items
const addedLots = new Set(cart.map(i => i.lot_name)); const addedLots = new Set(cart.map(i => (i.lot_name || '').toUpperCase()));
autocompleteFiltered = sectionComponents.filter(c => { autocompleteFiltered = sectionComponents.filter(c => {
if (!hasComponentPrice(c.lot_name)) return false; if (!hasComponentPrice(c.lot_name)) return false;
if (addedLots.has(c.lot_name)) return false; if (addedLots.has((c.lot_name || '').toUpperCase())) return false;
if (!isComponentAllowedByStockFilter(c)) return false; if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase(); const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower); return text.includes(searchLower);
@@ -2039,14 +2137,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 || '').toUpperCase()));
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 popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
}); });
const inCart = all.filter(c => cartLots.has((c.lot_name || '').toUpperCase()))
.sort((a, b) => a.lot_name.localeCompare(b.lot_name));
const notInCart = all.filter(c => !cartLots.has((c.lot_name || '').toUpperCase()))
.sort((a, b) => {
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
if (inCart.length && notInCart.length) {
autocompleteFiltered = [...inCart, {isDivider: true}, ...notInCart];
} else {
autocompleteFiltered = [...inCart, ...notInCart];
}
renderAutocomplete(); renderAutocomplete();
} }
@@ -2071,7 +2179,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;
@@ -2081,7 +2189,7 @@ function selectAutocompleteItemBOM(index, rowIdx) {
function clearSingleSelect(category) { function clearSingleSelect(category) {
cart = cart.filter(item => cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase() (item.category).toUpperCase() !== category.toUpperCase()
); );
renderTab(); renderTab();
updateCartUI(); updateCartUI();
@@ -2091,7 +2199,7 @@ function clearSingleSelect(category) {
function updateSingleQuantity(category, value) { function updateSingleQuantity(category, value) {
const qty = parseInt(value) || 1; const qty = parseInt(value) || 1;
const item = cart.find(i => const item = cart.find(i =>
(i.category || getCategoryFromLotName(i.lot_name)).toUpperCase() === category.toUpperCase() (i.category).toUpperCase() === category.toUpperCase()
); );
if (item) { if (item) {
@@ -2131,6 +2239,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);
@@ -2149,8 +2258,8 @@ function updateCartUI() {
// Sort cart items by category display order // Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => { const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase(); const catA = (a.category).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase(); const catB = (b.category).toUpperCase();
const orderA = categoryOrderMap[catA] || 9999; const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999; const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB; return orderA - orderB;
@@ -2158,7 +2267,7 @@ function updateCartUI() {
const grouped = {}; const grouped = {};
sortedCart.forEach(item => { sortedCart.forEach(item => {
const cat = item.category || getCategoryFromLotName(item.lot_name); const cat = item.category;
const tab = getTabForCategory(cat); const tab = getTabForCategory(cat);
if (!grouped[tab]) grouped[tab] = []; if (!grouped[tab]) grouped[tab] = [];
grouped[tab].push(item); grouped[tab].push(item);
@@ -2167,11 +2276,11 @@ function updateCartUI() {
// Sort tabs by minimum display order of their categories // Sort tabs by minimum display order of their categories
const sortedTabs = Object.entries(grouped).sort((a, b) => { const sortedTabs = Object.entries(grouped).sort((a, b) => {
const minOrderA = Math.min(...a[1].map(item => { const minOrderA = Math.min(...a[1].map(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase(); const cat = (item.category).toUpperCase();
return categoryOrderMap[cat] || 9999; return categoryOrderMap[cat] || 9999;
})); }));
const minOrderB = Math.min(...b[1].map(item => { const minOrderB = Math.min(...b[1].map(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase(); const cat = (item.category).toUpperCase();
return categoryOrderMap[cat] || 9999; return categoryOrderMap[cat] || 9999;
})); }));
return minOrderA - minOrderB; return minOrderA - minOrderB;
@@ -2402,8 +2511,7 @@ function restoreAutosaveDraftIfAny() {
warehouse_price: null, warehouse_price: null,
competitor_price: null, competitor_price: null,
description: item.description || '', description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name) category: item.category }));
}));
} }
if (typeof payload.server_count === 'number' && payload.server_count > 0) { if (typeof payload.server_count === 'number' && payload.server_count > 0) {
serverCount = payload.server_count; serverCount = payload.server_count;
@@ -2426,6 +2534,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 +2695,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) {
@@ -2620,8 +2731,8 @@ function renderSalePriceTable() {
} }
const sortedCart = [...cart].sort((a, b) => { const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase(); const catA = (a.category).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase(); const catB = (b.category).toUpperCase();
const orderA = categoryOrderMap[catA] || 9999; const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999; const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB; return orderA - orderB;
@@ -2709,7 +2820,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;
@@ -2724,8 +2835,8 @@ function calculateCustomPrice() {
// Build adjusted prices table // Build adjusted prices table
// Sort cart items by category display order // Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => { const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase(); const catA = (a.category).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase(); const catB = (b.category).toUpperCase();
const orderA = categoryOrderMap[catA] || 9999; const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999; const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB; return orderA - orderB;
@@ -2817,7 +2928,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 +2944,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 +2958,53 @@ 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;
// Create a revision of the current state before prices are updated
if (configUUID) {
try {
await fetch('/api/configs/' + configUUID + '/snapshot', { method: 'POST' });
} catch (e) {
console.warn('pre-refresh snapshot failed', e);
}
}
await saveConfig(false); await 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,62 +3174,44 @@ 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 = []; bomImportRaw = {
for (const raw of lines) { mode: 'raw',
const line = raw.trim(); rows: parsed,
if (!line) continue; columnTypes: ['pn', 'qty'],
const clean = line.startsWith('|') ? line.slice(1).trim() : line; ignoredRows: {},
if (!clean) continue; rowErrors: {},
const starIdx = clean.lastIndexOf('*'); uiError: ''
if (starIdx > 0) { };
const suffix = clean.slice(starIdx + 1).trim(); bomRows = [];
if (/^\d+$/.test(suffix)) { _setBomUIError('');
result.push([clean.slice(0, starIdx).trim(), suffix]); rebuildBOMRowsFromRaw();
continue; renderBOMTable();
} return true;
}
result.push([clean, '1']);
}
return result;
} }
function _isInspurBOMText(text) { // Detection and parsing of known single-column text BOM formats (Inspur, Russian
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0); // text BOM) lives only on the server (internal/services/vendor_workspace_import.go),
if (!lines.length) return false; // shared with the vendor file-import path. The paste handler asks the server to
let matches = 0; // parse; an unrecognized payload falls back to the generic Excel column grid below.
for (const line of lines) { async function _serverParseBOMText(text) {
const t = line.trim(); try {
const idx = t.lastIndexOf('*'); const resp = await fetch('/api/vendor-spec/parse-text', {
if (idx > 0 && /^\d+$/.test(t.slice(idx + 1).trim())) matches++; 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;
} }
return matches > 0 && matches >= Math.ceil(lines.length * 0.5);
} }
function handleBOMPaste(event) { function _applyGenericBOMPaste(text) {
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 = {
mode: 'raw',
rows: parsed,
columnTypes: ['pn', 'qty'],
ignoredRows: {},
rowErrors: {},
uiError: ''
};
bomRows = [];
_setBomUIError('');
rebuildBOMRowsFromRaw();
renderBOMTable();
return;
}
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 +3231,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 +3329,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 +3715,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 +3782,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 +4050,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 +4061,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 +4120,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 +4145,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).toUpperCase();
const catB = (b.category).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 +4171,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 +4183,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 +4398,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 +4491,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 +4504,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 +4517,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">
@@ -207,9 +207,11 @@
</div> </div>
<div> <div>
<label for="new-variant-value" class="block text-sm font-medium text-gray-700 mb-1">Вариант</label> <label for="new-variant-value" class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
<input id="new-variant-value" type="text" placeholder="Например: Lenovo" <input id="new-variant-value" type="text" placeholder="Например: B200"
pattern="[A-Za-z0-9._-]+"
title="Только буквы, цифры, дефис, точка, подчёркивание"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<div class="text-xs text-gray-500 mt-1">Оставьте пустым для main нельзя — нужно уникальное значение.</div> <div class="text-xs text-gray-500 mt-1">Буквы, цифры, дефис, точка, подчёркивание. Используется в URL: /КОД/Вариант.</div>
</div> </div>
</div> </div>
<div class="mt-6 flex justify-end gap-2"> <div class="mt-6 flex justify-end gap-2">
@@ -229,7 +231,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 +259,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 +341,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 +636,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');
@@ -842,6 +844,10 @@ async function createNewVariant() {
showToast('Укажите вариант', 'error'); showToast('Укажите вариант', 'error');
return; return;
} }
if (!/^[A-Za-z0-9._-]+$/.test(variant)) {
showToast('Имя варианта содержит недопустимые символы. Разрешены: буквы, цифры, дефис, точка, подчёркивание.', 'error');
return;
}
const payload = { const payload = {
code: code, code: code,
variant: variant, variant: variant,
@@ -1105,7 +1111,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 +1578,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,87 +1592,76 @@ 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. for (const cfg of configs) {
let latestEstimatePricelistId = null; if (cfg.disable_price_refresh) {
try { diffResults.push({ configName: cfg.name, skipped: true });
const plResp = await fetch('/api/pricelists?active_only=true&source=estimate&per_page=1'); continue;
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; const prevTotal = cfg.total_price || 0;
let newTotalSum = 0; const prevItemsMap = {};
for (const cfg of configs) { if (cfg.items) {
try { for (const item of cfg.items) prevItemsMap[item.lot_name] = item.unit_price;
const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined; }
const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
method: 'POST', try {
headers: body ? { 'Content-Type': 'application/json' } : {}, const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined;
body, const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
}); method: 'POST',
if (!resp.ok) { failed++; continue; } headers: body ? { 'Content-Type': 'application/json' } : {},
const updated = await resp.json(); body,
if (updated && updated.total_price != null) { });
cfg.total_price = updated.total_price; if (!resp.ok) {
const totalCell = document.querySelector('[data-total-uuid="' + cfg.uuid + '"]'); diffResults.push({ configName: cfg.name, error: true });
if (totalCell) totalCell.textContent = formatMoneyNoDecimals(updated.total_price); continue;
const serverCount = cfg.server_count || 1;
const unitPrice = serverCount > 0 ? (updated.total_price / serverCount) : 0;
const row = totalCell && totalCell.closest('tr');
if (row) {
const cells = row.querySelectorAll('td');
if (cells[4]) cells[4].textContent = formatMoneyNoDecimals(unitPrice);
} }
const updated = await resp.json();
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 + '"]');
if (totalCell && updated.total_price != null) {
totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
const sc = cfg.server_count || 1;
const row = totalCell.closest('tr');
if (row) {
const cells = row.querySelectorAll('td');
if (cells[4]) cells[4].textContent = formatMoneyNoDecimals(sc > 0 ? updated.total_price / sc : 0);
}
}
const itemDiffs = [];
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 });
} }
newTotalSum += cfg.total_price || 0; }
} catch { failed++; }
}
const footerTotal = document.querySelector('[data-footer-total="1"]'); updateFooterTotal();
if (footerTotal) footerTotal.textContent = formatMoneyNoDecimals(newTotalSum); showToast('Цены обновлены', 'success');
showPriceDiffModal(diffResults);
if (btn) { } catch(e) {
btn.disabled = false; showToast('Ошибка обновления цен', 'error');
btn.textContent = 'Обновить цены'; } finally {
btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium'; if (btn) {
} btn.disabled = false;
btn.textContent = 'Обновить цены';
if (failed > 0) { btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium';
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">
@@ -39,12 +39,18 @@
<div> <div>
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label> <label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input id="create-project-code" type="text" placeholder="Например: OPS-123" <input id="create-project-code" type="text" placeholder="Например: OPS-123"
pattern="[A-Za-z0-9._-]+"
title="Только буквы, цифры, дефис, точка, подчёркивание"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="text-xs text-gray-400 mt-1">Буквы, цифры, дефис, точка, подчёркивание. Код используется в URL.</p>
</div> </div>
<div> <div>
<label for="create-project-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label> <label for="create-project-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
<input id="create-project-variant" type="text" placeholder="Например: Lenovo" <input id="create-project-variant" type="text" placeholder="Например: B200"
pattern="[A-Za-z0-9._-]*"
title="Только буквы, цифры, дефис, точка, подчёркивание"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="text-xs text-gray-400 mt-1">Используется в URL: /КОД/Вариант</p>
</div> </div>
<div> <div>
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label> <label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
@@ -81,7 +87,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) {
@@ -396,6 +402,14 @@ async function createProject() {
alert('Введите код проекта'); alert('Введите код проекта');
return; return;
} }
if (!/^[A-Za-z0-9._-]+$/.test(code)) {
alert('Код проекта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
return;
}
if (variant && !/^[A-Za-z0-9._-]+$/.test(variant)) {
alert('Имя варианта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
return;
}
const resp = await fetch('/api/projects', { const resp = await fetch('/api/projects', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@@ -411,6 +425,11 @@ async function createProject() {
alert('Проект с таким кодом и вариантом уже существует'); alert('Проект с таким кодом и вариантом уже существует');
return; return;
} }
if (resp.status === 400) {
const body = await resp.json().catch(() => ({}));
alert(body.error || 'Некорректный запрос');
return;
}
alert('Не удалось создать проект'); alert('Не удалось создать проект');
return; return;
} }