Compare commits

...

20 Commits
v1.18 ... main

Author SHA1 Message Date
Mikhail Chusavitin
687ab99d85 docs: release notes v2.24
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:06:22 +03:00
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
42 changed files with 1622 additions and 727 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

@@ -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) {
@@ -276,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"`
@@ -297,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()
@@ -311,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()
@@ -349,7 +272,6 @@ 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
@@ -367,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)
@@ -381,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,
@@ -395,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,
@@ -556,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

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

@@ -656,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 {

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

@@ -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)
} }
@@ -888,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)
@@ -899,6 +928,10 @@ func (s *Service) PushPendingChanges() (int, error) {
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)
newAttempts := change.Attempts + 1 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 { if newAttempts >= maxPendingChangeAttempts {
slog.Error("abandoning pending change after max attempts", slog.Error("abandoning pending change after max attempts",
"id", change.ID, "type", change.EntityType, "op", change.Operation, "id", change.ID, "type", change.EntityType, "op", change.Operation,
@@ -919,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
} }
@@ -1606,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 {

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

@@ -0,0 +1,15 @@
# QuoteForge v2.24
Дата релиза: 2026-06-26
Тег: `v2.24`
## Что нового
### Исправления
- **Исправлен ReferenceError в конфигураторе при выборе компонента в секционированных вкладках (Storage, PCI).** Переменная `sectionCategories` не была определена — опечатка, должно быть `section.categories`. Ошибка возникала при клике на элемент автокомплита во вкладках с секциями.
## Запуск на 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();
@@ -910,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() {
@@ -1126,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;
} }
} }
@@ -1160,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);
@@ -1246,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);
}); });
} }
@@ -1316,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;
@@ -1369,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 = `
@@ -1391,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 += `
@@ -1458,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 = '';
@@ -1468,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
@@ -1505,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 += `
@@ -1718,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);
@@ -1774,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);
@@ -1879,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);
@@ -2049,14 +2136,14 @@ function showAutocompleteBOM(rowIdx, input) {
function filterAutocompleteBOM(rowIdx, search) { function filterAutocompleteBOM(rowIdx, search) {
const searchLower = (search || '').toLowerCase(); const searchLower = (search || '').toLowerCase();
const cartLots = new Set(cart.map(i => i.lot_name)); const cartLots = new Set(cart.map(i => (i.lot_name || '').toUpperCase()));
const all = (window._bomAllComponents || allComponents).filter(c => { 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);
}); });
const inCart = all.filter(c => cartLots.has(c.lot_name)) const inCart = all.filter(c => cartLots.has((c.lot_name || '').toUpperCase()))
.sort((a, b) => a.lot_name.localeCompare(b.lot_name)); .sort((a, b) => a.lot_name.localeCompare(b.lot_name));
const notInCart = all.filter(c => !cartLots.has(c.lot_name)) const notInCart = all.filter(c => !cartLots.has((c.lot_name || '').toUpperCase()))
.sort((a, b) => { .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;
@@ -2101,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();
@@ -2111,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) {
@@ -2151,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);
@@ -2169,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;
@@ -2178,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);
}); });
@@ -2187,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;
@@ -2422,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;
@@ -2643,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;
@@ -2747,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;
@@ -2880,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();
@@ -4049,8 +4144,8 @@ async function renderPricingTab() {
if (!bomRows.length) { if (!bomRows.length) {
const sortedByCategory = [...cart].sort((a, b) => { const sortedByCategory = [...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);
return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999); return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999);
}); });
sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); }); sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });

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