Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce7c8551be | ||
|
|
3788492089 | ||
|
|
f7d26a28f8 | ||
|
|
bb742d2f38 | ||
|
|
f70cc680f7 | ||
| 64c9c4e862 | |||
| cc91ca10fc | |||
| 7d190cc7a8 | |||
| 8b2dc6652a | |||
| cea979e327 | |||
| 4d002671ae | |||
| 949479550c | |||
|
|
677b5d898f | ||
|
|
b3cab3477b | ||
|
|
6d4a37df8b | ||
|
|
7cc101d24d | ||
|
|
4900cd073c | ||
|
|
c0588e9710 | ||
|
|
0cd4f99b46 | ||
|
|
4982adbe41 | ||
|
|
5359ae6ded | ||
|
|
76d93c6be8 | ||
|
|
c6385f6cf1 | ||
|
|
1ab5186d0c | ||
|
|
b6fdac1caa | ||
|
|
b837ca7866 | ||
|
|
c8092da370 | ||
|
|
4f105822c6 | ||
|
|
6df262b8ee | ||
|
|
0fc0366bb1 | ||
|
|
d204e337b5 | ||
|
|
d340bf80af |
@@ -40,14 +40,25 @@ Readiness guard:
|
||||
|
||||
## 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:
|
||||
- `local_components` is metadata-only;
|
||||
- quote calculation must not read prices from components;
|
||||
- `local_components` table has been removed; do not recreate it;
|
||||
- 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;
|
||||
- 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
|
||||
|
||||
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 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` = дисковая полка без контроллера.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -8,9 +8,8 @@ Main tables:
|
||||
|
||||
| Table | Purpose |
|
||||
| --- | --- |
|
||||
| `local_components` | synced component metadata |
|
||||
| `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_configurations` | user configurations |
|
||||
| `local_configuration_versions` | immutable revision snapshots |
|
||||
@@ -20,12 +19,14 @@ Main tables:
|
||||
| `connection_settings` | encrypted MariaDB connection settings |
|
||||
| `app_settings` | local app state |
|
||||
| `local_schema_migrations` | applied local migration markers |
|
||||
| `local_qt_settings` | server-pushed configurator settings cache (from `qt_settings`) |
|
||||
|
||||
Rules:
|
||||
- cache tables may be rebuilt if local migration recovery requires it;
|
||||
- user-authored tables must not be dropped as a recovery shortcut;
|
||||
- `local_pricelist_items` is the only valid runtime source of prices;
|
||||
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows.
|
||||
- `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_components` table has been removed; any reference to it is dead code.
|
||||
|
||||
## MariaDB
|
||||
|
||||
@@ -34,12 +35,13 @@ MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
|
||||
### QuoteForge tables (qt_*)
|
||||
|
||||
Runtime read:
|
||||
- `qt_categories` — pricelist categories
|
||||
- `qt_categories` — pricelist categories (note: `name`/`name_ru` columns being removed; QF does not use them)
|
||||
- `qt_lot_metadata` — component metadata, price settings
|
||||
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
||||
- `qt_pricelist_items` — pricelist rows
|
||||
- `qt_partnumber_books` — partnumber book headers
|
||||
- `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:
|
||||
- `qt_projects` — projects
|
||||
@@ -48,7 +50,7 @@ Runtime read/write:
|
||||
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
|
||||
|
||||
Insert-only tracking:
|
||||
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync
|
||||
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
|
||||
|
||||
Server-side only (not queried by client runtime):
|
||||
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
||||
@@ -91,11 +93,26 @@ Full column reference as of 2026-03-21 (`RFQ_LOG` final schema).
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| code | varchar(20) UNIQUE NOT NULL | |
|
||||
| name | varchar(100) NOT NULL | |
|
||||
| name_ru | varchar(100) | |
|
||||
| name | varchar(100) NOT NULL | being removed; QF does not use at runtime |
|
||||
| name_ru | varchar(100) | being removed; QF does not use at runtime |
|
||||
| display_order | bigint 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
|
||||
PK: (username, hostname)
|
||||
| Column | Type | Notes |
|
||||
@@ -312,6 +329,7 @@ PK: job_name
|
||||
| ignored_by | varchar(100) | |
|
||||
| created_at | datetime(3) | |
|
||||
| updated_at | datetime(3) | |
|
||||
| lot_suggestion | longtext (JSON) | nullable; set when user manually maps PN → LOT in vendor-spec UI; same format as `qt_partnumber_book_items.lots_json`; see [11-lot-suggestions.md](11-lot-suggestions.md) |
|
||||
|
||||
### stock_ignore_rules
|
||||
| Column | Type | Notes |
|
||||
|
||||
@@ -112,6 +112,41 @@ Rules:
|
||||
- lines that do not match `<description> - <quantity> шт.` are skipped;
|
||||
- no price data is present in the format; `unit_price` and `total_price` are left nil.
|
||||
|
||||
## Nx BOM import (quantity-first)
|
||||
|
||||
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a quantity-first BOM
|
||||
where each item line begins with `<qty>x <description>`.
|
||||
|
||||
Format: an optional header line ending with `, в составе:` followed by one component per line as
|
||||
`<qty>x <description>`. The `x` separator is case-insensitive; parentheses, commas, and hyphens
|
||||
inside the description are preserved as-is.
|
||||
|
||||
Example:
|
||||
```
|
||||
Сервер G893-SD1-AAX3, в составе:
|
||||
1x 8U 2CPU 8GPU Server System (32x DDR5 DIMM Slots,8x 2.5" Hot-Swap Drive Bays, 4+4 3000W, 2x 10Gb/s RJ45, 2x IPMI RJ45)
|
||||
2x Intel Xeon 8570 (56 cores, 2.1GHz, 300MB, 350W)
|
||||
32x 64GB DDR5 ECC RDIMM
|
||||
1x GPU Nvidia HGX H200 141GB 8GPU
|
||||
3x 1.92TB NVMe PCIe SFF RI
|
||||
5x 7.68TB NVMe PCIe SFF RI
|
||||
8x 1-port 400G NDR OSFP CX7
|
||||
2x 2-port 100GbE QSFP56 CX6
|
||||
1x 2-port 10GbE RJ45
|
||||
```
|
||||
|
||||
Rules:
|
||||
- the entire file becomes a single configuration (`server_count = 1`);
|
||||
- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the
|
||||
last whitespace-separated token before the comma;
|
||||
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
|
||||
- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and
|
||||
`description`, so rows resolve through the active partnumber book when matched and otherwise stay
|
||||
unresolved and editable in the UI;
|
||||
- lines that do not match `<qty>x <description>` are skipped;
|
||||
- no price data is present in the format; `unit_price` and `total_price` are left nil;
|
||||
- detection runs before Text BOM in the format switch (Inspur → Nx → Text).
|
||||
|
||||
## Pasted BOM text parsing
|
||||
|
||||
`POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM
|
||||
@@ -119,7 +154,7 @@ Rules:
|
||||
`{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`.
|
||||
|
||||
This shares the exact detectors and parsers used by the file-import path
|
||||
(`ParsePastedBOMText` → `IsInspurBOM`/`parseInspurBOM`, `IsTextBOM`/`parseTextBOM`), so paste and upload
|
||||
behave identically — there is no second parser in the frontend. The configurator's BOM paste box calls
|
||||
this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real spreadsheet table)
|
||||
falls back to the manual column-mapping grid.
|
||||
(`ParsePastedBOMText` → `IsInspurBOM`/`parseInspurBOM`, `IsNxBOM`/`parseNxBOM`, `IsTextBOM`/`parseTextBOM`),
|
||||
so paste and upload behave identically — there is no second parser in the frontend. The configurator's BOM
|
||||
paste box calls this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real
|
||||
spreadsheet table) falls back to the manual column-mapping grid.
|
||||
|
||||
161
bible-local/11-lot-suggestions.md
Normal file
161
bible-local/11-lot-suggestions.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# 11 - Lot Suggestions (qt_vendor_partnumber_seen)
|
||||
|
||||
## Purpose
|
||||
|
||||
`qt_vendor_partnumber_seen` records vendor partnumbers encountered during import
|
||||
that have no mapping in the active partnumber book. When a user manually maps
|
||||
such a partnumber to one or more LOT names in the QuoteForge UI, those mappings
|
||||
are written back to the server as **lot suggestions** — hints for the team that
|
||||
maintains `qt_partnumber_book_items`.
|
||||
|
||||
## Schema Extension
|
||||
|
||||
Add one nullable column to `qt_vendor_partnumber_seen`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE `qt_vendor_partnumber_seen`
|
||||
ADD COLUMN `lot_suggestion` longtext DEFAULT NULL
|
||||
COMMENT 'JSON array [{lot_name, qty}] — user-entered LOT mappings from the UI';
|
||||
```
|
||||
|
||||
### Updated table contract (relevant columns only)
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `partnumber` | varchar(255) UNIQUE NOT NULL | natural key |
|
||||
| `lot_suggestion` | longtext (JSON) | nullable; set when user maps the PN manually |
|
||||
|
||||
`lot_suggestion` contains the same JSON shape as `qt_partnumber_book_items.lots_json`:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "lot_name": "LOT_A", "qty": 1 },
|
||||
{ "lot_name": "LOT_B", "qty": 2 }
|
||||
]
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `null` or absent means no suggestion has been entered yet;
|
||||
- an empty array `[]` is not a valid value — use `null` instead;
|
||||
- a single PN may map to multiple lots (`lot_name` entries), each with its own `qty`;
|
||||
- the array is ordered — the order reflects the order of `lot_mappings[]` in the
|
||||
vendor spec row at the time of last user save;
|
||||
- `qty` must be a positive integer (≥ 1).
|
||||
|
||||
## Write Contract (QuoteForge → MariaDB)
|
||||
|
||||
QuoteForge writes `lot_suggestion` when all of the following are true:
|
||||
|
||||
1. The user saves a vendor BOM via `PUT /api/configs/:uuid/vendor-spec`.
|
||||
2. At least one `vendor_spec` row has a non-empty `lot_mappings[]` array (manually
|
||||
entered or confirmed by the user — not auto-resolved from a partnumber book).
|
||||
3. The MariaDB connection is available at the time of save.
|
||||
|
||||
For each such row:
|
||||
|
||||
```sql
|
||||
INSERT INTO qt_vendor_partnumber_seen
|
||||
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
|
||||
VALUES
|
||||
('manual', '', ?, ?, 0, NOW(3), ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
lot_suggestion = VALUES(lot_suggestion),
|
||||
last_seen_at = IF(lot_suggestion IS NULL, last_seen_at, NOW(3))
|
||||
```
|
||||
|
||||
- `lot_suggestion` value = JSON-marshalled `lot_mappings[]` from the vendor spec item,
|
||||
reusing the same `{lot_name, qty}` shape.
|
||||
- If the PN row already exists and `lot_suggestion` is already set, it is **overwritten**
|
||||
with the latest user input (the user is assumed to have corrected it).
|
||||
- If the user **clears** all lot_mappings for a PN (sets to empty), no update is sent —
|
||||
the existing `lot_suggestion` on the server is left untouched.
|
||||
- Rows where `lot_mappings[]` is empty or nil are skipped entirely (no insert, no update).
|
||||
- Writes are best-effort: a MariaDB error for one row is logged and skipped; remaining
|
||||
rows continue. A write failure does not fail the vendor-spec save.
|
||||
|
||||
## Read Contract (Partnumber-Book Creation Tool → MariaDB)
|
||||
|
||||
The tool that maintains `qt_partnumber_book_items` reads `qt_vendor_partnumber_seen`
|
||||
to discover new partnumbers and their suggested mappings.
|
||||
|
||||
### Discovery query
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
s.id,
|
||||
s.partnumber,
|
||||
s.description,
|
||||
s.vendor,
|
||||
s.lot_suggestion,
|
||||
s.last_seen_at,
|
||||
b.lots_json AS book_lots_json
|
||||
FROM qt_vendor_partnumber_seen s
|
||||
LEFT JOIN qt_partnumber_book_items b ON b.partnumber = s.partnumber
|
||||
WHERE s.is_ignored = 0
|
||||
AND s.lot_suggestion IS NOT NULL
|
||||
ORDER BY s.last_seen_at DESC;
|
||||
```
|
||||
|
||||
### Interpretation rules
|
||||
|
||||
| Condition | Meaning | Suggested action |
|
||||
|-----------|---------|-----------------|
|
||||
| `book_lots_json IS NULL` AND `lot_suggestion IS NOT NULL` | No book entry yet; user suggested mapping | Create new `qt_partnumber_book_items` row with `lots_json = lot_suggestion` |
|
||||
| `book_lots_json IS NOT NULL` AND `lot_suggestion IS NOT NULL` AND they differ | User corrected or extended the existing mapping | Review diff and decide whether to update `qt_partnumber_book_items` |
|
||||
| `book_lots_json IS NOT NULL` AND `lot_suggestion IS NOT NULL` AND they match | Suggestion already applied | No action needed |
|
||||
|
||||
### Suggestion format
|
||||
|
||||
`lot_suggestion` is valid JSON (or `null`). Parse it as an array of objects:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "lot_name": "LOT_A", "qty": 1 },
|
||||
{ "lot_name": "LOT_B", "qty": 2 }
|
||||
]
|
||||
```
|
||||
|
||||
Map directly to `qt_partnumber_book_items.lots_json` — the formats are identical.
|
||||
|
||||
### Multiple lots per PN
|
||||
|
||||
One PN may have multiple suggestion entries (e.g., a bundle). The array carries
|
||||
all of them. The book-creation tool must preserve the full array when writing
|
||||
`lots_json`, not just the first element.
|
||||
|
||||
### Qty semantics
|
||||
|
||||
`qty` in a lot suggestion means "how many of this LOT per one occurrence of the
|
||||
vendor PN". This matches `qt_partnumber_book_items.lots_json` exactly. Example:
|
||||
a server platform that comes with 4 PSUs would produce
|
||||
`[{"lot_name": "PS_1300W_Titanium", "qty": 4}]`.
|
||||
|
||||
## Permissions
|
||||
|
||||
The existing `qfs_user` grant covers this column — no new permission is required:
|
||||
|
||||
```sql
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
|
||||
```
|
||||
|
||||
The book-creation tool connects with its own credentials and needs at minimum:
|
||||
|
||||
```sql
|
||||
GRANT SELECT ON RFQ_LOG.qt_vendor_partnumber_seen TO '<book_tool_user>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_partnumber_book_items TO '<book_tool_user>'@'%';
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
Migration is applied outside this repo (server-side DDL):
|
||||
|
||||
```sql
|
||||
ALTER TABLE `qt_vendor_partnumber_seen`
|
||||
ADD COLUMN IF NOT EXISTS `lot_suggestion` longtext DEFAULT NULL
|
||||
COMMENT 'JSON [{lot_name, qty}] — user LOT suggestions from QuoteForge UI';
|
||||
```
|
||||
|
||||
QuoteForge handles a missing column gracefully: if the migration has not run yet,
|
||||
the write with `lot_suggestion` fails with "Unknown column" (MariaDB 1054), a warning
|
||||
is logged, and the row is re-inserted without the column. The app never crashes on
|
||||
migration lag.
|
||||
@@ -15,6 +15,7 @@ Project-specific architecture and operational contracts.
|
||||
| [07-dev.md](07-dev.md) | Development commands and guardrails |
|
||||
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
|
||||
| [10-agent-api-guide.md](10-agent-api-guide.md) | End-to-end API guide for agents pricing servers from a TZ |
|
||||
| [11-lot-suggestions.md](11-lot-suggestions.md) | lot_suggestion column in qt_vendor_partnumber_seen — write/read contract for manual UI mappings |
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
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`.
|
||||
@@ -779,7 +779,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
|
||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||
vendorSpecHandler := handlers.NewVendorSpecHandler(local)
|
||||
vendorSpecHandler := handlers.NewVendorSpecHandler(local, syncService)
|
||||
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
|
||||
respondError := handlers.RespondError
|
||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||
@@ -894,6 +894,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||
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
|
||||
partials := router.Group("/partials")
|
||||
{
|
||||
@@ -919,6 +940,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
|
||||
// Categories (public)
|
||||
api.GET("/categories", componentHandler.GetCategories)
|
||||
api.GET("/configurator-settings", componentHandler.GetConfiguratorSettings)
|
||||
|
||||
// Quote (public)
|
||||
quote := api.Group("/quote")
|
||||
@@ -1147,6 +1169,15 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
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) {
|
||||
uuid := c.Param("uuid")
|
||||
var req struct {
|
||||
@@ -1516,7 +1547,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
project, err := projectService.Create(dbUsername, &req)
|
||||
if err != nil {
|
||||
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)
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||
@@ -1554,7 +1587,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
if err != nil {
|
||||
switch {
|
||||
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)
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||
@@ -1776,7 +1811,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
syncAPI.GET("/readiness", syncHandler.GetReadiness)
|
||||
syncAPI.GET("/info", syncHandler.GetInfo)
|
||||
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
|
||||
syncAPI.POST("/components", syncHandler.SyncComponents)
|
||||
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
||||
syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks)
|
||||
syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen)
|
||||
|
||||
@@ -45,38 +45,55 @@ func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) {
|
||||
func TestResolveLotCategoriesStrict_FallbackToLatestPricelist(t *testing.T) {
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
// Older pricelist used by the configuration — CPU_B has no category here
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 2,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-002",
|
||||
Name: "test",
|
||||
Name: "old",
|
||||
IsActive: false,
|
||||
CreatedAt: time.Now().Add(-time.Hour),
|
||||
SyncedAt: time.Now().Add(-time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("save old pricelist: %v", err)
|
||||
}
|
||||
oldPL, err := local.GetLocalPricelistByServerID(2)
|
||||
if err != nil {
|
||||
t.Fatalf("get old pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: oldPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
|
||||
}); err != nil {
|
||||
t.Fatalf("save old pricelist items: %v", err)
|
||||
}
|
||||
|
||||
// Newer active pricelist — CPU_B has category set
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 3,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-003",
|
||||
Name: "latest",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
t.Fatalf("save latest pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(2)
|
||||
latestPL, err := local.GetLocalPricelistByServerID(3)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
t.Fatalf("get latest pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: localPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
|
||||
{PricelistID: latestPL.ID, LotName: "CPU_B", LotCategory: "CPU", Price: 10},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local 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)
|
||||
t.Fatalf("save latest pricelist items: %v", err)
|
||||
}
|
||||
|
||||
cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"})
|
||||
|
||||
@@ -125,3 +125,102 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
for _, item := range items {
|
||||
resultItems = append(resultItems, gin.H{
|
||||
"id": item.ID,
|
||||
"lot_name": item.LotName,
|
||||
"lot_description": descMap[item.LotName],
|
||||
"lot_description": "",
|
||||
"price": item.Price,
|
||||
"category": item.LotCategory,
|
||||
"available_qty": item.AvailableQty,
|
||||
|
||||
@@ -74,7 +74,7 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
||||
|
||||
// local_db_stats.json
|
||||
writeJSON("local_db_stats.json", map[string]any{
|
||||
"components": h.localDB.CountLocalComponents(),
|
||||
"components": h.localDB.CountComponents(),
|
||||
"configurations": h.localDB.CountConfigurations(),
|
||||
"projects": h.localDB.CountProjects(),
|
||||
"pricelists": h.localDB.CountLocalPricelists(),
|
||||
@@ -139,6 +139,7 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
IsUsed bool `json:"is_used"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
bySource := map[string][]plEntry{}
|
||||
for _, pl := range pricelists {
|
||||
@@ -150,12 +151,78 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
||||
CreatedAt: pl.CreatedAt,
|
||||
SyncedAt: pl.SyncedAt,
|
||||
IsUsed: pl.IsUsed,
|
||||
IsActive: pl.IsActive,
|
||||
}
|
||||
bySource[pl.Source] = append(bySource[pl.Source], e)
|
||||
}
|
||||
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
|
||||
migrations, err := h.localDB.GetSchemaMigrations()
|
||||
if err != nil {
|
||||
@@ -163,6 +230,44 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
||||
}
|
||||
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)
|
||||
if h.logFilePath != "" {
|
||||
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
|
||||
type SyncStatusResponse struct {
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||||
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||||
@@ -61,7 +60,6 @@ type SyncStatusResponse struct {
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_sync"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||||
}
|
||||
@@ -80,19 +78,16 @@ type SyncReadinessResponse struct {
|
||||
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
connStatus := h.connMgr.GetStatus()
|
||||
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||||
lastComponentSync := h.localDB.GetComponentSyncTime()
|
||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||
componentsCount := h.localDB.CountLocalComponents()
|
||||
componentsCount := h.localDB.CountComponents()
|
||||
pricelistsCount := h.localDB.CountLocalPricelists()
|
||||
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||
readiness := h.getReadinessLocal()
|
||||
|
||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||
LastComponentSync: lastComponentSync,
|
||||
LastPricelistSync: lastPricelistSync,
|
||||
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||
@@ -103,7 +98,6 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
ComponentsCount: componentsCount,
|
||||
PricelistsCount: pricelistsCount,
|
||||
ServerPricelists: 0,
|
||||
NeedComponentSync: needComponentSync,
|
||||
NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
|
||||
Readiness: readiness,
|
||||
})
|
||||
@@ -169,48 +163,6 @@ type SyncResultResponse struct {
|
||||
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
|
||||
// POST /api/sync/pricelists
|
||||
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
@@ -232,6 +184,10 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
}
|
||||
h.localDB.AppendSyncLog("pricelists", "ok", "", synced, startTime, time.Since(startTime).Milliseconds())
|
||||
|
||||
if _, err := h.syncService.PullPartnumberBooks(); err != nil {
|
||||
slog.Warn("partnumber books pull failed after pricelist sync", "error", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
Message: "Pricelists synced successfully",
|
||||
@@ -272,7 +228,6 @@ type SyncAllResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
PendingPushed int `json:"pending_pushed"`
|
||||
ComponentsSynced int `json:"components_synced"`
|
||||
PricelistsSynced int `json:"pricelists_synced"`
|
||||
ProjectsImported int `json:"projects_imported"`
|
||||
ProjectsUpdated int `json:"projects_updated"`
|
||||
@@ -293,7 +248,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
var pendingPushed, componentsSynced, pricelistsSynced int
|
||||
var pricelistsSynced int
|
||||
|
||||
// Push local pending changes first (projects/configurations)
|
||||
pendingPushed, err := h.syncService.PushPendingChanges()
|
||||
@@ -307,34 +262,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
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
|
||||
plNow := time.Now()
|
||||
pricelistsSynced, err = h.syncService.SyncPricelists()
|
||||
@@ -342,16 +269,19 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, plNow, time.Since(plNow).Milliseconds())
|
||||
slog.Error("pricelist sync failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "pricelist sync failed",
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
"success": false,
|
||||
"error": "pricelist sync failed",
|
||||
"pending_pushed": pendingPushed,
|
||||
})
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
h.localDB.AppendSyncLog("pricelists", "ok", "", pricelistsSynced, plNow, time.Since(plNow).Milliseconds())
|
||||
|
||||
if _, err := h.syncService.PullPartnumberBooks(); err != nil {
|
||||
slog.Warn("partnumber books pull failed during full sync", "error", err)
|
||||
}
|
||||
|
||||
projectsResult, err := h.syncService.ImportProjectsToLocal()
|
||||
if err != nil {
|
||||
slog.Error("project import failed during full sync", "error", err)
|
||||
@@ -359,7 +289,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
"success": false,
|
||||
"error": "project import failed",
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
"pricelists_synced": pricelistsSynced,
|
||||
})
|
||||
_ = c.Error(err)
|
||||
@@ -373,7 +302,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
"success": false,
|
||||
"error": "configuration import failed",
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
"pricelists_synced": pricelistsSynced,
|
||||
"projects_imported": projectsResult.Imported,
|
||||
"projects_updated": projectsResult.Updated,
|
||||
@@ -387,7 +315,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
Success: true,
|
||||
Message: "Full sync completed successfully",
|
||||
PendingPushed: pendingPushed,
|
||||
ComponentsSynced: componentsSynced,
|
||||
PricelistsSynced: pricelistsSynced,
|
||||
ProjectsImported: projectsResult.Imported,
|
||||
ProjectsUpdated: projectsResult.Updated,
|
||||
@@ -548,7 +475,7 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
// Get local counts
|
||||
configCount := h.localDB.CountConfigurations()
|
||||
projectCount := h.localDB.CountProjects()
|
||||
componentCount := h.localDB.CountLocalComponents()
|
||||
componentCount := h.localDB.CountComponents()
|
||||
pricelistCount := h.localDB.CountLocalPricelists()
|
||||
|
||||
// Get error count (only changes with LastError != "")
|
||||
|
||||
@@ -4,11 +4,12 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -16,12 +17,14 @@ import (
|
||||
type VendorSpecHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
configService *services.LocalConfigurationService
|
||||
syncService *syncsvc.Service // optional; nil = no server push
|
||||
}
|
||||
|
||||
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
||||
func NewVendorSpecHandler(localDB *localdb.LocalDB, syncService *syncsvc.Service) *VendorSpecHandler {
|
||||
return &VendorSpecHandler{
|
||||
localDB: localDB,
|
||||
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
|
||||
syncService: syncService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,9 +114,57 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Push manual lot mappings as suggestions to the server (best-effort, non-blocking).
|
||||
h.pushLotSuggestions(body.VendorSpec)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
||||
}
|
||||
|
||||
// pushLotSuggestions sends manual PN→LOT mappings to qt_vendor_partnumber_seen.lot_suggestion.
|
||||
// Errors are logged and silently dropped — they must not affect the HTTP response.
|
||||
func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
|
||||
if h.syncService == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var items []syncsvc.SeenPartnumber
|
||||
for _, row := range spec {
|
||||
if row.VendorPartnumber == "" || len(row.LotMappings) == 0 {
|
||||
continue
|
||||
}
|
||||
suggestion := make([]syncsvc.LotSuggestionEntry, 0, len(row.LotMappings))
|
||||
for _, m := range row.LotMappings {
|
||||
if m.LotName == "" {
|
||||
continue
|
||||
}
|
||||
qty := m.QuantityPerPN
|
||||
if qty < 1 {
|
||||
qty = 1
|
||||
}
|
||||
suggestion = append(suggestion, syncsvc.LotSuggestionEntry{
|
||||
LotName: m.LotName,
|
||||
Qty: qty,
|
||||
})
|
||||
}
|
||||
if len(suggestion) == 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, syncsvc.SeenPartnumber{
|
||||
Partnumber: row.VendorPartnumber,
|
||||
Description: row.Description,
|
||||
LotSuggestion: suggestion,
|
||||
})
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.syncService.PushPartnumberSeen(items); err != nil {
|
||||
slog.Warn("vendor_spec: failed to push lot suggestions to server", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
@@ -121,7 +172,7 @@ func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpe
|
||||
merged := make(map[string]int, len(in))
|
||||
order := make([]string, 0, len(in))
|
||||
for _, m := range in {
|
||||
lot := strings.TrimSpace(m.LotName)
|
||||
lot := models.NormalizeLotName(m.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2,11 +2,8 @@ package localdb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ComponentFilter for searching with filters
|
||||
@@ -24,344 +21,213 @@ type ComponentSyncResult struct {
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// SyncComponents loads components from MariaDB (lot + qt_lot_metadata) into local_components
|
||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Build the component catalog from every runtime source of LOT names.
|
||||
// Storage lots may exist in qt_lot_metadata / qt_pricelist_items before they appear in lot,
|
||||
// so the sync cannot start from lot alone.
|
||||
type componentRow struct {
|
||||
LotName string
|
||||
LotDescription string
|
||||
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
|
||||
// latestActivePricelistID returns the local DB id of the most recently created
|
||||
// active pricelist for the given source ("estimate", "warehouse", etc.).
|
||||
func (l *LocalDB) latestActivePricelistID(source string) (uint, error) {
|
||||
var id uint
|
||||
err := l.db.Table("local_pricelists").
|
||||
Select("id").
|
||||
Where("is_active = ? AND source = ?", true, source).
|
||||
Order("created_at DESC, id DESC").
|
||||
Limit(1).
|
||||
Scan(&id).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
slog.Warn("no components found in MariaDB")
|
||||
return &ComponentSyncResult{
|
||||
Duration: time.Since(startTime),
|
||||
}, nil
|
||||
if id == 0 {
|
||||
return 0, fmt.Errorf("no active %s pricelist", source)
|
||||
}
|
||||
|
||||
// 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
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// SearchLocalComponents searches components in local cache by query string
|
||||
// Searches in lot_name, lot_description, category, and model fields
|
||||
// pricelistItemRow is used for scanning rows from local_pricelist_items.
|
||||
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) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
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
|
||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||
if err != nil {
|
||||
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.
|
||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||
// SearchLocalComponentsByCategory searches components in the latest active
|
||||
// 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) {
|
||||
result := make(map[string]string, len(lotNames))
|
||||
if len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
Category string `gorm:"column:category"`
|
||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||
if err != nil {
|
||||
return result, nil
|
||||
}
|
||||
var rows []row
|
||||
if err := l.db.Model(&LocalComponent{}).
|
||||
Select("lot_name, category").
|
||||
Where("lot_name IN ?", lotNames).
|
||||
Find(&rows).Error; err != nil {
|
||||
|
||||
// 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 []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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// GetLocalComponentCategories returns distinct categories from local components
|
||||
// GetLocalComponentCategories returns distinct categories from the latest
|
||||
// active estimate pricelist.
|
||||
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
||||
var categories []string
|
||||
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)
|
||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||
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
|
||||
func (l *LocalDB) SetComponentSyncTime(t time.Time) error {
|
||||
return l.db.Exec(`
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
`, "last_component_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
||||
// CountComponents returns the number of distinct lot names in the latest
|
||||
// active estimate pricelist (used to check if data is available).
|
||||
func (l *LocalDB) CountComponents() int64 {
|
||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
items := make(LocalConfigItems, len(cfg.Items))
|
||||
for i, item := range cfg.Items {
|
||||
items[i] = LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
LotName: models.NormalizeLotName(item.LotName),
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
@@ -271,7 +271,7 @@ func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *Lo
|
||||
partnumbers = append(partnumbers, item.Partnumbers...)
|
||||
return &LocalPricelistItem{
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
LotName: models.NormalizeLotName(item.LotName),
|
||||
LotCategory: item.LotCategory,
|
||||
Price: item.Price,
|
||||
AvailableQty: item.AvailableQty,
|
||||
|
||||
@@ -46,7 +46,6 @@ type LocalDB struct {
|
||||
var localReadOnlyCacheTables = []string{
|
||||
"local_pricelist_items",
|
||||
"local_pricelists",
|
||||
"local_components",
|
||||
"local_partnumber_book_items",
|
||||
"local_partnumber_books",
|
||||
}
|
||||
@@ -78,7 +77,6 @@ func ResetData(dbPath string) error {
|
||||
"local_configuration_versions",
|
||||
"local_pricelists",
|
||||
"local_pricelist_items",
|
||||
"local_components",
|
||||
"local_sync_guard_state",
|
||||
"pending_changes",
|
||||
"app_settings",
|
||||
@@ -224,12 +222,12 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
|
||||
&LocalConfigurationVersion{},
|
||||
&LocalPricelist{},
|
||||
&LocalPricelistItem{},
|
||||
&LocalComponent{},
|
||||
&AppSetting{},
|
||||
&LocalSyncGuardState{},
|
||||
&PendingChange{},
|
||||
&LocalPartnumberBook{},
|
||||
&SyncLogEntry{},
|
||||
&LocalQtSetting{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -691,6 +689,22 @@ func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) {
|
||||
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) {
|
||||
var project LocalProject
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
@@ -1254,11 +1249,10 @@ func (l *LocalDB) CountAllPricelistItems() int64 {
|
||||
return count
|
||||
}
|
||||
|
||||
// CountComponents returns the number of rows in local_components.
|
||||
func (l *LocalDB) CountComponents() int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalComponent{}).Count(&count)
|
||||
return count
|
||||
|
||||
// DBFilePath returns the path to the SQLite database file.
|
||||
func (l *LocalDB) DBFilePath() string {
|
||||
return l.path
|
||||
}
|
||||
|
||||
// DBFileSizeBytes returns the size of the SQLite database file in bytes.
|
||||
@@ -1270,11 +1264,11 @@ func (l *LocalDB) DBFileSizeBytes() int64 {
|
||||
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) {
|
||||
var pricelist LocalPricelist
|
||||
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)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
@@ -1283,11 +1277,11 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
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) {
|
||||
var pricelist LocalPricelist
|
||||
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)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
@@ -1296,6 +1290,17 @@ func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelis
|
||||
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
|
||||
func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
@@ -1363,6 +1368,30 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
|
||||
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.
|
||||
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
|
||||
var count int64
|
||||
@@ -1427,10 +1456,11 @@ func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem
|
||||
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) {
|
||||
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 {
|
||||
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.
|
||||
// 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) {
|
||||
result := make(map[string]float64, len(lotNames))
|
||||
if len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
var rows []row
|
||||
// Use UPPER(lot_name) so rows synced before normalization (mixed-case) are still matched.
|
||||
if err := l.db.Model(&LocalPricelistItem{}).
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
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
|
||||
@@ -1480,15 +1516,27 @@ func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uin
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
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
|
||||
if err := l.db.Model(&LocalPricelistItem{}).
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1672,12 +1720,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
|
||||
var remainingErrors []string
|
||||
|
||||
for _, change := range erroredChanges {
|
||||
var modified bool
|
||||
var repairErr error
|
||||
switch change.EntityType {
|
||||
case "project":
|
||||
repairErr = l.repairProjectChange(&change)
|
||||
modified, repairErr = l.repairProjectChange(&change)
|
||||
case "configuration":
|
||||
repairErr = l.repairConfigurationChange(&change)
|
||||
modified, repairErr = l.repairConfigurationChange(&change)
|
||||
default:
|
||||
repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
|
||||
}
|
||||
@@ -1688,7 +1737,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
|
||||
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{}{
|
||||
"last_error": "",
|
||||
"attempts": 0,
|
||||
@@ -1704,12 +1759,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("project not found locally: %w", err)
|
||||
return false, fmt.Errorf("project not found locally: %w", err)
|
||||
}
|
||||
|
||||
modified := false
|
||||
@@ -1735,7 +1791,7 @@ func (l *LocalDB) repairProjectChange(change *PendingChange) error {
|
||||
if strings.TrimSpace(project.OwnerUsername) == "" {
|
||||
project.OwnerUsername = l.GetDBUser()
|
||||
if project.OwnerUsername == "" {
|
||||
return fmt.Errorf("cannot determine owner username")
|
||||
return false, fmt.Errorf("cannot determine owner username")
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
@@ -1756,18 +1812,19 @@ func (l *LocalDB) repairProjectChange(change *PendingChange) error {
|
||||
|
||||
if modified {
|
||||
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
|
||||
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
|
||||
// repairConfigurationChange validates and fixes configuration data.
|
||||
// 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration not found locally: %w", err)
|
||||
return false, fmt.Errorf("configuration not found locally: %w", err)
|
||||
}
|
||||
|
||||
modified := false
|
||||
@@ -1779,7 +1836,7 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
|
||||
// Project doesn't exist locally - use default system project
|
||||
systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
|
||||
if sysErr != nil {
|
||||
return fmt.Errorf("getting system project: %w", sysErr)
|
||||
return false, fmt.Errorf("getting system project: %w", sysErr)
|
||||
}
|
||||
config.ProjectUUID = &systemProject.UUID
|
||||
modified = true
|
||||
@@ -1788,11 +1845,11 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
|
||||
|
||||
if modified {
|
||||
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.
|
||||
@@ -1854,28 +1911,6 @@ func (l *LocalDB) GetLocalPricelistItemsPage(pricelistID uint, search string, pa
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetLocalComponentDescriptionsByLotNames returns a map of lot_name → lot_description for the given lots.
|
||||
func (l *LocalDB) GetLocalComponentDescriptionsByLotNames(lotNames []string) (map[string]string, error) {
|
||||
if len(lotNames) == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
type row struct {
|
||||
LotName string
|
||||
LotDescription string
|
||||
}
|
||||
var rows []row
|
||||
if err := l.db.Table("local_components").
|
||||
Select("lot_name, lot_description").
|
||||
Where("lot_name IN ?", lotNames).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("fetch component descriptions: %w", err)
|
||||
}
|
||||
m := make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
m[r.LotName] = r.LotDescription
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// GetSchemaMigrations returns all applied local schema migrations ordered by applied_at.
|
||||
func (l *LocalDB) GetSchemaMigrations() ([]LocalSchemaMigration, error) {
|
||||
|
||||
@@ -1120,3 +1120,4 @@ func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error {
|
||||
slog.Info("deduplicated local_pricelist_items and added unique index")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
// AppSetting stores application settings in local SQLite
|
||||
@@ -46,7 +48,13 @@ func (c *LocalConfigItems) Scan(value interface{}) error {
|
||||
default:
|
||||
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 {
|
||||
@@ -169,7 +177,8 @@ type LocalPricelist struct {
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_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 {
|
||||
@@ -356,3 +365,12 @@ func (v *VendorSpec) Scan(value interface{}) error {
|
||||
}
|
||||
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
|
||||
|
||||
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
|
||||
type Lot struct {
|
||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||
|
||||
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" }
|
||||
@@ -157,7 +157,7 @@ func (r *PartnumberBookRepository) listCatalogItems(partnumbers localdb.LocalStr
|
||||
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers))
|
||||
if search != "" {
|
||||
trimmedSearch := "%" + search + "%"
|
||||
query = query.Where("partnumber LIKE ? OR lots_json LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
|
||||
query = query.Where("partnumber LIKE ? OR CAST(lots_json AS TEXT) LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
|
||||
}
|
||||
|
||||
var total int64
|
||||
|
||||
@@ -269,12 +269,21 @@ func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (
|
||||
}
|
||||
|
||||
// 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) {
|
||||
result := make(map[string]float64, len(lotNames))
|
||||
if pricelistID == 0 || len(lotNames) == 0 {
|
||||
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
|
||||
if err := r.db.Select("lot_name, price").
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -23,6 +23,11 @@ func (r *ProjectRepository) Update(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{
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
|
||||
@@ -53,13 +53,14 @@ type ProjectExportData struct {
|
||||
}
|
||||
|
||||
type ProjectPricingExportOptions struct {
|
||||
IncludeLOT bool `json:"include_lot"`
|
||||
IncludeBOM bool `json:"include_bom"`
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
|
||||
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||
IncludeLOT bool `json:"include_lot"`
|
||||
IncludeBOM bool `json:"include_bom"`
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
|
||||
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||
ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows
|
||||
}
|
||||
|
||||
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
|
||||
@@ -87,14 +88,15 @@ type ProjectPricingExportConfig struct {
|
||||
}
|
||||
|
||||
type ProjectPricingExportRow struct {
|
||||
LotDisplay string
|
||||
VendorPN string
|
||||
Description string
|
||||
Quantity int
|
||||
BOMTotal *float64
|
||||
Estimate *float64
|
||||
Stock *float64
|
||||
Competitor *float64
|
||||
LotDisplay string
|
||||
VendorPN string
|
||||
Description string
|
||||
Quantity int
|
||||
BOMTotal *float64
|
||||
Estimate *float64
|
||||
Stock *float64
|
||||
Competitor *float64
|
||||
ManualPrice *float64 // proportional share of the user-defined total price
|
||||
}
|
||||
|
||||
// ToCSV writes project export data in the new structured CSV format.
|
||||
@@ -388,17 +390,37 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
description = componentDescriptions[rowMappings[0].LotName]
|
||||
}
|
||||
|
||||
pricingRow := ProjectPricingExportRow{
|
||||
LotDisplay: formatLotDisplay(rowMappings),
|
||||
VendorPN: row.VendorPartnumber,
|
||||
Description: description,
|
||||
Quantity: exportPositiveInt(row.Quantity, 1),
|
||||
BOMTotal: vendorRowTotal(row),
|
||||
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
|
||||
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
|
||||
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
|
||||
if len(rowMappings) == 0 {
|
||||
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||
LotDisplay: "н/д",
|
||||
VendorPN: row.VendorPartnumber,
|
||||
Description: description,
|
||||
Quantity: exportPositiveInt(row.Quantity, 1),
|
||||
BOMTotal: vendorRowTotal(row),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// One export row per LOT mapping so that bundles (1 PN → N LOTs) appear
|
||||
// as separate lines, matching the frontend pricing table layout.
|
||||
pnQty := exportPositiveInt(row.Quantity, 1)
|
||||
for i, mapping := range rowMappings {
|
||||
lotQty := pnQty * mapping.QuantityPerPN
|
||||
var bomTotal *float64
|
||||
if i == 0 {
|
||||
bomTotal = vendorRowTotal(row)
|
||||
}
|
||||
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||
LotDisplay: mapping.LotName,
|
||||
VendorPN: row.VendorPartnumber,
|
||||
Description: description,
|
||||
Quantity: lotQty,
|
||||
BOMTotal: bomTotal,
|
||||
Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }),
|
||||
Stock: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Stock }),
|
||||
Competitor: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Competitor }),
|
||||
})
|
||||
}
|
||||
block.Rows = append(block.Rows, pricingRow)
|
||||
}
|
||||
|
||||
for _, item := range cfg.Items {
|
||||
@@ -422,10 +444,22 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
catOrder := defaultCategoryOrder()
|
||||
lotNames := make([]string, 0, len(cfg.Items))
|
||||
for _, item := range cfg.Items {
|
||||
if item.LotName != "" {
|
||||
lotNames = append(lotNames, item.LotName)
|
||||
}
|
||||
}
|
||||
itemCategories := s.resolveCategories(cfg.PricelistID, lotNames)
|
||||
sortedItems := sortConfigItemsByCategoryMap(cfg.Items, catOrder, itemCategories)
|
||||
for _, item := range sortedItems {
|
||||
if item.LotName == "" {
|
||||
continue
|
||||
}
|
||||
@@ -444,10 +478,29 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||
}
|
||||
|
||||
return block, nil
|
||||
}
|
||||
|
||||
// sortConfigItemsByCategoryMap returns a copy of items sorted by category display order.
|
||||
// categories maps lot_name → category code; catOrder maps category code → display order.
|
||||
func sortConfigItemsByCategoryMap(items models.ConfigItems, catOrder map[string]int, categories map[string]string) models.ConfigItems {
|
||||
sorted := make(models.ConfigItems, len(items))
|
||||
copy(sorted, items)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
orderI, hasI := categoryDisplayOrder(catOrder, categories[sorted[i].LotName])
|
||||
orderJ, hasJ := categoryDisplayOrder(catOrder, categories[sorted[j].LotName])
|
||||
if hasI && hasJ {
|
||||
return orderI < orderJ
|
||||
}
|
||||
return hasI && !hasJ
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
|
||||
for i := range rows {
|
||||
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
|
||||
@@ -603,16 +656,8 @@ func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string
|
||||
return prices
|
||||
}
|
||||
|
||||
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
|
||||
lots := collectPricingLots(cfg, localCfg, true)
|
||||
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 (s *ExportService) resolveLotDescriptions(_ *models.Configuration, _ *localdb.LocalConfiguration) map[string]string {
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
||||
@@ -696,6 +741,52 @@ func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.V
|
||||
return floatPtr(total)
|
||||
}
|
||||
|
||||
// distributeManualPrice sets ManualPrice on each row proportionally based on the
|
||||
// row's Estimate share. The last row with a price absorbs rounding remainder so
|
||||
// the sum of ManualPrice values always equals manualPrice exactly.
|
||||
func distributeManualPrice(rows []ProjectPricingExportRow, manualPrice float64) {
|
||||
if manualPrice <= 0 || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
totalEstimate := 0.0
|
||||
for _, row := range rows {
|
||||
if row.Estimate != nil && *row.Estimate > 0 {
|
||||
totalEstimate += *row.Estimate
|
||||
}
|
||||
}
|
||||
if totalEstimate <= 0 {
|
||||
return
|
||||
}
|
||||
lastIdx := -1
|
||||
for i, row := range rows {
|
||||
if row.Estimate != nil && *row.Estimate > 0 {
|
||||
lastIdx = i
|
||||
}
|
||||
}
|
||||
assigned := 0.0
|
||||
for i, row := range rows {
|
||||
if row.Estimate == nil || *row.Estimate <= 0 {
|
||||
continue
|
||||
}
|
||||
var share float64
|
||||
if i == lastIdx {
|
||||
share = math.Round((manualPrice-assigned)*100) / 100
|
||||
} else {
|
||||
share = math.Round((*row.Estimate/totalEstimate)*manualPrice*100) / 100
|
||||
assigned += share
|
||||
}
|
||||
rows[i].ManualPrice = floatPtr(share)
|
||||
}
|
||||
}
|
||||
|
||||
func computeSingleLotTotal(priceMap map[string]pricingLevels, lotName string, qty int, selector func(pricingLevels) *float64) *float64 {
|
||||
price := selector(priceMap[lotName])
|
||||
if price == nil || *price <= 0 {
|
||||
return nil
|
||||
}
|
||||
return floatPtr(*price * float64(qty))
|
||||
}
|
||||
|
||||
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
|
||||
if unitPrice == nil || *unitPrice <= 0 {
|
||||
return nil
|
||||
@@ -716,7 +807,7 @@ func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quanti
|
||||
}
|
||||
|
||||
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||
headers := make([]string, 0, 8)
|
||||
headers := make([]string, 0, 9)
|
||||
headers = append(headers, "Line Item")
|
||||
if opts.IncludeLOT {
|
||||
headers = append(headers, "LOT")
|
||||
@@ -734,11 +825,14 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||
if opts.IncludeCompetitor {
|
||||
headers = append(headers, "Конкуренты")
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
headers = append(headers, "Ручная цена")
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
|
||||
record := make([]string, 0, 8)
|
||||
record := make([]string, 0, 9)
|
||||
record = append(record, "")
|
||||
if opts.IncludeLOT {
|
||||
record = append(record, emptyDash(row.LotDisplay))
|
||||
@@ -760,11 +854,14 @@ func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions
|
||||
if opts.IncludeCompetitor {
|
||||
record = append(record, formatMoneyValue(row.Competitor))
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
record = append(record, formatMoneyValue(row.ManualPrice))
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
|
||||
record := make([]string, 0, 8)
|
||||
record := make([]string, 0, 9)
|
||||
record = append(record, fmt.Sprintf("%d", cfg.Line))
|
||||
if opts.IncludeLOT {
|
||||
record = append(record, "")
|
||||
@@ -786,19 +883,12 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
|
||||
if opts.IncludeCompetitor {
|
||||
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
record = append(record, formatMoneyValue(opts.ManualPrice))
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
|
||||
switch len(mappings) {
|
||||
case 0:
|
||||
return "н/д"
|
||||
case 1:
|
||||
return mappings[0].LotName
|
||||
default:
|
||||
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func formatMoneyValue(value *float64) string {
|
||||
if value == nil {
|
||||
|
||||
@@ -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
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
@@ -462,6 +469,18 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
localCfg.UpdatedAt = now
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
@@ -859,6 +885,18 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
|
||||
localCfg.UpdatedAt = now
|
||||
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", "")
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
|
||||
if serverCount < 1 {
|
||||
@@ -1432,12 +1480,25 @@ func (s *LocalConfigurationService) appendVersionTx(
|
||||
localCfg *localdb.LocalConfiguration,
|
||||
operation 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) {
|
||||
snapshot, err := s.buildConfigurationSnapshot(localCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build snapshot: %w", err)
|
||||
}
|
||||
changeNote := fmt.Sprintf("%s via local-first flow", operation)
|
||||
if noteOverride != "" {
|
||||
changeNote = noteOverride
|
||||
}
|
||||
|
||||
var createdByPtr *string
|
||||
if createdBy != "" {
|
||||
@@ -1478,6 +1539,35 @@ func (s *LocalConfigurationService) appendVersionTx(
|
||||
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) {
|
||||
return localdb.BuildConfigurationSnapshot(localCfg)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,14 +17,19 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||
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 {
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
@@ -64,6 +70,9 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("project code is required")
|
||||
}
|
||||
if !projectCodeRe.MatchString(code) {
|
||||
return nil, ErrProjectCodeInvalidChars
|
||||
}
|
||||
variant := strings.TrimSpace(req.Variant)
|
||||
if err := validateProjectVariantName(variant); err != nil {
|
||||
return nil, err
|
||||
@@ -106,6 +115,9 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("project code is required")
|
||||
}
|
||||
if !projectCodeRe.MatchString(code) {
|
||||
return nil, ErrProjectCodeInvalidChars
|
||||
}
|
||||
localProject.Code = code
|
||||
}
|
||||
if req.Variant != nil {
|
||||
@@ -183,6 +195,9 @@ func validateProjectVariantName(variant string) error {
|
||||
if normalizeProjectVariant(variant) == "main" {
|
||||
return ErrReservedMainVariant
|
||||
}
|
||||
if variant != "" && !projectCodeRe.MatchString(variant) {
|
||||
return ErrProjectVariantInvalidChars
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -282,6 +297,24 @@ func (s *ProjectService) GetByUUID(projectUUID, ownerUsername string) (*models.P
|
||||
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) {
|
||||
project, err := s.GetByUUID(projectUUID, ownerUsername)
|
||||
if err != nil {
|
||||
|
||||
@@ -111,6 +111,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
||||
if len(req.Items) == 0 {
|
||||
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.
|
||||
if s.localDB != nil {
|
||||
@@ -245,6 +248,16 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
|
||||
if len(req.Items) == 0 {
|
||||
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))
|
||||
seenLots := make(map[string]struct{}, len(req.Items))
|
||||
@@ -303,8 +316,12 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
|
||||
}
|
||||
|
||||
for _, reqItem := range req.Items {
|
||||
responseLotName := originalLotNames[reqItem.LotName]
|
||||
if responseLotName == "" {
|
||||
responseLotName = reqItem.LotName
|
||||
}
|
||||
item := PriceLevelsItem{
|
||||
LotName: reqItem.LotName,
|
||||
LotName: responseLotName,
|
||||
Quantity: reqItem.Quantity,
|
||||
PriceMissing: make([]string, 0, 3),
|
||||
}
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SeenPartnumber represents an unresolved vendor partnumber to report.
|
||||
type SeenPartnumber struct {
|
||||
Partnumber string
|
||||
Description string
|
||||
Ignored bool
|
||||
Partnumber string
|
||||
Description string
|
||||
Ignored bool
|
||||
LotSuggestion []LotSuggestionEntry // optional; set when user manually mapped PN → LOT in UI
|
||||
}
|
||||
|
||||
// LotSuggestionEntry is one suggested LOT mapping for a vendor partnumber.
|
||||
// JSON shape mirrors qt_partnumber_book_items.lots_json: {"lot_name", "qty"}.
|
||||
type LotSuggestionEntry struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Qty int `json:"qty"`
|
||||
}
|
||||
|
||||
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
||||
// Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
|
||||
// When LotSuggestion is provided the column is updated too; if the column does not exist yet
|
||||
// (migration pending) the write is retried without it and a warning is logged — the app never panics.
|
||||
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
@@ -30,7 +41,43 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
if item.Partnumber == "" {
|
||||
continue
|
||||
}
|
||||
err := mariaDB.Exec(`
|
||||
|
||||
if len(item.LotSuggestion) > 0 {
|
||||
suggJSON, marshalErr := json.Marshal(item.LotSuggestion)
|
||||
if marshalErr != nil {
|
||||
slog.Error("partnumber_seen: failed to marshal lot_suggestion, skipping suggestion",
|
||||
"partnumber", item.Partnumber, "error", marshalErr)
|
||||
suggJSON = nil
|
||||
}
|
||||
|
||||
if suggJSON != nil {
|
||||
err = mariaDB.Exec(`
|
||||
INSERT INTO qt_vendor_partnumber_seen
|
||||
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
|
||||
VALUES
|
||||
('manual', '', ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
lot_suggestion = VALUES(lot_suggestion),
|
||||
last_seen_at = NOW(3)
|
||||
`, item.Partnumber, item.Description, item.Ignored, now, string(suggJSON)).Error
|
||||
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Column not yet migrated — fall through to insert without lot_suggestion.
|
||||
if !isUnknownColumnError(err) {
|
||||
slog.Error("partnumber_seen: failed to upsert with lot_suggestion",
|
||||
"partnumber", item.Partnumber, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Warn("partnumber_seen: lot_suggestion column missing (migration pending), inserting without it",
|
||||
"partnumber", item.Partnumber)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert without lot_suggestion (baseline behaviour or fallback).
|
||||
err = mariaDB.Exec(`
|
||||
INSERT INTO qt_vendor_partnumber_seen
|
||||
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
||||
VALUES
|
||||
@@ -40,10 +87,18 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
||||
if err != nil {
|
||||
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||
// Continue with remaining items
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("partnumber_seen pushed to server", "count", len(items))
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUnknownColumnError returns true when MariaDB reports that a column does not exist.
|
||||
func isUnknownColumnError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unknown column") || strings.Contains(msg, "1054")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -320,14 +321,37 @@ func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
|
||||
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
|
||||
}
|
||||
|
||||
var pending localdb.PendingChange
|
||||
var errored []localdb.PendingChange
|
||||
if err := local.DB().
|
||||
Where("TRIM(COALESCE(last_error, '')) <> ''").
|
||||
Order("id DESC").
|
||||
First(&pending).Error; err == nil {
|
||||
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
|
||||
Limit(20).
|
||||
Find(&errored).Error; err != nil || len(errored) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, nil
|
||||
|
||||
type errorEntry struct {
|
||||
Type string `json:"type"`
|
||||
UUID string `json:"uuid"`
|
||||
Op string `json:"op"`
|
||||
Attempts int `json:"attempts"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
entries := make([]errorEntry, 0, len(errored))
|
||||
for _, ch := range errored {
|
||||
entries = append(entries, errorEntry{
|
||||
Type: ch.EntityType,
|
||||
UUID: ch.EntityUUID,
|
||||
Op: ch.Operation,
|
||||
Attempts: ch.Attempts,
|
||||
Error: strings.TrimSpace(ch.LastError),
|
||||
})
|
||||
}
|
||||
detail, jsonErr := json.Marshal(entries)
|
||||
if jsonErr != nil {
|
||||
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(errored[0].LastError))
|
||||
}
|
||||
return optionalString("PENDING_CHANGE_ERROR"), optionalString(string(detail))
|
||||
}
|
||||
|
||||
func optionalString(value string) *string {
|
||||
|
||||
@@ -322,6 +322,12 @@ func (s *Service) NeedSync() (bool, error) {
|
||||
|
||||
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
||||
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")
|
||||
plSyncStart := time.Now()
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
|
||||
@@ -392,6 +404,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
CreatedAt: pl.CreatedAt,
|
||||
SyncedAt: time.Now(),
|
||||
IsUsed: false,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
itemCount, err := s.syncNewPricelistSnapshot(localPL)
|
||||
@@ -414,6 +427,12 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
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).
|
||||
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)
|
||||
}
|
||||
|
||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||
for i, item := range serverItems {
|
||||
localItems[i] = *localdb.PricelistItemToLocal(&item, 0)
|
||||
seen := make(map[string]struct{}, len(serverItems))
|
||||
localItems := make([]localdb.LocalPricelistItem, 0, len(serverItems))
|
||||
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
|
||||
@@ -843,7 +869,7 @@ func (s *Service) SyncPricelistsIfNeeded() error {
|
||||
}
|
||||
|
||||
slog.Info("new pricelists detected, syncing...")
|
||||
_, err = s.SyncPricelists()
|
||||
_, err = s.syncPricelists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("syncing pricelists: %w", err)
|
||||
}
|
||||
@@ -851,6 +877,11 @@ func (s *Service) SyncPricelistsIfNeeded() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxPendingChangeAttempts is the number of failed attempts after which a pending change
|
||||
// is considered unrecoverable and removed from the queue. Applies only to changes that
|
||||
// fail with a non-transient error (e.g. corrupt payload, unknown operation).
|
||||
const maxPendingChangeAttempts = 20
|
||||
|
||||
// PushPendingChanges pushes all pending changes to the server
|
||||
func (s *Service) PushPendingChanges() (int, error) {
|
||||
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||
@@ -864,6 +895,14 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
slog.Info("purged orphan configuration pending changes", "removed", removed)
|
||||
}
|
||||
|
||||
// Auto-repair locally-fixable problems (e.g. stale project references)
|
||||
// before attempting to push, so that repaired changes succeed on this cycle.
|
||||
if repaired, _, repairErr := s.localDB.RepairPendingChanges(); repairErr != nil {
|
||||
slog.Warn("auto-repair of errored pending changes failed", "error", repairErr)
|
||||
} else if repaired > 0 {
|
||||
slog.Info("auto-repaired errored pending changes", "repaired", repaired)
|
||||
}
|
||||
|
||||
changes, err := s.localDB.GetPendingChanges()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting pending changes: %w", err)
|
||||
@@ -875,7 +914,10 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
}
|
||||
|
||||
slog.Info("pushing pending changes", "count", len(changes))
|
||||
pushStart := time.Now()
|
||||
pushed := 0
|
||||
failed := 0
|
||||
var firstErr string
|
||||
var syncedIDs []int64
|
||||
sortedChanges := prioritizeProjectChanges(changes)
|
||||
|
||||
@@ -884,8 +926,18 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
if err != nil {
|
||||
s.markConnectionBroken(err)
|
||||
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
||||
// Increment attempts
|
||||
newAttempts := change.Attempts + 1
|
||||
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
|
||||
if firstErr == "" {
|
||||
firstErr = err.Error()
|
||||
}
|
||||
failed++
|
||||
if newAttempts >= maxPendingChangeAttempts {
|
||||
slog.Error("abandoning pending change after max attempts",
|
||||
"id", change.ID, "type", change.EntityType, "op", change.Operation,
|
||||
"attempts", newAttempts, "last_error", err.Error())
|
||||
syncedIDs = append(syncedIDs, change.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -900,7 +952,13 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("pending changes pushed", "pushed", pushed, "failed", len(changes)-pushed)
|
||||
if failed > 0 {
|
||||
s.localDB.AppendSyncLog("changes", "error", firstErr, pushed, pushStart, time.Since(pushStart).Milliseconds())
|
||||
} else {
|
||||
s.localDB.AppendSyncLog("changes", "ok", "", pushed, pushStart, time.Since(pushStart).Milliseconds())
|
||||
}
|
||||
|
||||
slog.Info("pending changes pushed", "pushed", pushed, "failed", failed)
|
||||
return pushed, nil
|
||||
}
|
||||
|
||||
@@ -912,7 +970,11 @@ func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
|
||||
case "configuration":
|
||||
return s.pushConfigurationChange(change)
|
||||
default:
|
||||
return fmt.Errorf("unknown entity type: %s", change.EntityType)
|
||||
// Unknown entity type: this change was queued by a newer or different build
|
||||
// and cannot be processed. Remove it from the queue.
|
||||
slog.Warn("dropping pending change with unknown entity type",
|
||||
"id", change.ID, "type", change.EntityType, "op", change.Operation)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,7 +1107,10 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
||||
case "delete":
|
||||
return s.pushConfigurationDelete(change)
|
||||
default:
|
||||
return fmt.Errorf("unknown operation: %s", change.Operation)
|
||||
// Unknown operation: queued by a newer or different build. Drop from queue.
|
||||
slog.Warn("dropping pending change with unknown operation",
|
||||
"id", change.ID, "type", change.EntityType, "op", change.Operation)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1245,24 +1310,30 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
||||
|
||||
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
|
||||
if localErr != nil {
|
||||
return err
|
||||
// Project not found locally either: stale reference (project was deleted).
|
||||
// Fall through to system project so this configuration is not stuck forever.
|
||||
slog.Warn("configuration references missing project, assigning to system project",
|
||||
"cfg_uuid", cfg.UUID,
|
||||
"project_uuid", *cfg.ProjectUUID,
|
||||
)
|
||||
} else {
|
||||
modelProject := localdb.LocalToProject(localProject)
|
||||
if modelProject.OwnerUsername == "" {
|
||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||
}
|
||||
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
if modelProject.ID > 0 {
|
||||
serverID := modelProject.ID
|
||||
localProject.ServerID = &serverID
|
||||
localProject.SyncStatus = "synced"
|
||||
now := time.Now()
|
||||
localProject.SyncedAt = &now
|
||||
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
modelProject := localdb.LocalToProject(localProject)
|
||||
if modelProject.OwnerUsername == "" {
|
||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||
}
|
||||
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
if modelProject.ID > 0 {
|
||||
serverID := modelProject.ID
|
||||
localProject.ServerID = &serverID
|
||||
localProject.SyncStatus = "synced"
|
||||
now := time.Now()
|
||||
localProject.SyncedAt = &now
|
||||
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
systemProject := &models.Project{}
|
||||
@@ -1574,24 +1645,3 @@ func (s *Service) getConnectionStatus() db.ConnectionStatus {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
pushed, err := w.service.PushPendingChanges()
|
||||
if err != nil {
|
||||
@@ -100,5 +95,10 @@ func (w *Worker) runSync() {
|
||||
return
|
||||
}
|
||||
|
||||
// Pull partnumber books together with pricelists
|
||||
if _, err := w.service.PullPartnumberBooks(); err != nil {
|
||||
w.logger.Warn("background sync: failed to pull partnumber books", "error", err)
|
||||
}
|
||||
|
||||
w.logger.Info("background sync cycle completed")
|
||||
}
|
||||
|
||||
@@ -135,6 +135,8 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
||||
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
|
||||
case IsInspurBOM(data):
|
||||
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
|
||||
case IsNxBOM(data):
|
||||
workspace, err = parseNxBOM(data, filepath.Base(sourceFileName))
|
||||
case IsTextBOM(data):
|
||||
workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
|
||||
default:
|
||||
@@ -683,6 +685,93 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
// nxBOMItemLine matches a quantity-first BOM line: "<qty>x <description>"
|
||||
// where the quantity prefix is digits followed immediately by "x" (case-insensitive).
|
||||
// Parentheses, commas, and hyphens inside the description are preserved.
|
||||
var nxBOMItemLine = regexp.MustCompile(`(?i)^(\d+)[xX]\s+(.+\S)\s*$`)
|
||||
|
||||
// IsNxBOM reports whether data looks like a quantity-first "Nx" BOM where each
|
||||
// item line begins with "<qty>x <description>" (e.g. "2x Intel Xeon 8570 ...").
|
||||
func IsNxBOM(data []byte) bool {
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
if nxBOMItemLine.MatchString(strings.TrimSpace(raw)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseNxBOM parses a quantity-first "Nx" BOM into a single configuration.
|
||||
// An optional header line ending with ", в составе:" supplies server_model and name.
|
||||
// Each "<qty>x <description>" line becomes one vendor spec row; description is stored
|
||||
// as both vendor_partnumber and description so rows resolve through the active
|
||||
// partnumber book when matched and otherwise stay unresolved and editable in the UI.
|
||||
func parseNxBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
rows := make([]localdb.VendorSpecItem, 0, len(lines))
|
||||
sortOrder := 10
|
||||
serverModel := ""
|
||||
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
|
||||
if fields := strings.Fields(m[1]); len(fields) > 0 {
|
||||
serverModel = fields[len(fields)-1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
m := nxBOMItemLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
qty, err := strconv.Atoi(m[1])
|
||||
if err != nil || qty <= 0 {
|
||||
continue
|
||||
}
|
||||
description := strings.TrimSpace(m[2])
|
||||
if description == "" {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, localdb.VendorSpecItem{
|
||||
SortOrder: sortOrder,
|
||||
VendorPartnumber: description,
|
||||
Quantity: qty,
|
||||
Description: description,
|
||||
})
|
||||
sortOrder += 10
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("Nx BOM has no importable rows")
|
||||
}
|
||||
|
||||
name := serverModel
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
|
||||
}
|
||||
if name == "" {
|
||||
name = "Nx BOM Import"
|
||||
}
|
||||
|
||||
return &importedWorkspace{
|
||||
SourceFormat: "Nx",
|
||||
SourceFileName: sourceFileName,
|
||||
Configurations: []importedConfiguration{
|
||||
{
|
||||
GroupID: "nx-0",
|
||||
Name: name,
|
||||
Line: 10,
|
||||
ServerCount: 1,
|
||||
ServerModel: serverModel,
|
||||
Rows: rows,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// textBOMItemLine matches a human-readable BOM line of the form
|
||||
// "<description> - <quantity> шт." where the separator may be a hyphen,
|
||||
// en-dash or em-dash and the quantity may have an optional space before "шт".
|
||||
@@ -709,6 +798,8 @@ func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) {
|
||||
switch {
|
||||
case IsInspurBOM(data):
|
||||
ws, err = parseInspurBOM(data, "")
|
||||
case IsNxBOM(data):
|
||||
ws, err = parseNxBOM(data, "")
|
||||
case IsTextBOM(data):
|
||||
ws, err = parseTextBOM(data, "")
|
||||
default:
|
||||
|
||||
25
releases/v1.14/RELEASE_NOTES.md
Normal file
25
releases/v1.14/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# QuoteForge v1.14
|
||||
|
||||
Дата релиза: 2026-06-16
|
||||
Тег: `v1.14`
|
||||
|
||||
Предыдущий релиз: `v1.13`
|
||||
|
||||
## Ключевые изменения
|
||||
|
||||
- добавлен импорт человекочитаемого текстового BOM формата `<описание> - <кол-во> шт.`
|
||||
(с необязательным заголовком, оканчивающимся на `, в составе:`) — как при загрузке файла
|
||||
через `POST /api/projects/:uuid/vendor-import`, так и при вставке в конфигураторе;
|
||||
- заголовок конфигурации определяется по маркеру `, в составе:` с любым префиксом
|
||||
(`Сервер X3` и `Вычислительный GPU сервер X3` → модель `X3`);
|
||||
- парсинг устойчив к пробелам в начале/конце строки (в P/N не попадает лишний пробел),
|
||||
а также к запятым и дефисам внутри описания (`RAID0,1,10`, `8-GPU-2304GB`);
|
||||
- вставка BOM в конфигураторе и импорт файла используют единый серверный парсер
|
||||
(`POST /api/vendor-spec/parse-text`) — дублирующая логика разбора на фронтенде удалена;
|
||||
- сабмодуль `bible` обновлён до актуальных контрактов (build-version-display,
|
||||
local-first-recovery, резервные копии миграций).
|
||||
|
||||
## Запуск на macOS
|
||||
|
||||
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
|
||||
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||
20
releases/v1.16/RELEASE_NOTES.md
Normal file
20
releases/v1.16/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# QuoteForge v1.16
|
||||
|
||||
Дата релиза: 2026-06-16
|
||||
Тег: `v1.16`
|
||||
|
||||
Предыдущий релиз: `v1.15`
|
||||
|
||||
## Ключевые изменения
|
||||
|
||||
- self-heal застрявших pending changes: конфигурации со ссылкой на удалённый проект теперь автоматически переназначаются на «Без проекта» вместо вечной ошибки;
|
||||
- авторемонт очереди (`RepairPendingChanges`) запускается автоматически перед каждым push-циклом;
|
||||
- после 20 неудачных попыток неисправимые записи удаляются из очереди (логируются как ERROR);
|
||||
- неизвестные `entity_type` и `operation` в очереди дропаются с предупреждением вместо блокировки;
|
||||
- детальная диагностика в `qt_client_schema_state.last_sync_error_text`: теперь JSON-массив с `uuid`/`op`/`attempts`/`error` по каждому застрявшему изменению;
|
||||
- книги партномеров синхронизируются автоматически вместе с прайслистами.
|
||||
|
||||
## Запуск на macOS
|
||||
|
||||
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
|
||||
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||
15
releases/v1.17/RELEASE_NOTES.md
Normal file
15
releases/v1.17/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# QuoteForge v1.17
|
||||
|
||||
Дата релиза: 2026-06-16
|
||||
Тег: `v1.17`
|
||||
|
||||
Предыдущий релиз: `v1.16`
|
||||
|
||||
## Ключевые изменения
|
||||
|
||||
- исправлен поиск в разделе Партномера по LOT-имени и описанию — `lots_json` хранится как BLOB, `modernc.org/sqlite` не коерсит BLOB→TEXT при LIKE, исправлено через `CAST(lots_json AS TEXT) LIKE`;
|
||||
|
||||
## Запуск на macOS
|
||||
|
||||
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
|
||||
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||
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.
|
||||
@@ -53,45 +53,34 @@ mkdir -p "${RELEASE_DIR}"
|
||||
# Create release notes template only when missing.
|
||||
ensure_release_notes "${RELEASE_DIR}/RELEASE_NOTES.md"
|
||||
|
||||
# Build for all platforms
|
||||
# Build binaries
|
||||
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
|
||||
echo ""
|
||||
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
|
||||
if [ -f "bin/qfs-darwin-arm64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
|
||||
fi
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
|
||||
|
||||
# Windows AMD64
|
||||
if [ -f "bin/qfs-windows-amd64.exe" ]; then
|
||||
cd bin
|
||||
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
|
||||
fi
|
||||
cd bin
|
||||
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
|
||||
|
||||
# Generate checksums
|
||||
echo ""
|
||||
|
||||
@@ -629,11 +629,13 @@
|
||||
|
||||
const totalColor = totalDelta > 0 ? 'text-red-600' : totalDelta < 0 ? 'text-green-600' : 'text-gray-600';
|
||||
const totalArrow = _fmtArrow(r.prevTotal || 0, r.newTotal || 0);
|
||||
const totalPrevHtml = totalDelta !== 0
|
||||
? `<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span>`
|
||||
: '';
|
||||
html += `<div class="flex justify-between items-center text-sm bg-gray-50 rounded px-3 py-2 mb-1">
|
||||
<span class="text-gray-600 font-medium">Итог конфигурации</span>
|
||||
<span>
|
||||
<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span>
|
||||
<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
|
||||
${totalPrevHtml}<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Ревизии - OFS{{end}}
|
||||
{{define "title"}}QFS Ревизии{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Мои конфигурации - OFS{{end}}
|
||||
{{define "title"}}QFS Мои конфигурации{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -55,12 +55,12 @@
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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">
|
||||
<button type="button" id="type-server-btn" onclick="setCreateType('server')"
|
||||
<div id="config-type-buttons" class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
|
||||
<button type="button" data-type="server" onclick="setCreateType('server')"
|
||||
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||
Сервер
|
||||
</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">
|
||||
СХД
|
||||
</button>
|
||||
@@ -532,18 +532,51 @@ async function cloneConfig() {
|
||||
}
|
||||
|
||||
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) {
|
||||
createConfigType = type;
|
||||
document.getElementById('type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' +
|
||||
(type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200');
|
||||
document.getElementById('type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' +
|
||||
(type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
|
||||
document.querySelectorAll('#config-type-buttons button').forEach(btn => {
|
||||
const active = btn.dataset.type === type;
|
||||
btn.className = 'flex-1 py-2 text-sm font-medium ' +
|
||||
(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() {
|
||||
createConfigType = 'server';
|
||||
setCreateType('server');
|
||||
loadCfgSettings().then(s => renderConfigTypeButtons(s && s.config_types));
|
||||
document.getElementById('opportunity-number').value = '';
|
||||
document.getElementById('create-project-input').value = '';
|
||||
document.getElementById('create-modal').classList.remove('hidden');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}OFS - Конфигуратор{{end}}
|
||||
{{define "title"}}QFS Конфигуратор{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -417,7 +417,7 @@ let TAB_CONFIG = {
|
||||
|
||||
let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
.flatMap(t => t.categories)
|
||||
.map(c => c.toUpperCase());
|
||||
.map(c => ciStr(c));
|
||||
|
||||
// State
|
||||
let configUUID = '{{.ConfigUUID}}';
|
||||
@@ -488,7 +488,7 @@ function updateConfigBreadcrumbs() {
|
||||
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||
configEl.title = fullConfigName;
|
||||
versionEl.textContent = 'main';
|
||||
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — OFS';
|
||||
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — QFS';
|
||||
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||
if (configNameLinkEl && configUUID) {
|
||||
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||
@@ -504,6 +504,9 @@ let currentTab = 'base';
|
||||
let allComponents = [];
|
||||
let cart = [];
|
||||
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 hasUnsavedChanges = false;
|
||||
let exitSaveStarted = false;
|
||||
@@ -710,7 +713,7 @@ async function loadWarehouseInStockLots() {
|
||||
const lotNames = Array.isArray(data.lot_names) ? data.lot_names : [];
|
||||
lotNames.forEach(lot => {
|
||||
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);
|
||||
// Don't block UI while stock set is being loaded.
|
||||
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
|
||||
@@ -757,16 +760,16 @@ async function loadCategoriesFromAPI() {
|
||||
// Build category order map
|
||||
categoryOrderMap = {};
|
||||
cats.forEach(cat => {
|
||||
categoryOrderMap[cat.code.toUpperCase()] = cat.display_order;
|
||||
categoryOrderMap[ciStr(cat.code)] = cat.display_order;
|
||||
});
|
||||
|
||||
// Build list of unassigned categories
|
||||
const knownCodes = Object.values(TAB_CONFIG)
|
||||
.flatMap(t => t.categories)
|
||||
.map(c => c.toUpperCase());
|
||||
.map(c => ciStr(c));
|
||||
|
||||
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)
|
||||
.map(cat => cat.code);
|
||||
|
||||
@@ -776,13 +779,102 @@ async function loadCategoriesFromAPI() {
|
||||
// Rebuild ASSIGNED_CATEGORIES
|
||||
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
.flatMap(t => t.categories)
|
||||
.map(c => c.toUpperCase());
|
||||
.map(c => ciStr(c));
|
||||
} catch(e) {
|
||||
console.error('Failed to load categories, using defaults', e);
|
||||
// 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
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
// RBAC disabled - no token check required
|
||||
@@ -791,8 +883,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load categories in background (defaults are usable immediately).
|
||||
// Load categories and configurator settings in background (defaults are usable immediately).
|
||||
const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
|
||||
loadCfgSettings().then(s => applyServerSettings(s)).catch(() => {});
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID);
|
||||
@@ -832,8 +925,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
description: item.description || '',
|
||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||
}));
|
||||
category: item.category }));
|
||||
}
|
||||
serverModelForQuote = config.server_model || '';
|
||||
supportCode = config.support_code || '';
|
||||
@@ -861,6 +953,10 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
loadAllComponents(),
|
||||
categoriesPromise,
|
||||
]);
|
||||
cart = cart.map(item => ({
|
||||
...item,
|
||||
category: item.category || allComponents.find(c => c.lot_name.toUpperCase() === (item.lot_name || '').toUpperCase())?.category || ''
|
||||
}));
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
updateRefreshPricesButtonState();
|
||||
@@ -879,6 +975,12 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Save pricing state (ручная цена) on page exit so it survives navigation
|
||||
window.addEventListener('pagehide', saveConfigOnExit);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') saveConfigOnExit();
|
||||
});
|
||||
|
||||
// Load vendor spec BOM for this configuration
|
||||
if (configUUID) {
|
||||
loadVendorSpec(configUUID);
|
||||
@@ -904,7 +1006,7 @@ const BOM_LOT_DATALIST_DIVIDER = '────────';
|
||||
function _bomLotValid(v) {
|
||||
const lot = (v || '').trim();
|
||||
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() {
|
||||
@@ -1120,19 +1222,16 @@ function applyPriceSettings() {
|
||||
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true });
|
||||
}
|
||||
|
||||
function getCategoryFromLotName(lotName) {
|
||||
const parts = lotName.split('_');
|
||||
return parts[0] || '';
|
||||
}
|
||||
function ciStr(s) { return (s || '').toLowerCase(); }
|
||||
|
||||
function getComponentCategory(comp) {
|
||||
return (comp.category || getCategoryFromLotName(comp.lot_name)).toUpperCase();
|
||||
return comp.category || '';
|
||||
}
|
||||
|
||||
function getTabForCategory(category) {
|
||||
const cat = category.toUpperCase();
|
||||
const cat = ciStr(category);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1154,77 +1253,78 @@ function switchTab(tab) {
|
||||
renderTab();
|
||||
}
|
||||
|
||||
// Hardcoded fallback constants — used only when server has not provided config_types data
|
||||
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
|
||||
|
||||
// Storage-only categories — hidden for server configs
|
||||
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_PCI_CATEGORIES = ['GPU', 'DPU'];
|
||||
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() {
|
||||
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
|
||||
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
|
||||
const storageSections = [
|
||||
{ title: 'RAID Контроллеры', categories: ['RAID'] },
|
||||
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
|
||||
];
|
||||
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
|
||||
const pciSections = [
|
||||
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
||||
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
||||
{ title: 'HBA', categories: ['HBA'] },
|
||||
{ title: 'HIC', categories: ['HIC'] }
|
||||
];
|
||||
const powerCategories = ['PS', 'PSU'];
|
||||
// Filter each tab's categories by visibility for current configType.
|
||||
// Uses server-driven allowlists when available; falls back to hardcoded constants.
|
||||
Object.keys(TAB_CONFIG).forEach(tabKey => {
|
||||
if (tabKey === 'other') return;
|
||||
const tab = TAB_CONFIG[tabKey];
|
||||
if (!tab || !Array.isArray(tab.categories)) return;
|
||||
|
||||
TAB_CONFIG.base.categories = baseCategories.filter(c => {
|
||||
if (configType === 'storage') {
|
||||
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
|
||||
// Snapshot the full category list for this tab (stored in _allCategories if not yet saved)
|
||||
if (!tab._allCategories) tab._allCategories = [...tab.categories];
|
||||
|
||||
tab.categories = tab._allCategories.filter(c => isCategoryVisibleForConfigType(c, configType));
|
||||
|
||||
if (Array.isArray(tab._allSections || tab.sections)) {
|
||||
const allSections = tab._allSections || tab.sections;
|
||||
if (!tab._allSections) tab._allSections = allSections.map(s => ({ ...s, categories: [...s.categories] }));
|
||||
tab.sections = tab._allSections
|
||||
.map(section => ({
|
||||
...section,
|
||||
categories: section.categories.filter(c => isCategoryVisibleForConfigType(c, configType))
|
||||
}))
|
||||
.filter(section => section.categories.length > 0);
|
||||
}
|
||||
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
|
||||
});
|
||||
|
||||
TAB_CONFIG.storage.categories = storageCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
|
||||
});
|
||||
TAB_CONFIG.storage.sections = storageSections.filter(section => {
|
||||
if (configType === 'storage') {
|
||||
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
|
||||
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
|
||||
// Rebuild assigned categories index using the full static list (_allCategories),
|
||||
// not the filtered one — hidden categories still belong to their tab, not to Other.
|
||||
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
.flatMap(t => t.categories)
|
||||
.map(c => c.toUpperCase());
|
||||
.flatMap(t => t._allCategories || t.categories)
|
||||
.map(c => ciStr(c));
|
||||
}
|
||||
|
||||
function updateTabVisibility() {
|
||||
const visibleTabs = _effectiveAlwaysVisibleTabs();
|
||||
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}"]`);
|
||||
if (!btn) continue;
|
||||
const hasComponents = getComponentsForTab(tabId).length > 0;
|
||||
const hasCartItems = cart.some(item => {
|
||||
const cat = (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase();
|
||||
return getTabForCategory(cat) === tabId;
|
||||
return getTabForCategory(item.category) === tabId;
|
||||
});
|
||||
const visible = hasComponents || hasCartItems;
|
||||
btn.classList.toggle('hidden', !visible);
|
||||
@@ -1240,15 +1340,15 @@ function getComponentsForTab(tab) {
|
||||
return allComponents.filter(comp => {
|
||||
const category = getComponentCategory(comp);
|
||||
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) {
|
||||
return allComponents.filter(comp => {
|
||||
return getComponentCategory(comp) === category.toUpperCase();
|
||||
return ciStr(getComponentCategory(comp)) === ciStr(category);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1310,10 +1410,10 @@ function renderSingleSelectTab(categories) {
|
||||
categories.forEach(cat => {
|
||||
const catLabel = cat === 'MB' ? 'MB' : cat === 'CPU' ? 'CPU' : cat === 'MEM' ? 'MEM' : cat;
|
||||
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 estimate = selectedItem?.estimate_price ?? price;
|
||||
const qty = selectedItem?.quantity || 1;
|
||||
@@ -1363,9 +1463,7 @@ function renderSingleSelectTab(categories) {
|
||||
function renderMultiSelectTab(components) {
|
||||
// Get cart items for this tab
|
||||
const tabItems = cart.filter(item => {
|
||||
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
|
||||
const tab = getTabForCategory(cat);
|
||||
return tab === currentTab;
|
||||
return getTabForCategory(item.category) === currentTab;
|
||||
});
|
||||
|
||||
let html = `
|
||||
@@ -1385,7 +1483,7 @@ function renderMultiSelectTab(components) {
|
||||
|
||||
// Render existing cart items for this tab
|
||||
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;
|
||||
|
||||
html += `
|
||||
@@ -1452,9 +1550,7 @@ function renderMultiSelectTab(components) {
|
||||
function renderMultiSelectTabWithSections(sections) {
|
||||
// Get cart items for this tab
|
||||
const tabItems = cart.filter(item => {
|
||||
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
|
||||
const tab = getTabForCategory(cat);
|
||||
return tab === currentTab;
|
||||
return getTabForCategory(item.category) === currentTab;
|
||||
});
|
||||
|
||||
let html = '';
|
||||
@@ -1462,17 +1558,14 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
|
||||
sections.forEach((section, sectionIdx) => {
|
||||
// Get components for this section's categories
|
||||
const sectionCategories = section.categories.map(c => c.toUpperCase());
|
||||
const sectionComponents = allComponents.filter(comp => {
|
||||
const category = getComponentCategory(comp);
|
||||
return sectionCategories.includes(category);
|
||||
return section.categories.some(c => ciStr(c) === ciStr(getComponentCategory(comp)));
|
||||
});
|
||||
totalComponents += sectionComponents.length;
|
||||
|
||||
// Get cart items for this section
|
||||
const sectionItems = tabItems.filter(item => {
|
||||
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
|
||||
return sectionCategories.includes(cat);
|
||||
return section.categories.some(c => ciStr(c) === ciStr(item.category));
|
||||
});
|
||||
|
||||
// Section header
|
||||
@@ -1499,7 +1592,7 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
|
||||
// Render existing cart items for this section
|
||||
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;
|
||||
|
||||
html += `
|
||||
@@ -1656,6 +1749,10 @@ function renderAutocomplete() {
|
||||
|
||||
// Build autocomplete items based on mode
|
||||
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => {
|
||||
if (comp.isDivider) {
|
||||
return `<div class="px-3 py-1 text-xs text-gray-400 border-t border-gray-200 select-none cursor-default" style="pointer-events:none">── прочие ──</div>`;
|
||||
}
|
||||
|
||||
let onmousedown;
|
||||
|
||||
if (autocompleteMode === 'section') {
|
||||
@@ -1708,7 +1805,7 @@ function selectAutocompleteItem(index) {
|
||||
|
||||
// Remove existing item of this category
|
||||
cart = cart.filter(item =>
|
||||
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== autocompleteCategory.toUpperCase()
|
||||
ciStr(item.category) !== ciStr(autocompleteCategory)
|
||||
);
|
||||
|
||||
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
|
||||
@@ -1764,11 +1861,11 @@ function filterAutocompleteMulti(search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
|
||||
// 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 => {
|
||||
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;
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
return text.includes(searchLower);
|
||||
@@ -1869,11 +1966,11 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
|
||||
});
|
||||
|
||||
// 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 => {
|
||||
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;
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
return text.includes(searchLower);
|
||||
@@ -2039,14 +2136,24 @@ function showAutocompleteBOM(rowIdx, input) {
|
||||
|
||||
function filterAutocompleteBOM(rowIdx, search) {
|
||||
const searchLower = (search || '').toLowerCase();
|
||||
autocompleteFiltered = (window._bomAllComponents || allComponents).filter(c => {
|
||||
const cartLots = new Set(cart.map(i => (i.lot_name || '').toUpperCase()));
|
||||
const all = (window._bomAllComponents || allComponents).filter(c => {
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
return text.includes(searchLower);
|
||||
}).sort((a, b) => {
|
||||
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
|
||||
if (popDiff !== 0) return popDiff;
|
||||
return a.lot_name.localeCompare(b.lot_name);
|
||||
});
|
||||
const inCart = all.filter(c => cartLots.has((c.lot_name || '').toUpperCase()))
|
||||
.sort((a, b) => a.lot_name.localeCompare(b.lot_name));
|
||||
const notInCart = all.filter(c => !cartLots.has((c.lot_name || '').toUpperCase()))
|
||||
.sort((a, b) => {
|
||||
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
|
||||
if (popDiff !== 0) return popDiff;
|
||||
return a.lot_name.localeCompare(b.lot_name);
|
||||
});
|
||||
if (inCart.length && notInCart.length) {
|
||||
autocompleteFiltered = [...inCart, {isDivider: true}, ...notInCart];
|
||||
} else {
|
||||
autocompleteFiltered = [...inCart, ...notInCart];
|
||||
}
|
||||
renderAutocomplete();
|
||||
}
|
||||
|
||||
@@ -2071,7 +2178,7 @@ function handleAutocompleteKeyBOM(event, rowIdx) {
|
||||
|
||||
function selectAutocompleteItemBOM(index, rowIdx) {
|
||||
const comp = autocompleteFiltered[index];
|
||||
if (!comp) return;
|
||||
if (!comp || comp.isDivider) return;
|
||||
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
|
||||
if (!row) return;
|
||||
row.manual_lot = comp.lot_name;
|
||||
@@ -2081,7 +2188,7 @@ function selectAutocompleteItemBOM(index, rowIdx) {
|
||||
|
||||
function clearSingleSelect(category) {
|
||||
cart = cart.filter(item =>
|
||||
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
|
||||
ciStr(item.category) !== ciStr(category)
|
||||
);
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
@@ -2091,7 +2198,7 @@ function clearSingleSelect(category) {
|
||||
function updateSingleQuantity(category, value) {
|
||||
const qty = parseInt(value) || 1;
|
||||
const item = cart.find(i =>
|
||||
(i.category || getCategoryFromLotName(i.lot_name)).toUpperCase() === category.toUpperCase()
|
||||
ciStr(i.category) === ciStr(category)
|
||||
);
|
||||
|
||||
if (item) {
|
||||
@@ -2131,6 +2238,7 @@ function removeFromCart(lotName) {
|
||||
|
||||
function updateCartUI() {
|
||||
updateTabVisibility();
|
||||
updateRequiredCategoryBadges();
|
||||
window._currentCart = cart; // expose for BOM/Pricing tabs
|
||||
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||
document.getElementById('cart-total').textContent = formatMoney(total);
|
||||
@@ -2149,8 +2257,8 @@ function updateCartUI() {
|
||||
|
||||
// Sort cart items by category display order
|
||||
const sortedCart = [...cart].sort((a, b) => {
|
||||
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
|
||||
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
|
||||
const catA = ciStr(a.category);
|
||||
const catB = ciStr(b.category);
|
||||
const orderA = categoryOrderMap[catA] || 9999;
|
||||
const orderB = categoryOrderMap[catB] || 9999;
|
||||
return orderA - orderB;
|
||||
@@ -2158,8 +2266,7 @@ function updateCartUI() {
|
||||
|
||||
const grouped = {};
|
||||
sortedCart.forEach(item => {
|
||||
const cat = item.category || getCategoryFromLotName(item.lot_name);
|
||||
const tab = getTabForCategory(cat);
|
||||
const tab = getTabForCategory(item.category);
|
||||
if (!grouped[tab]) grouped[tab] = [];
|
||||
grouped[tab].push(item);
|
||||
});
|
||||
@@ -2167,11 +2274,11 @@ function updateCartUI() {
|
||||
// Sort tabs by minimum display order of their categories
|
||||
const sortedTabs = Object.entries(grouped).sort((a, b) => {
|
||||
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;
|
||||
}));
|
||||
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 minOrderA - minOrderB;
|
||||
@@ -2402,8 +2509,7 @@ function restoreAutosaveDraftIfAny() {
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
description: item.description || '',
|
||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||
}));
|
||||
category: item.category }));
|
||||
}
|
||||
if (typeof payload.server_count === 'number' && payload.server_count > 0) {
|
||||
serverCount = payload.server_count;
|
||||
@@ -2426,6 +2532,9 @@ function restoreAutosaveDraftIfAny() {
|
||||
customPriceInput.value = '';
|
||||
}
|
||||
}
|
||||
if (payload.notes) {
|
||||
restorePricingStateFromNotes(payload.notes);
|
||||
}
|
||||
hasUnsavedChanges = true;
|
||||
} catch (_) {
|
||||
// ignore invalid draft
|
||||
@@ -2620,8 +2729,8 @@ function renderSalePriceTable() {
|
||||
}
|
||||
|
||||
const sortedCart = [...cart].sort((a, b) => {
|
||||
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
|
||||
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
|
||||
const catA = ciStr(a.category);
|
||||
const catB = ciStr(b.category);
|
||||
const orderA = categoryOrderMap[catA] || 9999;
|
||||
const orderB = categoryOrderMap[catB] || 9999;
|
||||
return orderA - orderB;
|
||||
@@ -2724,8 +2833,8 @@ function calculateCustomPrice() {
|
||||
// Build adjusted prices table
|
||||
// Sort cart items by category display order
|
||||
const sortedCart = [...cart].sort((a, b) => {
|
||||
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
|
||||
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
|
||||
const catA = ciStr(a.category);
|
||||
const catB = ciStr(b.category);
|
||||
const orderA = categoryOrderMap[catA] || 9999;
|
||||
const orderB = categoryOrderMap[catB] || 9999;
|
||||
return orderA - orderB;
|
||||
@@ -2857,6 +2966,15 @@ async function refreshPrices() {
|
||||
}
|
||||
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 refreshPriceLevels({ force: true, noCache: true });
|
||||
renderTab();
|
||||
@@ -3209,7 +3327,7 @@ function _bomRawLotCell(rowIdx) {
|
||||
cart.forEach(item => { cartMap[item.lot_name] = item.quantity; });
|
||||
const isUnresolved = !map.resolved_lot || map.resolution_source === 'unresolved';
|
||||
const cartQty = map.resolved_lot ? (cartMap[map.resolved_lot] ?? null) : null;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== map.quantity;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== map.quantity * _getRowLotQtyPerPN(map);
|
||||
const notInCart = map.resolved_lot && cartQty === null;
|
||||
|
||||
if (isUnresolved) {
|
||||
@@ -3595,7 +3713,7 @@ function _renderBOMParsedTable() {
|
||||
const tr = document.createElement('tr');
|
||||
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
|
||||
const cartQty = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== row.quantity;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== row.quantity * _getRowLotQtyPerPN(row);
|
||||
const notInCart = row.resolved_lot && cartQty === null;
|
||||
if (isUnresolved) unresolved++;
|
||||
if (qtyMismatch || notInCart) mismatches++;
|
||||
@@ -3662,7 +3780,7 @@ function _renderBOMRawTable() {
|
||||
else if (parsed) {
|
||||
const isUnresolved = !parsed.resolved_lot || parsed.resolution_source === 'unresolved';
|
||||
const cartQty = parsed.resolved_lot ? (cartMap[parsed.resolved_lot] ?? null) : null;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== parsed.quantity;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== parsed.quantity * _getRowLotQtyPerPN(parsed);
|
||||
const notInCart = parsed.resolved_lot && cartQty === null;
|
||||
if (isUnresolved) unresolved++;
|
||||
if (qtyMismatch || notInCart) mismatches++;
|
||||
@@ -3930,6 +4048,9 @@ async function renderPricingTab() {
|
||||
};
|
||||
|
||||
// Collect LOTs to price: from BOM rows (resolved) or from cart
|
||||
// Use cart quantity when available (source of truth); fall back to BOM-computed quantity.
|
||||
const _cartQtyMap = {};
|
||||
cart.forEach(item => { if (item?.lot_name) _cartQtyMap[item.lot_name] = item.quantity; });
|
||||
let itemsForPriceLevels = [];
|
||||
if (bomRows.length) {
|
||||
const seen = new Set();
|
||||
@@ -3938,13 +4059,13 @@ async function renderPricingTab() {
|
||||
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
||||
if (baseLot && !seen.has(baseLot)) {
|
||||
seen.add(baseLot);
|
||||
itemsForPriceLevels.push({ lot_name: baseLot, quantity: row.quantity * _getRowLotQtyPerPN(row) });
|
||||
itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[baseLot] ?? (row.quantity * _getRowLotQtyPerPN(row)) });
|
||||
}
|
||||
if (allocs.length) {
|
||||
allocs.forEach(a => {
|
||||
if (!seen.has(a.lot_name)) {
|
||||
seen.add(a.lot_name);
|
||||
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: row.quantity * a.quantity });
|
||||
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3997,6 +4118,8 @@ async function renderPricingTab() {
|
||||
|
||||
// ─── Build shared row data (unit prices for display, totals for math) ────
|
||||
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
|
||||
const cartQtyMap = {};
|
||||
cart.forEach(item => { if (item?.lot_name) cartQtyMap[item.lot_name] = item.quantity; });
|
||||
const _buildRows = () => {
|
||||
const result = [];
|
||||
const coveredLots = new Set();
|
||||
@@ -4020,7 +4143,12 @@ async function renderPricingTab() {
|
||||
};
|
||||
|
||||
if (!bomRows.length) {
|
||||
cart.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
|
||||
const sortedByCategory = [...cart].sort((a, b) => {
|
||||
const catA = ciStr(a.category);
|
||||
const catB = ciStr(b.category);
|
||||
return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999);
|
||||
});
|
||||
sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
|
||||
return { result, coveredLots };
|
||||
}
|
||||
|
||||
@@ -4041,7 +4169,7 @@ async function renderPricingTab() {
|
||||
if (baseLot) {
|
||||
const u = _getUnitPrices(priceMap[baseLot]);
|
||||
const lotQty = _getRowLotQtyPerPN(row);
|
||||
const qty = row.quantity * lotQty;
|
||||
const qty = cartQtyMap[baseLot] ?? (row.quantity * lotQty);
|
||||
subRows.push({
|
||||
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
|
||||
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||
@@ -4053,7 +4181,7 @@ async function renderPricingTab() {
|
||||
}
|
||||
allocs.forEach(a => {
|
||||
const u = _getUnitPrices(priceMap[a.lot_name]);
|
||||
const qty = row.quantity * a.quantity;
|
||||
const qty = cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity);
|
||||
subRows.push({
|
||||
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
|
||||
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||
@@ -4374,6 +4502,8 @@ function setPricingCustomPriceFromVendor() {
|
||||
async function exportPricingCSV(table) {
|
||||
if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; }
|
||||
const basis = table === 'sale' ? 'ddp' : 'fob';
|
||||
const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy';
|
||||
const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || '');
|
||||
try {
|
||||
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
|
||||
method: 'POST',
|
||||
@@ -4385,6 +4515,7 @@ async function exportPricingCSV(table) {
|
||||
include_stock: true,
|
||||
include_competitor: true,
|
||||
basis: basis,
|
||||
manual_price: manualPrice > 0 ? manualPrice : null,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}OFS - Партномера{{end}}
|
||||
{{define "title"}}QFS Партномера{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -22,20 +22,17 @@
|
||||
</div>
|
||||
|
||||
<div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера.
|
||||
Нет активного листа сопоставлений. Книги загружаются автоматически вместе с прайслистами.
|
||||
</div>
|
||||
|
||||
<!-- All books list (collapsed by default) -->
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<!-- Header row — always visible -->
|
||||
<div class="px-4 py-3 flex items-center justify-between">
|
||||
<div class="px-4 py-3">
|
||||
<button onclick="toggleBooksSection()" class="flex items-center gap-2 text-sm font-semibold text-gray-800 hover:text-gray-600 select-none">
|
||||
<svg id="books-chevron" class="w-4 h-4 text-gray-400 transition-transform duration-150" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
Снимки сопоставлений (Partnumber Books)
|
||||
</button>
|
||||
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 text-sm font-medium">
|
||||
Синхронизировать
|
||||
</button>
|
||||
</div>
|
||||
<!-- Collapsible body -->
|
||||
<div id="books-section-body" class="hidden border-t">
|
||||
@@ -69,7 +66,7 @@
|
||||
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
|
||||
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
|
||||
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
|
||||
<input type="text" id="pn-search" placeholder="Поиск по PN, LOT или описанию..."
|
||||
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
oninput="onItemsSearchInput(this.value)">
|
||||
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Прайслист - OFS{{end}}
|
||||
{{define "title"}}QFS Прайслист{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Прайслисты - OFS{{end}}
|
||||
{{define "title"}}QFS Прайслисты{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Проект - OFS{{end}}
|
||||
{{define "title"}}QFS Проект{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -207,9 +207,11 @@
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<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 class="mt-6 flex justify-end gap-2">
|
||||
@@ -842,6 +844,10 @@ async function createNewVariant() {
|
||||
showToast('Укажите вариант', 'error');
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(variant)) {
|
||||
showToast('Имя варианта содержит недопустимые символы. Разрешены: буквы, цифры, дефис, точка, подчёркивание.', 'error');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
code: code,
|
||||
variant: variant,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Мои проекты - OFS{{end}}
|
||||
{{define "title"}}QFS Мои проекты{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -39,12 +39,18 @@
|
||||
<div>
|
||||
<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"
|
||||
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">
|
||||
<p class="text-xs text-gray-400 mt-1">Буквы, цифры, дефис, точка, подчёркивание. Код используется в URL.</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<p class="text-xs text-gray-400 mt-1">Используется в URL: /КОД/Вариант</p>
|
||||
</div>
|
||||
<div>
|
||||
<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('Введите код проекта');
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
@@ -411,6 +425,11 @@ async function createProject() {
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
if (resp.status === 400) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
alert(body.error || 'Некорректный запрос');
|
||||
return;
|
||||
}
|
||||
alert('Не удалось создать проект');
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user