Compare commits

...

30 Commits
v1.15 ... v2.24

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -8,9 +8,8 @@ Main tables:
| Table | Purpose | | Table | Purpose |
| --- | --- | | --- | --- |
| `local_components` | synced component metadata |
| `local_pricelists` | local pricelist headers | | `local_pricelists` | local pricelist headers |
| `local_pricelist_items` | local pricelist rows, the only runtime price source | | `local_pricelist_items` | pricelist rows; the only runtime source of prices and component catalog |
| `local_projects` | user projects | | `local_projects` | user projects |
| `local_configurations` | user configurations | | `local_configurations` | user configurations |
| `local_configuration_versions` | immutable revision snapshots | | `local_configuration_versions` | immutable revision snapshots |
@@ -20,12 +19,14 @@ 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;
- user-authored tables must not be dropped as a recovery shortcut; - user-authored tables must not be dropped as a recovery shortcut;
- `local_pricelist_items` is the only valid runtime source of prices; - `local_pricelist_items` is the only valid runtime source of prices and component catalog; do not add a separate component cache table;
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows. - configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows;
- `local_components` table has been removed; any reference to it is dead code.
## MariaDB ## MariaDB
@@ -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
@@ -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 |

View File

@@ -112,6 +112,41 @@ Rules:
- lines that do not match `<description> - <quantity> шт.` are skipped; - 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. - 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 ## Pasted BOM text parsing
`POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM `POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM
@@ -119,7 +154,7 @@ Rules:
`{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`. `{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`.
This shares the exact detectors and parsers used by the file-import path This shares the exact detectors and parsers used by the file-import path
(`ParsePastedBOMText``IsInspurBOM`/`parseInspurBOM`, `IsTextBOM`/`parseTextBOM`), so paste and upload (`ParsePastedBOMText``IsInspurBOM`/`parseInspurBOM`, `IsNxBOM`/`parseNxBOM`, `IsTextBOM`/`parseTextBOM`),
behave identically — there is no second parser in the frontend. The configurator's BOM paste box calls so paste and upload behave identically — there is no second parser in the frontend. The configurator's BOM
this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real spreadsheet table) paste box calls this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real
falls back to the manual column-mapping grid. spreadsheet table) falls back to the manual column-mapping grid.

View File

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

View File

@@ -894,6 +894,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/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")
{ {
@@ -919,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")
@@ -1147,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 {
@@ -1516,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)
@@ -1554,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)
@@ -1776,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"})

View File

@@ -125,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

@@ -177,22 +177,12 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
return return
} }
lotNames := make([]string, len(items))
for i, item := range items {
lotNames[i] = item.LotName
}
descMap, err := h.localDB.GetLocalComponentDescriptionsByLotNames(lotNames)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
resultItems := make([]gin.H, 0, len(items)) 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,

View File

@@ -74,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(),
@@ -139,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 {
@@ -150,12 +151,78 @@ 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
migrations, err := h.localDB.GetSchemaMigrations() migrations, err := h.localDB.GetSchemaMigrations()
if err != nil { if err != nil {
@@ -163,6 +230,44 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
} }
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 {

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()
@@ -345,13 +272,16 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
"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 != "")

View File

@@ -4,9 +4,9 @@ import (
"errors" "errors"
"log/slog" "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" syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
@@ -172,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
} }

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{},
) )
} }
@@ -691,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 {
@@ -1220,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 {
@@ -1254,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.
@@ -1270,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 {
@@ -1283,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 {
@@ -1296,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
@@ -1363,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
@@ -1427,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
} }
@@ -1438,26 +1468,32 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
} }
// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query. // 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
@@ -1480,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
} }
@@ -1672,12 +1720,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
var remainingErrors []string var remainingErrors []string
for _, change := range erroredChanges { for _, change := range erroredChanges {
var modified bool
var repairErr error var repairErr error
switch change.EntityType { switch change.EntityType {
case "project": case "project":
repairErr = l.repairProjectChange(&change) modified, repairErr = l.repairProjectChange(&change)
case "configuration": case "configuration":
repairErr = l.repairConfigurationChange(&change) modified, repairErr = l.repairConfigurationChange(&change)
default: default:
repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType) repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
} }
@@ -1688,7 +1737,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
continue continue
} }
// Clear error and reset attempts // Only reset attempts when the repair actually changed local data.
// If nothing was modified, the error is server-side; leaving attempts
// intact lets maxPendingChangeAttempts eventually abandon the change.
if !modified {
continue
}
if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{ if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{
"last_error": "", "last_error": "",
"attempts": 0, "attempts": 0,
@@ -1704,12 +1759,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
} }
// repairProjectChange validates and fixes project data. // repairProjectChange validates and fixes project data.
// Returns (modified, err): modified=true only when local data was actually changed.
// Note: This only validates local data. Server-side conflicts (like duplicate code+variant) // Note: This only validates local data. Server-side conflicts (like duplicate code+variant)
// are handled by sync service layer with deduplication logic. // are handled by sync service layer with deduplication logic.
func (l *LocalDB) repairProjectChange(change *PendingChange) error { func (l *LocalDB) repairProjectChange(change *PendingChange) (bool, error) {
project, err := l.GetProjectByUUID(change.EntityUUID) project, err := l.GetProjectByUUID(change.EntityUUID)
if err != nil { if err != nil {
return fmt.Errorf("project not found locally: %w", err) return false, fmt.Errorf("project not found locally: %w", err)
} }
modified := false modified := false
@@ -1735,7 +1791,7 @@ func (l *LocalDB) repairProjectChange(change *PendingChange) error {
if strings.TrimSpace(project.OwnerUsername) == "" { if strings.TrimSpace(project.OwnerUsername) == "" {
project.OwnerUsername = l.GetDBUser() project.OwnerUsername = l.GetDBUser()
if project.OwnerUsername == "" { if project.OwnerUsername == "" {
return fmt.Errorf("cannot determine owner username") return false, fmt.Errorf("cannot determine owner username")
} }
modified = true modified = true
} }
@@ -1756,18 +1812,19 @@ func (l *LocalDB) repairProjectChange(change *PendingChange) error {
if modified { if modified {
if err := l.SaveProject(project); err != nil { if err := l.SaveProject(project); err != nil {
return fmt.Errorf("saving repaired project: %w", err) return false, fmt.Errorf("saving repaired project: %w", err)
} }
} }
return nil return modified, nil
} }
// repairConfigurationChange validates and fixes configuration data // repairConfigurationChange validates and fixes configuration data.
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error { // Returns (modified, err): modified=true only when local data was actually changed.
func (l *LocalDB) repairConfigurationChange(change *PendingChange) (bool, error) {
config, err := l.GetConfigurationByUUID(change.EntityUUID) config, err := l.GetConfigurationByUUID(change.EntityUUID)
if err != nil { if err != nil {
return fmt.Errorf("configuration not found locally: %w", err) return false, fmt.Errorf("configuration not found locally: %w", err)
} }
modified := false modified := false
@@ -1779,7 +1836,7 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
// Project doesn't exist locally - use default system project // Project doesn't exist locally - use default system project
systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername) systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
if sysErr != nil { if sysErr != nil {
return fmt.Errorf("getting system project: %w", sysErr) return false, fmt.Errorf("getting system project: %w", sysErr)
} }
config.ProjectUUID = &systemProject.UUID config.ProjectUUID = &systemProject.UUID
modified = true modified = true
@@ -1788,11 +1845,11 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
if modified { if modified {
if err := l.SaveConfiguration(config); err != nil { if err := l.SaveConfiguration(config); err != nil {
return fmt.Errorf("saving repaired configuration: %w", err) return false, fmt.Errorf("saving repaired configuration: %w", err)
} }
} }
return nil return modified, nil
} }
// GetSyncGuardState returns the latest readiness guard state. // GetSyncGuardState returns the latest readiness guard state.
@@ -1854,28 +1911,6 @@ func (l *LocalDB) GetLocalPricelistItemsPage(pricelistID uint, search string, pa
return items, total, nil return items, total, nil
} }
// GetLocalComponentDescriptionsByLotNames returns a map of lot_name → lot_description for the given lots.
func (l *LocalDB) GetLocalComponentDescriptionsByLotNames(lotNames []string) (map[string]string, error) {
if len(lotNames) == 0 {
return map[string]string{}, nil
}
type row struct {
LotName string
LotDescription string
}
var rows []row
if err := l.db.Table("local_components").
Select("lot_name, lot_description").
Where("lot_name IN ?", lotNames).
Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("fetch component descriptions: %w", err)
}
m := make(map[string]string, len(rows))
for _, r := range rows {
m[r.LotName] = r.LotDescription
}
return m, nil
}
// GetSchemaMigrations returns all applied local schema migrations ordered by applied_at. // GetSchemaMigrations returns all applied local schema migrations ordered by applied_at.
func (l *LocalDB) GetSchemaMigrations() ([]LocalSchemaMigration, error) { func (l *LocalDB) GetSchemaMigrations() ([]LocalSchemaMigration, error) {

View File

@@ -1120,3 +1120,4 @@ func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error {
slog.Info("deduplicated local_pricelist_items and added unique index") 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 {
@@ -170,6 +178,7 @@ type LocalPricelist struct {
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,5 +1,13 @@
package models package models
import "strings"
// NormalizeLotName returns the canonical form of a lot name: trimmed and uppercased.
// Apply at every point where a lot name enters the system (sync, API input, config load).
func NormalizeLotName(s string) string {
return strings.ToUpper(strings.TrimSpace(s))
}
// Lot represents existing lot table // Lot represents existing lot table
type Lot struct { type Lot struct {
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"` LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`

View File

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

View File

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

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

View File

@@ -60,6 +60,7 @@ type ProjectPricingExportOptions struct {
IncludeCompetitor bool `json:"include_competitor"` IncludeCompetitor bool `json:"include_competitor"`
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob" Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3 SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows
} }
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 { func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
@@ -95,6 +96,7 @@ type ProjectPricingExportRow struct {
Estimate *float64 Estimate *float64
Stock *float64 Stock *float64
Competitor *float64 Competitor *float64
ManualPrice *float64 // proportional share of the user-defined total price
} }
// ToCSV writes project export data in the new structured CSV format. // ToCSV writes project export data in the new structured CSV format.
@@ -388,17 +390,37 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
description = componentDescriptions[rowMappings[0].LotName] description = componentDescriptions[rowMappings[0].LotName]
} }
pricingRow := ProjectPricingExportRow{ if len(rowMappings) == 0 {
LotDisplay: formatLotDisplay(rowMappings), block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: "н/д",
VendorPN: row.VendorPartnumber, VendorPN: row.VendorPartnumber,
Description: description, Description: description,
Quantity: exportPositiveInt(row.Quantity, 1), Quantity: exportPositiveInt(row.Quantity, 1),
BOMTotal: vendorRowTotal(row), BOMTotal: vendorRowTotal(row),
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }), })
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }), continue
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }), }
// One export row per LOT mapping so that bundles (1 PN → N LOTs) appear
// as separate lines, matching the frontend pricing table layout.
pnQty := exportPositiveInt(row.Quantity, 1)
for i, mapping := range rowMappings {
lotQty := pnQty * mapping.QuantityPerPN
var bomTotal *float64
if i == 0 {
bomTotal = vendorRowTotal(row)
}
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: mapping.LotName,
VendorPN: row.VendorPartnumber,
Description: description,
Quantity: lotQty,
BOMTotal: bomTotal,
Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }),
Stock: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Stock }),
Competitor: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Competitor }),
})
} }
block.Rows = append(block.Rows, pricingRow)
} }
for _, item := range cfg.Items { for _, item := range cfg.Items {
@@ -422,10 +444,22 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
if opts.isDDP() { if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor()) applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
distributeManualPrice(block.Rows, *opts.ManualPrice)
}
return block, nil return block, nil
} }
catOrder := defaultCategoryOrder()
lotNames := make([]string, 0, len(cfg.Items))
for _, item := range cfg.Items { for _, item := range cfg.Items {
if item.LotName != "" {
lotNames = append(lotNames, item.LotName)
}
}
itemCategories := s.resolveCategories(cfg.PricelistID, lotNames)
sortedItems := sortConfigItemsByCategoryMap(cfg.Items, catOrder, itemCategories)
for _, item := range sortedItems {
if item.LotName == "" { if item.LotName == "" {
continue continue
} }
@@ -444,10 +478,29 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
if opts.isDDP() { if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor()) applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
distributeManualPrice(block.Rows, *opts.ManualPrice)
}
return block, nil return block, nil
} }
// sortConfigItemsByCategoryMap returns a copy of items sorted by category display order.
// categories maps lot_name → category code; catOrder maps category code → display order.
func sortConfigItemsByCategoryMap(items models.ConfigItems, catOrder map[string]int, categories map[string]string) models.ConfigItems {
sorted := make(models.ConfigItems, len(items))
copy(sorted, items)
sort.SliceStable(sorted, func(i, j int) bool {
orderI, hasI := categoryDisplayOrder(catOrder, categories[sorted[i].LotName])
orderJ, hasJ := categoryDisplayOrder(catOrder, categories[sorted[j].LotName])
if hasI && hasJ {
return orderI < orderJ
}
return hasI && !hasJ
})
return sorted
}
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) { func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
for i := range rows { for i := range rows {
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor) rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
@@ -603,16 +656,8 @@ func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string
return prices 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)
if s.localDB == nil || len(lots) == 0 {
return map[string]string{} return map[string]string{}
}
descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots)
if err != nil {
return map[string]string{}
}
return descriptions
} }
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string { func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
@@ -696,6 +741,52 @@ func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.V
return floatPtr(total) 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
@@ -716,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")
@@ -734,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))
@@ -760,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, "")
@@ -786,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

@@ -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)
@@ -820,6 +839,13 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
} }
} }
// Capture fingerprint of the current state before any mutations.
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
}
preRefreshCfg := *localCfg
// Update prices for all items from pricelist // 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 {
@@ -859,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)
@@ -866,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 {
@@ -1432,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 != "" {
@@ -1478,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"
@@ -22,8 +23,13 @@ var (
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

@@ -111,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 {
@@ -245,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))
@@ -303,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),
} }

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,8 +1310,13 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID) localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
if localErr != nil { if localErr != nil {
return err // Project not found locally either: stale reference (project was deleted).
} // Fall through to system project so this configuration is not stuck forever.
slog.Warn("configuration references missing project, assigning to system project",
"cfg_uuid", cfg.UUID,
"project_uuid", *cfg.ProjectUUID,
)
} else {
modelProject := localdb.LocalToProject(localProject) modelProject := localdb.LocalToProject(localProject)
if modelProject.OwnerUsername == "" { if modelProject.OwnerUsername == "" {
modelProject.OwnerUsername = cfg.OwnerUsername modelProject.OwnerUsername = cfg.OwnerUsername
@@ -1264,6 +1334,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
} }
return nil return nil
} }
}
systemProject := &models.Project{} systemProject := &models.Project{}
err := mariaDB. err := mariaDB.
@@ -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

@@ -135,6 +135,8 @@ 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): case IsTextBOM(data):
workspace, err = parseTextBOM(data, filepath.Base(sourceFileName)) workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
default: default:
@@ -683,6 +685,93 @@ 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 // textBOMItemLine matches a human-readable BOM line of the form
// "<description> - <quantity> шт." where the separator may be a hyphen, // "<description> - <quantity> шт." where the separator may be a hyphen,
// en-dash or em-dash and the quantity may have an optional space before "шт". // en-dash or em-dash and the quantity may have an optional space before "шт".
@@ -709,6 +798,8 @@ func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) {
switch { switch {
case IsInspurBOM(data): case IsInspurBOM(data):
ws, err = parseInspurBOM(data, "") ws, err = parseInspurBOM(data, "")
case IsNxBOM(data):
ws, err = parseNxBOM(data, "")
case IsTextBOM(data): case IsTextBOM(data):
ws, err = parseTextBOM(data, "") ws, err = parseTextBOM(data, "")
default: default:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
# QuoteForge v2.22
Дата релиза: 2026-06-26
Тег: `v2.22`
## Что нового
### Исправления
- **MB-автокомплит в конфигураторе теперь работает в offline-режиме.** Корневая причина: прайслист мог быть синхронизирован до введения нормализации имён лотов, из-за чего SQLite хранил их в исходном регистре (`MB_AMD_2.Rome_...`). Запрос на поиск цены отправлял уже нормализованное имя (`MB_AMD_2.ROME_...`), `IN`-сравнение в SQLite регистрозависимо — совпадений не было, цена возвращалась как null, и автокомплит показывал пустой список. Все запросы к `local_pricelist_items` по `lot_name` переведены на `UPPER(lot_name)`.
- **Удалён мёртвый код инференса категории из имени лота.** Функция `getCategoryFromLotName` на фронтенде выводила категорию из префикса лота (`DKC_AFF_A1K``DKC`) как fallback. Категория всегда приходит из прайслиста; функция удалена. Позиции без категории корректно попадают во вкладку «Other».
- **Удалена таблица `local_components` и весь связанный с ней код.** Источник данных для компонентов — только `local_pricelist_items`. Убраны маршрут `POST /api/sync/components`, поля `ComponentsSynced` и `LastComponentSync` в ответах синхронизации.
- **Support bundle расширен диагностическими файлами:** `latest_pricelist_items.json` (все позиции активного estimate-прайслиста), `autocomplete_lots.json` (позиции по категориям с флагом `has_price`), `local.db` (полная копия SQLite-базы).
- **Регистронезависимые сравнения lot_name на фронтенде:** Set-коллекции для склада, добавленных позиций и корзины BOM теперь нормализуют ключи через `.toUpperCase()`.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -0,0 +1,23 @@
# QuoteForge v2.23
Дата релиза: 2026-06-26
Тег: `v2.23`
## Что нового
### Исправления
- **Конфигуратор больше не зависает на «Загрузка...».** При открытии сохранённой конфигурации поле `category` у позиций корзины было `undefined``config.items` хранятся только `lot_name/quantity/unit_price`), что приводило к `TypeError` в JS. Теперь после загрузки `allComponents` корзина обогащается категориями из справочника компонентов.
- **Регистронезависимые сравнения категорий в конфигураторе.** Все сравнения `category` переведены на хелпер `ciStr()` вместо принудительного `.toUpperCase()` — интерфейс показывает категории как есть, логика сравнения регистронезависима.
- **Вкладка Other показывает только компоненты без назначенной категории.** Исправлена ошибка при которой компоненты DKC/CTL/ENC попадали в Other при режиме «server»: `ASSIGNED_CATEGORIES` пересобирался из отфильтрованного списка, а не из полного статического. Теперь используется `_allCategories`.
- **Исправлена ошибка «record not found» при синхронизации проектов.** `UpsertByUUID` передавал ненулевой `ID` в `INSERT … ON DUPLICATE KEY UPDATE`, из-за чего MariaDB разрешала коллизию по первичному ключу чужой строки, не обновляя `uuid`, — последующий `SELECT` не находил запись. Теперь `project.ID` сбрасывается в `0` до вставки.
- **Устранён бесконечный retry при ошибках синхронизации на стороне сервера.** `RepairPendingChanges` сбрасывал счётчик попыток даже если локальные данные не менялись, что создавало бесконечный цикл при серверных ошибках. Repair-функции теперь возвращают `(bool, error)` и сброс происходит только при `modified=true`.
## Запуск на 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

@@ -629,11 +629,13 @@
const totalColor = totalDelta > 0 ? 'text-red-600' : totalDelta < 0 ? 'text-green-600' : 'text-gray-600'; 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 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"> 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 class="text-gray-600 font-medium">Итог конфигурации</span>
<span> <span>
<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span> ${totalPrevHtml}<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
</span> </span>
</div>`; </div>`;
} }

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>
@@ -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">
@@ -417,7 +417,7 @@ let TAB_CONFIG = {
let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG) let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories) .flatMap(t => t.categories)
.map(c => c.toUpperCase()); .map(c => ciStr(c));
// State // State
let configUUID = '{{.ConfigUUID}}'; let configUUID = '{{.ConfigUUID}}';
@@ -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
@@ -757,16 +760,16 @@ async function loadCategoriesFromAPI() {
// Build category order map // Build category order map
categoryOrderMap = {}; categoryOrderMap = {};
cats.forEach(cat => { cats.forEach(cat => {
categoryOrderMap[cat.code.toUpperCase()] = cat.display_order; categoryOrderMap[ciStr(cat.code)] = cat.display_order;
}); });
// Build list of unassigned categories // Build list of unassigned categories
const knownCodes = Object.values(TAB_CONFIG) const knownCodes = Object.values(TAB_CONFIG)
.flatMap(t => t.categories) .flatMap(t => t.categories)
.map(c => c.toUpperCase()); .map(c => ciStr(c));
const unassignedCategories = cats const unassignedCategories = cats
.filter(cat => !knownCodes.includes(cat.code.toUpperCase())) .filter(cat => !knownCodes.includes(ciStr(cat.code)))
.sort((a, b) => a.display_order - b.display_order) .sort((a, b) => a.display_order - b.display_order)
.map(cat => cat.code); .map(cat => cat.code);
@@ -776,13 +779,102 @@ async function loadCategoriesFromAPI() {
// Rebuild ASSIGNED_CATEGORIES // Rebuild ASSIGNED_CATEGORIES
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG) ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories) .flatMap(t => t.categories)
.map(c => c.toUpperCase()); .map(c => ciStr(c));
} catch(e) { } catch(e) {
console.error('Failed to load categories, using defaults', e); console.error('Failed to load categories, using defaults', e);
// Will use default configuration if API fails // Will use default configuration if API fails
} }
} }
async function loadCfgSettings() {
if (typeof _cfgSettings !== 'undefined' && _cfgSettings) return _cfgSettings;
try {
const r = await fetch('/api/configurator-settings');
if (r.ok) {
window._cfgSettings = await r.json();
return window._cfgSettings;
}
} catch(e) { /* fallback to hardcoded */ }
return null;
}
function applyServerSettings(settings) {
if (!settings) return;
// config_types → category allowlist map
if (Array.isArray(settings.config_types) && settings.config_types.length) {
configTypeCategoryMap = {};
settings.config_types.forEach(ct => {
if (ct.code && Array.isArray(ct.categories)) {
configTypeCategoryMap[ct.code] = new Set(ct.categories.map(c => c.toUpperCase()));
}
});
}
// tab_config → update TAB_CONFIG (preserve .other)
if (Array.isArray(settings.tab_config) && settings.tab_config.length) {
const otherTab = TAB_CONFIG.other;
TAB_CONFIG = {};
settings.tab_config.forEach(tab => {
TAB_CONFIG[tab.key] = {
categories: Array.isArray(tab.categories) ? tab.categories : [],
singleSelect: !!tab.single_select,
label: tab.label || tab.key,
sections: tab.sections || undefined
};
});
TAB_CONFIG.other = otherTab || { categories: [], singleSelect: false, label: 'Other' };
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG).flatMap(t => t.categories).map(c => ciStr(c));
}
// always_visible_tabs
if (Array.isArray(settings.always_visible_tabs) && settings.always_visible_tabs.length) {
alwaysVisibleTabsSet = new Set(settings.always_visible_tabs);
}
// required_categories
if (settings.required_categories && typeof settings.required_categories === 'object') {
requiredCategoriesMap = {};
Object.entries(settings.required_categories).forEach(([ct, codes]) => {
if (Array.isArray(codes)) {
requiredCategoriesMap[ct] = new Set(codes.map(c => c.toUpperCase()));
}
});
}
applyConfigTypeToTabs();
updateTabVisibility();
updateRequiredCategoryBadges();
}
function updateRequiredCategoryBadges() {
const required = requiredCategoriesMap[configType];
if (!required || !required.size) return;
// Build set of categories that have at least one cart item
const filledCategories = new Set(
cart.map(item => (item.category || '').toUpperCase())
);
// For each tab, check if it contains any required-but-unfilled category
Object.entries(TAB_CONFIG).forEach(([tabKey, tabCfg]) => {
const btn = document.querySelector(`[data-tab="${tabKey}"]`);
if (!btn) return;
const tabCategories = (tabCfg.categories || []).map(c => c.toUpperCase());
const hasUnfilled = tabCategories.some(cat => required.has(cat) && !filledCategories.has(cat));
const badge = btn.querySelector('.required-badge');
if (hasUnfilled) {
if (!badge) {
const dot = document.createElement('span');
dot.className = 'required-badge inline-block w-1.5 h-1.5 bg-orange-400 rounded-full ml-1 align-middle';
btn.appendChild(dot);
}
} else if (badge) {
badge.remove();
}
});
}
// Initialize // 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 || '';
@@ -861,6 +953,10 @@ document.addEventListener('DOMContentLoaded', async function() {
loadAllComponents(), loadAllComponents(),
categoriesPromise, categoriesPromise,
]); ]);
cart = cart.map(item => ({
...item,
category: item.category || allComponents.find(c => c.lot_name.toUpperCase() === (item.lot_name || '').toUpperCase())?.category || ''
}));
syncPriceSettingsControls(); syncPriceSettingsControls();
renderPricelistSettingsSummary(); renderPricelistSettingsSummary();
updateRefreshPricesButtonState(); updateRefreshPricesButtonState();
@@ -879,6 +975,12 @@ document.addEventListener('DOMContentLoaded', async function() {
} }
}); });
// Save pricing state (ручная цена) on page exit so it survives navigation
window.addEventListener('pagehide', saveConfigOnExit);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') saveConfigOnExit();
});
// Load vendor spec BOM for this configuration // Load vendor spec BOM for this configuration
if (configUUID) { if (configUUID) {
loadVendorSpec(configUUID); loadVendorSpec(configUUID);
@@ -904,7 +1006,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() {
@@ -1120,19 +1222,16 @@ function applyPriceSettings() {
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true }); schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true });
} }
function getCategoryFromLotName(lotName) { function ciStr(s) { return (s || '').toLowerCase(); }
const parts = lotName.split('_');
return parts[0] || '';
}
function getComponentCategory(comp) { function getComponentCategory(comp) {
return (comp.category || getCategoryFromLotName(comp.lot_name)).toUpperCase(); return comp.category || '';
} }
function getTabForCategory(category) { function getTabForCategory(category) {
const cat = category.toUpperCase(); const cat = ciStr(category);
for (const [tabKey, tabConfig] of Object.entries(TAB_CONFIG)) { for (const [tabKey, tabConfig] of Object.entries(TAB_CONFIG)) {
if (tabConfig.categories.map(c => c.toUpperCase()).includes(cat)) { if (tabConfig.categories.some(c => ciStr(c) === cat)) {
return tabKey; return tabKey;
} }
} }
@@ -1154,77 +1253,78 @@ function switchTab(tab) {
renderTab(); renderTab();
} }
// Hardcoded fallback constants — used only when server has not provided config_types data
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']); const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
// Storage-only categories — hidden for server configs
const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC']; const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC'];
// Server-only categories — hidden for storage configs
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM']; const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID']; const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID'];
const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU']; const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU'];
const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU']; const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU'];
function applyConfigTypeToTabs() { function isCategoryVisibleForConfigType(code, cfgType) {
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC']; const allowed = configTypeCategoryMap[cfgType];
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL']; if (!allowed || allowed.size === 0) return _hardcodedCategoryVisible(code, cfgType);
const storageSections = [ return allowed.has(code.toUpperCase());
{ title: 'RAID Контроллеры', categories: ['RAID'] }, }
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
];
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
const pciSections = [
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
{ title: 'HBA', categories: ['HBA'] },
{ title: 'HIC', categories: ['HIC'] }
];
const powerCategories = ['PS', 'PSU'];
TAB_CONFIG.base.categories = baseCategories.filter(c => { function _hardcodedCategoryVisible(code, cfgType) {
if (configType === 'storage') { if (cfgType === 'storage') {
return !SERVER_ONLY_BASE_CATEGORIES.includes(c); if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return true;
} if (SERVER_ONLY_BASE_CATEGORIES.includes(code)) return false;
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c); 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;
TAB_CONFIG.storage.categories = storageCategories.filter(c => { } else {
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true; if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return false;
});
TAB_CONFIG.storage.sections = storageSections.filter(section => {
if (configType === 'storage') {
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
} }
return true; return true;
}); }
TAB_CONFIG.pci.categories = pciCategories.filter(c => { function _effectiveAlwaysVisibleTabs() {
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC'; return alwaysVisibleTabsSet || ALWAYS_VISIBLE_TABS;
}); }
TAB_CONFIG.pci.sections = pciSections.filter(section => {
if (configType === 'storage') { function applyConfigTypeToTabs() {
return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat)); // Filter each tab's categories by visibility for current configType.
// Uses server-driven allowlists when available; falls back to hardcoded constants.
Object.keys(TAB_CONFIG).forEach(tabKey => {
if (tabKey === 'other') return;
const tab = TAB_CONFIG[tabKey];
if (!tab || !Array.isArray(tab.categories)) return;
// Snapshot the full category list for this tab (stored in _allCategories if not yet saved)
if (!tab._allCategories) tab._allCategories = [...tab.categories];
tab.categories = tab._allCategories.filter(c => isCategoryVisibleForConfigType(c, configType));
if (Array.isArray(tab._allSections || tab.sections)) {
const allSections = tab._allSections || tab.sections;
if (!tab._allSections) tab._allSections = allSections.map(s => ({ ...s, categories: [...s.categories] }));
tab.sections = tab._allSections
.map(section => ({
...section,
categories: section.categories.filter(c => isCategoryVisibleForConfigType(c, configType))
}))
.filter(section => section.categories.length > 0);
} }
return section.title !== 'HIC';
});
TAB_CONFIG.power.categories = powerCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true;
}); });
// Rebuild assigned categories index // Rebuild assigned categories index using the full static list (_allCategories),
// not the filtered one — hidden categories still belong to their tab, not to Other.
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG) ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories) .flatMap(t => t._allCategories || t.categories)
.map(c => c.toUpperCase()); .map(c => ciStr(c));
} }
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(); return getTabForCategory(item.category) === tabId;
return getTabForCategory(cat) === tabId;
}); });
const visible = hasComponents || hasCartItems; const visible = hasComponents || hasCartItems;
btn.classList.toggle('hidden', !visible); btn.classList.toggle('hidden', !visible);
@@ -1240,15 +1340,15 @@ function getComponentsForTab(tab) {
return allComponents.filter(comp => { return allComponents.filter(comp => {
const category = getComponentCategory(comp); const category = getComponentCategory(comp);
if (tab === 'other') { if (tab === 'other') {
return !ASSIGNED_CATEGORIES.includes(category); return !ASSIGNED_CATEGORIES.includes(ciStr(category));
} }
return config.categories.map(c => c.toUpperCase()).includes(category); return config.categories.some(c => ciStr(c) === ciStr(category));
}); });
} }
function getComponentsForCategory(category) { function getComponentsForCategory(category) {
return allComponents.filter(comp => { return allComponents.filter(comp => {
return getComponentCategory(comp) === category.toUpperCase(); return ciStr(getComponentCategory(comp)) === ciStr(category);
}); });
} }
@@ -1310,10 +1410,10 @@ function renderSingleSelectTab(categories) {
categories.forEach(cat => { 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() ciStr(item.category) === ciStr(cat)
); );
const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null; const comp = selectedItem ? allComponents.find(c => c.lot_name.toUpperCase() === (selectedItem.lot_name || '').toUpperCase()) : null;
const price = comp?.current_price || 0; const 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,9 +1463,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(); return getTabForCategory(item.category) === currentTab;
const tab = getTabForCategory(cat);
return tab === currentTab;
}); });
let html = ` let html = `
@@ -1385,7 +1483,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,9 +1550,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(); return getTabForCategory(item.category) === currentTab;
const tab = getTabForCategory(cat);
return tab === currentTab;
}); });
let html = ''; let html = '';
@@ -1462,17 +1558,14 @@ function renderMultiSelectTabWithSections(sections) {
sections.forEach((section, sectionIdx) => { sections.forEach((section, sectionIdx) => {
// Get components for this section's categories // Get components for this section's categories
const sectionCategories = section.categories.map(c => c.toUpperCase());
const sectionComponents = allComponents.filter(comp => { const sectionComponents = allComponents.filter(comp => {
const category = getComponentCategory(comp); return section.categories.some(c => ciStr(c) === ciStr(getComponentCategory(comp)));
return sectionCategories.includes(category);
}); });
totalComponents += sectionComponents.length; totalComponents += sectionComponents.length;
// 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(); return section.categories.some(c => ciStr(c) === ciStr(item.category));
return sectionCategories.includes(cat);
}); });
// Section header // Section header
@@ -1499,7 +1592,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 +1749,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 +1805,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() ciStr(item.category) !== ciStr(autocompleteCategory)
); );
const qtyInput = document.getElementById('qty-' + autocompleteCategory); const qtyInput = document.getElementById('qty-' + autocompleteCategory);
@@ -1764,11 +1861,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 +1966,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 +2136,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 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); const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff; if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name); return a.lot_name.localeCompare(b.lot_name);
}); });
if (inCart.length && notInCart.length) {
autocompleteFiltered = [...inCart, {isDivider: true}, ...notInCart];
} else {
autocompleteFiltered = [...inCart, ...notInCart];
}
renderAutocomplete(); renderAutocomplete();
} }
@@ -2071,7 +2178,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 +2188,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() ciStr(item.category) !== ciStr(category)
); );
renderTab(); renderTab();
updateCartUI(); updateCartUI();
@@ -2091,7 +2198,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() ciStr(i.category) === ciStr(category)
); );
if (item) { if (item) {
@@ -2131,6 +2238,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 +2257,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 = ciStr(a.category);
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase(); const catB = ciStr(b.category);
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,8 +2266,7 @@ function updateCartUI() {
const grouped = {}; const grouped = {};
sortedCart.forEach(item => { sortedCart.forEach(item => {
const cat = item.category || getCategoryFromLotName(item.lot_name); const tab = getTabForCategory(item.category);
const tab = getTabForCategory(cat);
if (!grouped[tab]) grouped[tab] = []; if (!grouped[tab]) grouped[tab] = [];
grouped[tab].push(item); grouped[tab].push(item);
}); });
@@ -2167,11 +2274,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 = ciStr(item.category);
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 = ciStr(item.category);
return categoryOrderMap[cat] || 9999; return categoryOrderMap[cat] || 9999;
})); }));
return minOrderA - minOrderB; return minOrderA - minOrderB;
@@ -2402,8 +2509,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 +2532,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
@@ -2620,8 +2729,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 = ciStr(a.category);
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase(); const catB = ciStr(b.category);
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;
@@ -2724,8 +2833,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 = ciStr(a.category);
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase(); const catB = ciStr(b.category);
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;
@@ -2857,6 +2966,15 @@ async function refreshPrices() {
} }
beforeTotal *= serverCount; 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();
@@ -3209,7 +3327,7 @@ function _bomRawLotCell(rowIdx) {
cart.forEach(item => { cartMap[item.lot_name] = item.quantity; }); cart.forEach(item => { cartMap[item.lot_name] = item.quantity; });
const isUnresolved = !map.resolved_lot || map.resolution_source === 'unresolved'; const isUnresolved = !map.resolved_lot || map.resolution_source === 'unresolved';
const cartQty = map.resolved_lot ? (cartMap[map.resolved_lot] ?? null) : null; const cartQty = map.resolved_lot ? (cartMap[map.resolved_lot] ?? null) : null;
const qtyMismatch = cartQty !== null && cartQty !== map.quantity; const qtyMismatch = cartQty !== null && cartQty !== map.quantity * _getRowLotQtyPerPN(map);
const notInCart = map.resolved_lot && cartQty === null; const notInCart = map.resolved_lot && cartQty === null;
if (isUnresolved) { if (isUnresolved) {
@@ -3595,7 +3713,7 @@ function _renderBOMParsedTable() {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved'; const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
const cartQty = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null; const cartQty = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null;
const qtyMismatch = cartQty !== null && cartQty !== row.quantity; const qtyMismatch = cartQty !== null && cartQty !== row.quantity * _getRowLotQtyPerPN(row);
const notInCart = row.resolved_lot && cartQty === null; const notInCart = row.resolved_lot && cartQty === null;
if (isUnresolved) unresolved++; if (isUnresolved) unresolved++;
if (qtyMismatch || notInCart) mismatches++; if (qtyMismatch || notInCart) mismatches++;
@@ -3662,7 +3780,7 @@ function _renderBOMRawTable() {
else if (parsed) { else if (parsed) {
const isUnresolved = !parsed.resolved_lot || parsed.resolution_source === 'unresolved'; const isUnresolved = !parsed.resolved_lot || parsed.resolution_source === 'unresolved';
const cartQty = parsed.resolved_lot ? (cartMap[parsed.resolved_lot] ?? null) : null; const cartQty = parsed.resolved_lot ? (cartMap[parsed.resolved_lot] ?? null) : null;
const qtyMismatch = cartQty !== null && cartQty !== parsed.quantity; const qtyMismatch = cartQty !== null && cartQty !== parsed.quantity * _getRowLotQtyPerPN(parsed);
const notInCart = parsed.resolved_lot && cartQty === null; const notInCart = parsed.resolved_lot && cartQty === null;
if (isUnresolved) unresolved++; if (isUnresolved) unresolved++;
if (qtyMismatch || notInCart) mismatches++; if (qtyMismatch || notInCart) mismatches++;
@@ -3930,6 +4048,9 @@ async function renderPricingTab() {
}; };
// Collect LOTs to price: from BOM rows (resolved) or from cart // Collect LOTs to price: from BOM rows (resolved) or from cart
// Use cart quantity when available (source of truth); fall back to BOM-computed quantity.
const _cartQtyMap = {};
cart.forEach(item => { if (item?.lot_name) _cartQtyMap[item.lot_name] = item.quantity; });
let itemsForPriceLevels = []; let itemsForPriceLevels = [];
if (bomRows.length) { if (bomRows.length) {
const seen = new Set(); const seen = new Set();
@@ -3938,13 +4059,13 @@ async function renderPricingTab() {
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1); const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
if (baseLot && !seen.has(baseLot)) { if (baseLot && !seen.has(baseLot)) {
seen.add(baseLot); seen.add(baseLot);
itemsForPriceLevels.push({ lot_name: baseLot, quantity: row.quantity * _getRowLotQtyPerPN(row) }); itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[baseLot] ?? (row.quantity * _getRowLotQtyPerPN(row)) });
} }
if (allocs.length) { if (allocs.length) {
allocs.forEach(a => { allocs.forEach(a => {
if (!seen.has(a.lot_name)) { if (!seen.has(a.lot_name)) {
seen.add(a.lot_name); seen.add(a.lot_name);
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: row.quantity * a.quantity }); itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity) });
} }
}); });
} }
@@ -3997,6 +4118,8 @@ async function renderPricingTab() {
// ─── Build shared row data (unit prices for display, totals for math) ──── // ─── Build shared row data (unit prices for display, totals for math) ────
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize. // Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
const cartQtyMap = {};
cart.forEach(item => { if (item?.lot_name) cartQtyMap[item.lot_name] = item.quantity; });
const _buildRows = () => { const _buildRows = () => {
const result = []; const result = [];
const coveredLots = new Set(); const coveredLots = new Set();
@@ -4020,7 +4143,12 @@ async function renderPricingTab() {
}; };
if (!bomRows.length) { if (!bomRows.length) {
cart.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); }); const sortedByCategory = [...cart].sort((a, b) => {
const catA = ciStr(a.category);
const catB = ciStr(b.category);
return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999);
});
sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
return { result, coveredLots }; return { result, coveredLots };
} }
@@ -4041,7 +4169,7 @@ async function renderPricingTab() {
if (baseLot) { if (baseLot) {
const u = _getUnitPrices(priceMap[baseLot]); const u = _getUnitPrices(priceMap[baseLot]);
const lotQty = _getRowLotQtyPerPN(row); const lotQty = _getRowLotQtyPerPN(row);
const qty = row.quantity * lotQty; const qty = cartQtyMap[baseLot] ?? (row.quantity * lotQty);
subRows.push({ subRows.push({
lotCell: escapeHtml(baseLot), lotText: baseLot, qty, lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0, estUnit: u.estUnit > 0 ? u.estUnit : 0,
@@ -4053,7 +4181,7 @@ async function renderPricingTab() {
} }
allocs.forEach(a => { allocs.forEach(a => {
const u = _getUnitPrices(priceMap[a.lot_name]); const u = _getUnitPrices(priceMap[a.lot_name]);
const qty = row.quantity * a.quantity; const qty = cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity);
subRows.push({ subRows.push({
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty, lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0, estUnit: u.estUnit > 0 ? u.estUnit : 0,
@@ -4374,6 +4502,8 @@ function setPricingCustomPriceFromVendor() {
async function exportPricingCSV(table) { async function exportPricingCSV(table) {
if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; } if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; }
const basis = table === 'sale' ? 'ddp' : 'fob'; const basis = table === 'sale' ? 'ddp' : 'fob';
const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy';
const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || '');
try { try {
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, { const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
method: 'POST', method: 'POST',
@@ -4385,6 +4515,7 @@ async function exportPricingCSV(table) {
include_stock: true, include_stock: true,
include_competitor: true, include_competitor: true,
basis: basis, basis: basis,
manual_price: manualPrice > 0 ? manualPrice : null,
}), }),
}); });
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; } if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }

View File

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

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">

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">

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">
@@ -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">
@@ -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,

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>
@@ -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;
} }