Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f24584f65c | ||
|
|
f6766ce6b8 | ||
|
|
464d2a48d7 | ||
|
|
b23eb1d75a | ||
|
|
cc72052c8a | ||
|
|
687ab99d85 | ||
|
|
ce7c8551be | ||
|
|
3788492089 | ||
|
|
f7d26a28f8 | ||
|
|
bb742d2f38 | ||
|
|
f70cc680f7 | ||
| 64c9c4e862 | |||
| cc91ca10fc | |||
| 7d190cc7a8 | |||
| 8b2dc6652a | |||
| cea979e327 | |||
| 4d002671ae | |||
| 949479550c | |||
|
|
677b5d898f | ||
|
|
b3cab3477b | ||
|
|
6d4a37df8b | ||
|
|
7cc101d24d | ||
|
|
4900cd073c | ||
|
|
c0588e9710 | ||
|
|
0cd4f99b46 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,15 @@ 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.
|
- `local_pricelist_items.lot_category` is the single source of a LOT's category at runtime (populated by sync from `qt_pricelist_items.lot_category`); do not derive category from a lot_name prefix or from `qt_categories`/`qt_lot_metadata`;
|
||||||
|
- 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 +36,11 @@ 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_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
|
||||||
@@ -51,6 +52,8 @@ Insert-only tracking:
|
|||||||
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
|
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
|
||||||
|
|
||||||
Server-side only (not queried by client runtime):
|
Server-side only (not queried by client runtime):
|
||||||
|
- `qt_categories` — pricelist category registry; QF runtime serves category lists for the UI from `models.DefaultCategories` (Go) overlaid with categories present in `local_pricelist_items`, not from this table. `name`/`name_ru` columns being removed.
|
||||||
|
- `qt_lot_metadata` — component metadata / price settings; the Go server-side component/category management layer (`ComponentRepository`, `CategoryRepository`, `ComponentService`) was removed — no client code reads this table
|
||||||
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
||||||
- `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime)
|
- `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime)
|
||||||
- `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`)
|
- `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`)
|
||||||
@@ -91,11 +94,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 |
|
||||||
|
|||||||
165
bible-local/server-contract-qt-settings.md
Normal file
165
bible-local/server-contract-qt-settings.md
Normal 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`.
|
||||||
@@ -677,8 +677,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
var projectService *services.ProjectService
|
var projectService *services.ProjectService
|
||||||
|
|
||||||
syncService = sync.NewService(connMgr, local)
|
syncService = sync.NewService(connMgr, local)
|
||||||
componentService := services.NewComponentService(nil, nil)
|
quoteService := services.NewQuoteService(nil, local)
|
||||||
quoteService := services.NewQuoteService(nil, nil, local, nil)
|
|
||||||
exportService := services.NewExportService(cfg.Export, local)
|
exportService := services.NewExportService(cfg.Export, local)
|
||||||
|
|
||||||
// isOnline function for local-first architecture
|
// isOnline function for local-first architecture
|
||||||
@@ -775,7 +774,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
templatesPath := filepath.Join("web", "templates")
|
templatesPath := filepath.Join("web", "templates")
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
componentHandler := handlers.NewComponentHandler(componentService, local)
|
componentHandler := handlers.NewComponentHandler(local)
|
||||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||||
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
|
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
|
||||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||||
@@ -894,6 +893,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 +939,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 +1168,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 +1546,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 +1586,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 +1810,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)
|
||||||
|
|||||||
@@ -1,31 +1,12 @@
|
|||||||
package article
|
package article
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrMissingCategoryForLot is returned when a lot has no category in local_pricelist_items.lot_category.
|
|
||||||
var ErrMissingCategoryForLot = errors.New("missing_category_for_lot")
|
|
||||||
|
|
||||||
type MissingCategoryForLotError struct {
|
|
||||||
LotName string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *MissingCategoryForLotError) Error() string {
|
|
||||||
if e == nil || strings.TrimSpace(e.LotName) == "" {
|
|
||||||
return ErrMissingCategoryForLot.Error()
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s: %s", ErrMissingCategoryForLot.Error(), e.LotName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *MissingCategoryForLotError) Unwrap() error {
|
|
||||||
return ErrMissingCategoryForLot
|
|
||||||
}
|
|
||||||
|
|
||||||
type Group string
|
type Group string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -61,9 +42,10 @@ func GroupForLotCategory(cat string) (group Group, ok bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveLotCategoriesStrict resolves categories for lotNames using local_pricelist_items.lot_category
|
// ResolveLotCategories returns lot_category for each lotName found in local_pricelist_items
|
||||||
// for a given server pricelist id. If any lot is missing or has empty category, returns an error.
|
// for the given server pricelist. Lots not found in the pricelist are omitted from the result —
|
||||||
func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
// callers must treat a missing key as "no category" and skip that lot.
|
||||||
|
func ResolveLotCategories(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||||
if local == nil {
|
if local == nil {
|
||||||
return nil, fmt.Errorf("local db is nil")
|
return nil, fmt.Errorf("local db is nil")
|
||||||
}
|
}
|
||||||
@@ -71,30 +53,8 @@ func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
missing := make([]string, 0)
|
for lot, cat := range cats {
|
||||||
for _, lot := range lotNames {
|
cats[lot] = strings.TrimSpace(cat)
|
||||||
cat := strings.TrimSpace(cats[lot])
|
|
||||||
if cat == "" {
|
|
||||||
missing = append(missing, lot)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cats[lot] = cat
|
|
||||||
}
|
|
||||||
if len(missing) > 0 {
|
|
||||||
fallback, err := local.GetLocalComponentCategoriesByLotNames(missing)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, lot := range missing {
|
|
||||||
if cat := strings.TrimSpace(fallback[lot]); cat != "" {
|
|
||||||
cats[lot] = cat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, lot := range missing {
|
|
||||||
if strings.TrimSpace(cats[lot]) == "" {
|
|
||||||
return nil, &MissingCategoryForLotError{LotName: lot}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return cats, nil
|
return cats, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package article
|
package article
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,7 +8,7 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
|
func TestResolveLotCategories_MissingLotOmitted(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)
|
||||||
@@ -36,16 +35,19 @@ func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
|
|||||||
t.Fatalf("save local items: %v", err)
|
t.Fatalf("save local items: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"})
|
cats, err := ResolveLotCategories(local, 1, []string{"CPU_A", "UNKNOWN"})
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatalf("expected error")
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if !errors.Is(err, ErrMissingCategoryForLot) {
|
if cats["CPU_A"] != "" {
|
||||||
t.Fatalf("expected ErrMissingCategoryForLot, got %v", err)
|
t.Fatalf("expected empty category for lot with blank lot_category, got %q", cats["CPU_A"])
|
||||||
|
}
|
||||||
|
if _, ok := cats["UNKNOWN"]; ok {
|
||||||
|
t.Fatalf("expected UNKNOWN lot to be omitted from result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) {
|
func TestResolveLotCategories_ReturnsKnownCategories(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)
|
||||||
@@ -53,39 +55,40 @@ func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) {
|
|||||||
t.Cleanup(func() { _ = local.Close() })
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
ServerID: 2,
|
ServerID: 1,
|
||||||
Source: "estimate",
|
Source: "estimate",
|
||||||
Version: "S-2026-02-11-002",
|
Version: "S-2026-02-11-001",
|
||||||
Name: "test",
|
Name: "test",
|
||||||
|
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 pricelist: %v", err)
|
||||||
}
|
}
|
||||||
localPL, err := local.GetLocalPricelistByServerID(2)
|
pl, err := local.GetLocalPricelistByServerID(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get local pricelist: %v", err)
|
t.Fatalf("get 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: pl.ID, LotName: "CPU_B", LotCategory: "CPU", Price: 10},
|
||||||
|
{PricelistID: pl.ID, LotName: "MB_X", LotCategory: "MB", Price: 5},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("save local items: %v", err)
|
t.Fatalf("save 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 := ResolveLotCategories(local, 1, []string{"CPU_B", "MB_X", "NOT_IN_PL"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected fallback, got error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if cats["CPU_B"] != "CPU" {
|
if cats["CPU_B"] != "CPU" {
|
||||||
t.Fatalf("expected CPU, got %q", cats["CPU_B"])
|
t.Fatalf("expected CPU, got %q", cats["CPU_B"])
|
||||||
}
|
}
|
||||||
|
if cats["MB_X"] != "MB" {
|
||||||
|
t.Fatalf("expected MB, got %q", cats["MB_X"])
|
||||||
|
}
|
||||||
|
if _, ok := cats["NOT_IN_PL"]; ok {
|
||||||
|
t.Fatalf("expected NOT_IN_PL to be omitted")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGroupForLotCategory(t *testing.T) {
|
func TestGroupForLotCategory(t *testing.T) {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
|
|||||||
return BuildResult{}, fmt.Errorf("pricelist_id required for article")
|
return BuildResult{}, fmt.Errorf("pricelist_id required for article")
|
||||||
}
|
}
|
||||||
|
|
||||||
cats, err := ResolveLotCategoriesStrict(local, *opts.ServerPricelist, lotNames)
|
cats, err := ResolveLotCategories(local, *opts.ServerPricelist, lotNames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildResult{}, err
|
return BuildResult{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,17 @@ import (
|
|||||||
|
|
||||||
"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/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ComponentHandler struct {
|
type ComponentHandler struct {
|
||||||
componentService *services.ComponentService
|
localDB *localdb.LocalDB
|
||||||
localDB *localdb.LocalDB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewComponentHandler(componentService *services.ComponentService, localDB *localdb.LocalDB) *ComponentHandler {
|
func NewComponentHandler(localDB *localdb.LocalDB) *ComponentHandler {
|
||||||
return &ComponentHandler{
|
return &ComponentHandler{
|
||||||
componentService: componentService,
|
localDB: localDB,
|
||||||
localDB: localDB,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,17 +31,10 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
perPage = 20
|
perPage = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
filter := repository.ComponentFilter{
|
|
||||||
Category: c.Query("category"),
|
|
||||||
Search: c.Query("search"),
|
|
||||||
HasPrice: c.Query("has_price") == "true",
|
|
||||||
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
|
|
||||||
}
|
|
||||||
|
|
||||||
localFilter := localdb.ComponentFilter{
|
localFilter := localdb.ComponentFilter{
|
||||||
Category: filter.Category,
|
Category: c.Query("category"),
|
||||||
Search: filter.Search,
|
Search: c.Query("search"),
|
||||||
HasPrice: filter.HasPrice,
|
HasPrice: c.Query("has_price") == "true",
|
||||||
}
|
}
|
||||||
offset := (page - 1) * perPage
|
offset := (page - 1) * perPage
|
||||||
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
||||||
@@ -125,3 +115,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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -346,10 +269,9 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, plNow, time.Since(plNow).Milliseconds())
|
h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, plNow, time.Since(plNow).Milliseconds())
|
||||||
slog.Error("pricelist sync failed during full sync", "error", err)
|
slog.Error("pricelist sync failed during full sync", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "pricelist sync failed",
|
"error": "pricelist sync failed",
|
||||||
"pending_pushed": pendingPushed,
|
"pending_pushed": pendingPushed,
|
||||||
"components_synced": componentsSynced,
|
|
||||||
})
|
})
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -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 != "")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
@@ -100,7 +99,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
|||||||
body.VendorSpec[i].SortOrder = (i + 1) * 10
|
body.VendorSpec[i].SortOrder = (i + 1) * 10
|
||||||
}
|
}
|
||||||
// Persist canonical LOT mapping only.
|
// Persist canonical LOT mapping only.
|
||||||
body.VendorSpec[i].LotMappings = normalizeLotMappings(body.VendorSpec[i].LotMappings)
|
body.VendorSpec[i].LotMappings = localdb.NormalizeLotMappings(body.VendorSpec[i].LotMappings)
|
||||||
body.VendorSpec[i].ResolvedLotName = ""
|
body.VendorSpec[i].ResolvedLotName = ""
|
||||||
body.VendorSpec[i].ResolutionSource = ""
|
body.VendorSpec[i].ResolutionSource = ""
|
||||||
body.VendorSpec[i].ManualLotSuggestion = ""
|
body.VendorSpec[i].ManualLotSuggestion = ""
|
||||||
@@ -165,39 +164,6 @@ func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
|
||||||
if len(in) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
merged := make(map[string]int, len(in))
|
|
||||||
order := make([]string, 0, len(in))
|
|
||||||
for _, m := range in {
|
|
||||||
lot := strings.TrimSpace(m.LotName)
|
|
||||||
if lot == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
qty := m.QuantityPerPN
|
|
||||||
if qty < 1 {
|
|
||||||
qty = 1
|
|
||||||
}
|
|
||||||
if _, exists := merged[lot]; !exists {
|
|
||||||
order = append(order, lot)
|
|
||||||
}
|
|
||||||
merged[lot] += qty
|
|
||||||
}
|
|
||||||
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
|
|
||||||
for _, lot := range order {
|
|
||||||
out = append(out, localdb.VendorSpecLotMapping{
|
|
||||||
LotName: lot,
|
|
||||||
QuantityPerPN: merged[lot],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
|
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
|
||||||
// POST /api/configs/:uuid/vendor-spec/resolve
|
// POST /api/configs/:uuid/vendor-spec/resolve
|
||||||
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,12 +6,48 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NormalizeLotMappings is the single canonical normalizer for vendor BOM LOT
|
||||||
|
// mappings. LOT names are canonicalized to their uppercase form (see
|
||||||
|
// models.NormalizeLotName) so that all BOM↔cart matching is case-insensitive,
|
||||||
|
// duplicate LOTs are merged (summing quantity-per-PN), and quantities are at
|
||||||
|
// least 1. Returns nil for an empty result. Both the persistence path
|
||||||
|
// (handlers) and the CSV export path must use this — do not reimplement it.
|
||||||
|
func NormalizeLotMappings(in []VendorSpecLotMapping) []VendorSpecLotMapping {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
merged := make(map[string]int, len(in))
|
||||||
|
order := make([]string, 0, len(in))
|
||||||
|
for _, m := range in {
|
||||||
|
lot := models.NormalizeLotName(m.LotName)
|
||||||
|
if lot == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty := m.QuantityPerPN
|
||||||
|
if qty < 1 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
if _, exists := merged[lot]; !exists {
|
||||||
|
order = append(order, lot)
|
||||||
|
}
|
||||||
|
merged[lot] += qty
|
||||||
|
}
|
||||||
|
if len(order) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]VendorSpecLotMapping, 0, len(order))
|
||||||
|
for _, lot := range order {
|
||||||
|
out = append(out, VendorSpecLotMapping{LotName: lot, QuantityPerPN: merged[lot]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ConfigurationToLocal converts models.Configuration to LocalConfiguration
|
// ConfigurationToLocal converts models.Configuration to LocalConfiguration
|
||||||
func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
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 +307,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,
|
||||||
@@ -294,41 +330,3 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComponentToLocal converts models.LotMetadata to LocalComponent
|
|
||||||
func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
|
|
||||||
var lotDesc string
|
|
||||||
var category string
|
|
||||||
|
|
||||||
if meta.Lot != nil {
|
|
||||||
lotDesc = meta.Lot.LotDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
|
||||||
if len(meta.LotName) > 0 {
|
|
||||||
for i, ch := range meta.LotName {
|
|
||||||
if ch == '_' {
|
|
||||||
category = meta.LotName[:i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &LocalComponent{
|
|
||||||
LotName: meta.LotName,
|
|
||||||
LotDescription: lotDesc,
|
|
||||||
Category: category,
|
|
||||||
Model: meta.Model,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LocalToComponent converts LocalComponent to models.LotMetadata
|
|
||||||
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
|
||||||
return &models.LotMetadata{
|
|
||||||
LotName: local.LotName,
|
|
||||||
Model: local.Model,
|
|
||||||
Lot: &models.Lot{
|
|
||||||
LotName: local.LotName,
|
|
||||||
LotDescription: local.LotDescription,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,36 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestNormalizeLotMappings_CaseInsensitiveMerge(t *testing.T) {
|
||||||
|
in := []VendorSpecLotMapping{
|
||||||
|
{LotName: "cpu_intel_6960p", QuantityPerPN: 1},
|
||||||
|
{LotName: "CPU_INTEL_6960P", QuantityPerPN: 2},
|
||||||
|
{LotName: " ps_5200w_Titanium ", QuantityPerPN: 0},
|
||||||
|
{LotName: "", QuantityPerPN: 5},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := NormalizeLotMappings(in)
|
||||||
|
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("expected 2 merged mappings, got %d: %+v", len(out), out)
|
||||||
|
}
|
||||||
|
if out[0].LotName != "CPU_INTEL_6960P" || out[0].QuantityPerPN != 3 {
|
||||||
|
t.Fatalf("expected CPU_INTEL_6960P qty 3, got %+v", out[0])
|
||||||
|
}
|
||||||
|
if out[1].LotName != "PS_5200W_TITANIUM" || out[1].QuantityPerPN != 1 {
|
||||||
|
t.Fatalf("expected PS_5200W_TITANIUM qty 1 (clamped), got %+v", out[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeLotMappings_Empty(t *testing.T) {
|
||||||
|
if NormalizeLotMappings(nil) != nil {
|
||||||
|
t.Fatal("expected nil for empty input")
|
||||||
|
}
|
||||||
|
if NormalizeLotMappings([]VendorSpecLotMapping{{LotName: " "}}) != nil {
|
||||||
|
t.Fatal("expected nil when all entries blank")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
|
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
|
||||||
item := &models.PricelistItem{
|
item := &models.PricelistItem{
|
||||||
LotName: "CPU_A",
|
LotName: "CPU_A",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppSetting stores application settings in local SQLite
|
// AppSetting stores application settings in local SQLite
|
||||||
@@ -46,7 +48,13 @@ func (c *LocalConfigItems) Scan(value interface{}) error {
|
|||||||
default:
|
default:
|
||||||
return errors.New("type assertion failed for LocalConfigItems")
|
return errors.New("type assertion failed for LocalConfigItems")
|
||||||
}
|
}
|
||||||
return json.Unmarshal(bytes, c)
|
if err := json.Unmarshal(bytes, c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range *c {
|
||||||
|
(*c)[i].LotName = models.NormalizeLotName((*c)[i].LotName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c LocalConfigItems) Total() float64 {
|
func (c LocalConfigItems) Total() float64 {
|
||||||
@@ -169,7 +177,8 @@ type LocalPricelist struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
|
CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
|
||||||
SyncedAt time.Time `json:"synced_at"`
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||||
|
IsActive bool `gorm:"not null;default:true;index" json:"is_active"` // Mirrors qt_pricelists.is_active
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LocalPricelist) TableName() string {
|
func (LocalPricelist) TableName() string {
|
||||||
@@ -356,3 +365,12 @@ func (v *VendorSpec) Scan(value interface{}) error {
|
|||||||
}
|
}
|
||||||
return json.Unmarshal(bytes, v)
|
return json.Unmarshal(bytes, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LocalQtSetting caches server-pushed settings from qt_settings (MariaDB) into local SQLite.
|
||||||
|
// Synced during component sync. Each row is a JSON-valued setting identified by name.
|
||||||
|
type LocalQtSetting struct {
|
||||||
|
Name string `gorm:"primaryKey;size:100"`
|
||||||
|
Value string `gorm:"type:text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LocalQtSetting) TableName() string { return "local_qt_settings" }
|
||||||
|
|||||||
126
internal/localdb/qt_settings.go
Normal file
126
internal/localdb/qt_settings.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql/driver"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PriceMethod string
|
|
||||||
|
|
||||||
const (
|
|
||||||
PriceMethodManual PriceMethod = "manual"
|
|
||||||
PriceMethodMedian PriceMethod = "median"
|
|
||||||
PriceMethodAverage PriceMethod = "average"
|
|
||||||
PriceMethodWeightedMedian PriceMethod = "weighted_median"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Specs map[string]interface{}
|
|
||||||
|
|
||||||
func (s Specs) Value() (driver.Value, error) {
|
|
||||||
return json.Marshal(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Specs) Scan(value interface{}) error {
|
|
||||||
if value == nil {
|
|
||||||
*s = make(Specs)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
bytes, ok := value.([]byte)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("type assertion to []byte failed")
|
|
||||||
}
|
|
||||||
return json.Unmarshal(bytes, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
type LotMetadata struct {
|
|
||||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
|
||||||
CategoryID *uint `gorm:"column:category_id" json:"category_id"`
|
|
||||||
Model string `gorm:"size:100" json:"model"`
|
|
||||||
Specs Specs `gorm:"type:json" json:"specs"`
|
|
||||||
CurrentPrice *float64 `gorm:"type:decimal(12,2)" json:"current_price"`
|
|
||||||
PriceMethod PriceMethod `gorm:"type:enum('manual','median','average','weighted_median');default:'median'" json:"price_method"`
|
|
||||||
PricePeriodDays int `gorm:"default:90" json:"price_period_days"`
|
|
||||||
PriceCoefficient float64 `gorm:"type:decimal(5,2);default:0" json:"price_coefficient"`
|
|
||||||
ManualPrice *float64 `gorm:"type:decimal(12,2)" json:"manual_price"`
|
|
||||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
|
||||||
RequestCount int `gorm:"default:0" json:"request_count"`
|
|
||||||
LastRequestDate *time.Time `gorm:"type:date" json:"last_request_date"`
|
|
||||||
PopularityScore float64 `gorm:"type:decimal(10,4);default:0" json:"popularity_score"`
|
|
||||||
MetaPrices string `gorm:"size:1000" json:"meta_prices"`
|
|
||||||
MetaMethod string `gorm:"size:20" json:"meta_method"`
|
|
||||||
MetaPeriodDays int `gorm:"default:90" json:"meta_period_days"`
|
|
||||||
IsHidden bool `gorm:"default:false" json:"is_hidden"`
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`
|
|
||||||
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (LotMetadata) TableName() string {
|
|
||||||
return "qt_lot_metadata"
|
|
||||||
}
|
|
||||||
|
|
||||||
type PriceFreshness string
|
|
||||||
|
|
||||||
const (
|
|
||||||
FreshnessFresh PriceFreshness = "fresh"
|
|
||||||
FreshnessNormal PriceFreshness = "normal"
|
|
||||||
FreshnessStale PriceFreshness = "stale"
|
|
||||||
FreshnessCritical PriceFreshness = "critical"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m *LotMetadata) GetPriceFreshness(greenDays, yellowDays, redDays, minQuotes int) PriceFreshness {
|
|
||||||
if m.CurrentPrice == nil || *m.CurrentPrice == 0 {
|
|
||||||
return FreshnessCritical
|
|
||||||
}
|
|
||||||
if m.PriceUpdatedAt == nil {
|
|
||||||
return FreshnessCritical
|
|
||||||
}
|
|
||||||
|
|
||||||
daysSince := int(time.Since(*m.PriceUpdatedAt).Hours() / 24)
|
|
||||||
|
|
||||||
if daysSince < greenDays && m.RequestCount >= minQuotes {
|
|
||||||
return FreshnessFresh
|
|
||||||
} else if daysSince < yellowDays {
|
|
||||||
return FreshnessNormal
|
|
||||||
} else if daysSince < redDays {
|
|
||||||
return FreshnessStale
|
|
||||||
}
|
|
||||||
return FreshnessCritical
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
func AllModels() []interface{} {
|
func AllModels() []interface{} {
|
||||||
return []interface{}{
|
return []interface{}{
|
||||||
&Category{},
|
&Category{},
|
||||||
&LotMetadata{},
|
|
||||||
&Project{},
|
&Project{},
|
||||||
&Configuration{},
|
&Configuration{},
|
||||||
&Pricelist{},
|
&Pricelist{},
|
||||||
|
|||||||
10
internal/models/qt_setting.go
Normal file
10
internal/models/qt_setting.go
Normal 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" }
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CategoryRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
|
|
||||||
return &CategoryRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *CategoryRepository) GetAll() ([]models.Category, error) {
|
|
||||||
var categories []models.Category
|
|
||||||
err := r.db.Order("display_order ASC").Find(&categories).Error
|
|
||||||
return categories, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *CategoryRepository) GetByCode(code string) (*models.Category, error) {
|
|
||||||
var category models.Category
|
|
||||||
err := r.db.Where("code = ?", code).First(&category).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &category, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) {
|
|
||||||
var category models.Category
|
|
||||||
err := r.db.First(&category, id).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &category, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateIfNotExists creates a new category if it doesn't exist, returns existing one if it does
|
|
||||||
func (r *CategoryRepository) CreateIfNotExists(code string) (*models.Category, error) {
|
|
||||||
// Try to find existing
|
|
||||||
existing, err := r.GetByCode(code)
|
|
||||||
if err == nil {
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get max display order to put new category at the end
|
|
||||||
var maxOrder int
|
|
||||||
r.db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder)
|
|
||||||
|
|
||||||
// Create new category
|
|
||||||
newCat := &models.Category{
|
|
||||||
Code: code,
|
|
||||||
Name: code, // Use code as name initially
|
|
||||||
NameRu: code,
|
|
||||||
DisplayOrder: maxOrder + 1,
|
|
||||||
IsRequired: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.db.Create(newCat).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return newCat, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create creates a new category
|
|
||||||
func (r *CategoryRepository) Create(category *models.Category) error {
|
|
||||||
return r.db.Create(category).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates an existing category
|
|
||||||
func (r *CategoryRepository) Update(category *models.Category) error {
|
|
||||||
return r.db.Save(category).Error
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ComponentRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewComponentRepository(db *gorm.DB) *ComponentRepository {
|
|
||||||
return &ComponentRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ComponentFilter struct {
|
|
||||||
Category string
|
|
||||||
Search string
|
|
||||||
HasPrice bool
|
|
||||||
ExcludeHidden bool
|
|
||||||
SortField string
|
|
||||||
SortDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
|
||||||
var components []models.LotMetadata
|
|
||||||
var total int64
|
|
||||||
|
|
||||||
query := r.db.Model(&models.LotMetadata{}).
|
|
||||||
Preload("Lot").
|
|
||||||
Preload("Category")
|
|
||||||
|
|
||||||
if filter.Category != "" {
|
|
||||||
query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id").
|
|
||||||
Where("qt_categories.code = ?", filter.Category)
|
|
||||||
}
|
|
||||||
if filter.Search != "" {
|
|
||||||
search := "%" + filter.Search + "%"
|
|
||||||
query = query.Where("lot_name LIKE ? OR model LIKE ?", search, search)
|
|
||||||
}
|
|
||||||
if filter.HasPrice {
|
|
||||||
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
|
||||||
}
|
|
||||||
if filter.ExcludeHidden {
|
|
||||||
query = query.Where("is_hidden = ? OR is_hidden IS NULL", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
query.Count(&total)
|
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
sortDir := "ASC"
|
|
||||||
if filter.SortDir == "desc" {
|
|
||||||
sortDir = "DESC"
|
|
||||||
}
|
|
||||||
|
|
||||||
switch filter.SortField {
|
|
||||||
case "popularity_score":
|
|
||||||
query = query.Order("popularity_score " + sortDir)
|
|
||||||
case "current_price":
|
|
||||||
query = query.Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
|
||||||
Order("current_price " + sortDir)
|
|
||||||
case "lot_name":
|
|
||||||
query = query.Order("lot_name " + sortDir)
|
|
||||||
default:
|
|
||||||
// Default: sort by popularity, no price goes last
|
|
||||||
query = query.
|
|
||||||
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
|
||||||
Order("popularity_score DESC")
|
|
||||||
}
|
|
||||||
|
|
||||||
err := query.
|
|
||||||
Offset(offset).
|
|
||||||
Limit(limit).
|
|
||||||
Find(&components).Error
|
|
||||||
|
|
||||||
return components, total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ComponentRepository) GetByLotName(lotName string) (*models.LotMetadata, error) {
|
|
||||||
var component models.LotMetadata
|
|
||||||
err := r.db.
|
|
||||||
Preload("Lot").
|
|
||||||
Preload("Category").
|
|
||||||
Where("lot_name = ?", lotName).
|
|
||||||
First(&component).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &component, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ComponentRepository) GetMultiple(lotNames []string) ([]models.LotMetadata, error) {
|
|
||||||
var components []models.LotMetadata
|
|
||||||
err := r.db.
|
|
||||||
Preload("Lot").
|
|
||||||
Preload("Category").
|
|
||||||
Where("lot_name IN ?", lotNames).
|
|
||||||
Find(&components).Error
|
|
||||||
return components, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ComponentRepository) Update(component *models.LotMetadata) error {
|
|
||||||
return r.db.Save(component).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ComponentRepository) DB() *gorm.DB {
|
|
||||||
return r.db
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
|
||||||
return r.db.Create(component).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ComponentRepository) IncrementRequestCount(lotName string) error {
|
|
||||||
now := time.Now()
|
|
||||||
return r.db.Model(&models.LotMetadata{}).
|
|
||||||
Where("lot_name = ?", lotName).
|
|
||||||
Updates(map[string]interface{}{
|
|
||||||
"request_count": gorm.Expr("request_count + 1"),
|
|
||||||
"last_request_date": now,
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllLots returns all lots from the existing lot table
|
|
||||||
func (r *ComponentRepository) GetAllLots() ([]models.Lot, error) {
|
|
||||||
var lots []models.Lot
|
|
||||||
err := r.db.Find(&lots).Error
|
|
||||||
return lots, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLotsWithoutMetadata returns lots that don't have qt_lot_metadata entries
|
|
||||||
func (r *ComponentRepository) GetLotsWithoutMetadata() ([]models.Lot, error) {
|
|
||||||
var lots []models.Lot
|
|
||||||
err := r.db.
|
|
||||||
Where("lot_name NOT IN (SELECT lot_name FROM qt_lot_metadata)").
|
|
||||||
Find(&lots).Error
|
|
||||||
return lots, err
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -1,393 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DataSource defines the unified interface for data access
|
|
||||||
// It abstracts whether data comes from MariaDB (online) or SQLite (offline)
|
|
||||||
type DataSource interface {
|
|
||||||
// Components
|
|
||||||
GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error)
|
|
||||||
GetComponent(lotName string) (*models.LotMetadata, error)
|
|
||||||
|
|
||||||
// Configurations
|
|
||||||
SaveConfiguration(cfg *models.Configuration) error
|
|
||||||
GetConfigurations(ownerUsername string) ([]models.Configuration, error)
|
|
||||||
GetConfigurationByUUID(uuid string) (*models.Configuration, error)
|
|
||||||
DeleteConfiguration(uuid string) error
|
|
||||||
|
|
||||||
// Pricelists (read-only in offline mode)
|
|
||||||
GetPricelists() ([]models.PricelistSummary, error)
|
|
||||||
GetPricelistByID(id uint) (*models.Pricelist, error)
|
|
||||||
GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error)
|
|
||||||
GetLatestPricelist() (*models.Pricelist, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnifiedRepo implements DataSource with automatic online/offline switching
|
|
||||||
type UnifiedRepo struct {
|
|
||||||
mariaDB *gorm.DB
|
|
||||||
localDB *localdb.LocalDB
|
|
||||||
isOnline bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUnifiedRepo creates a new unified repository
|
|
||||||
func NewUnifiedRepo(mariaDB *gorm.DB, localDB *localdb.LocalDB, isOnline bool) *UnifiedRepo {
|
|
||||||
return &UnifiedRepo{
|
|
||||||
mariaDB: mariaDB,
|
|
||||||
localDB: localDB,
|
|
||||||
isOnline: isOnline,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetOnlineStatus updates the online/offline status
|
|
||||||
func (r *UnifiedRepo) SetOnlineStatus(online bool) {
|
|
||||||
r.isOnline = online
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsOnline returns the current online/offline status
|
|
||||||
func (r *UnifiedRepo) IsOnline() bool {
|
|
||||||
return r.isOnline
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component methods
|
|
||||||
|
|
||||||
// GetComponents returns components from MariaDB (online) or local cache (offline)
|
|
||||||
func (r *UnifiedRepo) GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
|
||||||
if r.isOnline {
|
|
||||||
return r.getComponentsOnline(filter, offset, limit)
|
|
||||||
}
|
|
||||||
return r.getComponentsOffline(filter, offset, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UnifiedRepo) getComponentsOnline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
|
||||||
repo := NewComponentRepository(r.mariaDB)
|
|
||||||
return repo.List(filter, offset, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
|
||||||
var components []localdb.LocalComponent
|
|
||||||
query := r.localDB.DB().Model(&localdb.LocalComponent{})
|
|
||||||
|
|
||||||
// Apply filters
|
|
||||||
if filter.Category != "" {
|
|
||||||
query = query.Where("category = ?", filter.Category)
|
|
||||||
}
|
|
||||||
if filter.Search != "" {
|
|
||||||
search := "%" + filter.Search + "%"
|
|
||||||
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
query.Count(&total)
|
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
sortDir := "ASC"
|
|
||||||
if filter.SortDir == "desc" {
|
|
||||||
sortDir = "DESC"
|
|
||||||
}
|
|
||||||
switch filter.SortField {
|
|
||||||
case "lot_name":
|
|
||||||
query = query.Order("lot_name " + sortDir)
|
|
||||||
default:
|
|
||||||
query = query.Order("lot_name ASC")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := query.Offset(offset).Limit(limit).Find(&components).Error; err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("fetching offline components: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to models.LotMetadata
|
|
||||||
result := make([]models.LotMetadata, len(components))
|
|
||||||
for i, comp := range components {
|
|
||||||
result[i] = models.LotMetadata{
|
|
||||||
LotName: comp.LotName,
|
|
||||||
Model: comp.Model,
|
|
||||||
Lot: &models.Lot{
|
|
||||||
LotName: comp.LotName,
|
|
||||||
LotDescription: comp.LotDescription,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetComponent returns a single component by lot name
|
|
||||||
func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error) {
|
|
||||||
if r.isOnline {
|
|
||||||
repo := NewComponentRepository(r.mariaDB)
|
|
||||||
return repo.GetByLotName(lotName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var comp localdb.LocalComponent
|
|
||||||
if err := r.localDB.DB().Where("lot_name = ?", lotName).First(&comp).Error; err != nil {
|
|
||||||
return nil, fmt.Errorf("fetching offline component: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &models.LotMetadata{
|
|
||||||
LotName: comp.LotName,
|
|
||||||
Model: comp.Model,
|
|
||||||
Lot: &models.Lot{
|
|
||||||
LotName: comp.LotName,
|
|
||||||
LotDescription: comp.LotDescription,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuration methods
|
|
||||||
|
|
||||||
// SaveConfiguration saves a configuration (online: MariaDB, offline: SQLite + pending_changes)
|
|
||||||
func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
|
|
||||||
if r.isOnline {
|
|
||||||
repo := NewConfigurationRepository(r.mariaDB)
|
|
||||||
return repo.Create(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offline: save to local SQLite and queue for sync
|
|
||||||
localCfg := &localdb.LocalConfiguration{
|
|
||||||
UUID: cfg.UUID,
|
|
||||||
Name: cfg.Name,
|
|
||||||
TotalPrice: cfg.TotalPrice,
|
|
||||||
CustomPrice: cfg.CustomPrice,
|
|
||||||
Notes: cfg.Notes,
|
|
||||||
IsTemplate: cfg.IsTemplate,
|
|
||||||
ServerCount: cfg.ServerCount,
|
|
||||||
CreatedAt: cfg.CreatedAt,
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
SyncStatus: "pending",
|
|
||||||
OriginalUsername: cfg.OwnerUsername,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert items
|
|
||||||
localItems := make(localdb.LocalConfigItems, len(cfg.Items))
|
|
||||||
for i, item := range cfg.Items {
|
|
||||||
localItems[i] = localdb.LocalConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: item.UnitPrice,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
localCfg.Items = localItems
|
|
||||||
|
|
||||||
if err := r.localDB.SaveConfiguration(localCfg); err != nil {
|
|
||||||
return fmt.Errorf("saving local configuration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to pending changes queue
|
|
||||||
payload, err := json.Marshal(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshaling configuration for sync: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConfigurations returns all configurations for a user
|
|
||||||
func (r *UnifiedRepo) GetConfigurations(ownerUsername string) ([]models.Configuration, error) {
|
|
||||||
if r.isOnline {
|
|
||||||
repo := NewConfigurationRepository(r.mariaDB)
|
|
||||||
configs, _, err := repo.ListByUser(ownerUsername, 0, 1000)
|
|
||||||
return configs, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offline: get from local SQLite
|
|
||||||
localConfigs, err := r.localDB.GetConfigurations()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetching local configurations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to models.Configuration
|
|
||||||
result := make([]models.Configuration, len(localConfigs))
|
|
||||||
for i, lc := range localConfigs {
|
|
||||||
items := make(models.ConfigItems, len(lc.Items))
|
|
||||||
for j, item := range lc.Items {
|
|
||||||
items[j] = models.ConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: item.UnitPrice,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result[i] = models.Configuration{
|
|
||||||
UUID: lc.UUID,
|
|
||||||
OwnerUsername: lc.OriginalUsername,
|
|
||||||
Name: lc.Name,
|
|
||||||
Items: items,
|
|
||||||
TotalPrice: lc.TotalPrice,
|
|
||||||
CustomPrice: lc.CustomPrice,
|
|
||||||
Notes: lc.Notes,
|
|
||||||
IsTemplate: lc.IsTemplate,
|
|
||||||
ServerCount: lc.ServerCount,
|
|
||||||
CreatedAt: lc.CreatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConfigurationByUUID returns a configuration by UUID
|
|
||||||
func (r *UnifiedRepo) GetConfigurationByUUID(uuid string) (*models.Configuration, error) {
|
|
||||||
if r.isOnline {
|
|
||||||
repo := NewConfigurationRepository(r.mariaDB)
|
|
||||||
return repo.GetByUUID(uuid)
|
|
||||||
}
|
|
||||||
|
|
||||||
localCfg, err := r.localDB.GetConfigurationByUUID(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetching local configuration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make(models.ConfigItems, len(localCfg.Items))
|
|
||||||
for i, item := range localCfg.Items {
|
|
||||||
items[i] = models.ConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: item.UnitPrice,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &models.Configuration{
|
|
||||||
UUID: localCfg.UUID,
|
|
||||||
Name: localCfg.Name,
|
|
||||||
Items: items,
|
|
||||||
TotalPrice: localCfg.TotalPrice,
|
|
||||||
CustomPrice: localCfg.CustomPrice,
|
|
||||||
Notes: localCfg.Notes,
|
|
||||||
IsTemplate: localCfg.IsTemplate,
|
|
||||||
ServerCount: localCfg.ServerCount,
|
|
||||||
CreatedAt: localCfg.CreatedAt,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteConfiguration deletes a configuration
|
|
||||||
func (r *UnifiedRepo) DeleteConfiguration(uuid string) error {
|
|
||||||
if r.isOnline {
|
|
||||||
// Get ID first
|
|
||||||
cfg, err := r.GetConfigurationByUUID(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
repo := NewConfigurationRepository(r.mariaDB)
|
|
||||||
return repo.Delete(cfg.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offline: delete from local and queue sync
|
|
||||||
if err := r.localDB.DeleteConfiguration(uuid); err != nil {
|
|
||||||
return fmt.Errorf("deleting local configuration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.localDB.AddPendingChange("configuration", uuid, "delete", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pricelist methods
|
|
||||||
|
|
||||||
// GetPricelists returns all pricelists
|
|
||||||
func (r *UnifiedRepo) GetPricelists() ([]models.PricelistSummary, error) {
|
|
||||||
if r.isOnline {
|
|
||||||
repo := NewPricelistRepository(r.mariaDB)
|
|
||||||
summaries, _, err := repo.List(0, 1000)
|
|
||||||
return summaries, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offline: get from local cache
|
|
||||||
localPLs, err := r.localDB.GetLocalPricelists()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetching local pricelists: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
summaries := make([]models.PricelistSummary, len(localPLs))
|
|
||||||
for i, pl := range localPLs {
|
|
||||||
itemCount := r.localDB.CountLocalPricelistItems(pl.ID)
|
|
||||||
summaries[i] = models.PricelistSummary{
|
|
||||||
ID: pl.ServerID,
|
|
||||||
Version: pl.Version,
|
|
||||||
CreatedAt: pl.CreatedAt,
|
|
||||||
ItemCount: itemCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return summaries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPricelistByID returns a pricelist by ID
|
|
||||||
func (r *UnifiedRepo) GetPricelistByID(id uint) (*models.Pricelist, error) {
|
|
||||||
if r.isOnline {
|
|
||||||
repo := NewPricelistRepository(r.mariaDB)
|
|
||||||
return repo.GetByID(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offline: get from local cache
|
|
||||||
localPL, err := r.localDB.GetLocalPricelistByServerID(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetching local pricelist: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
itemCount := r.localDB.CountLocalPricelistItems(localPL.ID)
|
|
||||||
return &models.Pricelist{
|
|
||||||
ID: localPL.ServerID,
|
|
||||||
Version: localPL.Version,
|
|
||||||
CreatedAt: localPL.CreatedAt,
|
|
||||||
ItemCount: int(itemCount),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPricelistItems returns items for a pricelist
|
|
||||||
func (r *UnifiedRepo) GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error) {
|
|
||||||
if r.isOnline {
|
|
||||||
repo := NewPricelistRepository(r.mariaDB)
|
|
||||||
items, _, err := repo.GetItems(pricelistID, 0, 100000, "")
|
|
||||||
return items, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offline: get from local cache
|
|
||||||
// First find the local pricelist by server ID
|
|
||||||
localPL, err := r.localDB.GetLocalPricelistByServerID(pricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetching local pricelist: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
localItems, err := r.localDB.GetLocalPricelistItems(localPL.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetching local pricelist items: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]models.PricelistItem, len(localItems))
|
|
||||||
for i, item := range localItems {
|
|
||||||
items[i] = models.PricelistItem{
|
|
||||||
ID: item.ID,
|
|
||||||
PricelistID: pricelistID,
|
|
||||||
LotName: item.LotName,
|
|
||||||
Price: item.Price,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLatestPricelist returns the latest pricelist
|
|
||||||
func (r *UnifiedRepo) GetLatestPricelist() (*models.Pricelist, error) {
|
|
||||||
if r.isOnline {
|
|
||||||
repo := NewPricelistRepository(r.mariaDB)
|
|
||||||
return repo.GetLatestActive()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offline: get from local cache
|
|
||||||
localPL, err := r.localDB.GetLatestLocalPricelist()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetching latest local pricelist: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
itemCount := r.localDB.CountLocalPricelistItems(localPL.ID)
|
|
||||||
return &models.Pricelist{
|
|
||||||
ID: localPL.ServerID,
|
|
||||||
Version: localPL.Version,
|
|
||||||
CreatedAt: localPL.CreatedAt,
|
|
||||||
ItemCount: int(itemCount),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,5 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ComponentService struct {
|
|
||||||
componentRepo *repository.ComponentRepository
|
|
||||||
categoryRepo *repository.CategoryRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewComponentService(
|
|
||||||
componentRepo *repository.ComponentRepository,
|
|
||||||
categoryRepo *repository.CategoryRepository,
|
|
||||||
) *ComponentService {
|
|
||||||
return &ComponentService{
|
|
||||||
componentRepo: componentRepo,
|
|
||||||
categoryRepo: categoryRepo,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParsePartNumber extracts category and model from lot_name
|
|
||||||
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
|
|
||||||
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", model="INTEL_4.Sapphire_2S_32xDDR5"
|
|
||||||
func ParsePartNumber(lotName string) (category, model string) {
|
|
||||||
parts := strings.SplitN(lotName, "_", 2)
|
|
||||||
if len(parts) >= 1 {
|
|
||||||
category = parts[0]
|
|
||||||
}
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
model = parts[1]
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type ComponentListResult struct {
|
type ComponentListResult struct {
|
||||||
Items []ComponentView `json:"items"`
|
Items []ComponentView `json:"items"`
|
||||||
TotalCount int64 `json:"total_count"`
|
TotalCount int64 `json:"total_count"`
|
||||||
@@ -47,178 +9,9 @@ type ComponentListResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ComponentView struct {
|
type ComponentView struct {
|
||||||
LotName string `json:"lot_name"`
|
LotName string `json:"lot_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
CategoryName string `json:"category_name"`
|
CategoryName string `json:"category_name"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
|
||||||
PopularityScore float64 `json:"popularity_score"`
|
|
||||||
Specs models.Specs `json:"specs,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) {
|
|
||||||
// If no database connection (offline mode), return empty list
|
|
||||||
// Components should be loaded via /api/sync/components first
|
|
||||||
if s.componentRepo == nil {
|
|
||||||
return &ComponentListResult{
|
|
||||||
Items: []ComponentView{},
|
|
||||||
TotalCount: 0,
|
|
||||||
Page: page,
|
|
||||||
PerPage: perPage,
|
|
||||||
TotalPages: 1,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if perPage < 1 {
|
|
||||||
perPage = 20
|
|
||||||
}
|
|
||||||
if perPage > 5000 {
|
|
||||||
perPage = 5000
|
|
||||||
}
|
|
||||||
offset := (page - 1) * perPage
|
|
||||||
|
|
||||||
components, total, err := s.componentRepo.List(filter, offset, perPage)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
views := make([]ComponentView, len(components))
|
|
||||||
for i, c := range components {
|
|
||||||
view := ComponentView{
|
|
||||||
LotName: c.LotName,
|
|
||||||
Model: c.Model,
|
|
||||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
|
||||||
PopularityScore: c.PopularityScore,
|
|
||||||
Specs: c.Specs,
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Lot != nil {
|
|
||||||
view.Description = c.Lot.LotDescription
|
|
||||||
}
|
|
||||||
if c.Category != nil {
|
|
||||||
view.Category = c.Category.Code
|
|
||||||
view.CategoryName = c.Category.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
views[i] = view
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
|
||||||
if totalPages < 1 {
|
|
||||||
totalPages = 1
|
|
||||||
}
|
|
||||||
return &ComponentListResult{
|
|
||||||
Items: views,
|
|
||||||
TotalCount: total,
|
|
||||||
Page: page,
|
|
||||||
PerPage: perPage,
|
|
||||||
TotalPages: totalPages,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) {
|
|
||||||
// If no database connection (offline mode), return error
|
|
||||||
if s.componentRepo == nil {
|
|
||||||
return nil, fmt.Errorf("offline mode: component data not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := s.componentRepo.GetByLotName(lotName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track usage (best-effort)
|
|
||||||
if err := s.componentRepo.IncrementRequestCount(lotName); err != nil {
|
|
||||||
slog.Warn("component: could not increment request count", "lot", lotName, "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
view := &ComponentView{
|
|
||||||
LotName: c.LotName,
|
|
||||||
Model: c.Model,
|
|
||||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
|
||||||
PopularityScore: c.PopularityScore,
|
|
||||||
Specs: c.Specs,
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Lot != nil {
|
|
||||||
view.Description = c.Lot.LotDescription
|
|
||||||
}
|
|
||||||
if c.Category != nil {
|
|
||||||
view.Category = c.Category.Code
|
|
||||||
view.CategoryName = c.Category.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
return view, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ComponentService) GetCategories() ([]models.Category, error) {
|
|
||||||
// If no database connection (offline mode), return default categories
|
|
||||||
if s.categoryRepo == nil {
|
|
||||||
return models.DefaultCategories, nil
|
|
||||||
}
|
|
||||||
return s.categoryRepo.GetAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportFromLot creates metadata entries for lots that don't have them
|
|
||||||
func (s *ComponentService) ImportFromLot() (int, error) {
|
|
||||||
// If no database connection (offline mode), return error
|
|
||||||
if s.componentRepo == nil || s.categoryRepo == nil {
|
|
||||||
return 0, fmt.Errorf("offline mode: import not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
lots, err := s.componentRepo.GetLotsWithoutMetadata()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
categories, err := s.categoryRepo.GetAll()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryMap := make(map[string]uint)
|
|
||||||
for _, cat := range categories {
|
|
||||||
categoryMap[strings.ToUpper(cat.Code)] = cat.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
imported := 0
|
|
||||||
for _, lot := range lots {
|
|
||||||
// Use lot_category from database if available, otherwise parse from lot_name
|
|
||||||
var category string
|
|
||||||
if lot.LotCategory != nil && *lot.LotCategory != "" {
|
|
||||||
category = strings.ToUpper(*lot.LotCategory)
|
|
||||||
} else {
|
|
||||||
category, _ = ParsePartNumber(lot.LotName)
|
|
||||||
category = strings.ToUpper(category)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, model := ParsePartNumber(lot.LotName)
|
|
||||||
|
|
||||||
metadata := &models.LotMetadata{
|
|
||||||
LotName: lot.LotName,
|
|
||||||
Model: model,
|
|
||||||
Specs: make(models.Specs),
|
|
||||||
}
|
|
||||||
|
|
||||||
if catID, ok := categoryMap[category]; ok {
|
|
||||||
metadata.CategoryID = &catID
|
|
||||||
} else {
|
|
||||||
// Create new category if it doesn't exist
|
|
||||||
newCat, err := s.categoryRepo.CreateIfNotExists(category)
|
|
||||||
if err == nil && newCat != nil {
|
|
||||||
metadata.CategoryID = &newCat.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.componentRepo.Create(metadata); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
imported++
|
|
||||||
}
|
|
||||||
|
|
||||||
return imported, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,8 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -14,37 +11,13 @@ var (
|
|||||||
ErrConfigForbidden = errors.New("access to configuration forbidden")
|
ErrConfigForbidden = errors.New("access to configuration forbidden")
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigurationGetter is an interface for services that can retrieve configurations
|
// ConfigurationGetter is an interface for services that can retrieve configurations.
|
||||||
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
|
// Used by handlers to work with LocalConfigurationService.
|
||||||
type ConfigurationGetter interface {
|
type ConfigurationGetter interface {
|
||||||
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
|
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
|
||||||
GetByUUIDNoAuth(uuid string) (*models.Configuration, error)
|
GetByUUIDNoAuth(uuid string) (*models.Configuration, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigurationService struct {
|
|
||||||
configRepo *repository.ConfigurationRepository
|
|
||||||
projectRepo *repository.ProjectRepository
|
|
||||||
componentRepo *repository.ComponentRepository
|
|
||||||
pricelistRepo *repository.PricelistRepository
|
|
||||||
quoteService *QuoteService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewConfigurationService(
|
|
||||||
configRepo *repository.ConfigurationRepository,
|
|
||||||
projectRepo *repository.ProjectRepository,
|
|
||||||
componentRepo *repository.ComponentRepository,
|
|
||||||
pricelistRepo *repository.PricelistRepository,
|
|
||||||
quoteService *QuoteService,
|
|
||||||
) *ConfigurationService {
|
|
||||||
return &ConfigurationService{
|
|
||||||
configRepo: configRepo,
|
|
||||||
projectRepo: projectRepo,
|
|
||||||
componentRepo: componentRepo,
|
|
||||||
pricelistRepo: pricelistRepo,
|
|
||||||
quoteService: quoteService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateConfigRequest struct {
|
type CreateConfigRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Items models.ConfigItems `json:"items"`
|
Items models.ConfigItems `json:"items"`
|
||||||
@@ -70,583 +43,3 @@ type ArticlePreviewRequest struct {
|
|||||||
SupportCode string `json:"support_code,omitempty"`
|
SupportCode string `json:"support_code,omitempty"`
|
||||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
|
||||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.Items.Total()
|
|
||||||
|
|
||||||
// If server count is greater than 1, multiply the total by server count
|
|
||||||
if req.ServerCount > 1 {
|
|
||||||
total *= float64(req.ServerCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
config := &models.Configuration{
|
|
||||||
UUID: uuid.New().String(),
|
|
||||||
OwnerUsername: ownerUsername,
|
|
||||||
ProjectUUID: projectUUID,
|
|
||||||
Name: req.Name,
|
|
||||||
Items: req.Items,
|
|
||||||
TotalPrice: &total,
|
|
||||||
CustomPrice: req.CustomPrice,
|
|
||||||
Notes: req.Notes,
|
|
||||||
IsTemplate: req.IsTemplate,
|
|
||||||
ServerCount: req.ServerCount,
|
|
||||||
ServerModel: req.ServerModel,
|
|
||||||
SupportCode: req.SupportCode,
|
|
||||||
Article: req.Article,
|
|
||||||
PricelistID: pricelistID,
|
|
||||||
WarehousePricelistID: req.WarehousePricelistID,
|
|
||||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
|
||||||
ConfigType: req.ConfigType,
|
|
||||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
|
||||||
OnlyInStock: req.OnlyInStock,
|
|
||||||
}
|
|
||||||
if config.ConfigType == "" {
|
|
||||||
config.ConfigType = "server"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.configRepo.Create(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
|
|
||||||
config, err := s.configRepo.GetByUUID(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrConfigNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow access if user owns config or it's a template
|
|
||||||
if !s.isOwner(config, ownerUsername) && !config.IsTemplate {
|
|
||||||
return nil, ErrConfigForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
|
||||||
config, err := s.configRepo.GetByUUID(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrConfigNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.isOwner(config, ownerUsername) {
|
|
||||||
return nil, ErrConfigForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.Items.Total()
|
|
||||||
|
|
||||||
// If server count is greater than 1, multiply the total by server count
|
|
||||||
if req.ServerCount > 1 {
|
|
||||||
total *= float64(req.ServerCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
config.Name = req.Name
|
|
||||||
config.ProjectUUID = projectUUID
|
|
||||||
config.Items = req.Items
|
|
||||||
config.TotalPrice = &total
|
|
||||||
config.CustomPrice = req.CustomPrice
|
|
||||||
config.Notes = req.Notes
|
|
||||||
config.IsTemplate = req.IsTemplate
|
|
||||||
config.ServerCount = req.ServerCount
|
|
||||||
config.ServerModel = req.ServerModel
|
|
||||||
config.SupportCode = req.SupportCode
|
|
||||||
config.Article = req.Article
|
|
||||||
config.PricelistID = pricelistID
|
|
||||||
config.WarehousePricelistID = req.WarehousePricelistID
|
|
||||||
config.CompetitorPricelistID = req.CompetitorPricelistID
|
|
||||||
config.DisablePriceRefresh = req.DisablePriceRefresh
|
|
||||||
config.OnlyInStock = req.OnlyInStock
|
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ConfigurationService) Delete(uuid string, ownerUsername string) error {
|
|
||||||
config, err := s.configRepo.GetByUUID(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return ErrConfigNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.isOwner(config, ownerUsername) {
|
|
||||||
return ErrConfigForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.configRepo.Delete(config.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
|
|
||||||
config, err := s.configRepo.GetByUUID(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrConfigNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.isOwner(config, ownerUsername) {
|
|
||||||
return nil, ErrConfigForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
config.Name = newName
|
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
|
|
||||||
return s.CloneToProject(configUUID, ownerUsername, newName, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) {
|
|
||||||
original, err := s.GetByUUID(configUUID, ownerUsername)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resolvedProjectUUID := original.ProjectUUID
|
|
||||||
if projectUUID != nil {
|
|
||||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create copy with new UUID and name
|
|
||||||
total := original.Items.Total()
|
|
||||||
|
|
||||||
// If server count is greater than 1, multiply the total by server count
|
|
||||||
if original.ServerCount > 1 {
|
|
||||||
total *= float64(original.ServerCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
clone := &models.Configuration{
|
|
||||||
UUID: uuid.New().String(),
|
|
||||||
OwnerUsername: ownerUsername,
|
|
||||||
ProjectUUID: resolvedProjectUUID,
|
|
||||||
Name: newName,
|
|
||||||
Items: original.Items,
|
|
||||||
TotalPrice: &total,
|
|
||||||
CustomPrice: original.CustomPrice,
|
|
||||||
Notes: original.Notes,
|
|
||||||
IsTemplate: false, // Clone is never a template
|
|
||||||
ServerCount: original.ServerCount,
|
|
||||||
ServerModel: original.ServerModel,
|
|
||||||
SupportCode: original.SupportCode,
|
|
||||||
Article: original.Article,
|
|
||||||
PricelistID: original.PricelistID,
|
|
||||||
WarehousePricelistID: original.WarehousePricelistID,
|
|
||||||
CompetitorPricelistID: original.CompetitorPricelistID,
|
|
||||||
DisablePriceRefresh: original.DisablePriceRefresh,
|
|
||||||
OnlyInStock: original.OnlyInStock,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.configRepo.Create(clone); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return clone, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if perPage < 1 || perPage > 100 {
|
|
||||||
perPage = 20
|
|
||||||
}
|
|
||||||
offset := (page - 1) * perPage
|
|
||||||
|
|
||||||
return s.configRepo.ListByUser(ownerUsername, offset, perPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAll returns all configurations without user filter (for use when auth is disabled)
|
|
||||||
func (s *ConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if perPage < 1 || perPage > 100 {
|
|
||||||
perPage = 20
|
|
||||||
}
|
|
||||||
offset := (page - 1) * perPage
|
|
||||||
|
|
||||||
return s.configRepo.ListAll(offset, perPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByUUIDNoAuth returns configuration without ownership check (for use when auth is disabled)
|
|
||||||
func (s *ConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
|
||||||
config, err := s.configRepo.GetByUUID(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrConfigNotFound
|
|
||||||
}
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateNoAuth updates configuration without ownership check
|
|
||||||
func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) {
|
|
||||||
config, err := s.configRepo.GetByUUID(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrConfigNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
projectUUID, err := s.resolveProjectUUID(config.OwnerUsername, req.ProjectUUID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.Items.Total()
|
|
||||||
if req.ServerCount > 1 {
|
|
||||||
total *= float64(req.ServerCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
config.Name = req.Name
|
|
||||||
config.ProjectUUID = projectUUID
|
|
||||||
config.Items = req.Items
|
|
||||||
config.TotalPrice = &total
|
|
||||||
config.CustomPrice = req.CustomPrice
|
|
||||||
config.Notes = req.Notes
|
|
||||||
config.IsTemplate = req.IsTemplate
|
|
||||||
config.ServerCount = req.ServerCount
|
|
||||||
config.ServerModel = req.ServerModel
|
|
||||||
config.SupportCode = req.SupportCode
|
|
||||||
config.Article = req.Article
|
|
||||||
config.PricelistID = pricelistID
|
|
||||||
config.WarehousePricelistID = req.WarehousePricelistID
|
|
||||||
config.CompetitorPricelistID = req.CompetitorPricelistID
|
|
||||||
config.DisablePriceRefresh = req.DisablePriceRefresh
|
|
||||||
config.OnlyInStock = req.OnlyInStock
|
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteNoAuth deletes configuration without ownership check
|
|
||||||
func (s *ConfigurationService) DeleteNoAuth(uuid string) error {
|
|
||||||
config, err := s.configRepo.GetByUUID(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return ErrConfigNotFound
|
|
||||||
}
|
|
||||||
return s.configRepo.Delete(config.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenameNoAuth renames configuration without ownership check
|
|
||||||
func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) {
|
|
||||||
config, err := s.configRepo.GetByUUID(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrConfigNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
config.Name = newName
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloneNoAuth clones configuration without ownership check
|
|
||||||
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
|
|
||||||
return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
|
||||||
original, err := s.configRepo.GetByUUID(configUUID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrConfigNotFound
|
|
||||||
}
|
|
||||||
resolvedProjectUUID := original.ProjectUUID
|
|
||||||
if projectUUID != nil {
|
|
||||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
total := original.Items.Total()
|
|
||||||
if original.ServerCount > 1 {
|
|
||||||
total *= float64(original.ServerCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
clone := &models.Configuration{
|
|
||||||
UUID: uuid.New().String(),
|
|
||||||
OwnerUsername: ownerUsername,
|
|
||||||
ProjectUUID: resolvedProjectUUID,
|
|
||||||
Name: newName,
|
|
||||||
Items: original.Items,
|
|
||||||
TotalPrice: &total,
|
|
||||||
CustomPrice: original.CustomPrice,
|
|
||||||
Notes: original.Notes,
|
|
||||||
IsTemplate: false,
|
|
||||||
ServerCount: original.ServerCount,
|
|
||||||
PricelistID: original.PricelistID,
|
|
||||||
OnlyInStock: original.OnlyInStock,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.configRepo.Create(clone); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return clone, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
|
|
||||||
_ = ownerUsername
|
|
||||||
if s.projectRepo == nil {
|
|
||||||
return projectUUID, nil
|
|
||||||
}
|
|
||||||
if projectUUID == nil || *projectUUID == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
project, err := s.projectRepo.GetByUUID(*projectUUID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrProjectNotFound
|
|
||||||
}
|
|
||||||
if !project.IsActive {
|
|
||||||
return nil, errors.New("project is archived")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &project.UUID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
|
|
||||||
if s.pricelistRepo == nil {
|
|
||||||
return pricelistID, nil
|
|
||||||
}
|
|
||||||
if pricelistID != nil && *pricelistID > 0 {
|
|
||||||
if _, err := s.pricelistRepo.GetByID(*pricelistID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return pricelistID, nil
|
|
||||||
}
|
|
||||||
latest, err := s.pricelistRepo.GetLatestActive()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &latest.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshPricesNoAuth refreshes prices without ownership check
|
|
||||||
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
|
||||||
config, err := s.configRepo.GetByUUID(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrConfigNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
var latestPricelistID *uint
|
|
||||||
if s.pricelistRepo != nil {
|
|
||||||
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
|
|
||||||
latestPricelistID = &pl.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
|
||||||
for i, item := range config.Items {
|
|
||||||
if latestPricelistID != nil {
|
|
||||||
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
|
|
||||||
updatedItems[i] = models.ConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: price,
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.componentRepo == nil {
|
|
||||||
updatedItems[i] = item
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
|
||||||
if err != nil || metadata.CurrentPrice == nil {
|
|
||||||
updatedItems[i] = item
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedItems[i] = models.ConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: *metadata.CurrentPrice,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
config.Items = updatedItems
|
|
||||||
total := updatedItems.Total()
|
|
||||||
if config.ServerCount > 1 {
|
|
||||||
total *= float64(config.ServerCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
config.TotalPrice = &total
|
|
||||||
if latestPricelistID != nil {
|
|
||||||
config.PricelistID = latestPricelistID
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
config.PriceUpdatedAt = &now
|
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if perPage < 1 || perPage > 100 {
|
|
||||||
perPage = 20
|
|
||||||
}
|
|
||||||
offset := (page - 1) * perPage
|
|
||||||
|
|
||||||
return s.configRepo.ListTemplates(offset, perPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshPrices updates all component prices in the configuration with current prices
|
|
||||||
func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
|
|
||||||
config, err := s.configRepo.GetByUUID(uuid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrConfigNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if !s.isOwner(config, ownerUsername) {
|
|
||||||
return nil, ErrConfigForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
var latestPricelistID *uint
|
|
||||||
if s.pricelistRepo != nil {
|
|
||||||
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
|
|
||||||
latestPricelistID = &pl.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update prices for all items
|
|
||||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
|
||||||
for i, item := range config.Items {
|
|
||||||
if latestPricelistID != nil {
|
|
||||||
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
|
|
||||||
updatedItems[i] = models.ConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: price,
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current component price
|
|
||||||
if s.componentRepo == nil {
|
|
||||||
updatedItems[i] = item
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
|
||||||
if err != nil || metadata.CurrentPrice == nil {
|
|
||||||
// Keep original item if component not found or no price available
|
|
||||||
updatedItems[i] = item
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update item with current price
|
|
||||||
updatedItems[i] = models.ConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: *metadata.CurrentPrice,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update configuration
|
|
||||||
config.Items = updatedItems
|
|
||||||
total := updatedItems.Total()
|
|
||||||
|
|
||||||
// If server count is greater than 1, multiply the total by server count
|
|
||||||
if config.ServerCount > 1 {
|
|
||||||
total *= float64(config.ServerCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
config.TotalPrice = &total
|
|
||||||
if latestPricelistID != nil {
|
|
||||||
config.PricelistID = latestPricelistID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set price update timestamp
|
|
||||||
now := time.Now()
|
|
||||||
config.PriceUpdatedAt = &now
|
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUsername string) bool {
|
|
||||||
if config == nil || ownerUsername == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return config.OwnerUsername == ownerUsername
|
|
||||||
}
|
|
||||||
|
|
||||||
// // Export configuration as JSON
|
|
||||||
// type ConfigExport struct {
|
|
||||||
// Name string `json:"name"`
|
|
||||||
// Notes string `json:"notes"`
|
|
||||||
// Items models.ConfigItems `json:"items"`
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func (s *ConfigurationService) ExportJSON(uuid string, userID uint) ([]byte, error) {
|
|
||||||
// config, err := s.GetByUUID(uuid, userID)
|
|
||||||
// if err != nil {
|
|
||||||
// return nil, err
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// export := ConfigExport{
|
|
||||||
// Name: config.Name,
|
|
||||||
// Notes: config.Notes,
|
|
||||||
// Items: config.Items,
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return json.MarshalIndent(export, "", " ")
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func (s *ConfigurationService) ImportJSON(userID uint, data []byte) (*models.Configuration, error) {
|
|
||||||
// var export ConfigExport
|
|
||||||
// if err := json.Unmarshal(data, &export); err != nil {
|
|
||||||
// return nil, err
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// req := &CreateConfigRequest{
|
|
||||||
// Name: export.Name,
|
|
||||||
// Notes: export.Notes,
|
|
||||||
// Items: export.Items,
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return s.Create(userID, req)
|
|
||||||
// }
|
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
|
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
|
||||||
coveredLots := make(map[string]struct{})
|
coveredLots := make(map[string]struct{})
|
||||||
for _, row := range localCfg.VendorSpec {
|
for _, row := range localCfg.VendorSpec {
|
||||||
rowMappings := normalizeLotMappings(row.LotMappings)
|
rowMappings := localdb.NormalizeLotMappings(row.LotMappings)
|
||||||
for _, mapping := range rowMappings {
|
for _, mapping := range rowMappings {
|
||||||
coveredLots[mapping.LotName] = struct{}{}
|
coveredLots[mapping.LotName] = struct{}{}
|
||||||
}
|
}
|
||||||
@@ -424,21 +424,22 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range cfg.Items {
|
for _, item := range cfg.Items {
|
||||||
if item.LotName == "" {
|
lot := models.NormalizeLotName(item.LotName)
|
||||||
|
if lot == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := coveredLots[item.LotName]; ok {
|
if _, ok := coveredLots[lot]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
|
estimate := estimateOnlyTotal(priceMap[lot].Estimate, item.UnitPrice, item.Quantity)
|
||||||
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||||
LotDisplay: item.LotName,
|
LotDisplay: lot,
|
||||||
VendorPN: "—",
|
VendorPN: "—",
|
||||||
Description: componentDescriptions[item.LotName],
|
Description: componentDescriptions[lot],
|
||||||
Quantity: exportPositiveInt(item.Quantity, 1),
|
Quantity: exportPositiveInt(item.Quantity, 1),
|
||||||
Estimate: estimate,
|
Estimate: estimate,
|
||||||
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
|
Stock: totalForUnitPrice(priceMap[lot].Stock, item.Quantity),
|
||||||
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
Competitor: totalForUnitPrice(priceMap[lot].Competitor, item.Quantity),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if opts.isDDP() {
|
if opts.isDDP() {
|
||||||
@@ -656,16 +657,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)
|
return map[string]string{}
|
||||||
if s.localDB == nil || len(lots) == 0 {
|
|
||||||
return map[string]string{}
|
|
||||||
}
|
|
||||||
descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots)
|
|
||||||
if err != nil {
|
|
||||||
return map[string]string{}
|
|
||||||
}
|
|
||||||
return descriptions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
||||||
@@ -673,7 +666,7 @@ func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfig
|
|||||||
out := make([]string, 0)
|
out := make([]string, 0)
|
||||||
if includeBOM && localCfg != nil {
|
if includeBOM && localCfg != nil {
|
||||||
for _, row := range localCfg.VendorSpec {
|
for _, row := range localCfg.VendorSpec {
|
||||||
for _, mapping := range normalizeLotMappings(row.LotMappings) {
|
for _, mapping := range localdb.NormalizeLotMappings(row.LotMappings) {
|
||||||
if _, ok := seen[mapping.LotName]; ok {
|
if _, ok := seen[mapping.LotName]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -696,28 +689,6 @@ func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfig
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
|
||||||
if len(mappings) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]localdb.VendorSpecLotMapping, 0, len(mappings))
|
|
||||||
for _, mapping := range mappings {
|
|
||||||
lot := strings.TrimSpace(mapping.LotName)
|
|
||||||
if lot == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
qty := mapping.QuantityPerPN
|
|
||||||
if qty < 1 {
|
|
||||||
qty = 1
|
|
||||||
}
|
|
||||||
out = append(out, localdb.VendorSpecLotMapping{
|
|
||||||
LotName: lot,
|
|
||||||
QuantityPerPN: qty,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
|
func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
|
||||||
if row.TotalPrice != nil {
|
if row.TotalPrice != nil {
|
||||||
return floatPtr(*row.TotalPrice)
|
return floatPtr(*row.TotalPrice)
|
||||||
@@ -728,27 +699,6 @@ func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
|
|||||||
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
|
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 {
|
|
||||||
if len(mappings) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
total := 0.0
|
|
||||||
hasValue := false
|
|
||||||
qty := exportPositiveInt(pnQty, 1)
|
|
||||||
for _, mapping := range mappings {
|
|
||||||
price := selector(priceMap[mapping.LotName])
|
|
||||||
if price == nil || *price <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
total += *price * float64(qty*mapping.QuantityPerPN)
|
|
||||||
hasValue = true
|
|
||||||
}
|
|
||||||
if !hasValue {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return floatPtr(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
// distributeManualPrice sets ManualPrice on each row proportionally based on the
|
// distributeManualPrice sets ManualPrice on each row proportionally based on the
|
||||||
// row's Estimate share. The last row with a price absorbs rounding remainder so
|
// row's Estimate share. The last row with a price absorbs rounding remainder so
|
||||||
// the sum of ManualPrice values always equals manualPrice exactly.
|
// the sum of ManualPrice values always equals manualPrice exactly.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -1723,7 +1813,8 @@ func (s *LocalConfigurationService) resolvePricelistID(pricelistID *uint) (*uint
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("pricelist %d not available locally", *pricelistID)
|
// Pricelist not found even after sync — fall back to the latest active one.
|
||||||
|
slog.Warn("pricelist not available locally, falling back to latest active", "server_pricelist_id", *pricelistID)
|
||||||
}
|
}
|
||||||
|
|
||||||
latest, err := s.localDB.GetLatestLocalPricelist()
|
latest, err := s.localDB.GetLatestLocalPricelist()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,14 +17,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrProjectNotFound = errors.New("project not found")
|
ErrProjectNotFound = errors.New("project not found")
|
||||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||||
|
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||||
|
ErrProjectVariantInvalidChars = errors.New("имя варианта содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs.
|
||||||
|
var projectCodeRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||||
|
|
||||||
type ProjectService struct {
|
type ProjectService struct {
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
}
|
}
|
||||||
@@ -64,6 +70,9 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
|||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, fmt.Errorf("project code is required")
|
return nil, fmt.Errorf("project code is required")
|
||||||
}
|
}
|
||||||
|
if !projectCodeRe.MatchString(code) {
|
||||||
|
return nil, ErrProjectCodeInvalidChars
|
||||||
|
}
|
||||||
variant := strings.TrimSpace(req.Variant)
|
variant := strings.TrimSpace(req.Variant)
|
||||||
if err := validateProjectVariantName(variant); err != nil {
|
if err := validateProjectVariantName(variant); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -106,6 +115,9 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
|||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, fmt.Errorf("project code is required")
|
return nil, fmt.Errorf("project code is required")
|
||||||
}
|
}
|
||||||
|
if !projectCodeRe.MatchString(code) {
|
||||||
|
return nil, ErrProjectCodeInvalidChars
|
||||||
|
}
|
||||||
localProject.Code = code
|
localProject.Code = code
|
||||||
}
|
}
|
||||||
if req.Variant != nil {
|
if req.Variant != nil {
|
||||||
@@ -183,6 +195,9 @@ func validateProjectVariantName(variant string) error {
|
|||||||
if normalizeProjectVariant(variant) == "main" {
|
if normalizeProjectVariant(variant) == "main" {
|
||||||
return ErrReservedMainVariant
|
return ErrReservedMainVariant
|
||||||
}
|
}
|
||||||
|
if variant != "" && !projectCodeRe.MatchString(variant) {
|
||||||
|
return ErrProjectVariantInvalidChars
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +297,24 @@ func (s *ProjectService) GetByUUID(projectUUID, ownerUsername string) (*models.P
|
|||||||
return localdb.LocalToProject(localProject), nil
|
return localdb.LocalToProject(localProject), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByCode finds the main variant of a project by its code (case-insensitive).
|
||||||
|
func (s *ProjectService) GetByCode(code string) (*models.Project, error) {
|
||||||
|
localProject, err := s.localDB.GetProjectByCode(code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
return localdb.LocalToProject(localProject), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByCodeAndVariant finds a project by code + variant (both case-insensitive).
|
||||||
|
func (s *ProjectService) GetByCodeAndVariant(code, variant string) (*models.Project, error) {
|
||||||
|
localProject, err := s.localDB.GetProjectByCodeAndVariant(code, variant)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
return localdb.LocalToProject(localProject), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) {
|
func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) {
|
||||||
project, err := s.GetByUUID(projectUUID, ownerUsername)
|
project, err := s.GetByUUID(projectUUID, ownerUsername)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,32 +18,22 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type QuoteService struct {
|
type QuoteService struct {
|
||||||
componentRepo *repository.ComponentRepository
|
pricelistRepo *repository.PricelistRepository
|
||||||
pricelistRepo *repository.PricelistRepository
|
localDB *localdb.LocalDB
|
||||||
localDB *localdb.LocalDB
|
cacheMu sync.RWMutex
|
||||||
pricingService priceResolver
|
priceCache map[string]cachedLotPrice
|
||||||
cacheMu sync.RWMutex
|
cacheTTL time.Duration
|
||||||
priceCache map[string]cachedLotPrice
|
|
||||||
cacheTTL time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
type priceResolver interface {
|
|
||||||
GetEffectivePrice(lotName string) (*float64, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQuoteService(
|
func NewQuoteService(
|
||||||
componentRepo *repository.ComponentRepository,
|
|
||||||
pricelistRepo *repository.PricelistRepository,
|
pricelistRepo *repository.PricelistRepository,
|
||||||
localDB *localdb.LocalDB,
|
localDB *localdb.LocalDB,
|
||||||
pricingService priceResolver,
|
|
||||||
) *QuoteService {
|
) *QuoteService {
|
||||||
return &QuoteService{
|
return &QuoteService{
|
||||||
componentRepo: componentRepo,
|
pricelistRepo: pricelistRepo,
|
||||||
pricelistRepo: pricelistRepo,
|
localDB: localDB,
|
||||||
localDB: localDB,
|
priceCache: make(map[string]cachedLotPrice, 4096),
|
||||||
pricingService: pricingService,
|
cacheTTL: 10 * time.Second,
|
||||||
priceCache: make(map[string]cachedLotPrice, 4096),
|
|
||||||
cacheTTL: 10 * time.Second,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +101,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 {
|
||||||
@@ -172,79 +165,23 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.componentRepo == nil || s.pricingService == nil {
|
return nil, errors.New("quote calculation requires local database")
|
||||||
return nil, errors.New("quote calculation not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &QuoteValidationResult{
|
|
||||||
Valid: true,
|
|
||||||
Items: make([]QuoteItem, 0, len(req.Items)),
|
|
||||||
Errors: make([]string, 0),
|
|
||||||
Warnings: make([]string, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
lotNames := make([]string, len(req.Items))
|
|
||||||
quantities := make(map[string]int)
|
|
||||||
for i, item := range req.Items {
|
|
||||||
lotNames[i] = item.LotName
|
|
||||||
quantities[item.LotName] = item.Quantity
|
|
||||||
}
|
|
||||||
|
|
||||||
components, err := s.componentRepo.GetMultiple(lotNames)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
componentMap := make(map[string]*models.LotMetadata)
|
|
||||||
for i := range components {
|
|
||||||
componentMap[components[i].LotName] = &components[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
var total float64
|
|
||||||
|
|
||||||
for _, reqItem := range req.Items {
|
|
||||||
comp, exists := componentMap[reqItem.LotName]
|
|
||||||
if !exists {
|
|
||||||
result.Valid = false
|
|
||||||
result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
item := QuoteItem{
|
|
||||||
LotName: reqItem.LotName,
|
|
||||||
Quantity: reqItem.Quantity,
|
|
||||||
HasPrice: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if comp.Lot != nil {
|
|
||||||
item.Description = comp.Lot.LotDescription
|
|
||||||
}
|
|
||||||
if comp.Category != nil {
|
|
||||||
item.Category = comp.Category.Code
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get effective price (override or calculated)
|
|
||||||
price, err := s.pricingService.GetEffectivePrice(reqItem.LotName)
|
|
||||||
if err == nil && price != nil && *price > 0 {
|
|
||||||
item.UnitPrice = *price
|
|
||||||
item.TotalPrice = *price * float64(reqItem.Quantity)
|
|
||||||
item.HasPrice = true
|
|
||||||
total += item.TotalPrice
|
|
||||||
} else {
|
|
||||||
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Items = append(result.Items, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Total = total
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) {
|
func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) {
|
||||||
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 +240,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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
||||||
db := newPriceLevelsTestDB(t)
|
db := newPriceLevelsTestDB(t)
|
||||||
repo := repository.NewPricelistRepository(db)
|
repo := repository.NewPricelistRepository(db)
|
||||||
service := NewQuoteService(nil, repo, nil, nil)
|
service := NewQuoteService(repo, nil)
|
||||||
|
|
||||||
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
|
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
|
||||||
_ = estimate
|
_ = estimate
|
||||||
@@ -57,7 +57,7 @@ func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
|||||||
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
|
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
|
||||||
db := newPriceLevelsTestDB(t)
|
db := newPriceLevelsTestDB(t)
|
||||||
repo := repository.NewPricelistRepository(db)
|
repo := repository.NewPricelistRepository(db)
|
||||||
service := NewQuoteService(nil, repo, nil, nil)
|
service := NewQuoteService(repo, nil)
|
||||||
|
|
||||||
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
|
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
|
||||||
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
|
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
20
releases/v1.18/RELEASE_NOTES.md
Normal file
20
releases/v1.18/RELEASE_NOTES.md
Normal 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.
|
||||||
35
releases/v2.19/RELEASE_NOTES.md
Normal file
35
releases/v2.19/RELEASE_NOTES.md
Normal 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.
|
||||||
29
releases/v2.21/RELEASE_NOTES.md
Normal file
29
releases/v2.21/RELEASE_NOTES.md
Normal 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.
|
||||||
23
releases/v2.22/RELEASE_NOTES.md
Normal file
23
releases/v2.22/RELEASE_NOTES.md
Normal 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.
|
||||||
23
releases/v2.23/RELEASE_NOTES.md
Normal file
23
releases/v2.23/RELEASE_NOTES.md
Normal 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.
|
||||||
15
releases/v2.24/RELEASE_NOTES.md
Normal file
15
releases/v2.24/RELEASE_NOTES.md
Normal 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.
|
||||||
17
releases/v2.25/RELEASE_NOTES.md
Normal file
17
releases/v2.25/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# QuoteForge v2.25
|
||||||
|
|
||||||
|
Дата релиза: 2026-06-29
|
||||||
|
Тег: `v2.25`
|
||||||
|
|
||||||
|
Предыдущий релиз: `v2.24`
|
||||||
|
|
||||||
|
## Ключевые изменения
|
||||||
|
|
||||||
|
- исправлено дублирование позиций в таблице «Цена покупки» и в экспорте CSV: сопоставление LOT между BOM и корзиной теперь регистронезависимое;
|
||||||
|
- нормализация LOT-маппингов BOM сведена в единую каноничную функцию на бэкенде (UPPERCASE + схлопывание дублей) — устранены разошедшиеся копии, дававшие разный результат на фронте и в CSV;
|
||||||
|
- единый источник категории LOT — `local_pricelist_items.lot_category`; удалён неиспользуемый серверный слой управления компонентами/категориями.
|
||||||
|
|
||||||
|
## Запуск на macOS
|
||||||
|
|
||||||
|
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
|
||||||
|
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 isCategoryVisibleForConfigType(code, cfgType) {
|
||||||
|
const allowed = configTypeCategoryMap[cfgType];
|
||||||
|
if (!allowed || allowed.size === 0) return _hardcodedCategoryVisible(code, cfgType);
|
||||||
|
return allowed.has(code.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hardcodedCategoryVisible(code, cfgType) {
|
||||||
|
if (cfgType === 'storage') {
|
||||||
|
if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return true;
|
||||||
|
if (SERVER_ONLY_BASE_CATEGORIES.includes(code)) return false;
|
||||||
|
if (STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(code)) return false;
|
||||||
|
if (STORAGE_HIDDEN_PCI_CATEGORIES.includes(code)) return false;
|
||||||
|
if (STORAGE_HIDDEN_POWER_CATEGORIES.includes(code)) return false;
|
||||||
|
} else {
|
||||||
|
if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _effectiveAlwaysVisibleTabs() {
|
||||||
|
return alwaysVisibleTabsSet || ALWAYS_VISIBLE_TABS;
|
||||||
|
}
|
||||||
|
|
||||||
function applyConfigTypeToTabs() {
|
function applyConfigTypeToTabs() {
|
||||||
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
|
// Filter each tab's categories by visibility for current configType.
|
||||||
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
|
// Uses server-driven allowlists when available; falls back to hardcoded constants.
|
||||||
const storageSections = [
|
Object.keys(TAB_CONFIG).forEach(tabKey => {
|
||||||
{ title: 'RAID Контроллеры', categories: ['RAID'] },
|
if (tabKey === 'other') return;
|
||||||
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
|
const tab = TAB_CONFIG[tabKey];
|
||||||
];
|
if (!tab || !Array.isArray(tab.categories)) return;
|
||||||
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
|
|
||||||
const pciSections = [
|
|
||||||
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
|
||||||
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
|
||||||
{ title: 'HBA', categories: ['HBA'] },
|
|
||||||
{ title: 'HIC', categories: ['HIC'] }
|
|
||||||
];
|
|
||||||
const powerCategories = ['PS', 'PSU'];
|
|
||||||
|
|
||||||
TAB_CONFIG.base.categories = baseCategories.filter(c => {
|
// Snapshot the full category list for this tab (stored in _allCategories if not yet saved)
|
||||||
if (configType === 'storage') {
|
if (!tab._allCategories) tab._allCategories = [...tab.categories];
|
||||||
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
|
|
||||||
|
tab.categories = tab._allCategories.filter(c => isCategoryVisibleForConfigType(c, configType));
|
||||||
|
|
||||||
|
if (Array.isArray(tab._allSections || tab.sections)) {
|
||||||
|
const allSections = tab._allSections || tab.sections;
|
||||||
|
if (!tab._allSections) tab._allSections = allSections.map(s => ({ ...s, categories: [...s.categories] }));
|
||||||
|
tab.sections = tab._allSections
|
||||||
|
.map(section => ({
|
||||||
|
...section,
|
||||||
|
categories: section.categories.filter(c => isCategoryVisibleForConfigType(c, configType))
|
||||||
|
}))
|
||||||
|
.filter(section => section.categories.length > 0);
|
||||||
}
|
}
|
||||||
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
TAB_CONFIG.storage.categories = storageCategories.filter(c => {
|
// Rebuild assigned categories index using the full static list (_allCategories),
|
||||||
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
|
// not the filtered one — hidden categories still belong to their tab, not to Other.
|
||||||
});
|
|
||||||
TAB_CONFIG.storage.sections = storageSections.filter(section => {
|
|
||||||
if (configType === 'storage') {
|
|
||||||
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
|
|
||||||
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC';
|
|
||||||
});
|
|
||||||
TAB_CONFIG.pci.sections = pciSections.filter(section => {
|
|
||||||
if (configType === 'storage') {
|
|
||||||
return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat));
|
|
||||||
}
|
|
||||||
return section.title !== 'HIC';
|
|
||||||
});
|
|
||||||
TAB_CONFIG.power.categories = powerCategories.filter(c => {
|
|
||||||
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rebuild assigned categories index
|
|
||||||
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();
|
||||||
@@ -3284,8 +3379,9 @@ function setBOMManualLotDraft(rowIdx, value, el) {
|
|||||||
|
|
||||||
function _getRowAllocations(row) {
|
function _getRowAllocations(row) {
|
||||||
const list = Array.isArray(row?.lot_allocations) ? row.lot_allocations : [];
|
const list = Array.isArray(row?.lot_allocations) ? row.lot_allocations : [];
|
||||||
|
// Canonical LOT identity is UPPERCASE (see NormalizeLotName on the backend).
|
||||||
return list.map(a => ({
|
return list.map(a => ({
|
||||||
lot_name: (a?.lot_name || '').trim(),
|
lot_name: (a?.lot_name || '').trim().toUpperCase(),
|
||||||
quantity: Math.max(1, parseInt(a?.quantity, 10) || 1)
|
quantity: Math.max(1, parseInt(a?.quantity, 10) || 1)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -3294,9 +3390,11 @@ function _getRowLotQtyPerPN(row) {
|
|||||||
return (Number.isFinite(q) && q > 0) ? q : 1;
|
return (Number.isFinite(q) && q > 0) ? q : 1;
|
||||||
}
|
}
|
||||||
function _getRowBaseLot(row) {
|
function _getRowBaseLot(row) {
|
||||||
if (row?.resolved_lot) return row.resolved_lot;
|
// Canonical LOT identity is UPPERCASE (see NormalizeLotName on the backend).
|
||||||
|
const resolved = (row?.resolved_lot || '').trim();
|
||||||
|
if (resolved) return resolved.toUpperCase();
|
||||||
const manual = (row?.manual_lot || '').trim();
|
const manual = (row?.manual_lot || '').trim();
|
||||||
if (manual && _bomLotValid(manual)) return manual;
|
if (manual && _bomLotValid(manual)) return manual.toUpperCase();
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
function _getRowCanonicalLotMappings(row) {
|
function _getRowCanonicalLotMappings(row) {
|
||||||
@@ -3944,40 +4042,39 @@ async function renderPricingTab() {
|
|||||||
const tfootSale = document.getElementById('pricing-foot-sale');
|
const tfootSale = document.getElementById('pricing-foot-sale');
|
||||||
|
|
||||||
const cart = window._currentCart || [];
|
const cart = window._currentCart || [];
|
||||||
|
// Canonical LOT key: matching/dedup must be case-insensitive (cart is UPPERCASE,
|
||||||
|
// BOM mappings may be mixed-case). See NormalizeLotName on the backend.
|
||||||
|
const U = s => (s || '').toUpperCase();
|
||||||
const compMap = {};
|
const compMap = {};
|
||||||
(window._bomAllComponents || allComponents).forEach(c => { compMap[c.lot_name] = c; });
|
(window._bomAllComponents || allComponents).forEach(c => { compMap[U(c.lot_name)] = c; });
|
||||||
const rowBaseLot = (row) => {
|
const rowBaseLot = (row) => _getRowBaseLot(row);
|
||||||
if (row?.resolved_lot) return row.resolved_lot;
|
|
||||||
if (row?.manual_lot && _bomLotValid(row.manual_lot)) return row.manual_lot;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Collect LOTs to price: from BOM rows (resolved) or from cart
|
// Collect LOTs to price: from BOM rows (resolved) or from cart
|
||||||
// Use cart quantity when available (source of truth); fall back to BOM-computed quantity.
|
// Use cart quantity when available (source of truth); fall back to BOM-computed quantity.
|
||||||
const _cartQtyMap = {};
|
const _cartQtyMap = {};
|
||||||
cart.forEach(item => { if (item?.lot_name) _cartQtyMap[item.lot_name] = item.quantity; });
|
cart.forEach(item => { if (item?.lot_name) _cartQtyMap[U(item.lot_name)] = item.quantity; });
|
||||||
let itemsForPriceLevels = [];
|
let itemsForPriceLevels = [];
|
||||||
if (bomRows.length) {
|
if (bomRows.length) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
bomRows.forEach(row => {
|
bomRows.forEach(row => {
|
||||||
const baseLot = rowBaseLot(row);
|
const baseLot = rowBaseLot(row);
|
||||||
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
||||||
if (baseLot && !seen.has(baseLot)) {
|
if (baseLot && !seen.has(U(baseLot))) {
|
||||||
seen.add(baseLot);
|
seen.add(U(baseLot));
|
||||||
itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[baseLot] ?? (row.quantity * _getRowLotQtyPerPN(row)) });
|
itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[U(baseLot)] ?? (row.quantity * _getRowLotQtyPerPN(row)) });
|
||||||
}
|
}
|
||||||
if (allocs.length) {
|
if (allocs.length) {
|
||||||
allocs.forEach(a => {
|
allocs.forEach(a => {
|
||||||
if (!seen.has(a.lot_name)) {
|
if (!seen.has(U(a.lot_name))) {
|
||||||
seen.add(a.lot_name);
|
seen.add(U(a.lot_name));
|
||||||
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity) });
|
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[U(a.lot_name)] ?? (row.quantity * a.quantity) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
cart.forEach(item => {
|
cart.forEach(item => {
|
||||||
if (!item?.lot_name || seen.has(item.lot_name)) return;
|
if (!item?.lot_name || seen.has(U(item.lot_name))) return;
|
||||||
seen.add(item.lot_name);
|
seen.add(U(item.lot_name));
|
||||||
itemsForPriceLevels.push({ lot_name: item.lot_name, quantity: item.quantity });
|
itemsForPriceLevels.push({ lot_name: item.lot_name, quantity: item.quantity });
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -4002,7 +4099,7 @@ async function renderPricingTab() {
|
|||||||
});
|
});
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
(data.items || []).forEach(i => { priceMap[i.lot_name] = i; });
|
(data.items || []).forEach(i => { priceMap[U(i.lot_name)] = i; });
|
||||||
}
|
}
|
||||||
} catch(e) { /* silent */ }
|
} catch(e) { /* silent */ }
|
||||||
}
|
}
|
||||||
@@ -4024,19 +4121,19 @@ async function renderPricingTab() {
|
|||||||
// ─── Build shared row data (unit prices for display, totals for math) ────
|
// ─── Build shared row data (unit prices for display, totals for math) ────
|
||||||
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
|
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
|
||||||
const cartQtyMap = {};
|
const cartQtyMap = {};
|
||||||
cart.forEach(item => { if (item?.lot_name) cartQtyMap[item.lot_name] = item.quantity; });
|
cart.forEach(item => { if (item?.lot_name) cartQtyMap[U(item.lot_name)] = item.quantity; });
|
||||||
const _buildRows = () => {
|
const _buildRows = () => {
|
||||||
const result = [];
|
const result = [];
|
||||||
const coveredLots = new Set();
|
const coveredLots = new Set();
|
||||||
|
|
||||||
const _pushCartRow = (item, isEstOnly) => {
|
const _pushCartRow = (item, isEstOnly) => {
|
||||||
const pl = priceMap[item.lot_name];
|
const pl = priceMap[U(item.lot_name)];
|
||||||
const u = _getUnitPrices(pl);
|
const u = _getUnitPrices(pl);
|
||||||
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
|
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
|
||||||
result.push({
|
result.push({
|
||||||
lotCell: escapeHtml(item.lot_name), lotText: item.lot_name,
|
lotCell: escapeHtml(item.lot_name), lotText: item.lot_name,
|
||||||
vendorPN: null,
|
vendorPN: null,
|
||||||
desc: (compMap[item.lot_name] || {}).description || '',
|
desc: (compMap[U(item.lot_name)] || {}).description || '',
|
||||||
qty: item.quantity,
|
qty: item.quantity,
|
||||||
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
||||||
est: estUnit * item.quantity,
|
est: estUnit * item.quantity,
|
||||||
@@ -4049,32 +4146,32 @@ 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(U(item.lot_name)); });
|
||||||
return { result, coveredLots };
|
return { result, coveredLots };
|
||||||
}
|
}
|
||||||
|
|
||||||
bomRows.forEach(row => {
|
bomRows.forEach(row => {
|
||||||
const baseLot = rowBaseLot(row);
|
const baseLot = rowBaseLot(row);
|
||||||
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
||||||
if (baseLot) coveredLots.add(baseLot);
|
if (baseLot) coveredLots.add(U(baseLot));
|
||||||
allocs.forEach(a => coveredLots.add(a.lot_name));
|
allocs.forEach(a => coveredLots.add(U(a.lot_name)));
|
||||||
|
|
||||||
const vendorOrigUnit = row.unit_price != null ? row.unit_price
|
const vendorOrigUnit = row.unit_price != null ? row.unit_price
|
||||||
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
|
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
|
||||||
const vendorOrig = row.total_price != null ? row.total_price
|
const vendorOrig = row.total_price != null ? row.total_price
|
||||||
: (row.unit_price != null ? row.unit_price * row.quantity : null);
|
: (row.unit_price != null ? row.unit_price * row.quantity : null);
|
||||||
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : '');
|
const desc = row.description || (baseLot ? ((compMap[U(baseLot)] || {}).description || '') : '');
|
||||||
|
|
||||||
// Build per-LOT sub-rows
|
// Build per-LOT sub-rows
|
||||||
const subRows = [];
|
const subRows = [];
|
||||||
if (baseLot) {
|
if (baseLot) {
|
||||||
const u = _getUnitPrices(priceMap[baseLot]);
|
const u = _getUnitPrices(priceMap[U(baseLot)]);
|
||||||
const lotQty = _getRowLotQtyPerPN(row);
|
const lotQty = _getRowLotQtyPerPN(row);
|
||||||
const qty = cartQtyMap[baseLot] ?? (row.quantity * lotQty);
|
const qty = cartQtyMap[U(baseLot)] ?? (row.quantity * lotQty);
|
||||||
subRows.push({
|
subRows.push({
|
||||||
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
|
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
|
||||||
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||||
@@ -4085,8 +4182,8 @@ async function renderPricingTab() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
allocs.forEach(a => {
|
allocs.forEach(a => {
|
||||||
const u = _getUnitPrices(priceMap[a.lot_name]);
|
const u = _getUnitPrices(priceMap[U(a.lot_name)]);
|
||||||
const qty = cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity);
|
const qty = cartQtyMap[U(a.lot_name)] ?? (row.quantity * a.quantity);
|
||||||
subRows.push({
|
subRows.push({
|
||||||
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
|
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
|
||||||
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||||
@@ -4128,9 +4225,9 @@ async function renderPricingTab() {
|
|||||||
|
|
||||||
// Estimate-only LOTs (cart items not covered by BOM)
|
// Estimate-only LOTs (cart items not covered by BOM)
|
||||||
cart.forEach(item => {
|
cart.forEach(item => {
|
||||||
if (!item?.lot_name || coveredLots.has(item.lot_name)) return;
|
if (!item?.lot_name || coveredLots.has(U(item.lot_name))) return;
|
||||||
_pushCartRow(item, true);
|
_pushCartRow(item, true);
|
||||||
coveredLots.add(item.lot_name);
|
coveredLots.add(U(item.lot_name));
|
||||||
});
|
});
|
||||||
|
|
||||||
return { result, coveredLots };
|
return { result, coveredLots };
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user