Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4900cd073c | ||
|
|
c0588e9710 | ||
|
|
0cd4f99b46 | ||
|
|
4982adbe41 | ||
|
|
5359ae6ded | ||
|
|
76d93c6be8 | ||
|
|
c6385f6cf1 | ||
|
|
1ab5186d0c | ||
|
|
b6fdac1caa | ||
|
|
b837ca7866 | ||
|
|
c8092da370 | ||
|
|
4f105822c6 | ||
|
|
6df262b8ee | ||
|
|
0fc0366bb1 | ||
|
|
d204e337b5 | ||
|
|
d340bf80af | ||
|
|
24c34eb0e1 | ||
|
|
6f2c261350 | ||
|
|
7233a0780f | ||
|
|
360c754952 | ||
| 184f54b663 | |||
| e548305396 | |||
| 09d694234d | |||
| 56782fa718 | |||
| 2bd57591ea | |||
| a81947b852 | |||
| 6146f6aec7 |
@@ -116,6 +116,28 @@ Rules:
|
|||||||
- storage configurations use the same vendor_spec + PN→LOT + pricing flow as server configurations;
|
- storage configurations use the same vendor_spec + PN→LOT + pricing flow as server configurations;
|
||||||
- storage component categories map to existing tabs: `ENC`/`DKC`/`CTL` → Base, `HIC` → PCI (HIC-карты СХД; `HBA`/`NIC` — серверные, не смешивать), `SSD`/`HDD` → Storage (используют существующие серверные LOT), `ACC` → Accessories (используют существующие серверные LOT), `SW` → SW.
|
- storage component categories map to existing tabs: `ENC`/`DKC`/`CTL` → Base, `HIC` → PCI (HIC-карты СХД; `HBA`/`NIC` — серверные, не смешивать), `SSD`/`HDD` → Storage (используют существующие серверные LOT), `ACC` → Accessories (используют существующие серверные LOT), `SW` → SW.
|
||||||
- `DKC` = контроллерная полка (модель СХД + тип дисков + кол-во слотов + кол-во контроллеров); `CTL` = контроллер (кэш + встроенные порты); `ENC` = дисковая полка без контроллера.
|
- `DKC` = контроллерная полка (модель СХД + тип дисков + кол-во слотов + кол-во контроллеров); `CTL` = контроллер (кэш + встроенные порты); `ENC` = дисковая полка без контроллера.
|
||||||
|
- the available config types and their localized names flow from `qt_settings.config_types` on the server;
|
||||||
|
QF falls back to hardcoded "server/Сервер" and "storage/СХД" when the setting is absent.
|
||||||
|
|
||||||
|
## Server-driven configurator settings (`qt_settings`)
|
||||||
|
|
||||||
|
QF reads four settings from `qt_settings` (MariaDB) and caches them in `local_qt_settings` (SQLite).
|
||||||
|
They are synced during every component sync. See `bible-local/server-contract-qt-settings.md` for the
|
||||||
|
full contract and JSON schemas.
|
||||||
|
|
||||||
|
| Setting key | Effect in QF |
|
||||||
|
|-------------|-------------|
|
||||||
|
| `config_types` | New-config modal buttons; category allowlist per config type |
|
||||||
|
| `tab_config` | Configurator tab structure, sections, singleSelect |
|
||||||
|
| `always_visible_tabs` | Which tabs are shown even when empty |
|
||||||
|
| `required_categories` | Per-config-type badge on tabs with unfilled required categories |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- sync runs after `SyncComponents`; failure is non-fatal (Warn log only);
|
||||||
|
- `local_qt_settings` is a read-only cache — never written by user actions;
|
||||||
|
- absent or unparseable settings: QF uses hardcoded fallbacks for that key only;
|
||||||
|
- `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere;
|
||||||
|
- `qt_categories.name` and `qt_categories.name_ru` are not used by QF runtime; do not depend on them.
|
||||||
|
|
||||||
## Vendor BOM contract
|
## Vendor BOM contract
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Main tables:
|
|||||||
| `connection_settings` | encrypted MariaDB connection settings |
|
| `connection_settings` | encrypted MariaDB connection settings |
|
||||||
| `app_settings` | local app state |
|
| `app_settings` | local app state |
|
||||||
| `local_schema_migrations` | applied local migration markers |
|
| `local_schema_migrations` | applied local migration markers |
|
||||||
|
| `local_qt_settings` | server-pushed configurator settings cache (from `qt_settings`) |
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- cache tables may be rebuilt if local migration recovery requires it;
|
- cache tables may be rebuilt if local migration recovery requires it;
|
||||||
@@ -34,12 +35,13 @@ MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
|
|||||||
### QuoteForge tables (qt_*)
|
### QuoteForge tables (qt_*)
|
||||||
|
|
||||||
Runtime read:
|
Runtime read:
|
||||||
- `qt_categories` — pricelist categories
|
- `qt_categories` — pricelist categories (note: `name`/`name_ru` columns being removed; QF does not use them)
|
||||||
- `qt_lot_metadata` — component metadata, price settings
|
- `qt_lot_metadata` — component metadata, price settings
|
||||||
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
||||||
- `qt_pricelist_items` — pricelist rows
|
- `qt_pricelist_items` — pricelist rows
|
||||||
- `qt_partnumber_books` — partnumber book headers
|
- `qt_partnumber_books` — partnumber book headers
|
||||||
- `qt_partnumber_book_items` — PN→LOT catalog payload
|
- `qt_partnumber_book_items` — PN→LOT catalog payload
|
||||||
|
- `qt_settings` — server-pushed configurator settings; schema managed by server-side agent (see `bible-local/server-contract-qt-settings.md`)
|
||||||
|
|
||||||
Runtime read/write:
|
Runtime read/write:
|
||||||
- `qt_projects` — projects
|
- `qt_projects` — projects
|
||||||
@@ -48,7 +50,7 @@ Runtime read/write:
|
|||||||
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
|
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
|
||||||
|
|
||||||
Insert-only tracking:
|
Insert-only tracking:
|
||||||
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync
|
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
|
||||||
|
|
||||||
Server-side only (not queried by client runtime):
|
Server-side only (not queried by client runtime):
|
||||||
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
||||||
@@ -91,11 +93,26 @@ Full column reference as of 2026-03-21 (`RFQ_LOG` final schema).
|
|||||||
|--------|------|-------|
|
|--------|------|-------|
|
||||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
| code | varchar(20) UNIQUE NOT NULL | |
|
| code | varchar(20) UNIQUE NOT NULL | |
|
||||||
| name | varchar(100) NOT NULL | |
|
| name | varchar(100) NOT NULL | being removed; QF does not use at runtime |
|
||||||
| name_ru | varchar(100) | |
|
| name_ru | varchar(100) | being removed; QF does not use at runtime |
|
||||||
| display_order | bigint DEFAULT 0 | |
|
| display_order | bigint DEFAULT 0 | |
|
||||||
| is_required | tinyint(1) DEFAULT 0 | |
|
| is_required | tinyint(1) DEFAULT 0 | |
|
||||||
|
|
||||||
|
### qt_settings
|
||||||
|
Managed by the server-side agent. QF has SELECT-only access.
|
||||||
|
See `bible-local/server-contract-qt-settings.md` for full schema and value formats.
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| name | varchar(100) PK | setting key |
|
||||||
|
| value | TEXT NOT NULL | JSON-encoded value |
|
||||||
|
|
||||||
|
### local_qt_settings (SQLite)
|
||||||
|
Read-only cache of `qt_settings`. Synced during component sync.
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| name | text PK | setting key |
|
||||||
|
| value | text | JSON value as-is from server |
|
||||||
|
|
||||||
### qt_client_schema_state
|
### qt_client_schema_state
|
||||||
PK: (username, hostname)
|
PK: (username, hostname)
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
@@ -312,6 +329,7 @@ PK: job_name
|
|||||||
| ignored_by | varchar(100) | |
|
| ignored_by | varchar(100) | |
|
||||||
| created_at | datetime(3) | |
|
| created_at | datetime(3) | |
|
||||||
| updated_at | datetime(3) | |
|
| updated_at | datetime(3) | |
|
||||||
|
| lot_suggestion | longtext (JSON) | nullable; set when user manually maps PN → LOT in vendor-spec UI; same format as `qt_partnumber_book_items.lots_json`; see [11-lot-suggestions.md](11-lot-suggestions.md) |
|
||||||
|
|
||||||
### stock_ignore_rules
|
### stock_ignore_rules
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
@@ -370,8 +388,6 @@ GRANT SELECT ON RFQ_LOG.qt_categories TO 'qfs_user'@'%';
|
|||||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'qfs_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'qfs_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'qfs_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'qfs_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'qfs_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'qfs_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.stock_log TO 'qfs_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.stock_ignore_rules TO 'qfs_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'qfs_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'qfs_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'qfs_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'qfs_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
|
GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
|
||||||
@@ -380,7 +396,6 @@ GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
|
|||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_projects TO 'qfs_user'@'%';
|
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_projects TO 'qfs_user'@'%';
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'qfs_user'@'%';
|
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'qfs_user'@'%';
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'qfs_user'@'%';
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'qfs_user'@'%';
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'qfs_user'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
|
||||||
|
|
||||||
FLUSH PRIVILEGES;
|
FLUSH PRIVILEGES;
|
||||||
|
|||||||
@@ -80,3 +80,81 @@ Rules:
|
|||||||
- configuration `name` is derived from the uploaded filename (without extension);
|
- configuration `name` is derived from the uploaded filename (without extension);
|
||||||
- lines that do not contain `*<digits>` are skipped;
|
- lines that do not contain `*<digits>` are skipped;
|
||||||
- no price data is present in the format; `unit_price` and `total_price` are left nil.
|
- no price data is present in the format; `unit_price` and `total_price` are left nil.
|
||||||
|
|
||||||
|
## Text BOM import
|
||||||
|
|
||||||
|
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a human-readable Russian text BOM.
|
||||||
|
|
||||||
|
Format: an optional header line ending with `, в составе:` followed by one component per line as
|
||||||
|
`<description> - <quantity> шт.`. The separator may be a hyphen, en-dash, or em-dash; the space before
|
||||||
|
`шт` is optional; a trailing `.` after `шт` is optional. Quantities are anchored to the end of the line,
|
||||||
|
so hyphens, commas, and digits inside the description (e.g. `8-GPU-2304GB`, `RAID0,1,10`) are preserved.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
Вычислительный GPU сервер G5500V7, в составе:
|
||||||
|
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
|
||||||
|
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
|
||||||
|
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
|
||||||
|
NVIDIA twin port transceiver, 800Gbps, OSFP - 8шт.
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- the entire file becomes a single configuration (`server_count = 1`);
|
||||||
|
- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the
|
||||||
|
last whitespace-separated token before the comma (so both `Сервер X3` and `Вычислительный GPU сервер X3`
|
||||||
|
resolve to `X3`);
|
||||||
|
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
|
||||||
|
- each line is trimmed, so leading/trailing whitespace never enters `vendor_partnumber`;
|
||||||
|
- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and
|
||||||
|
`description`, so rows resolve through the active partnumber book when matched and otherwise stay
|
||||||
|
unresolved and editable in the UI;
|
||||||
|
- lines that do not match `<description> - <quantity> шт.` are skipped;
|
||||||
|
- no price data is present in the format; `unit_price` and `total_price` are left nil.
|
||||||
|
|
||||||
|
## Nx BOM import (quantity-first)
|
||||||
|
|
||||||
|
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a quantity-first BOM
|
||||||
|
where each item line begins with `<qty>x <description>`.
|
||||||
|
|
||||||
|
Format: an optional header line ending with `, в составе:` followed by one component per line as
|
||||||
|
`<qty>x <description>`. The `x` separator is case-insensitive; parentheses, commas, and hyphens
|
||||||
|
inside the description are preserved as-is.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
Сервер G893-SD1-AAX3, в составе:
|
||||||
|
1x 8U 2CPU 8GPU Server System (32x DDR5 DIMM Slots,8x 2.5" Hot-Swap Drive Bays, 4+4 3000W, 2x 10Gb/s RJ45, 2x IPMI RJ45)
|
||||||
|
2x Intel Xeon 8570 (56 cores, 2.1GHz, 300MB, 350W)
|
||||||
|
32x 64GB DDR5 ECC RDIMM
|
||||||
|
1x GPU Nvidia HGX H200 141GB 8GPU
|
||||||
|
3x 1.92TB NVMe PCIe SFF RI
|
||||||
|
5x 7.68TB NVMe PCIe SFF RI
|
||||||
|
8x 1-port 400G NDR OSFP CX7
|
||||||
|
2x 2-port 100GbE QSFP56 CX6
|
||||||
|
1x 2-port 10GbE RJ45
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- the entire file becomes a single configuration (`server_count = 1`);
|
||||||
|
- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the
|
||||||
|
last whitespace-separated token before the comma;
|
||||||
|
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
|
||||||
|
- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and
|
||||||
|
`description`, so rows resolve through the active partnumber book when matched and otherwise stay
|
||||||
|
unresolved and editable in the UI;
|
||||||
|
- lines that do not match `<qty>x <description>` are skipped;
|
||||||
|
- no price data is present in the format; `unit_price` and `total_price` are left nil;
|
||||||
|
- detection runs before Text BOM in the format switch (Inspur → Nx → Text).
|
||||||
|
|
||||||
|
## Pasted BOM text parsing
|
||||||
|
|
||||||
|
`POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM
|
||||||
|
(Inspur and Russian text BOM) into rows. Request body: `{"text": "..."}`. Response:
|
||||||
|
`{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`.
|
||||||
|
|
||||||
|
This shares the exact detectors and parsers used by the file-import path
|
||||||
|
(`ParsePastedBOMText` → `IsInspurBOM`/`parseInspurBOM`, `IsNxBOM`/`parseNxBOM`, `IsTextBOM`/`parseTextBOM`),
|
||||||
|
so paste and upload behave identically — there is no second parser in the frontend. The configurator's BOM
|
||||||
|
paste box calls this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real
|
||||||
|
spreadsheet table) falls back to the manual column-mapping grid.
|
||||||
|
|||||||
563
bible-local/10-agent-api-guide.md
Normal file
563
bible-local/10-agent-api-guide.md
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
# 10 - Agent API Guide: Pricing Servers from a TZ
|
||||||
|
|
||||||
|
This guide is written for an AI agent that needs to price a server configuration
|
||||||
|
(техническое задание, ТЗ) using the QuoteForge HTTP API.
|
||||||
|
|
||||||
|
## Runtime assumptions
|
||||||
|
|
||||||
|
- QuoteForge runs locally, binds to `127.0.0.1:8080` by default.
|
||||||
|
- No authentication is required — the app is single-user, loopback-only.
|
||||||
|
- All responses are JSON. All request bodies are JSON unless stated otherwise.
|
||||||
|
- The port can be overridden with the `QF_SERVER_PORT` environment variable.
|
||||||
|
|
||||||
|
Base URL for all examples: `http://127.0.0.1:8080`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration composition rules
|
||||||
|
|
||||||
|
These rules are mandatory and must be respected before saving any configuration.
|
||||||
|
|
||||||
|
### 1. Every configuration must belong to a project
|
||||||
|
|
||||||
|
Configurations cannot be created in isolation. The correct sequence is:
|
||||||
|
|
||||||
|
1. Create a project (`POST /api/projects`) and save the returned `uuid`.
|
||||||
|
2. Create the configuration inside that project by passing `project_uuid` in the
|
||||||
|
config body, or by using `POST /api/projects/:uuid/configs`.
|
||||||
|
|
||||||
|
If the project for a given TZ already exists, retrieve its `uuid` first:
|
||||||
|
```
|
||||||
|
GET /api/projects?page=1&per_page=100
|
||||||
|
```
|
||||||
|
then pass the matching `uuid` in `project_uuid`.
|
||||||
|
|
||||||
|
### 2. Every server configuration must contain all four required component groups
|
||||||
|
|
||||||
|
A configuration is not valid for pricing unless items from all four of the
|
||||||
|
following category groups are present:
|
||||||
|
|
||||||
|
| Category code | Meaning | Notes |
|
||||||
|
|---------------|------------------|---------------------------------------------------|
|
||||||
|
| `MB` | Motherboard | exactly one MB per configuration |
|
||||||
|
| `CPU` | Processor | one or more CPUs |
|
||||||
|
| `MEM` | Memory / RAM | one or more memory modules |
|
||||||
|
| `PS` / `PSU` | Power supply | `PSU` is the current code; `PS` is legacy — both are accepted |
|
||||||
|
|
||||||
|
Before saving, verify the assembled BOM with `POST /api/quote/validate`:
|
||||||
|
the response `errors` array will contain `"Component not found: …"` entries
|
||||||
|
for unknown lot names, and `warnings` will list lots without a price.
|
||||||
|
Reject the configuration and report back to the user if any of the four
|
||||||
|
required categories is missing.
|
||||||
|
|
||||||
|
### 3. Category codes to use when searching
|
||||||
|
|
||||||
|
Use `category=<code>` in `GET /api/components` to narrow results:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/components?category=MB&search=X13&has_price=true
|
||||||
|
GET /api/components?category=CPU&search=Xeon+Gold&has_price=true
|
||||||
|
GET /api/components?category=MEM&search=32GB+DDR5&has_price=true
|
||||||
|
GET /api/components?category=PSU&search=800W&has_price=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Retrieve the full list of active categories at any time:
|
||||||
|
```
|
||||||
|
GET /api/categories
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typical workflow for pricing a server
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Check the app is up GET /api/ping
|
||||||
|
2. Find or create a project GET /api/projects → POST /api/projects
|
||||||
|
3. Find the latest pricelist GET /api/pricelists/latest?source=estimate
|
||||||
|
4. Look up lot names for MB GET /api/components?category=MB&search=…
|
||||||
|
5. Look up lot names for CPU GET /api/components?category=CPU&search=…
|
||||||
|
6. Look up lot names for MEM GET /api/components?category=MEM&search=…
|
||||||
|
7. Look up lot names for PSU GET /api/components?category=PSU&search=…
|
||||||
|
8. (Repeat for other components) GET /api/components?category=…&search=…
|
||||||
|
9. Validate and calculate the quote POST /api/quote/validate
|
||||||
|
10. (Optional) Compare price tiers POST /api/quote/price-levels
|
||||||
|
11. Save configuration in the project POST /api/projects/:uuid/configs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Verify the app is running
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/ping
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200 OK`:
|
||||||
|
```json
|
||||||
|
{"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Find or create a project
|
||||||
|
|
||||||
|
Each TZ maps to one project. Use the TZ identifier as the `code` field.
|
||||||
|
|
||||||
|
### Find an existing project
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/projects?page=1&per_page=100
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200 OK`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||||
|
"code": "TZ-123",
|
||||||
|
"variant": "",
|
||||||
|
"name": "Проект по ТЗ №123",
|
||||||
|
"tracker_url": "",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2026-06-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a new project
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/projects
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "TZ-123",
|
||||||
|
"name": "Проект по ТЗ №123",
|
||||||
|
"tracker_url": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
| field | type | required | description |
|
||||||
|
|---------------|--------|----------|--------------------------------------------------------------------|
|
||||||
|
| `code` | string | yes | short identifier, unique per variant; use the TZ number or ticket |
|
||||||
|
| `variant` | string | no | variant label within the same `code`; default is empty string |
|
||||||
|
| `name` | string | no | human-readable title |
|
||||||
|
| `tracker_url` | string | no | link to a ticket or issue tracker |
|
||||||
|
|
||||||
|
Response `201 Created`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||||
|
"code": "TZ-123",
|
||||||
|
"variant": "",
|
||||||
|
"name": "Проект по ТЗ №123",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2026-06-11T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the `uuid` — it is required to create configurations inside this project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Find the latest pricelist
|
||||||
|
|
||||||
|
QuoteForge maintains three pricing tiers. The `source` values are:
|
||||||
|
|
||||||
|
| source | meaning |
|
||||||
|
|--------------|-----------------------------|
|
||||||
|
| `estimate` | list / catalogue price |
|
||||||
|
| `warehouse` | stock price (purchase cost) |
|
||||||
|
| `competitor` | competitor reference price |
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/pricelists/latest?source=estimate
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200 OK`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"source": "estimate",
|
||||||
|
"version": "2026-05-28",
|
||||||
|
"item_count": 12500,
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2026-05-28T06:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `id` field is a numeric pricelist identifier. Pass it as `pricelist_id`
|
||||||
|
when calculating a quote to pin pricing to a specific pricelist.
|
||||||
|
|
||||||
|
To list all available pricelists:
|
||||||
|
```
|
||||||
|
GET /api/pricelists?source=estimate&active_only=true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Steps 4–8 — Look up component lot names
|
||||||
|
|
||||||
|
Each component is identified by a `lot_name` (internal SKU). The TZ typically
|
||||||
|
contains model names or descriptions; use the search endpoint to resolve them.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/components?search=Xeon+Gold+6342&category=CPU&has_price=true&page=1&per_page=20
|
||||||
|
```
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| parameter | default | description |
|
||||||
|
|------------------|---------|---------------------------------------------------|
|
||||||
|
| `search` | — | free-text search in lot name and description |
|
||||||
|
| `category` | — | filter by category code (`MB`, `CPU`, `MEM`, `PSU`, …) |
|
||||||
|
| `has_price` | false | return only components that have a price |
|
||||||
|
| `include_hidden` | false | include hidden/retired components |
|
||||||
|
| `page` | 1 | page number |
|
||||||
|
| `per_page` | 20 | page size |
|
||||||
|
|
||||||
|
Response `200 OK`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"lot_name": "CPU-XEON-6342",
|
||||||
|
"description": "Intel Xeon Gold 6342, 24C/48T, 2.8 GHz, LGA4189",
|
||||||
|
"category": "CPU",
|
||||||
|
"category_name": "CPU",
|
||||||
|
"model": "Xeon Gold 6342"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To look up a single component by exact lot name:
|
||||||
|
```
|
||||||
|
GET /api/components/CPU-XEON-6342
|
||||||
|
```
|
||||||
|
|
||||||
|
To list all known categories:
|
||||||
|
```
|
||||||
|
GET /api/categories
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 9 — Validate and calculate the quote
|
||||||
|
|
||||||
|
Before saving, validate the assembled BOM. This catches unknown lot names and
|
||||||
|
missing prices, and also confirms that all required categories are covered.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/quote/validate
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{"lot_name": "MB-X13DAI-N", "quantity": 1},
|
||||||
|
{"lot_name": "CPU-XEON-6342", "quantity": 2},
|
||||||
|
{"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8},
|
||||||
|
{"lot_name": "SSD-480GB-SATA", "quantity": 2},
|
||||||
|
{"lot_name": "PSU-800W-TITANIUM", "quantity": 2}
|
||||||
|
],
|
||||||
|
"pricelist_id": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200 OK`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": true,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"lot_name": "MB-X13DAI-N",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit_price": 95000.00,
|
||||||
|
"total_price": 95000.00,
|
||||||
|
"description": "Supermicro X13DAi-N dual-socket server board",
|
||||||
|
"category": "MB",
|
||||||
|
"has_price": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lot_name": "CPU-XEON-6342",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit_price": 87500.00,
|
||||||
|
"total_price": 175000.00,
|
||||||
|
"description": "Intel Xeon Gold 6342, 24C/48T, 2.8 GHz",
|
||||||
|
"category": "CPU",
|
||||||
|
"has_price": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lot_name": "RAM-32GB-DDR4-3200",
|
||||||
|
"quantity": 8,
|
||||||
|
"unit_price": 12000.00,
|
||||||
|
"total_price": 96000.00,
|
||||||
|
"description": "32 GB DDR4-3200 ECC RDIMM",
|
||||||
|
"category": "MEM",
|
||||||
|
"has_price": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lot_name": "PSU-800W-TITANIUM",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit_price": 18500.00,
|
||||||
|
"total_price": 37000.00,
|
||||||
|
"description": "800W 80+ Titanium redundant PSU",
|
||||||
|
"category": "PSU",
|
||||||
|
"has_price": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": [],
|
||||||
|
"warnings": [],
|
||||||
|
"total": 403000.00
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agent check after validation:**
|
||||||
|
|
||||||
|
1. `valid` must be `true` — all lot names resolved.
|
||||||
|
2. `errors` must be empty — no unknown components.
|
||||||
|
3. The returned `items` array must contain at least one entry from each required
|
||||||
|
category: `MB`, `CPU`, `MEM`, and `PS` or `PSU`.
|
||||||
|
4. Items with `has_price: false` are allowed but should be flagged to the user.
|
||||||
|
|
||||||
|
If any check fails, do not save the configuration. Report the issue and ask the
|
||||||
|
user to clarify or replace the problematic component.
|
||||||
|
|
||||||
|
For simple price totals without validation metadata use `POST /api/quote/calculate`
|
||||||
|
— identical request body, response contains only `items` and `total`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 10 (optional) — Compare price tiers
|
||||||
|
|
||||||
|
To see estimate, warehouse, and competitor prices side-by-side for a BOM:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/quote/price-levels
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{"lot_name": "CPU-XEON-6342", "quantity": 2},
|
||||||
|
{"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8}
|
||||||
|
],
|
||||||
|
"pricelist_ids": {
|
||||||
|
"estimate": 42,
|
||||||
|
"warehouse": 31,
|
||||||
|
"competitor": 15
|
||||||
|
},
|
||||||
|
"no_cache": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`pricelist_ids` is optional. When omitted the latest pricelist for each source
|
||||||
|
is used automatically.
|
||||||
|
|
||||||
|
Response `200 OK`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"lot_name": "CPU-XEON-6342",
|
||||||
|
"quantity": 2,
|
||||||
|
"estimate_price": 87500.00,
|
||||||
|
"warehouse_price": 71000.00,
|
||||||
|
"competitor_price": 85000.00,
|
||||||
|
"delta_wh_estimate_abs": -16500.00,
|
||||||
|
"delta_wh_estimate_pct": -18.86,
|
||||||
|
"delta_comp_estimate_abs": -2500.00,
|
||||||
|
"delta_comp_estimate_pct": -2.86,
|
||||||
|
"delta_comp_wh_abs": 14000.00,
|
||||||
|
"delta_comp_wh_pct": 19.72,
|
||||||
|
"price_missing": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resolved_pricelist_ids": {
|
||||||
|
"estimate": 42,
|
||||||
|
"warehouse": 31,
|
||||||
|
"competitor": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`price_missing` lists the source names for which no price was found for that lot.
|
||||||
|
Delta fields are `null` when either operand price is missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 11 — Save a configuration inside the project
|
||||||
|
|
||||||
|
Use the project-scoped endpoint so the configuration is immediately linked to
|
||||||
|
the correct project without a separate move operation.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/projects/:project_uuid/configs
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
The request body is identical to `POST /api/configs` — the `project_uuid` field
|
||||||
|
in the body is ignored when using the project-scoped route; the URL parameter
|
||||||
|
takes precedence.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Сервер по ТЗ №123 — вариант А",
|
||||||
|
"items": [
|
||||||
|
{"lot_name": "MB-X13DAI-N", "quantity": 1, "unit_price": 95000.00},
|
||||||
|
{"lot_name": "CPU-XEON-6342", "quantity": 2, "unit_price": 87500.00},
|
||||||
|
{"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8, "unit_price": 12000.00},
|
||||||
|
{"lot_name": "SSD-480GB-SATA", "quantity": 2, "unit_price": 8500.00},
|
||||||
|
{"lot_name": "PSU-800W-TITANIUM", "quantity": 2, "unit_price": 18500.00}
|
||||||
|
],
|
||||||
|
"server_model": "2U",
|
||||||
|
"support_code": "NBD",
|
||||||
|
"server_count": 1,
|
||||||
|
"pricelist_id": 42,
|
||||||
|
"warehouse_pricelist_id": 31,
|
||||||
|
"competitor_pricelist_id": 15,
|
||||||
|
"config_type": "server",
|
||||||
|
"notes": "Автоматически создано агентом на основании ТЗ №123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key fields:
|
||||||
|
|
||||||
|
| field | type | required | description |
|
||||||
|
|--------------------------|--------|----------|-----------------------------------------------------|
|
||||||
|
| `name` | string | yes | human-readable name |
|
||||||
|
| `items` | array | yes | `{lot_name, quantity, unit_price}` from validate |
|
||||||
|
| `server_model` | string | no | chassis/form-factor code; used for article generation |
|
||||||
|
| `support_code` | string | no | support tier code; used for article generation |
|
||||||
|
| `server_count` | int | no | number of identical servers; total is multiplied |
|
||||||
|
| `pricelist_id` | uint | no | estimate pricelist to attach |
|
||||||
|
| `warehouse_pricelist_id` | uint | no | warehouse pricelist to attach |
|
||||||
|
| `competitor_pricelist_id`| uint | no | competitor pricelist to attach |
|
||||||
|
| `config_type` | string | no | `"server"` (default) or `"storage"` |
|
||||||
|
| `notes` | string | no | free text |
|
||||||
|
| `custom_price` | float | no | override total price |
|
||||||
|
| `disable_price_refresh` | bool | no | prevent automatic price refresh on open |
|
||||||
|
| `only_in_stock` | bool | no | filter to in-stock components only |
|
||||||
|
|
||||||
|
Response `201 Created`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "Сервер по ТЗ №123 — вариант А",
|
||||||
|
"items": [...],
|
||||||
|
"total_price": 403000.00,
|
||||||
|
"server_count": 1,
|
||||||
|
"config_type": "server",
|
||||||
|
"article": "2U-6342x2-32GBx8-NBD",
|
||||||
|
"project_uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||||
|
"created_at": "2026-06-11T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `uuid` can be used for all subsequent operations on this configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working with saved configurations
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/configs/:uuid — retrieve a saved configuration
|
||||||
|
PUT /api/configs/:uuid — full update (same body as create)
|
||||||
|
POST /api/configs/:uuid/refresh-prices — re-price from latest pricelist
|
||||||
|
POST /api/configs/:uuid/clone — duplicate: body {"name": "clone name"}
|
||||||
|
GET /api/configs/:uuid/versions — revision history
|
||||||
|
GET /api/configs?page=1&per_page=20 — list all configurations
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error responses
|
||||||
|
|
||||||
|
All error responses follow the same shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error": "human-readable message"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common status codes:
|
||||||
|
|
||||||
|
| code | meaning |
|
||||||
|
|------|-------------------------------------------------------|
|
||||||
|
| 400 | invalid request body or validation failure |
|
||||||
|
| 404 | entity (component, pricelist, config) not found |
|
||||||
|
| 423 | sync readiness is blocked; retry after sync completes |
|
||||||
|
| 500 | internal server error |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minimal end-to-end example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE=http://127.0.0.1:8080
|
||||||
|
|
||||||
|
# 1. Verify the app is up
|
||||||
|
curl -s $BASE/api/ping
|
||||||
|
|
||||||
|
# 2. Create a project for this TZ
|
||||||
|
PROJECT_UUID=$(curl -s -X POST $BASE/api/projects \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"code": "TZ-123", "name": "Проект по ТЗ №123"}' | jq -r .uuid)
|
||||||
|
|
||||||
|
# 3. Get latest estimate pricelist
|
||||||
|
PRICELIST_ID=$(curl -s "$BASE/api/pricelists/latest?source=estimate" | jq .id)
|
||||||
|
|
||||||
|
# 4. Find lot names for required categories
|
||||||
|
curl -s "$BASE/api/components?category=MB&search=X13&has_price=true" | jq '.components[].lot_name'
|
||||||
|
curl -s "$BASE/api/components?category=CPU&search=Xeon&has_price=true" | jq '.components[].lot_name'
|
||||||
|
curl -s "$BASE/api/components?category=MEM&search=32GB&has_price=true" | jq '.components[].lot_name'
|
||||||
|
curl -s "$BASE/api/components?category=PSU&search=800W&has_price=true" | jq '.components[].lot_name'
|
||||||
|
|
||||||
|
# 5. Validate the BOM (must contain MB, CPU, MEM, PSU/PS)
|
||||||
|
curl -s -X POST $BASE/api/quote/validate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"pricelist_id\": $PRICELIST_ID,
|
||||||
|
\"items\": [
|
||||||
|
{\"lot_name\": \"MB-X13DAI-N\", \"quantity\": 1},
|
||||||
|
{\"lot_name\": \"CPU-XEON-6342\", \"quantity\": 2},
|
||||||
|
{\"lot_name\": \"RAM-32GB-DDR4-3200\", \"quantity\": 8},
|
||||||
|
{\"lot_name\": \"PSU-800W-TITANIUM\", \"quantity\": 2}
|
||||||
|
]
|
||||||
|
}" | jq '{valid, errors, warnings, total}'
|
||||||
|
|
||||||
|
# 6. Save the configuration inside the project
|
||||||
|
curl -s -X POST "$BASE/api/projects/$PROJECT_UUID/configs" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"name\": \"Сервер по ТЗ №123\",
|
||||||
|
\"pricelist_id\": $PRICELIST_ID,
|
||||||
|
\"server_model\": \"2U\",
|
||||||
|
\"server_count\": 1,
|
||||||
|
\"config_type\": \"server\",
|
||||||
|
\"items\": [
|
||||||
|
{\"lot_name\": \"MB-X13DAI-N\", \"quantity\": 1, \"unit_price\": 95000},
|
||||||
|
{\"lot_name\": \"CPU-XEON-6342\", \"quantity\": 2, \"unit_price\": 87500},
|
||||||
|
{\"lot_name\": \"RAM-32GB-DDR4-3200\", \"quantity\": 8, \"unit_price\": 12000},
|
||||||
|
{\"lot_name\": \"PSU-800W-TITANIUM\", \"quantity\": 2, \"unit_price\": 18500}
|
||||||
|
]
|
||||||
|
}" | jq '{uuid, total_price, article}'
|
||||||
|
```
|
||||||
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.
|
||||||
@@ -14,6 +14,8 @@ Project-specific architecture and operational contracts.
|
|||||||
| [06-backup.md](06-backup.md) | Backup contract and restore workflow |
|
| [06-backup.md](06-backup.md) | Backup contract and restore workflow |
|
||||||
| [07-dev.md](07-dev.md) | Development commands and guardrails |
|
| [07-dev.md](07-dev.md) | Development commands and guardrails |
|
||||||
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
|
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
|
||||||
|
| [10-agent-api-guide.md](10-agent-api-guide.md) | End-to-end API guide for agents pricing servers from a TZ |
|
||||||
|
| [11-lot-suggestions.md](11-lot-suggestions.md) | lot_suggestion column in qt_vendor_partnumber_seen — write/read contract for manual UI mappings |
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
|||||||
31
bible-local/decisions/README.md
Normal file
31
bible-local/decisions/README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Architectural Decision Log
|
||||||
|
|
||||||
|
One file per decision, named `YYYY-MM-DD-short-topic.md`.
|
||||||
|
|
||||||
|
Write a new entry when:
|
||||||
|
- Choosing between non-obvious implementation approaches.
|
||||||
|
- Intentionally rejecting a feature or pattern.
|
||||||
|
- A bug causes a rule change.
|
||||||
|
- Freezing or deprecating something.
|
||||||
|
|
||||||
|
Format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Decision: <short title>
|
||||||
|
|
||||||
|
**Date:** YYYY-MM-DD
|
||||||
|
**Status:** active | superseded by YYYY-MM-DD-topic.md
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Situation making this decision necessary.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
What was decided, stated clearly.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
What this means going forward; what is forbidden or required.
|
||||||
|
```
|
||||||
|
|
||||||
|
When a decision is superseded: add "superseded by" to the old file and create the new one.
|
||||||
|
Do NOT delete old entries.
|
||||||
|
Record the decision in the SAME COMMIT as the implementation code.
|
||||||
86
bible-local/runtime-flows.md
Normal file
86
bible-local/runtime-flows.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Runtime Flows
|
||||||
|
|
||||||
|
Critical mutation paths, deduplication logic, and cross-entity side effects.
|
||||||
|
Update this file in the same commit as any change to the flows below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Configuration save (create/update)
|
||||||
|
|
||||||
|
1. Handler receives JSON body; validates via `ShouldBindJSON`.
|
||||||
|
2. `LocalConfigurationService.Create` or `Update` is called.
|
||||||
|
3. Service computes `total_price` from `req.Items.Total()` (sum of `unit_price * quantity` per item).
|
||||||
|
4. A new revision snapshot is created via `createWithVersion`; revision number increments.
|
||||||
|
5. `quoteService.RecordUsage` is called best-effort (warn on failure, do not abort save).
|
||||||
|
6. Configuration row written to SQLite (`local_configurations`); version row appended to `local_configuration_versions`.
|
||||||
|
7. Pending change queued in `pending_changes` for later sync push.
|
||||||
|
|
||||||
|
**DO NOT** read prices from `local_components` during save - prices must already be on items.
|
||||||
|
**DO NOT** skip version creation on rename/reorder/project-move - those operations call different paths that must NOT call `createWithVersion`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Refresh prices (POST /api/configs/:uuid/refresh-prices)
|
||||||
|
|
||||||
|
1. Handler calls `LocalConfigurationService.RefreshPricesNoAuth(uuid, pricelistServerID)`.
|
||||||
|
2. If online, `SyncPricelistsIfNeeded` runs best-effort (warn on failure, do not block).
|
||||||
|
3. Resolves target pricelist in order:
|
||||||
|
a. Explicitly requested pricelist (`pricelistServerID` param).
|
||||||
|
b. Pricelist stored in configuration row (`localCfg.PricelistID`).
|
||||||
|
c. Latest local pricelist as fallback.
|
||||||
|
4. For each item in the config, looks up price from `local_pricelist_items` via `GetLocalPricesForLots` (batch, single query).
|
||||||
|
5. Items with matching prices are updated; items with no price keep their existing `unit_price`.
|
||||||
|
6. Updated configuration saved as a new version (same flow as §1 from step 4 onward).
|
||||||
|
|
||||||
|
**DO NOT** read prices from `qt_pricelist_items` (MariaDB) directly - prices come from SQLite cache only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Pricelist sync (POST /api/sync/pricelists)
|
||||||
|
|
||||||
|
1. Readiness guard checked; returns 423 if guard blocks sync.
|
||||||
|
2. `SyncService.SyncPricelists` pulls from `qt_pricelists` and `qt_pricelist_items` (MariaDB).
|
||||||
|
3. For each pricelist: header upserted first, then items replaced atomically via `ReplaceLocalPricelistItems`.
|
||||||
|
4. After all pricelists: `RecalculateAllLocalPricelistUsage` marks which pricelists are referenced by active configurations.
|
||||||
|
5. Sync result (status, error, timestamp) written to `app_settings` via `SetPricelistSyncResult`.
|
||||||
|
|
||||||
|
**DO NOT** write pricelist header without items in the same transaction - must be atomic.
|
||||||
|
**DO NOT** query MariaDB from runtime handlers outside sync/setup flows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Vendor spec apply (POST /api/configs/:uuid/vendor-spec/apply)
|
||||||
|
|
||||||
|
1. Incoming `items[]` (lot_name, quantity, unit_price) replace the configuration's `items` entirely.
|
||||||
|
2. New item list saved through `LocalConfigurationService.UpdateItemsNoAuth`.
|
||||||
|
3. A new revision is created reflecting the BOM-derived item state.
|
||||||
|
|
||||||
|
**DO NOT** apply vendor spec without going through the service layer - handler must not write items directly to DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Configuration versioning invariants
|
||||||
|
|
||||||
|
- `local_configuration_versions` is append-only; rows are never updated or deleted.
|
||||||
|
- Version deduplication: if new snapshot hash equals current head, no new version is created.
|
||||||
|
- Rollback = create new HEAD revision from old snapshot data (does not restore version pointer to old row).
|
||||||
|
- UI must always show "main" (implicit head) as the active state; never point to a numbered revision after save.
|
||||||
|
- Operations that do NOT create a new version: rename, reorder within project, project move, pricelist selector change only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Pending changes queue
|
||||||
|
|
||||||
|
- Every local write (create/update/delete) appends a row to `pending_changes`.
|
||||||
|
- `POST /api/sync/push` drains the queue by writing to MariaDB.
|
||||||
|
- If a push fails, `increment_attempts` and `last_error` are updated; row stays in queue.
|
||||||
|
- `RepairPendingChanges` reconciles orphaned changes (configuration/project deleted locally).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Error handling boundary rules
|
||||||
|
|
||||||
|
- Handlers: log 500 responses with `slog.Error`; surface error message via `RespondError`.
|
||||||
|
- Services: wrap errors with `fmt.Errorf("context: %w", err)`; do NOT log inside service.
|
||||||
|
- Repositories: return raw errors; no logging.
|
||||||
|
- Best-effort operations (usage stats, background sync): log `slog.Warn` and continue.
|
||||||
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`.
|
||||||
@@ -2,8 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||||
@@ -153,7 +153,7 @@ func main() {
|
|||||||
log.Printf(" Skipped: %d", skipped)
|
log.Printf(" Skipped: %d", skipped)
|
||||||
log.Printf(" Errors: %d", errors)
|
log.Printf(" Errors: %d", errors)
|
||||||
|
|
||||||
fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs")
|
slog.Info("Done! You can now run the server with: go run ./cmd/qfs")
|
||||||
}
|
}
|
||||||
|
|
||||||
func derefUint(v *uint) uint {
|
func derefUint(v *uint) uint {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -79,12 +80,12 @@ func main() {
|
|||||||
|
|
||||||
printPlan(actions)
|
printPlan(actions)
|
||||||
if len(actions) == 0 {
|
if len(actions) == 0 {
|
||||||
fmt.Println("Nothing to migrate.")
|
slog.Info("Nothing to migrate.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !*apply {
|
if !*apply {
|
||||||
fmt.Println("\nPreview complete. Re-run with -apply to execute.")
|
slog.Info("Preview complete. Re-run with -apply to execute.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ func main() {
|
|||||||
log.Fatalf("confirmation failed: %v", confirmErr)
|
log.Fatalf("confirmation failed: %v", confirmErr)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
fmt.Println("Aborted.")
|
slog.Info("Aborted.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +104,7 @@ func main() {
|
|||||||
log.Fatalf("migration failed: %v", err)
|
log.Fatalf("migration failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Migration completed successfully.")
|
slog.Info("Migration completed successfully.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureProjectsTable(db *gorm.DB) error {
|
func ensureProjectsTable(db *gorm.DB) error {
|
||||||
@@ -212,10 +213,8 @@ func printPlan(actions []migrationAction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Planned actions: %d\n", len(actions))
|
slog.Info("Plan summary", "actions", len(actions), "create", createCount, "reactivate", reactivateCount)
|
||||||
fmt.Printf("Projects to create: %d\n", createCount)
|
slog.Info("Details:")
|
||||||
fmt.Printf("Projects to reactivate: %d\n", reactivateCount)
|
|
||||||
fmt.Println("\nDetails:")
|
|
||||||
|
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
extra := ""
|
extra := ""
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ func printPlan(plan []updatePlanRow, apply bool) {
|
|||||||
log.Printf("%s [%s] local=%s server=%s", row.Code, variant, formatStamp(row.LocalUpdatedAt), formatStamp(row.ServerUpdatedAt))
|
log.Printf("%s [%s] local=%s server=%s", row.Code, variant, formatStamp(row.LocalUpdatedAt), formatStamp(row.ServerUpdatedAt))
|
||||||
}
|
}
|
||||||
if !apply {
|
if !apply {
|
||||||
fmt.Println("Re-run with -apply to write server updated_at into local SQLite.")
|
slog.Info("Re-run with -apply to write server updated_at into local SQLite.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -289,8 +289,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func showStartupConsoleWarning() {
|
func showStartupConsoleWarning() {
|
||||||
// Visible in console output.
|
slog.Warn(startupConsoleWarning)
|
||||||
fmt.Println(startupConsoleWarning)
|
|
||||||
// Keep the warning always visible in the console window title when supported.
|
// Keep the warning always visible in the console window title when supported.
|
||||||
fmt.Printf("\033]0;%s\007", startupConsoleWarning)
|
fmt.Printf("\033]0;%s\007", startupConsoleWarning)
|
||||||
}
|
}
|
||||||
@@ -678,8 +677,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
var projectService *services.ProjectService
|
var projectService *services.ProjectService
|
||||||
|
|
||||||
syncService = sync.NewService(connMgr, local)
|
syncService = sync.NewService(connMgr, local)
|
||||||
componentService := services.NewComponentService(nil, nil, nil)
|
componentService := services.NewComponentService(nil, nil)
|
||||||
quoteService := services.NewQuoteService(nil, nil, nil, local, nil)
|
quoteService := services.NewQuoteService(nil, nil, local, nil)
|
||||||
exportService := services.NewExportService(cfg.Export, local)
|
exportService := services.NewExportService(cfg.Export, local)
|
||||||
|
|
||||||
// isOnline function for local-first architecture
|
// isOnline function for local-first architecture
|
||||||
@@ -780,7 +779,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||||
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
|
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
|
||||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||||
vendorSpecHandler := handlers.NewVendorSpecHandler(local)
|
vendorSpecHandler := handlers.NewVendorSpecHandler(local, syncService)
|
||||||
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
|
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
|
||||||
respondError := handlers.RespondError
|
respondError := handlers.RespondError
|
||||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||||
@@ -920,6 +919,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
// Categories (public)
|
// Categories (public)
|
||||||
api.GET("/categories", componentHandler.GetCategories)
|
api.GET("/categories", componentHandler.GetCategories)
|
||||||
|
api.GET("/configurator-settings", componentHandler.GetConfiguratorSettings)
|
||||||
|
|
||||||
// Quote (public)
|
// Quote (public)
|
||||||
quote := api.Group("/quote")
|
quote := api.Group("/quote")
|
||||||
@@ -952,6 +952,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
|
pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stateless BOM text parsing shared by paste and file-import paths.
|
||||||
|
api.POST("/vendor-spec/parse-text", vendorSpecHandler.ParseText)
|
||||||
|
|
||||||
// Configurations (public - RBAC disabled)
|
// Configurations (public - RBAC disabled)
|
||||||
configs := api.Group("/configs")
|
configs := api.Group("/configs")
|
||||||
{
|
{
|
||||||
@@ -1726,7 +1729,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
|
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !services.IsCFXMLWorkspace(data) && !services.IsQuoteForgeCSV(data) && !services.IsInspurBOM(data) {
|
if !services.IsCFXMLWorkspace(data) && !services.IsQuoteForgeCSV(data) && !services.IsInspurBOM(data) && !services.IsTextBOM(data) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
60
internal/db/validate.go
Normal file
60
internal/db/validate.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gormmysql "gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errPermissionProbeRollback = errors.New("permission probe rollback")
|
||||||
|
|
||||||
|
// ValidateMariaDBConnection opens a one-off connection using dsn, pings, checks
|
||||||
|
// the required lot table exists, and probes write access to qt_client_schema_state.
|
||||||
|
// Returns (lot row count, canWrite, error).
|
||||||
|
func ValidateMariaDBConnection(dsn string) (int64, bool, error) {
|
||||||
|
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, fmt.Errorf("open MariaDB connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, fmt.Errorf("get database handle: %w", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
return 0, false, fmt.Errorf("ping MariaDB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lotCount int64
|
||||||
|
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
|
||||||
|
return 0, false, fmt.Errorf("check required table lot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lotCount, testSyncWritePermission(db), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSyncWritePermission(db *gorm.DB) bool {
|
||||||
|
sentinel := fmt.Sprintf("quoteforge-permission-check-%d", time.Now().UnixNano())
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO qt_client_schema_state (username, hostname, last_checked_at, updated_at)
|
||||||
|
VALUES (?, ?, NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_checked_at = VALUES(last_checked_at),
|
||||||
|
updated_at = VALUES(updated_at)
|
||||||
|
`, sentinel, "setup-check").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errPermissionProbeRollback
|
||||||
|
})
|
||||||
|
|
||||||
|
return errors.Is(err, errPermissionProbeRollback)
|
||||||
|
}
|
||||||
@@ -64,11 +64,16 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, &services.ComponentListResult{
|
c.JSON(http.StatusOK, &services.ComponentListResult{
|
||||||
Components: components,
|
Items: components,
|
||||||
Total: total,
|
TotalCount: total,
|
||||||
Page: page,
|
Page: page,
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
|
TotalPages: totalPages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,3 +125,102 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, models.DefaultCategories)
|
c.JSON(http.StatusOK, models.DefaultCategories)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ComponentHandler) GetConfiguratorSettings(c *gin.Context) {
|
||||||
|
s, _ := h.localDB.GetConfiguratorSettings()
|
||||||
|
if s == nil {
|
||||||
|
s = &localdb.ConfiguratorSettings{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.ConfigTypes) == 0 {
|
||||||
|
s.ConfigTypes = defaultConfigTypes()
|
||||||
|
}
|
||||||
|
if len(s.TabConfig) == 0 {
|
||||||
|
s.TabConfig = defaultTabConfig()
|
||||||
|
}
|
||||||
|
if len(s.AlwaysVisibleTabs) == 0 {
|
||||||
|
s.AlwaysVisibleTabs = []string{"base", "storage", "pci"}
|
||||||
|
}
|
||||||
|
if len(s.RequiredCategories) == 0 {
|
||||||
|
s.RequiredCategories = map[string][]string{"server": {"CPU", "MEM", "BB"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfigTypes() []localdb.ConfigTypeDef {
|
||||||
|
return []localdb.ConfigTypeDef{
|
||||||
|
{
|
||||||
|
Code: "server",
|
||||||
|
NameRu: "Сервер",
|
||||||
|
DisplayOrder: 10,
|
||||||
|
Categories: []string{
|
||||||
|
"MB", "CPU", "MEM", "RAID",
|
||||||
|
"SSD", "HDD", "M2", "EDSFF", "HHHL",
|
||||||
|
"GPU", "NIC", "HCA", "DPU", "HBA",
|
||||||
|
"PSU", "PS", "ACC", "RISERS", "CARD", "BB",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Code: "storage",
|
||||||
|
NameRu: "СХД",
|
||||||
|
DisplayOrder: 20,
|
||||||
|
Categories: []string{
|
||||||
|
"DKC", "CPU", "MEM", "PS",
|
||||||
|
"SSD", "HDD", "M2", "EDSFF", "HHHL",
|
||||||
|
"NIC", "HBA", "HCA", "ACC", "CARD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultTabConfig() []localdb.TabDef {
|
||||||
|
return []localdb.TabDef{
|
||||||
|
{
|
||||||
|
Key: "base",
|
||||||
|
Label: "Base",
|
||||||
|
SingleSelect: true,
|
||||||
|
Categories: []string{"MB", "CPU", "MEM", "ENC", "DKC", "CTL"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "storage",
|
||||||
|
Label: "Storage",
|
||||||
|
SingleSelect: false,
|
||||||
|
Categories: []string{"RAID", "M2", "SSD", "HDD", "EDSFF", "HHHL"},
|
||||||
|
Sections: []localdb.TabSection{
|
||||||
|
{Title: "RAID Контроллеры", Categories: []string{"RAID"}},
|
||||||
|
{Title: "Диски", Categories: []string{"M2", "SSD", "HDD", "EDSFF", "HHHL"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "pci",
|
||||||
|
Label: "PCI",
|
||||||
|
SingleSelect: false,
|
||||||
|
Categories: []string{"GPU", "DPU", "NIC", "HCA", "HBA", "HIC"},
|
||||||
|
Sections: []localdb.TabSection{
|
||||||
|
{Title: "GPU / DPU", Categories: []string{"GPU", "DPU"}},
|
||||||
|
{Title: "NIC / HCA", Categories: []string{"NIC", "HCA"}},
|
||||||
|
{Title: "HBA", Categories: []string{"HBA"}},
|
||||||
|
{Title: "HIC", Categories: []string{"HIC"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "power",
|
||||||
|
Label: "Power",
|
||||||
|
SingleSelect: false,
|
||||||
|
Categories: []string{"PS", "PSU"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "accessories",
|
||||||
|
Label: "Accessories",
|
||||||
|
SingleSelect: false,
|
||||||
|
Categories: []string{"ACC", "CARD"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "sw",
|
||||||
|
Label: "SW",
|
||||||
|
SingleSelect: false,
|
||||||
|
Categories: []string{"SW"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ type ProjectExportOptionsRequest struct {
|
|||||||
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||||
var req ExportRequest
|
var req ExportRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
|
|
||||||
// Validate before streaming (can return JSON error)
|
// Validate before streaming (can return JSON error)
|
||||||
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no items to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
// Get config before streaming (can return JSON error)
|
// Get config before streaming (can return JSON error)
|
||||||
config, err := h.configService.GetByUUID(uuid, h.dbUsername)
|
config, err := h.configService.GetByUUIDNoAuth(uuid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RespondError(c, http.StatusNotFound, "resource not found", err)
|
RespondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
return
|
return
|
||||||
@@ -160,7 +160,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
|
|
||||||
// Validate before streaming (can return JSON error)
|
// Validate before streaming (can return JSON error)
|
||||||
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no items to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Configs) == 0 {
|
if len(result.Configs) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,11 +228,11 @@ func (h *ExportHandler) ExportConfigPricingCSV(c *gin.Context) {
|
|||||||
|
|
||||||
var req ProjectExportOptionsRequest
|
var req ProjectExportOptionsRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := h.configService.GetByUUID(uuid, h.dbUsername)
|
config, err := h.configService.GetByUUIDNoAuth(uuid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RespondError(c, http.StatusNotFound, "resource not found", err)
|
RespondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
return
|
return
|
||||||
@@ -285,7 +285,7 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
|||||||
|
|
||||||
var req ProjectExportOptionsRequest
|
var req ProjectExportOptionsRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(result.Configs) == 0 {
|
if len(result.Configs) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*model
|
|||||||
return m.config, m.err
|
return m.config, m.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockConfigService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||||
|
return m.config, m.err
|
||||||
|
}
|
||||||
|
|
||||||
func TestExportCSV_Success(t *testing.T) {
|
func TestExportCSV_Success(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
@@ -124,8 +128,8 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
|
|||||||
handler.ExportCSV(c)
|
handler.ExportCSV(c)
|
||||||
|
|
||||||
// Should return 400 Bad Request
|
// Should return 400 Bad Request
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
t.Errorf("Expected status 400, got %d", w.Code)
|
t.Errorf("Expected status 422, got %d", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should return JSON error
|
// Should return JSON error
|
||||||
@@ -158,8 +162,8 @@ func TestExportCSV_EmptyItems(t *testing.T) {
|
|||||||
handler.ExportCSV(c)
|
handler.ExportCSV(c)
|
||||||
|
|
||||||
// Should return 400 Bad Request (validation error from gin binding)
|
// Should return 400 Bad Request (validation error from gin binding)
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
|
t.Logf("Status code: %d (expected 422 for empty items)", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,8 +294,8 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
|||||||
handler.ExportConfigCSV(c)
|
handler.ExportConfigCSV(c)
|
||||||
|
|
||||||
// Should return 400 Bad Request
|
// Should return 400 Bad Request
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
t.Errorf("Expected status 400, got %d", w.Code)
|
t.Errorf("Expected status 422, got %d", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should return JSON error
|
// Should return JSON error
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ func (h *PartnumberBooksHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"books": summaries,
|
"items": summaries,
|
||||||
"total": len(summaries),
|
"total_count": len(summaries),
|
||||||
|
"page": 1,
|
||||||
|
"per_page": len(summaries),
|
||||||
|
"total_pages": 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +65,7 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
|||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid book ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +80,8 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
|||||||
perPage = 100
|
perPage = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find local book by server_id
|
book, err := h.localDB.GetLocalPartnumberBookByServerID(uint(id))
|
||||||
var book localdb.LocalPartnumberBook
|
if err != nil {
|
||||||
if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -90,15 +92,20 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"book_id": book.ServerID,
|
"book_id": book.ServerID,
|
||||||
"version": book.Version,
|
"version": book.Version,
|
||||||
"is_active": book.IsActive,
|
"is_active": book.IsActive,
|
||||||
"partnumbers": book.PartnumbersJSON,
|
"partnumbers": book.PartnumbersJSON,
|
||||||
"items": items,
|
"items": items,
|
||||||
"total": total,
|
"total_count": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
|
"total_pages": totalPages,
|
||||||
"search": search,
|
"search": search,
|
||||||
"book_total": bookRepo.CountBookItems(book.ID),
|
"book_total": bookRepo.CountBookItems(book.ID),
|
||||||
"lot_count": bookRepo.CountDistinctLots(book.ID),
|
"lot_count": bookRepo.CountDistinctLots(book.ID),
|
||||||
|
|||||||
@@ -106,11 +106,16 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"pricelists": summaries,
|
"items": summaries,
|
||||||
"total": total,
|
"total_count": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
|
"total_pages": totalPages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +124,7 @@ func (h *PricelistHandler) Get(c *gin.Context) {
|
|||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +151,7 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
|||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,40 +170,21 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
|||||||
if perPage < 1 {
|
if perPage < 1 {
|
||||||
perPage = 50
|
perPage = 50
|
||||||
}
|
}
|
||||||
var items []localdb.LocalPricelistItem
|
|
||||||
dbq := h.localDB.DB().Model(&localdb.LocalPricelistItem{}).Where("pricelist_id = ?", localPL.ID)
|
|
||||||
if strings.TrimSpace(search) != "" {
|
|
||||||
dbq = dbq.Where("lot_name LIKE ?", "%"+strings.TrimSpace(search)+"%")
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
if err := dbq.Count(&total).Error; err != nil {
|
|
||||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
offset := (page - 1) * perPage
|
|
||||||
|
|
||||||
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
|
items, total, err := h.localDB.GetLocalPricelistItemsPage(localPL.ID, strings.TrimSpace(search), page, perPage)
|
||||||
|
if err != nil {
|
||||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lotNames := make([]string, len(items))
|
lotNames := make([]string, len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
lotNames[i] = item.LotName
|
lotNames[i] = item.LotName
|
||||||
}
|
}
|
||||||
type compRow struct {
|
descMap, err := h.localDB.GetLocalComponentDescriptionsByLotNames(lotNames)
|
||||||
LotName string
|
if err != nil {
|
||||||
LotDescription string
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
return
|
||||||
var comps []compRow
|
|
||||||
if len(lotNames) > 0 {
|
|
||||||
h.localDB.DB().Table("local_components").
|
|
||||||
Select("lot_name, lot_description").
|
|
||||||
Where("lot_name IN ?", lotNames).
|
|
||||||
Scan(&comps)
|
|
||||||
}
|
|
||||||
descMap := make(map[string]string, len(comps))
|
|
||||||
for _, c := range comps {
|
|
||||||
descMap[c.LotName] = c.LotDescription
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resultItems := make([]gin.H, 0, len(items))
|
resultItems := make([]gin.H, 0, len(items))
|
||||||
@@ -217,12 +203,14 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"source": localPL.Source,
|
"source": localPL.Source,
|
||||||
"items": resultItems,
|
"items": resultItems,
|
||||||
"total": total,
|
"total_count": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
|
"total_pages": totalPages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +218,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
|
|||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,21 +141,21 @@ func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Pricelists []struct {
|
Items []struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
} `json:"pricelists"`
|
} `json:"items"`
|
||||||
Total int `json:"total"`
|
TotalCount int `json:"total_count"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("unmarshal response: %v", err)
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
}
|
}
|
||||||
if resp.Total != 1 {
|
if resp.TotalCount != 1 {
|
||||||
t.Fatalf("expected total=1, got %d", resp.Total)
|
t.Fatalf("expected total=1, got %d", resp.TotalCount)
|
||||||
}
|
}
|
||||||
if len(resp.Pricelists) != 1 {
|
if len(resp.Items) != 1 {
|
||||||
t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists))
|
t.Fatalf("expected 1 pricelist, got %d", len(resp.Items))
|
||||||
}
|
}
|
||||||
if resp.Pricelists[0].ID != 10 {
|
if resp.Items[0].ID != 10 {
|
||||||
t.Fatalf("expected pricelist id=10, got %d", resp.Pricelists[0].ID)
|
t.Fatalf("expected pricelist id=10, got %d", resp.Items[0].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ func NewQuoteHandler(quoteService *services.QuoteService) *QuoteHandler {
|
|||||||
func (h *QuoteHandler) Validate(c *gin.Context) {
|
func (h *QuoteHandler) Validate(c *gin.Context) {
|
||||||
var req services.QuoteRequest
|
var req services.QuoteRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.quoteService.ValidateAndCalculate(&req)
|
result, err := h.quoteService.ValidateAndCalculate(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,13 +34,13 @@ func (h *QuoteHandler) Validate(c *gin.Context) {
|
|||||||
func (h *QuoteHandler) Calculate(c *gin.Context) {
|
func (h *QuoteHandler) Calculate(c *gin.Context) {
|
||||||
var req services.QuoteRequest
|
var req services.QuoteRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.quoteService.ValidateAndCalculate(&req)
|
result, err := h.quoteService.ValidateAndCalculate(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +53,13 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
|
|||||||
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
|
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
|
||||||
var req services.PriceLevelsRequest
|
var req services.PriceLevelsRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.quoteService.CalculatePriceLevels(&req)
|
result, err := h.quoteService.CalculatePriceLevels(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -15,9 +14,6 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||||
gormmysql "gorm.io/driver/mysql"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SetupHandler struct {
|
type SetupHandler struct {
|
||||||
@@ -27,8 +23,6 @@ type SetupHandler struct {
|
|||||||
restartSig chan struct{}
|
restartSig chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var errPermissionProbeRollback = errors.New("permission probe rollback")
|
|
||||||
|
|
||||||
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
|
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"sub": func(a, b int) int { return a - b },
|
"sub": func(a, b int) int { return a - b },
|
||||||
@@ -93,7 +87,7 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
||||||
lotCount, canWrite, err := validateMariaDBConnection(dsn)
|
lotCount, canWrite, err := db.ValidateMariaDBConnection(dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -135,7 +129,7 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
|||||||
|
|
||||||
// Test connection first
|
// Test connection first
|
||||||
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
||||||
if _, _, err := validateMariaDBConnection(dsn); err != nil {
|
if _, _, err := db.ValidateMariaDBConnection(dsn); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -214,46 +208,3 @@ func buildMySQLDSN(host string, port int, database, user, password string, timeo
|
|||||||
return cfg.FormatDSN()
|
return cfg.FormatDSN()
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateMariaDBConnection(dsn string) (int64, bool, error) {
|
|
||||||
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return 0, false, fmt.Errorf("open MariaDB connection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
return 0, false, fmt.Errorf("get database handle: %w", err)
|
|
||||||
}
|
|
||||||
defer sqlDB.Close()
|
|
||||||
|
|
||||||
if err := sqlDB.Ping(); err != nil {
|
|
||||||
return 0, false, fmt.Errorf("ping MariaDB: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var lotCount int64
|
|
||||||
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
|
|
||||||
return 0, false, fmt.Errorf("check required table lot: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lotCount, testSyncWritePermission(db), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSyncWritePermission(db *gorm.DB) bool {
|
|
||||||
sentinel := fmt.Sprintf("quoteforge-permission-check-%d", time.Now().UnixNano())
|
|
||||||
err := db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Exec(`
|
|
||||||
INSERT INTO qt_client_schema_state (username, hostname, last_checked_at, updated_at)
|
|
||||||
VALUES (?, ?, NOW(), NOW())
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
last_checked_at = VALUES(last_checked_at),
|
|
||||||
updated_at = VALUES(updated_at)
|
|
||||||
`, sentinel, "setup-check").Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return errPermissionProbeRollback
|
|
||||||
})
|
|
||||||
|
|
||||||
return errors.Is(err, errPermissionProbeRollback)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -39,7 +40,10 @@ func NewSupportBundleHandler(local *localdb.LocalDB, connMgr *db.ConnectionManag
|
|||||||
// GET /api/support-bundle
|
// GET /api/support-bundle
|
||||||
func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
hostname, _ := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("support bundle: could not get hostname", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
c.Header("Content-Type", "application/zip")
|
c.Header("Content-Type", "application/zip")
|
||||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="qfs-bundle-%s.zip"`, now.Format("20060102-150405")))
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="qfs-bundle-%s.zip"`, now.Format("20060102-150405")))
|
||||||
@@ -153,8 +157,10 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// schema_migrations.json
|
// schema_migrations.json
|
||||||
var migrations []localdb.LocalSchemaMigration
|
migrations, err := h.localDB.GetSchemaMigrations()
|
||||||
_ = h.localDB.DB().Order("applied_at ASC").Find(&migrations).Error
|
if err != nil {
|
||||||
|
slog.Warn("support bundle: could not load schema migrations", "err", err)
|
||||||
|
}
|
||||||
writeJSON("schema_migrations.json", migrations)
|
writeJSON("schema_migrations.json", migrations)
|
||||||
|
|
||||||
// app.log (tail 5 MiB)
|
// app.log (tail 5 MiB)
|
||||||
@@ -169,7 +175,9 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if _, err := f.Seek(offset, io.SeekStart); err == nil {
|
if _, err := f.Seek(offset, io.SeekStart); err == nil {
|
||||||
if w, err := zw.Create("app.log"); err == nil {
|
if w, err := zw.Create("app.log"); err == nil {
|
||||||
_, _ = io.Copy(w, f)
|
if _, err := io.Copy(w, f); err != nil {
|
||||||
|
slog.Warn("support bundle: error copying log file", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,10 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
|||||||
_ = h.localDB.SetComponentSyncResult("ok", "", now)
|
_ = h.localDB.SetComponentSyncResult("ok", "", now)
|
||||||
h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds())
|
h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds())
|
||||||
|
|
||||||
|
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
|
||||||
|
slog.Warn("qt_settings sync failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncResultResponse{
|
c.JSON(http.StatusOK, SyncResultResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Components synced successfully",
|
Message: "Components synced successfully",
|
||||||
@@ -232,6 +236,10 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
h.localDB.AppendSyncLog("pricelists", "ok", "", synced, startTime, time.Since(startTime).Milliseconds())
|
h.localDB.AppendSyncLog("pricelists", "ok", "", synced, startTime, time.Since(startTime).Milliseconds())
|
||||||
|
|
||||||
|
if _, err := h.syncService.PullPartnumberBooks(); err != nil {
|
||||||
|
slog.Warn("partnumber books pull failed after pricelist sync", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncResultResponse{
|
c.JSON(http.StatusOK, SyncResultResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Pricelists synced successfully",
|
Message: "Pricelists synced successfully",
|
||||||
@@ -335,6 +343,10 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds())
|
h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds())
|
||||||
componentsSynced = compResult.TotalSynced
|
componentsSynced = compResult.TotalSynced
|
||||||
|
|
||||||
|
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
|
||||||
|
slog.Warn("qt_settings sync failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Sync pricelists
|
// Sync pricelists
|
||||||
plNow := time.Now()
|
plNow := time.Now()
|
||||||
pricelistsSynced, err = h.syncService.SyncPricelists()
|
pricelistsSynced, err = h.syncService.SyncPricelists()
|
||||||
@@ -352,6 +364,10 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
h.localDB.AppendSyncLog("pricelists", "ok", "", pricelistsSynced, plNow, time.Since(plNow).Milliseconds())
|
h.localDB.AppendSyncLog("pricelists", "ok", "", pricelistsSynced, plNow, time.Since(plNow).Milliseconds())
|
||||||
|
|
||||||
|
if _, err := h.syncService.PullPartnumberBooks(); err != nil {
|
||||||
|
slog.Warn("partnumber books pull failed during full sync", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
projectsResult, err := h.syncService.ImportProjectsToLocal()
|
projectsResult, err := h.syncService.ImportProjectsToLocal()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("project import failed during full sync", "error", err)
|
slog.Error("project import failed during full sync", "error", err)
|
||||||
@@ -739,7 +755,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
|
|||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
|
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,12 +17,14 @@ import (
|
|||||||
type VendorSpecHandler struct {
|
type VendorSpecHandler struct {
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
configService *services.LocalConfigurationService
|
configService *services.LocalConfigurationService
|
||||||
|
syncService *syncsvc.Service // optional; nil = no server push
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
func NewVendorSpecHandler(localDB *localdb.LocalDB, syncService *syncsvc.Service) *VendorSpecHandler {
|
||||||
return &VendorSpecHandler{
|
return &VendorSpecHandler{
|
||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
|
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
|
||||||
|
syncService: syncService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +40,28 @@ func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfigurati
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseText parses a pasted single-column text BOM (Inspur or Russian text BOM)
|
||||||
|
// using the same parsers as the vendor file-import path. It is stateless: no
|
||||||
|
// configuration is required. Returns the parsed rows and the detected format, or
|
||||||
|
// an empty result when the text is not a recognized single-column format (the
|
||||||
|
// client then falls back to manual column mapping).
|
||||||
|
// POST /api/vendor-spec/parse-text
|
||||||
|
func (h *VendorSpecHandler) ParseText(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, format := services.ParsePastedBOMText(body.Text)
|
||||||
|
if rows == nil {
|
||||||
|
rows = []localdb.VendorSpecItem{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"rows": rows, "format": format})
|
||||||
|
}
|
||||||
|
|
||||||
// GetVendorSpec returns the vendor spec (BOM) for a configuration.
|
// GetVendorSpec returns the vendor spec (BOM) for a configuration.
|
||||||
// GET /api/configs/:uuid/vendor-spec
|
// GET /api/configs/:uuid/vendor-spec
|
||||||
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
|
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
|
||||||
@@ -65,7 +91,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
|||||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +114,57 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push manual lot mappings as suggestions to the server (best-effort, non-blocking).
|
||||||
|
h.pushLotSuggestions(body.VendorSpec)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pushLotSuggestions sends manual PN→LOT mappings to qt_vendor_partnumber_seen.lot_suggestion.
|
||||||
|
// Errors are logged and silently dropped — they must not affect the HTTP response.
|
||||||
|
func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
|
||||||
|
if h.syncService == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []syncsvc.SeenPartnumber
|
||||||
|
for _, row := range spec {
|
||||||
|
if row.VendorPartnumber == "" || len(row.LotMappings) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
suggestion := make([]syncsvc.LotSuggestionEntry, 0, len(row.LotMappings))
|
||||||
|
for _, m := range row.LotMappings {
|
||||||
|
if m.LotName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty := m.QuantityPerPN
|
||||||
|
if qty < 1 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
suggestion = append(suggestion, syncsvc.LotSuggestionEntry{
|
||||||
|
LotName: m.LotName,
|
||||||
|
Qty: qty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(suggestion) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, syncsvc.SeenPartnumber{
|
||||||
|
Partnumber: row.VendorPartnumber,
|
||||||
|
Description: row.Description,
|
||||||
|
LotSuggestion: suggestion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.syncService.PushPartnumberSeen(items); err != nil {
|
||||||
|
slog.Warn("vendor_spec: failed to push lot suggestions to server", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||||
if len(in) == 0 {
|
if len(in) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -136,7 +210,7 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
|||||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +223,11 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
book, _ := bookRepo.GetActiveBook()
|
book, err := bookRepo.GetActiveBook()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("vendor spec resolve: no active partnumber book", "err", err)
|
||||||
|
book = nil
|
||||||
|
}
|
||||||
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
|
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
@@ -179,7 +257,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
|
|||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
|
|||||||
&PendingChange{},
|
&PendingChange{},
|
||||||
&LocalPartnumberBook{},
|
&LocalPartnumberBook{},
|
||||||
&SyncLogEntry{},
|
&SyncLogEntry{},
|
||||||
|
&LocalQtSetting{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +498,10 @@ func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string
|
|||||||
// HasSettings returns true if connection settings exist
|
// HasSettings returns true if connection settings exist
|
||||||
func (l *LocalDB) HasSettings() bool {
|
func (l *LocalDB) HasSettings() bool {
|
||||||
var count int64
|
var count int64
|
||||||
l.db.Model(&ConnectionSettings{}).Count(&count)
|
if err := l.db.Model(&ConnectionSettings{}).Count(&count).Error; err != nil {
|
||||||
|
slog.Error("localdb: HasSettings count failed", "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,14 +1048,18 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
|
|||||||
// CountConfigurations returns the number of local configurations
|
// CountConfigurations returns the number of local configurations
|
||||||
func (l *LocalDB) CountConfigurations() int64 {
|
func (l *LocalDB) CountConfigurations() int64 {
|
||||||
var count int64
|
var count int64
|
||||||
l.db.Model(&LocalConfiguration{}).Count(&count)
|
if err := l.db.Model(&LocalConfiguration{}).Count(&count).Error; err != nil {
|
||||||
|
slog.Error("localdb: CountConfigurations failed", "err", err)
|
||||||
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountProjects returns the number of local projects
|
// CountProjects returns the number of local projects
|
||||||
func (l *LocalDB) CountProjects() int64 {
|
func (l *LocalDB) CountProjects() int64 {
|
||||||
var count int64
|
var count int64
|
||||||
l.db.Model(&LocalProject{}).Count(&count)
|
if err := l.db.Model(&LocalProject{}).Count(&count).Error; err != nil {
|
||||||
|
slog.Error("localdb: CountProjects failed", "err", err)
|
||||||
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1819,3 +1827,62 @@ func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requi
|
|||||||
}),
|
}),
|
||||||
}).Create(state).Error
|
}).Create(state).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocalPartnumberBookByServerID returns a local partnumber book by its server-side ID.
|
||||||
|
func (l *LocalDB) GetLocalPartnumberBookByServerID(serverID uint) (*LocalPartnumberBook, error) {
|
||||||
|
var book LocalPartnumberBook
|
||||||
|
if err := l.db.Where("server_id = ?", serverID).First(&book).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &book, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalPricelistItemsPage returns a paginated, searchable list of items for a pricelist.
|
||||||
|
func (l *LocalDB) GetLocalPricelistItemsPage(pricelistID uint, search string, page, perPage int) ([]LocalPricelistItem, int64, error) {
|
||||||
|
dbq := l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID)
|
||||||
|
if search != "" {
|
||||||
|
dbq = dbq.Where("lot_name LIKE ?", "%"+search+"%")
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := dbq.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
var items []LocalPricelistItem
|
||||||
|
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("fetch pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
var migrations []LocalSchemaMigration
|
||||||
|
if err := l.db.Order("applied_at ASC").Find(&migrations).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch schema migrations: %w", err)
|
||||||
|
}
|
||||||
|
return migrations, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -356,3 +356,12 @@ func (v *VendorSpec) Scan(value interface{}) error {
|
|||||||
}
|
}
|
||||||
return json.Unmarshal(bytes, v)
|
return json.Unmarshal(bytes, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LocalQtSetting caches server-pushed settings from qt_settings (MariaDB) into local SQLite.
|
||||||
|
// Synced during component sync. Each row is a JSON-valued setting identified by name.
|
||||||
|
type LocalQtSetting struct {
|
||||||
|
Name string `gorm:"primaryKey;size:100"`
|
||||||
|
Value string `gorm:"type:text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LocalQtSetting) TableName() string { return "local_qt_settings" }
|
||||||
|
|||||||
122
internal/localdb/qt_settings.go
Normal file
122
internal/localdb/qt_settings.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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. Returns an error if
|
||||||
|
// the qt_settings table doesn't exist on the server (old server without the
|
||||||
|
// table) or on any query/write failure.
|
||||||
|
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 {
|
||||||
|
return fmt.Errorf("reading qt_settings from MariaDB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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,93 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql/driver"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AlertHighDemandStalePrice AlertType = "high_demand_stale_price"
|
|
||||||
AlertPriceSpike AlertType = "price_spike"
|
|
||||||
AlertPriceDrop AlertType = "price_drop"
|
|
||||||
AlertNoRecentQuotes AlertType = "no_recent_quotes"
|
|
||||||
AlertTrendingNoPrice AlertType = "trending_no_price"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertSeverity string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SeverityLow AlertSeverity = "low"
|
|
||||||
SeverityMedium AlertSeverity = "medium"
|
|
||||||
SeverityHigh AlertSeverity = "high"
|
|
||||||
SeverityCritical AlertSeverity = "critical"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AlertStatusNew AlertStatus = "new"
|
|
||||||
AlertStatusAcknowledged AlertStatus = "acknowledged"
|
|
||||||
AlertStatusResolved AlertStatus = "resolved"
|
|
||||||
AlertStatusIgnored AlertStatus = "ignored"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertDetails map[string]interface{}
|
|
||||||
|
|
||||||
func (d AlertDetails) Value() (driver.Value, error) {
|
|
||||||
return json.Marshal(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *AlertDetails) Scan(value interface{}) error {
|
|
||||||
if value == nil {
|
|
||||||
*d = make(AlertDetails)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
bytes, ok := value.([]byte)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("type assertion to []byte failed")
|
|
||||||
}
|
|
||||||
return json.Unmarshal(bytes, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
type PricingAlert struct {
|
|
||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
|
||||||
LotName string `gorm:"size:255;not null" json:"lot_name"`
|
|
||||||
AlertType AlertType `gorm:"type:enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price');not null" json:"alert_type"`
|
|
||||||
Severity AlertSeverity `gorm:"type:enum('low','medium','high','critical');default:'medium'" json:"severity"`
|
|
||||||
Message string `gorm:"type:text;not null" json:"message"`
|
|
||||||
Details AlertDetails `gorm:"type:json" json:"details"`
|
|
||||||
Status AlertStatus `gorm:"type:enum('new','acknowledged','resolved','ignored');default:'new'" json:"status"`
|
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (PricingAlert) TableName() string {
|
|
||||||
return "qt_pricing_alerts"
|
|
||||||
}
|
|
||||||
|
|
||||||
type TrendDirection string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TrendUp TrendDirection = "up"
|
|
||||||
TrendStable TrendDirection = "stable"
|
|
||||||
TrendDown TrendDirection = "down"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ComponentUsageStats struct {
|
|
||||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
|
||||||
QuotesTotal int `gorm:"default:0" json:"quotes_total"`
|
|
||||||
QuotesLast30d int `gorm:"default:0" json:"quotes_last_30d"`
|
|
||||||
QuotesLast7d int `gorm:"default:0" json:"quotes_last_7d"`
|
|
||||||
TotalQuantity int `gorm:"default:0" json:"total_quantity"`
|
|
||||||
TotalRevenue float64 `gorm:"type:decimal(14,2);default:0" json:"total_revenue"`
|
|
||||||
TrendDirection TrendDirection `gorm:"type:enum('up','stable','down');default:'stable'" json:"trend_direction"`
|
|
||||||
TrendPercent float64 `gorm:"type:decimal(5,2);default:0" json:"trend_percent"`
|
|
||||||
LastUsedAt *time.Time `json:"last_used_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ComponentUsageStats) TableName() string {
|
|
||||||
return "qt_component_usage_stats"
|
|
||||||
}
|
|
||||||
@@ -124,16 +124,3 @@ func (Configuration) TableName() string {
|
|||||||
return "qt_configurations"
|
return "qt_configurations"
|
||||||
}
|
}
|
||||||
|
|
||||||
type PriceOverride struct {
|
|
||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
|
||||||
LotName string `gorm:"size:255;not null" json:"lot_name"`
|
|
||||||
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
|
||||||
ValidFrom time.Time `gorm:"type:date;not null" json:"valid_from"`
|
|
||||||
ValidUntil *time.Time `gorm:"type:date" json:"valid_until"`
|
|
||||||
Reason string `gorm:"type:text" json:"reason"`
|
|
||||||
CreatedBy uint `gorm:"not null" json:"created_by"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (PriceOverride) TableName() string {
|
|
||||||
return "qt_price_overrides"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// Lot represents existing lot table
|
// Lot represents existing lot table
|
||||||
type Lot struct {
|
type Lot struct {
|
||||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||||
@@ -12,43 +10,3 @@ type Lot struct {
|
|||||||
func (Lot) TableName() string {
|
func (Lot) TableName() string {
|
||||||
return "lot"
|
return "lot"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supplier represents existing supplier table (READ-ONLY)
|
|
||||||
type Supplier struct {
|
|
||||||
SupplierName string `gorm:"column:supplier_name;primaryKey;size:255"`
|
|
||||||
SupplierComment string `gorm:"column:supplier_comment;size:10000"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (Supplier) TableName() string {
|
|
||||||
return "supplier"
|
|
||||||
}
|
|
||||||
|
|
||||||
// StockLog stores warehouse stock snapshots imported from external files.
|
|
||||||
type StockLog struct {
|
|
||||||
StockLogID uint `gorm:"column:stock_log_id;primaryKey;autoIncrement"`
|
|
||||||
Partnumber string `gorm:"column:partnumber;size:255;not null"`
|
|
||||||
Supplier *string `gorm:"column:supplier;size:255"`
|
|
||||||
Date time.Time `gorm:"column:date;type:date;not null"`
|
|
||||||
Price float64 `gorm:"column:price;not null"`
|
|
||||||
Quality *string `gorm:"column:quality;size:255"`
|
|
||||||
Comments *string `gorm:"column:comments;size:15000"`
|
|
||||||
Vendor *string `gorm:"column:vendor;size:255"`
|
|
||||||
Qty *float64 `gorm:"column:qty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (StockLog) TableName() string {
|
|
||||||
return "stock_log"
|
|
||||||
}
|
|
||||||
|
|
||||||
// StockIgnoreRule contains import ignore pattern rules.
|
|
||||||
type StockIgnoreRule struct {
|
|
||||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
|
||||||
Target string `gorm:"column:target;size:20;not null" json:"target"` // partnumber|description
|
|
||||||
MatchType string `gorm:"column:match_type;size:20;not null" json:"match_type"` // exact|prefix|suffix
|
|
||||||
Pattern string `gorm:"column:pattern;size:500;not null" json:"pattern"`
|
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (StockIgnoreRule) TableName() string {
|
|
||||||
return "stock_ignore_rules"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ func AllModels() []interface{} {
|
|||||||
&LotMetadata{},
|
&LotMetadata{},
|
||||||
&Project{},
|
&Project{},
|
||||||
&Configuration{},
|
&Configuration{},
|
||||||
&PriceOverride{},
|
|
||||||
&PricingAlert{},
|
|
||||||
&ComponentUsageStats{},
|
|
||||||
&Pricelist{},
|
&Pricelist{},
|
||||||
&PricelistItem{},
|
&PricelistItem{},
|
||||||
}
|
}
|
||||||
|
|||||||
10
internal/models/qt_setting.go
Normal file
10
internal/models/qt_setting.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// QtSetting is the MariaDB-side model for qt_settings.
|
||||||
|
// The table is managed by the server-side agent; QF only reads from it.
|
||||||
|
type QtSetting struct {
|
||||||
|
Name string `gorm:"primaryKey;size:100" json:"name"`
|
||||||
|
Value string `gorm:"type:text" json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (QtSetting) TableName() string { return "qt_settings" }
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAlertRepository(db *gorm.DB) *AlertRepository {
|
|
||||||
return &AlertRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) Create(alert *models.PricingAlert) error {
|
|
||||||
return r.db.Create(alert).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) GetByID(id uint) (*models.PricingAlert, error) {
|
|
||||||
var alert models.PricingAlert
|
|
||||||
err := r.db.First(&alert, id).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &alert, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) Update(alert *models.PricingAlert) error {
|
|
||||||
return r.db.Save(alert).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertFilter struct {
|
|
||||||
Status models.AlertStatus
|
|
||||||
Severity models.AlertSeverity
|
|
||||||
Type models.AlertType
|
|
||||||
LotName string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) List(filter AlertFilter, offset, limit int) ([]models.PricingAlert, int64, error) {
|
|
||||||
var alerts []models.PricingAlert
|
|
||||||
var total int64
|
|
||||||
|
|
||||||
query := r.db.Model(&models.PricingAlert{})
|
|
||||||
|
|
||||||
if filter.Status != "" {
|
|
||||||
query = query.Where("status = ?", filter.Status)
|
|
||||||
}
|
|
||||||
if filter.Severity != "" {
|
|
||||||
query = query.Where("severity = ?", filter.Severity)
|
|
||||||
}
|
|
||||||
if filter.Type != "" {
|
|
||||||
query = query.Where("alert_type = ?", filter.Type)
|
|
||||||
}
|
|
||||||
if filter.LotName != "" {
|
|
||||||
query = query.Where("lot_name = ?", filter.LotName)
|
|
||||||
}
|
|
||||||
|
|
||||||
query.Count(&total)
|
|
||||||
|
|
||||||
err := query.
|
|
||||||
Order("FIELD(severity, 'critical', 'high', 'medium', 'low')").
|
|
||||||
Order("created_at DESC").
|
|
||||||
Offset(offset).
|
|
||||||
Limit(limit).
|
|
||||||
Find(&alerts).Error
|
|
||||||
|
|
||||||
return alerts, total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) CountByStatus(status models.AlertStatus) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
err := r.db.Model(&models.PricingAlert{}).
|
|
||||||
Where("status = ?", status).
|
|
||||||
Count(&count).Error
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) UpdateStatus(id uint, status models.AlertStatus) error {
|
|
||||||
return r.db.Model(&models.PricingAlert{}).
|
|
||||||
Where("id = ?", id).
|
|
||||||
Update("status", status).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) ExistsByLotAndType(lotName string, alertType models.AlertType) (bool, error) {
|
|
||||||
var count int64
|
|
||||||
err := r.db.Model(&models.PricingAlert{}).
|
|
||||||
Where("lot_name = ? AND alert_type = ? AND status IN ('new', 'acknowledged')", lotName, alertType).
|
|
||||||
Count(&count).Error
|
|
||||||
return count > 0, err
|
|
||||||
}
|
|
||||||
@@ -157,7 +157,7 @@ func (r *PartnumberBookRepository) listCatalogItems(partnumbers localdb.LocalStr
|
|||||||
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers))
|
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers))
|
||||||
if search != "" {
|
if search != "" {
|
||||||
trimmedSearch := "%" + search + "%"
|
trimmedSearch := "%" + search + "%"
|
||||||
query = query.Where("partnumber LIKE ? OR lots_json LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
|
query = query.Where("partnumber LIKE ? OR CAST(lots_json AS TEXT) LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
|
||||||
}
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.StockLog{}); err != nil {
|
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}); err != nil {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
return NewPricelistRepository(db)
|
return NewPricelistRepository(db)
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StatsRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStatsRepository(db *gorm.DB) *StatsRepository {
|
|
||||||
return &StatsRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *StatsRepository) GetByLotName(lotName string) (*models.ComponentUsageStats, error) {
|
|
||||||
var stats models.ComponentUsageStats
|
|
||||||
err := r.db.Where("lot_name = ?", lotName).First(&stats).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *StatsRepository) Upsert(stats *models.ComponentUsageStats) error {
|
|
||||||
return r.db.Save(stats).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *StatsRepository) IncrementUsage(lotName string, quantity int, revenue float64) error {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
result := r.db.Model(&models.ComponentUsageStats{}).
|
|
||||||
Where("lot_name = ?", lotName).
|
|
||||||
Updates(map[string]interface{}{
|
|
||||||
"quotes_total": gorm.Expr("quotes_total + 1"),
|
|
||||||
"quotes_last_30d": gorm.Expr("quotes_last_30d + 1"),
|
|
||||||
"quotes_last_7d": gorm.Expr("quotes_last_7d + 1"),
|
|
||||||
"total_quantity": gorm.Expr("total_quantity + ?", quantity),
|
|
||||||
"total_revenue": gorm.Expr("total_revenue + ?", revenue),
|
|
||||||
"last_used_at": now,
|
|
||||||
})
|
|
||||||
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
stats := &models.ComponentUsageStats{
|
|
||||||
LotName: lotName,
|
|
||||||
QuotesTotal: 1,
|
|
||||||
QuotesLast30d: 1,
|
|
||||||
QuotesLast7d: 1,
|
|
||||||
TotalQuantity: quantity,
|
|
||||||
TotalRevenue: revenue,
|
|
||||||
LastUsedAt: &now,
|
|
||||||
}
|
|
||||||
return r.db.Create(stats).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *StatsRepository) GetTopComponents(limit int) ([]models.ComponentUsageStats, error) {
|
|
||||||
var stats []models.ComponentUsageStats
|
|
||||||
err := r.db.
|
|
||||||
Order("quotes_last_30d DESC").
|
|
||||||
Limit(limit).
|
|
||||||
Find(&stats).Error
|
|
||||||
return stats, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *StatsRepository) GetTrendingComponents(limit int) ([]models.ComponentUsageStats, error) {
|
|
||||||
var stats []models.ComponentUsageStats
|
|
||||||
err := r.db.
|
|
||||||
Where("trend_direction = ? AND trend_percent > ?", models.TrendUp, 20).
|
|
||||||
Order("trend_percent DESC").
|
|
||||||
Limit(limit).
|
|
||||||
Find(&stats).Error
|
|
||||||
return stats, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetWeeklyCounters resets quotes_last_7d (run weekly via cron)
|
|
||||||
func (r *StatsRepository) ResetWeeklyCounters() error {
|
|
||||||
return r.db.Model(&models.ComponentUsageStats{}).
|
|
||||||
Where("1 = 1").
|
|
||||||
Update("quotes_last_7d", 0).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetMonthlyCounters resets quotes_last_30d (run monthly via cron)
|
|
||||||
func (r *StatsRepository) ResetMonthlyCounters() error {
|
|
||||||
return r.db.Model(&models.ComponentUsageStats{}).
|
|
||||||
Where("1 = 1").
|
|
||||||
Update("quotes_last_30d", 0).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
@@ -11,18 +12,15 @@ import (
|
|||||||
type ComponentService struct {
|
type ComponentService struct {
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
categoryRepo *repository.CategoryRepository
|
categoryRepo *repository.CategoryRepository
|
||||||
statsRepo *repository.StatsRepository
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewComponentService(
|
func NewComponentService(
|
||||||
componentRepo *repository.ComponentRepository,
|
componentRepo *repository.ComponentRepository,
|
||||||
categoryRepo *repository.CategoryRepository,
|
categoryRepo *repository.CategoryRepository,
|
||||||
statsRepo *repository.StatsRepository,
|
|
||||||
) *ComponentService {
|
) *ComponentService {
|
||||||
return &ComponentService{
|
return &ComponentService{
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
categoryRepo: categoryRepo,
|
categoryRepo: categoryRepo,
|
||||||
statsRepo: statsRepo,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +39,11 @@ func ParsePartNumber(lotName string) (category, model string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ComponentListResult struct {
|
type ComponentListResult struct {
|
||||||
Components []ComponentView `json:"components"`
|
Items []ComponentView `json:"items"`
|
||||||
Total int64 `json:"total"`
|
TotalCount int64 `json:"total_count"`
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
PerPage int `json:"per_page"`
|
PerPage int `json:"per_page"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ComponentView struct {
|
type ComponentView struct {
|
||||||
@@ -63,10 +62,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
|||||||
// Components should be loaded via /api/sync/components first
|
// Components should be loaded via /api/sync/components first
|
||||||
if s.componentRepo == nil {
|
if s.componentRepo == nil {
|
||||||
return &ComponentListResult{
|
return &ComponentListResult{
|
||||||
Components: []ComponentView{},
|
Items: []ComponentView{},
|
||||||
Total: 0,
|
TotalCount: 0,
|
||||||
Page: page,
|
Page: page,
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
|
TotalPages: 1,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,11 +107,16 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
|||||||
views[i] = view
|
views[i] = view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
return &ComponentListResult{
|
return &ComponentListResult{
|
||||||
Components: views,
|
Items: views,
|
||||||
Total: total,
|
TotalCount: total,
|
||||||
Page: page,
|
Page: page,
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
|
TotalPages: totalPages,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +131,10 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track usage
|
// Track usage (best-effort)
|
||||||
_ = s.componentRepo.IncrementRequestCount(lotName)
|
if err := s.componentRepo.IncrementRequestCount(lotName); err != nil {
|
||||||
|
slog.Warn("component: could not increment request count", "lot", lotName, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
view := &ComponentView{
|
view := &ComponentView{
|
||||||
LotName: c.LotName,
|
LotName: c.LotName,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ var (
|
|||||||
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
|
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
|
||||||
type ConfigurationGetter interface {
|
type ConfigurationGetter interface {
|
||||||
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
|
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
|
||||||
|
GetByUUIDNoAuth(uuid string) (*models.Configuration, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigurationService struct {
|
type ConfigurationService struct {
|
||||||
@@ -116,9 +117,6 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record usage stats
|
|
||||||
_ = s.quoteService.RecordUsage(req.Items)
|
|
||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,13 +53,14 @@ type ProjectExportData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectPricingExportOptions struct {
|
type ProjectPricingExportOptions struct {
|
||||||
IncludeLOT bool `json:"include_lot"`
|
IncludeLOT bool `json:"include_lot"`
|
||||||
IncludeBOM bool `json:"include_bom"`
|
IncludeBOM bool `json:"include_bom"`
|
||||||
IncludeEstimate bool `json:"include_estimate"`
|
IncludeEstimate bool `json:"include_estimate"`
|
||||||
IncludeStock bool `json:"include_stock"`
|
IncludeStock bool `json:"include_stock"`
|
||||||
IncludeCompetitor bool `json:"include_competitor"`
|
IncludeCompetitor bool `json:"include_competitor"`
|
||||||
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
|
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
|
||||||
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||||
|
ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
|
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
|
||||||
@@ -87,14 +88,15 @@ type ProjectPricingExportConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectPricingExportRow struct {
|
type ProjectPricingExportRow struct {
|
||||||
LotDisplay string
|
LotDisplay string
|
||||||
VendorPN string
|
VendorPN string
|
||||||
Description string
|
Description string
|
||||||
Quantity int
|
Quantity int
|
||||||
BOMTotal *float64
|
BOMTotal *float64
|
||||||
Estimate *float64
|
Estimate *float64
|
||||||
Stock *float64
|
Stock *float64
|
||||||
Competitor *float64
|
Competitor *float64
|
||||||
|
ManualPrice *float64 // proportional share of the user-defined total price
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCSV writes project export data in the new structured CSV format.
|
// ToCSV writes project export data in the new structured CSV format.
|
||||||
@@ -388,17 +390,37 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
description = componentDescriptions[rowMappings[0].LotName]
|
description = componentDescriptions[rowMappings[0].LotName]
|
||||||
}
|
}
|
||||||
|
|
||||||
pricingRow := ProjectPricingExportRow{
|
if len(rowMappings) == 0 {
|
||||||
LotDisplay: formatLotDisplay(rowMappings),
|
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||||
VendorPN: row.VendorPartnumber,
|
LotDisplay: "н/д",
|
||||||
Description: description,
|
VendorPN: row.VendorPartnumber,
|
||||||
Quantity: exportPositiveInt(row.Quantity, 1),
|
Description: description,
|
||||||
BOMTotal: vendorRowTotal(row),
|
Quantity: exportPositiveInt(row.Quantity, 1),
|
||||||
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
|
BOMTotal: vendorRowTotal(row),
|
||||||
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
|
})
|
||||||
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// One export row per LOT mapping so that bundles (1 PN → N LOTs) appear
|
||||||
|
// as separate lines, matching the frontend pricing table layout.
|
||||||
|
pnQty := exportPositiveInt(row.Quantity, 1)
|
||||||
|
for i, mapping := range rowMappings {
|
||||||
|
lotQty := pnQty * mapping.QuantityPerPN
|
||||||
|
var bomTotal *float64
|
||||||
|
if i == 0 {
|
||||||
|
bomTotal = vendorRowTotal(row)
|
||||||
|
}
|
||||||
|
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||||
|
LotDisplay: mapping.LotName,
|
||||||
|
VendorPN: row.VendorPartnumber,
|
||||||
|
Description: description,
|
||||||
|
Quantity: lotQty,
|
||||||
|
BOMTotal: bomTotal,
|
||||||
|
Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }),
|
||||||
|
Stock: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Stock }),
|
||||||
|
Competitor: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Competitor }),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
block.Rows = append(block.Rows, pricingRow)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range cfg.Items {
|
for _, item := range cfg.Items {
|
||||||
@@ -422,10 +444,22 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
if opts.isDDP() {
|
if opts.isDDP() {
|
||||||
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||||
}
|
}
|
||||||
|
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||||
|
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||||
|
}
|
||||||
return block, nil
|
return block, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
catOrder := defaultCategoryOrder()
|
||||||
|
lotNames := make([]string, 0, len(cfg.Items))
|
||||||
for _, item := range cfg.Items {
|
for _, item := range cfg.Items {
|
||||||
|
if item.LotName != "" {
|
||||||
|
lotNames = append(lotNames, item.LotName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemCategories := s.resolveCategories(cfg.PricelistID, lotNames)
|
||||||
|
sortedItems := sortConfigItemsByCategoryMap(cfg.Items, catOrder, itemCategories)
|
||||||
|
for _, item := range sortedItems {
|
||||||
if item.LotName == "" {
|
if item.LotName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -444,10 +478,29 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
if opts.isDDP() {
|
if opts.isDDP() {
|
||||||
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||||
}
|
}
|
||||||
|
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||||
|
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||||
|
}
|
||||||
|
|
||||||
return block, nil
|
return block, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortConfigItemsByCategoryMap returns a copy of items sorted by category display order.
|
||||||
|
// categories maps lot_name → category code; catOrder maps category code → display order.
|
||||||
|
func sortConfigItemsByCategoryMap(items models.ConfigItems, catOrder map[string]int, categories map[string]string) models.ConfigItems {
|
||||||
|
sorted := make(models.ConfigItems, len(items))
|
||||||
|
copy(sorted, items)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
orderI, hasI := categoryDisplayOrder(catOrder, categories[sorted[i].LotName])
|
||||||
|
orderJ, hasJ := categoryDisplayOrder(catOrder, categories[sorted[j].LotName])
|
||||||
|
if hasI && hasJ {
|
||||||
|
return orderI < orderJ
|
||||||
|
}
|
||||||
|
return hasI && !hasJ
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
|
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
|
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
|
||||||
@@ -567,45 +620,52 @@ func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
estimatePrices := s.batchLookupPrices(estimateID, lots)
|
||||||
|
stockPrices := s.batchLookupPrices(warehouseID, lots)
|
||||||
|
competitorPrices := s.batchLookupPrices(competitorID, lots)
|
||||||
|
|
||||||
for _, lot := range lots {
|
for _, lot := range lots {
|
||||||
level := pricingLevels{}
|
level := pricingLevels{}
|
||||||
level.Estimate = s.lookupPricePointer(estimateID, lot)
|
if p, ok := estimatePrices[lot]; ok {
|
||||||
level.Stock = s.lookupPricePointer(warehouseID, lot)
|
level.Estimate = floatPtr(p)
|
||||||
level.Competitor = s.lookupPricePointer(competitorID, lot)
|
}
|
||||||
|
if p, ok := stockPrices[lot]; ok {
|
||||||
|
level.Stock = floatPtr(p)
|
||||||
|
}
|
||||||
|
if p, ok := competitorPrices[lot]; ok {
|
||||||
|
level.Competitor = floatPtr(p)
|
||||||
|
}
|
||||||
result[lot] = level
|
result[lot] = level
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 {
|
// batchLookupPrices fetches prices for all lots from a pricelist in a single query.
|
||||||
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" {
|
func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string) map[string]float64 {
|
||||||
|
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || len(lots) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
|
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
prices, err := s.localDB.GetLocalPricesForLots(localPL.ID, lots)
|
||||||
if err != nil || price <= 0 {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return floatPtr(price)
|
return prices
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
|
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
|
||||||
lots := collectPricingLots(cfg, localCfg, true)
|
lots := collectPricingLots(cfg, localCfg, true)
|
||||||
result := make(map[string]string, len(lots))
|
if s.localDB == nil || len(lots) == 0 {
|
||||||
if s.localDB == nil {
|
return map[string]string{}
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
for _, lot := range lots {
|
descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots)
|
||||||
component, err := s.localDB.GetLocalComponent(lot)
|
if err != nil {
|
||||||
if err != nil {
|
return map[string]string{}
|
||||||
continue
|
|
||||||
}
|
|
||||||
result[lot] = component.LotDescription
|
|
||||||
}
|
}
|
||||||
return result
|
return descriptions
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
||||||
@@ -689,6 +749,52 @@ func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.V
|
|||||||
return floatPtr(total)
|
return floatPtr(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// distributeManualPrice sets ManualPrice on each row proportionally based on the
|
||||||
|
// row's Estimate share. The last row with a price absorbs rounding remainder so
|
||||||
|
// the sum of ManualPrice values always equals manualPrice exactly.
|
||||||
|
func distributeManualPrice(rows []ProjectPricingExportRow, manualPrice float64) {
|
||||||
|
if manualPrice <= 0 || len(rows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totalEstimate := 0.0
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Estimate != nil && *row.Estimate > 0 {
|
||||||
|
totalEstimate += *row.Estimate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if totalEstimate <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastIdx := -1
|
||||||
|
for i, row := range rows {
|
||||||
|
if row.Estimate != nil && *row.Estimate > 0 {
|
||||||
|
lastIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assigned := 0.0
|
||||||
|
for i, row := range rows {
|
||||||
|
if row.Estimate == nil || *row.Estimate <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var share float64
|
||||||
|
if i == lastIdx {
|
||||||
|
share = math.Round((manualPrice-assigned)*100) / 100
|
||||||
|
} else {
|
||||||
|
share = math.Round((*row.Estimate/totalEstimate)*manualPrice*100) / 100
|
||||||
|
assigned += share
|
||||||
|
}
|
||||||
|
rows[i].ManualPrice = floatPtr(share)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeSingleLotTotal(priceMap map[string]pricingLevels, lotName string, qty int, selector func(pricingLevels) *float64) *float64 {
|
||||||
|
price := selector(priceMap[lotName])
|
||||||
|
if price == nil || *price <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return floatPtr(*price * float64(qty))
|
||||||
|
}
|
||||||
|
|
||||||
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
|
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
|
||||||
if unitPrice == nil || *unitPrice <= 0 {
|
if unitPrice == nil || *unitPrice <= 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -709,7 +815,7 @@ func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quanti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||||
headers := make([]string, 0, 8)
|
headers := make([]string, 0, 9)
|
||||||
headers = append(headers, "Line Item")
|
headers = append(headers, "Line Item")
|
||||||
if opts.IncludeLOT {
|
if opts.IncludeLOT {
|
||||||
headers = append(headers, "LOT")
|
headers = append(headers, "LOT")
|
||||||
@@ -727,11 +833,14 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
|||||||
if opts.IncludeCompetitor {
|
if opts.IncludeCompetitor {
|
||||||
headers = append(headers, "Конкуренты")
|
headers = append(headers, "Конкуренты")
|
||||||
}
|
}
|
||||||
|
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||||
|
headers = append(headers, "Ручная цена")
|
||||||
|
}
|
||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
|
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
|
||||||
record := make([]string, 0, 8)
|
record := make([]string, 0, 9)
|
||||||
record = append(record, "")
|
record = append(record, "")
|
||||||
if opts.IncludeLOT {
|
if opts.IncludeLOT {
|
||||||
record = append(record, emptyDash(row.LotDisplay))
|
record = append(record, emptyDash(row.LotDisplay))
|
||||||
@@ -753,11 +862,14 @@ func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions
|
|||||||
if opts.IncludeCompetitor {
|
if opts.IncludeCompetitor {
|
||||||
record = append(record, formatMoneyValue(row.Competitor))
|
record = append(record, formatMoneyValue(row.Competitor))
|
||||||
}
|
}
|
||||||
|
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||||
|
record = append(record, formatMoneyValue(row.ManualPrice))
|
||||||
|
}
|
||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
|
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
|
||||||
record := make([]string, 0, 8)
|
record := make([]string, 0, 9)
|
||||||
record = append(record, fmt.Sprintf("%d", cfg.Line))
|
record = append(record, fmt.Sprintf("%d", cfg.Line))
|
||||||
if opts.IncludeLOT {
|
if opts.IncludeLOT {
|
||||||
record = append(record, "")
|
record = append(record, "")
|
||||||
@@ -779,19 +891,12 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
|
|||||||
if opts.IncludeCompetitor {
|
if opts.IncludeCompetitor {
|
||||||
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
|
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
|
||||||
}
|
}
|
||||||
|
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||||
|
record = append(record, formatMoneyValue(opts.ManualPrice))
|
||||||
|
}
|
||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
|
|
||||||
switch len(mappings) {
|
|
||||||
case 0:
|
|
||||||
return "н/д"
|
|
||||||
case 1:
|
|
||||||
return mappings[0].LotName
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatMoneyValue(value *float64) string {
|
func formatMoneyValue(value *float64) string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -118,9 +119,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
}
|
}
|
||||||
cfg.Line = localCfg.Line
|
cfg.Line = localCfg.Line
|
||||||
|
|
||||||
// Record usage stats
|
|
||||||
_ = s.quoteService.RecordUsage(req.Items)
|
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +405,9 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
|
|
||||||
// Refresh local pricelists when online.
|
// Refresh local pricelists when online.
|
||||||
if s.isOnline() {
|
if s.isOnline() {
|
||||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||||
|
slog.Warn("local configuration: background pricelist sync failed", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the pricelist stored in the config; fall back to latest if unavailable.
|
// Use the pricelist stored in the config; fall back to latest if unavailable.
|
||||||
@@ -791,7 +791,9 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.isOnline() {
|
if s.isOnline() {
|
||||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||||
|
slog.Warn("local configuration: background pricelist sync failed", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve which pricelist to use:
|
// Resolve which pricelist to use:
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ var (
|
|||||||
|
|
||||||
type QuoteService struct {
|
type QuoteService struct {
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
statsRepo *repository.StatsRepository
|
|
||||||
pricelistRepo *repository.PricelistRepository
|
pricelistRepo *repository.PricelistRepository
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
pricingService priceResolver
|
pricingService priceResolver
|
||||||
@@ -34,14 +33,12 @@ type priceResolver interface {
|
|||||||
|
|
||||||
func NewQuoteService(
|
func NewQuoteService(
|
||||||
componentRepo *repository.ComponentRepository,
|
componentRepo *repository.ComponentRepository,
|
||||||
statsRepo *repository.StatsRepository,
|
|
||||||
pricelistRepo *repository.PricelistRepository,
|
pricelistRepo *repository.PricelistRepository,
|
||||||
localDB *localdb.LocalDB,
|
localDB *localdb.LocalDB,
|
||||||
pricingService priceResolver,
|
pricingService priceResolver,
|
||||||
) *QuoteService {
|
) *QuoteService {
|
||||||
return &QuoteService{
|
return &QuoteService{
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
statsRepo: statsRepo,
|
|
||||||
pricelistRepo: pricelistRepo,
|
pricelistRepo: pricelistRepo,
|
||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
pricingService: pricingService,
|
pricingService: pricingService,
|
||||||
@@ -504,18 +501,3 @@ func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordUsage records that components were used in a quote
|
|
||||||
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
|
|
||||||
if s.statsRepo == nil {
|
|
||||||
// Offline mode: usage stats are unavailable and should not block config saves.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range items {
|
|
||||||
revenue := item.UnitPrice * float64(item.Quantity)
|
|
||||||
if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
||||||
db := newPriceLevelsTestDB(t)
|
db := newPriceLevelsTestDB(t)
|
||||||
repo := repository.NewPricelistRepository(db)
|
repo := repository.NewPricelistRepository(db)
|
||||||
service := NewQuoteService(nil, nil, repo, nil, nil)
|
service := NewQuoteService(nil, repo, nil, nil)
|
||||||
|
|
||||||
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
|
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
|
||||||
_ = estimate
|
_ = estimate
|
||||||
@@ -57,7 +57,7 @@ func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
|||||||
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
|
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
|
||||||
db := newPriceLevelsTestDB(t)
|
db := newPriceLevelsTestDB(t)
|
||||||
repo := repository.NewPricelistRepository(db)
|
repo := repository.NewPricelistRepository(db)
|
||||||
service := NewQuoteService(nil, nil, repo, nil, nil)
|
service := NewQuoteService(nil, repo, nil, nil)
|
||||||
|
|
||||||
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
|
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
|
||||||
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
|
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
package sync
|
package sync
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SeenPartnumber represents an unresolved vendor partnumber to report.
|
// SeenPartnumber represents an unresolved vendor partnumber to report.
|
||||||
type SeenPartnumber struct {
|
type SeenPartnumber struct {
|
||||||
Partnumber string
|
Partnumber string
|
||||||
Description string
|
Description string
|
||||||
Ignored bool
|
Ignored bool
|
||||||
|
LotSuggestion []LotSuggestionEntry // optional; set when user manually mapped PN → LOT in UI
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotSuggestionEntry is one suggested LOT mapping for a vendor partnumber.
|
||||||
|
// JSON shape mirrors qt_partnumber_book_items.lots_json: {"lot_name", "qty"}.
|
||||||
|
type LotSuggestionEntry struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Qty int `json:"qty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
||||||
// Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
|
// When LotSuggestion is provided the column is updated too; if the column does not exist yet
|
||||||
|
// (migration pending) the write is retried without it and a warning is logged — the app never panics.
|
||||||
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -30,7 +41,43 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
|||||||
if item.Partnumber == "" {
|
if item.Partnumber == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err := mariaDB.Exec(`
|
|
||||||
|
if len(item.LotSuggestion) > 0 {
|
||||||
|
suggJSON, marshalErr := json.Marshal(item.LotSuggestion)
|
||||||
|
if marshalErr != nil {
|
||||||
|
slog.Error("partnumber_seen: failed to marshal lot_suggestion, skipping suggestion",
|
||||||
|
"partnumber", item.Partnumber, "error", marshalErr)
|
||||||
|
suggJSON = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if suggJSON != nil {
|
||||||
|
err = mariaDB.Exec(`
|
||||||
|
INSERT INTO qt_vendor_partnumber_seen
|
||||||
|
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
|
||||||
|
VALUES
|
||||||
|
('manual', '', ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
lot_suggestion = VALUES(lot_suggestion),
|
||||||
|
last_seen_at = NOW(3)
|
||||||
|
`, item.Partnumber, item.Description, item.Ignored, now, string(suggJSON)).Error
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column not yet migrated — fall through to insert without lot_suggestion.
|
||||||
|
if !isUnknownColumnError(err) {
|
||||||
|
slog.Error("partnumber_seen: failed to upsert with lot_suggestion",
|
||||||
|
"partnumber", item.Partnumber, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Warn("partnumber_seen: lot_suggestion column missing (migration pending), inserting without it",
|
||||||
|
"partnumber", item.Partnumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert without lot_suggestion (baseline behaviour or fallback).
|
||||||
|
err = mariaDB.Exec(`
|
||||||
INSERT INTO qt_vendor_partnumber_seen
|
INSERT INTO qt_vendor_partnumber_seen
|
||||||
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
||||||
VALUES
|
VALUES
|
||||||
@@ -40,10 +87,18 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
|||||||
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||||
// Continue with remaining items
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("partnumber_seen pushed to server", "count", len(items))
|
slog.Info("partnumber_seen pushed to server", "count", len(items))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isUnknownColumnError returns true when MariaDB reports that a column does not exist.
|
||||||
|
func isUnknownColumnError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "unknown column") || strings.Contains(msg, "1054")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sync
|
package sync
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -320,14 +321,37 @@ func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
|
|||||||
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
|
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
|
||||||
}
|
}
|
||||||
|
|
||||||
var pending localdb.PendingChange
|
var errored []localdb.PendingChange
|
||||||
if err := local.DB().
|
if err := local.DB().
|
||||||
Where("TRIM(COALESCE(last_error, '')) <> ''").
|
Where("TRIM(COALESCE(last_error, '')) <> ''").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
First(&pending).Error; err == nil {
|
Limit(20).
|
||||||
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
|
Find(&errored).Error; err != nil || len(errored) == 0 {
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, nil
|
|
||||||
|
type errorEntry struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
entries := make([]errorEntry, 0, len(errored))
|
||||||
|
for _, ch := range errored {
|
||||||
|
entries = append(entries, errorEntry{
|
||||||
|
Type: ch.EntityType,
|
||||||
|
UUID: ch.EntityUUID,
|
||||||
|
Op: ch.Operation,
|
||||||
|
Attempts: ch.Attempts,
|
||||||
|
Error: strings.TrimSpace(ch.LastError),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
detail, jsonErr := json.Marshal(entries)
|
||||||
|
if jsonErr != nil {
|
||||||
|
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(errored[0].LastError))
|
||||||
|
}
|
||||||
|
return optionalString("PENDING_CHANGE_ERROR"), optionalString(string(detail))
|
||||||
}
|
}
|
||||||
|
|
||||||
func optionalString(value string) *string {
|
func optionalString(value string) *string {
|
||||||
|
|||||||
@@ -851,6 +851,11 @@ func (s *Service) SyncPricelistsIfNeeded() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxPendingChangeAttempts is the number of failed attempts after which a pending change
|
||||||
|
// is considered unrecoverable and removed from the queue. Applies only to changes that
|
||||||
|
// fail with a non-transient error (e.g. corrupt payload, unknown operation).
|
||||||
|
const maxPendingChangeAttempts = 20
|
||||||
|
|
||||||
// PushPendingChanges pushes all pending changes to the server
|
// PushPendingChanges pushes all pending changes to the server
|
||||||
func (s *Service) PushPendingChanges() (int, error) {
|
func (s *Service) PushPendingChanges() (int, error) {
|
||||||
if _, err := s.EnsureReadinessForSync(); err != nil {
|
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||||
@@ -864,6 +869,14 @@ func (s *Service) PushPendingChanges() (int, error) {
|
|||||||
slog.Info("purged orphan configuration pending changes", "removed", removed)
|
slog.Info("purged orphan configuration pending changes", "removed", removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-repair locally-fixable problems (e.g. stale project references)
|
||||||
|
// before attempting to push, so that repaired changes succeed on this cycle.
|
||||||
|
if repaired, _, repairErr := s.localDB.RepairPendingChanges(); repairErr != nil {
|
||||||
|
slog.Warn("auto-repair of errored pending changes failed", "error", repairErr)
|
||||||
|
} else if repaired > 0 {
|
||||||
|
slog.Info("auto-repaired errored pending changes", "repaired", repaired)
|
||||||
|
}
|
||||||
|
|
||||||
changes, err := s.localDB.GetPendingChanges()
|
changes, err := s.localDB.GetPendingChanges()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("getting pending changes: %w", err)
|
return 0, fmt.Errorf("getting pending changes: %w", err)
|
||||||
@@ -884,8 +897,14 @@ func (s *Service) PushPendingChanges() (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
s.markConnectionBroken(err)
|
s.markConnectionBroken(err)
|
||||||
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
||||||
// Increment attempts
|
newAttempts := change.Attempts + 1
|
||||||
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
|
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
|
||||||
|
if newAttempts >= maxPendingChangeAttempts {
|
||||||
|
slog.Error("abandoning pending change after max attempts",
|
||||||
|
"id", change.ID, "type", change.EntityType, "op", change.Operation,
|
||||||
|
"attempts", newAttempts, "last_error", err.Error())
|
||||||
|
syncedIDs = append(syncedIDs, change.ID)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,7 +931,11 @@ func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
|
|||||||
case "configuration":
|
case "configuration":
|
||||||
return s.pushConfigurationChange(change)
|
return s.pushConfigurationChange(change)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown entity type: %s", change.EntityType)
|
// Unknown entity type: this change was queued by a newer or different build
|
||||||
|
// and cannot be processed. Remove it from the queue.
|
||||||
|
slog.Warn("dropping pending change with unknown entity type",
|
||||||
|
"id", change.ID, "type", change.EntityType, "op", change.Operation)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1045,7 +1068,10 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
|||||||
case "delete":
|
case "delete":
|
||||||
return s.pushConfigurationDelete(change)
|
return s.pushConfigurationDelete(change)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown operation: %s", change.Operation)
|
// Unknown operation: queued by a newer or different build. Drop from queue.
|
||||||
|
slog.Warn("dropping pending change with unknown operation",
|
||||||
|
"id", change.ID, "type", change.EntityType, "op", change.Operation)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,24 +1271,30 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
|||||||
|
|
||||||
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
|
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
|
||||||
if localErr != nil {
|
if localErr != nil {
|
||||||
return err
|
// Project not found locally either: stale reference (project was deleted).
|
||||||
|
// Fall through to system project so this configuration is not stuck forever.
|
||||||
|
slog.Warn("configuration references missing project, assigning to system project",
|
||||||
|
"cfg_uuid", cfg.UUID,
|
||||||
|
"project_uuid", *cfg.ProjectUUID,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
modelProject := localdb.LocalToProject(localProject)
|
||||||
|
if modelProject.OwnerUsername == "" {
|
||||||
|
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||||
|
}
|
||||||
|
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
||||||
|
return createErr
|
||||||
|
}
|
||||||
|
if modelProject.ID > 0 {
|
||||||
|
serverID := modelProject.ID
|
||||||
|
localProject.ServerID = &serverID
|
||||||
|
localProject.SyncStatus = "synced"
|
||||||
|
now := time.Now()
|
||||||
|
localProject.SyncedAt = &now
|
||||||
|
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
modelProject := localdb.LocalToProject(localProject)
|
|
||||||
if modelProject.OwnerUsername == "" {
|
|
||||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
|
||||||
}
|
|
||||||
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
|
||||||
return createErr
|
|
||||||
}
|
|
||||||
if modelProject.ID > 0 {
|
|
||||||
serverID := modelProject.ID
|
|
||||||
localProject.ServerID = &serverID
|
|
||||||
localProject.SyncStatus = "synced"
|
|
||||||
now := time.Now()
|
|
||||||
localProject.SyncedAt = &now
|
|
||||||
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
systemProject := &models.Project{}
|
systemProject := &models.Project{}
|
||||||
|
|||||||
@@ -100,5 +100,10 @@ func (w *Worker) runSync() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pull partnumber books together with pricelists
|
||||||
|
if _, err := w.service.PullPartnumberBooks(); err != nil {
|
||||||
|
w.logger.Warn("background sync: failed to pull partnumber books", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
w.logger.Info("background sync cycle completed")
|
w.logger.Info("background sync cycle completed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -134,6 +135,10 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
|||||||
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
|
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
|
||||||
case IsInspurBOM(data):
|
case IsInspurBOM(data):
|
||||||
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
|
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
|
||||||
|
case IsNxBOM(data):
|
||||||
|
workspace, err = parseNxBOM(data, filepath.Base(sourceFileName))
|
||||||
|
case IsTextBOM(data):
|
||||||
|
workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported vendor export format")
|
return nil, fmt.Errorf("unsupported vendor export format")
|
||||||
}
|
}
|
||||||
@@ -269,13 +274,17 @@ func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *loca
|
|||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(order)
|
sort.Strings(order)
|
||||||
|
|
||||||
|
var priceMap map[string]float64
|
||||||
|
if estimatePricelist != nil && local != nil && len(order) > 0 {
|
||||||
|
priceMap, _ = local.GetLocalPricesForLots(estimatePricelist.ID, order)
|
||||||
|
}
|
||||||
|
|
||||||
items := make(localdb.LocalConfigItems, 0, len(order))
|
items := make(localdb.LocalConfigItems, 0, len(order))
|
||||||
for _, lotName := range order {
|
for _, lotName := range order {
|
||||||
unitPrice := 0.0
|
unitPrice := 0.0
|
||||||
if estimatePricelist != nil && local != nil {
|
if priceMap != nil {
|
||||||
if price, err := local.GetLocalPriceForLot(estimatePricelist.ID, lotName); err == nil && price > 0 {
|
unitPrice = priceMap[lotName]
|
||||||
unitPrice = price
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
items = append(items, localdb.LocalConfigItem{
|
items = append(items, localdb.LocalConfigItem{
|
||||||
LotName: lotName,
|
LotName: lotName,
|
||||||
@@ -676,6 +685,211 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nxBOMItemLine matches a quantity-first BOM line: "<qty>x <description>"
|
||||||
|
// where the quantity prefix is digits followed immediately by "x" (case-insensitive).
|
||||||
|
// Parentheses, commas, and hyphens inside the description are preserved.
|
||||||
|
var nxBOMItemLine = regexp.MustCompile(`(?i)^(\d+)[xX]\s+(.+\S)\s*$`)
|
||||||
|
|
||||||
|
// IsNxBOM reports whether data looks like a quantity-first "Nx" BOM where each
|
||||||
|
// item line begins with "<qty>x <description>" (e.g. "2x Intel Xeon 8570 ...").
|
||||||
|
func IsNxBOM(data []byte) bool {
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
if nxBOMItemLine.MatchString(strings.TrimSpace(raw)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNxBOM parses a quantity-first "Nx" BOM into a single configuration.
|
||||||
|
// An optional header line ending with ", в составе:" supplies server_model and name.
|
||||||
|
// Each "<qty>x <description>" line becomes one vendor spec row; description is stored
|
||||||
|
// as both vendor_partnumber and description so rows resolve through the active
|
||||||
|
// partnumber book when matched and otherwise stay unresolved and editable in the UI.
|
||||||
|
func parseNxBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
rows := make([]localdb.VendorSpecItem, 0, len(lines))
|
||||||
|
sortOrder := 10
|
||||||
|
serverModel := ""
|
||||||
|
|
||||||
|
for _, raw := range lines {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
|
||||||
|
if fields := strings.Fields(m[1]); len(fields) > 0 {
|
||||||
|
serverModel = fields[len(fields)-1]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := nxBOMItemLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty, err := strconv.Atoi(m[1])
|
||||||
|
if err != nil || qty <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
description := strings.TrimSpace(m[2])
|
||||||
|
if description == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, localdb.VendorSpecItem{
|
||||||
|
SortOrder: sortOrder,
|
||||||
|
VendorPartnumber: description,
|
||||||
|
Quantity: qty,
|
||||||
|
Description: description,
|
||||||
|
})
|
||||||
|
sortOrder += 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, fmt.Errorf("Nx BOM has no importable rows")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := serverModel
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "Nx BOM Import"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &importedWorkspace{
|
||||||
|
SourceFormat: "Nx",
|
||||||
|
SourceFileName: sourceFileName,
|
||||||
|
Configurations: []importedConfiguration{
|
||||||
|
{
|
||||||
|
GroupID: "nx-0",
|
||||||
|
Name: name,
|
||||||
|
Line: 10,
|
||||||
|
ServerCount: 1,
|
||||||
|
ServerModel: serverModel,
|
||||||
|
Rows: rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// textBOMItemLine matches a human-readable BOM line of the form
|
||||||
|
// "<description> - <quantity> шт." where the separator may be a hyphen,
|
||||||
|
// en-dash or em-dash and the quantity may have an optional space before "шт".
|
||||||
|
// The quantity anchor at the end keeps internal hyphens/digits in the
|
||||||
|
// description (e.g. "8-GPU-2304GB") from being mistaken for the separator.
|
||||||
|
var textBOMItemLine = regexp.MustCompile(`(?i)^(.*\S)\s*[-–—]\s*(\d+)\s*шт\.?\s*$`)
|
||||||
|
|
||||||
|
// textBOMHeaderLine matches a configuration header ending with ", в составе:"
|
||||||
|
// regardless of the leading words (e.g. "Сервер <model>" or
|
||||||
|
// "Вычислительный GPU сервер <model>"). The captured group is everything before
|
||||||
|
// the comma; the model is its last whitespace-separated token.
|
||||||
|
var textBOMHeaderLine = regexp.MustCompile(`(?i)^(.*?)\s*,\s*в\s+составе`)
|
||||||
|
|
||||||
|
// ParsePastedBOMText detects and parses a single-column text BOM (Inspur or
|
||||||
|
// Russian text BOM) pasted into the configurator. It shares the same detectors
|
||||||
|
// and parsers as the vendor file-import path, so paste and upload behave
|
||||||
|
// identically. It returns the parsed vendor spec rows and the detected format,
|
||||||
|
// or (nil, "") when the text is not a recognized single-column format and the
|
||||||
|
// caller should fall back to manual column mapping.
|
||||||
|
func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) {
|
||||||
|
data := []byte(text)
|
||||||
|
var ws *importedWorkspace
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case IsInspurBOM(data):
|
||||||
|
ws, err = parseInspurBOM(data, "")
|
||||||
|
case IsNxBOM(data):
|
||||||
|
ws, err = parseNxBOM(data, "")
|
||||||
|
case IsTextBOM(data):
|
||||||
|
ws, err = parseTextBOM(data, "")
|
||||||
|
default:
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
if err != nil || ws == nil || len(ws.Configurations) == 0 {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
return ws.Configurations[0].Rows, ws.SourceFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTextBOM reports whether data looks like a human-readable Russian text BOM,
|
||||||
|
// i.e. it contains at least one "<description> - <quantity> шт." line.
|
||||||
|
func IsTextBOM(data []byte) bool {
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
if textBOMItemLine.MatchString(strings.TrimSpace(raw)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTextBOM parses a human-readable Russian text BOM into a single configuration.
|
||||||
|
// The optional "Сервер <model>, в составе:" header provides the configuration name and
|
||||||
|
// server model. Each "<description> - <quantity> шт." line becomes one vendor spec row.
|
||||||
|
// The format carries no partnumbers, so rows stay unresolved and editable in the UI
|
||||||
|
// until mapped through the active partnumber book.
|
||||||
|
func parseTextBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
rows := make([]localdb.VendorSpecItem, 0, len(lines))
|
||||||
|
sortOrder := 10
|
||||||
|
serverModel := ""
|
||||||
|
|
||||||
|
for _, raw := range lines {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
|
||||||
|
if fields := strings.Fields(m[1]); len(fields) > 0 {
|
||||||
|
serverModel = fields[len(fields)-1]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := textBOMItemLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
description := strings.TrimSpace(m[1])
|
||||||
|
qty, err := strconv.Atoi(m[2])
|
||||||
|
if err != nil || qty <= 0 || description == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, localdb.VendorSpecItem{
|
||||||
|
SortOrder: sortOrder,
|
||||||
|
VendorPartnumber: description,
|
||||||
|
Quantity: qty,
|
||||||
|
Description: description,
|
||||||
|
})
|
||||||
|
sortOrder += 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, fmt.Errorf("text BOM has no importable rows")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := serverModel
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "Text BOM Import"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &importedWorkspace{
|
||||||
|
SourceFormat: "Text",
|
||||||
|
SourceFileName: sourceFileName,
|
||||||
|
Configurations: []importedConfiguration{
|
||||||
|
{
|
||||||
|
GroupID: "text-0",
|
||||||
|
Name: name,
|
||||||
|
Line: 10,
|
||||||
|
ServerCount: 1,
|
||||||
|
ServerModel: serverModel,
|
||||||
|
Rows: rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export.
|
// IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export.
|
||||||
// The file starts (after optional UTF-8 BOM) with the header line:
|
// The file starts (after optional UTF-8 BOM) with the header line:
|
||||||
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."
|
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -588,3 +589,232 @@ func TestIsInspurBOM(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsTextBOM(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"CPU Intel 6760P - 2 шт.", true},
|
||||||
|
{"Fan 18Krpm 8086 - 20 шт.\nRail L-Type 665mm - 1 шт.", true},
|
||||||
|
{"NVIDIA transceiver - 8шт.", true}, // no space before шт
|
||||||
|
{"Сервер KR9288X3, в составе:\nFan - 4 шт.", true},
|
||||||
|
{"|CPU_AMD*1\n|PSU*2", false}, // Inspur
|
||||||
|
{"<CFXML>\n</CFXML>", false},
|
||||||
|
{"just text\nno quantities", false},
|
||||||
|
{"CPU - 2 pcs.", false}, // not Russian шт
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := IsTextBOM([]byte(tc.input))
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("IsTextBOM(%q) = %v, want %v", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTextBOM(t *testing.T) {
|
||||||
|
const sample = `Сервер KR9288X3, в составе:
|
||||||
|
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
|
||||||
|
incl. onboard 800G XDR - 8 шт.
|
||||||
|
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
|
||||||
|
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
|
||||||
|
SSD 960G U.2 16GTps 2.5in RAID_1 - 2 шт.
|
||||||
|
SSD 3.84T U.2 16GTps 2.5in R-Standard - 2 шт.
|
||||||
|
NIC 25Gbps 2Port LC Nvidia CX6LX PCIe MM GEN4 - 1 шт.
|
||||||
|
PowerSupply 3200W Titanium 220VACor240VDC - 2 шт.
|
||||||
|
PowerSupply 3300W Titanium 220VACor240VDC - 6 шт.
|
||||||
|
PowerCord 1.9M C20 C19 - 14 шт.
|
||||||
|
Rail L-Type 665mm - 1 шт.
|
||||||
|
Chassis 2.5x12 gpu - 1 шт.
|
||||||
|
Fan 18Krpm 8086 - 20 шт.
|
||||||
|
NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up to 50m, flat top - 8шт.`
|
||||||
|
|
||||||
|
workspace, err := parseTextBOM([]byte(sample), "spec.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if workspace.SourceFormat != "Text" {
|
||||||
|
t.Fatalf("expected SourceFormat Text, got %q", workspace.SourceFormat)
|
||||||
|
}
|
||||||
|
if len(workspace.Configurations) != 1 {
|
||||||
|
t.Fatalf("expected 1 configuration, got %d", len(workspace.Configurations))
|
||||||
|
}
|
||||||
|
cfg := workspace.Configurations[0]
|
||||||
|
if cfg.Name != "KR9288X3" {
|
||||||
|
t.Fatalf("expected name KR9288X3 (from header), got %q", cfg.Name)
|
||||||
|
}
|
||||||
|
if cfg.ServerModel != "KR9288X3" {
|
||||||
|
t.Fatalf("expected ServerModel KR9288X3, got %q", cfg.ServerModel)
|
||||||
|
}
|
||||||
|
if cfg.ServerCount != 1 {
|
||||||
|
t.Fatalf("expected ServerCount 1, got %d", cfg.ServerCount)
|
||||||
|
}
|
||||||
|
const wantRows = 14
|
||||||
|
if len(cfg.Rows) != wantRows {
|
||||||
|
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsByDesc := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
|
||||||
|
for _, r := range cfg.Rows {
|
||||||
|
rowsByDesc[r.Description] = r
|
||||||
|
if r.VendorPartnumber != r.Description {
|
||||||
|
t.Fatalf("expected VendorPartnumber to mirror Description, got pn=%q desc=%q", r.VendorPartnumber, r.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description with internal hyphens and digits must not be split early.
|
||||||
|
gpu, ok := rowsByDesc["GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected GPU row not found (check hyphen handling)")
|
||||||
|
}
|
||||||
|
if gpu.Quantity != 1 {
|
||||||
|
t.Fatalf("GPU: expected qty 1, got %d", gpu.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
mem, ok := rowsByDesc["Mem 128G DDR5-6400MHz ECC-RDIMM"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected Mem row not found")
|
||||||
|
}
|
||||||
|
if mem.Quantity != 16 {
|
||||||
|
t.Fatalf("Mem: expected qty 16, got %d", mem.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quantity with no space before "шт" and commas/hyphens in description.
|
||||||
|
xcvr, ok := rowsByDesc["NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up to 50m, flat top"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected transceiver row not found (check no-space quantity)")
|
||||||
|
}
|
||||||
|
if xcvr.Quantity != 8 {
|
||||||
|
t.Fatalf("transceiver: expected qty 8, got %d", xcvr.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTextBOMVariantHeaderAndLeadingSpace(t *testing.T) {
|
||||||
|
// Header does not start with "Сервер"; some lines have leading/trailing spaces;
|
||||||
|
// descriptions contain commas and internal hyphens.
|
||||||
|
const sample = `Вычислительный GPU сервер G5500V7, в составе:
|
||||||
|
Серверное шасси G5500 V7 (12NVMe + 8SAS/SATA) - 1 шт.
|
||||||
|
Процессор Intel 8558P 48C 2.7G 260MB 350W - 2 шт.
|
||||||
|
Модуль оперативной памяти Mem 128G DDR5-5600MHz ECC-RDIMM - 16 шт.
|
||||||
|
Накопитель SSD 2.5" NVMe 3.84TB - 8 шт.
|
||||||
|
Накопитель SSD 2.5" SATA 3.84TB - 2 шт.
|
||||||
|
Адаптер SAS/SATA RAID0,1,10 12Gb/s-no Cache - 1 шт.
|
||||||
|
Адаптер 25GE(CX6-Lx)-Dual Port SFP28 - 1 шт.
|
||||||
|
Сетевая карта 4 x 1G, Base-T - 1 шт.
|
||||||
|
Адаптер HBA Emulex LPe32002 2 Port 32GFC - 1 шт.
|
||||||
|
Крепежный комплект Ball Bearing Rail Kit - 1 шт.
|
||||||
|
Кабельный органайзер Cable Management Arm - 1 шт.
|
||||||
|
Кабель питания PowerCord 3m C20 C19 - 4 шт.
|
||||||
|
Видеокарта (видеоускоритель) NVIDIA RTX PRO 6000 Server Edition 96GB GDDR7 - 8 шт.
|
||||||
|
Блок питания 3000W Titanium AC Power Supply - 4 шт.`
|
||||||
|
|
||||||
|
workspace, err := parseTextBOM([]byte(sample), "spec.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
cfg := workspace.Configurations[0]
|
||||||
|
if cfg.ServerModel != "G5500V7" {
|
||||||
|
t.Fatalf("expected ServerModel G5500V7 (last token before comma), got %q", cfg.ServerModel)
|
||||||
|
}
|
||||||
|
if cfg.Name != "G5500V7" {
|
||||||
|
t.Fatalf("expected name G5500V7, got %q", cfg.Name)
|
||||||
|
}
|
||||||
|
const wantRows = 14
|
||||||
|
if len(cfg.Rows) != wantRows {
|
||||||
|
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range cfg.Rows {
|
||||||
|
if r.VendorPartnumber != strings.TrimSpace(r.VendorPartnumber) {
|
||||||
|
t.Fatalf("vendor_partnumber has surrounding whitespace: %q", r.VendorPartnumber)
|
||||||
|
}
|
||||||
|
if r.Description != strings.TrimSpace(r.Description) {
|
||||||
|
t.Fatalf("description has surrounding whitespace: %q", r.Description)
|
||||||
|
}
|
||||||
|
if r.VendorPartnumber == "" {
|
||||||
|
t.Fatal("empty vendor_partnumber")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsByDesc := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
|
||||||
|
for _, r := range cfg.Rows {
|
||||||
|
rowsByDesc[r.VendorPartnumber] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leading-space line must yield a trimmed P/N.
|
||||||
|
sata, ok := rowsByDesc[`Накопитель SSD 2.5" SATA 3.84TB`]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected SATA SSD row not found (check leading-space trimming)")
|
||||||
|
}
|
||||||
|
if sata.Quantity != 2 {
|
||||||
|
t.Fatalf("SATA SSD: expected qty 2, got %d", sata.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commas inside the description must not break parsing.
|
||||||
|
raid, ok := rowsByDesc["Адаптер SAS/SATA RAID0,1,10 12Gb/s-no Cache"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected RAID adapter row not found (check commas in description)")
|
||||||
|
}
|
||||||
|
if raid.Quantity != 1 {
|
||||||
|
t.Fatalf("RAID adapter: expected qty 1, got %d", raid.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu, ok := rowsByDesc["Видеокарта (видеоускоритель) NVIDIA RTX PRO 6000 Server Edition 96GB GDDR7"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected GPU row not found")
|
||||||
|
}
|
||||||
|
if gpu.Quantity != 8 {
|
||||||
|
t.Fatalf("GPU: expected qty 8, got %d", gpu.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePastedBOMText(t *testing.T) {
|
||||||
|
t.Run("text BOM", func(t *testing.T) {
|
||||||
|
rows, format := ParsePastedBOMText("Сервер X1, в составе:\nCPU Intel 6760P - 2 шт.\nMem 128G - 16 шт.")
|
||||||
|
if format != "Text" {
|
||||||
|
t.Fatalf("expected format Text, got %q", format)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 rows, got %d", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].VendorPartnumber != "CPU Intel 6760P" || rows[0].Quantity != 2 {
|
||||||
|
t.Fatalf("unexpected first row: %+v", rows[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inspur BOM", func(t *testing.T) {
|
||||||
|
rows, format := ParsePastedBOMText("|CPU_AMD*1\n|PSU*2")
|
||||||
|
if format != "Inspur" {
|
||||||
|
t.Fatalf("expected format Inspur, got %q", format)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 || rows[1].Quantity != 2 {
|
||||||
|
t.Fatalf("unexpected rows: %+v", rows)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unrecognized falls through", func(t *testing.T) {
|
||||||
|
rows, format := ParsePastedBOMText("col a\tcol b\nfoo\tbar")
|
||||||
|
if rows != nil || format != "" {
|
||||||
|
t.Fatalf("expected nil/empty for unrecognized text, got rows=%+v format=%q", rows, format)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTextBOMNameFromFilename(t *testing.T) {
|
||||||
|
const sample = "CPU Intel 6760P - 2 шт.\nMem 128G - 16 шт."
|
||||||
|
workspace, err := parseTextBOM([]byte(sample), "my-config.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
cfg := workspace.Configurations[0]
|
||||||
|
if cfg.Name != "my-config" {
|
||||||
|
t.Fatalf("expected name my-config (from filename), got %q", cfg.Name)
|
||||||
|
}
|
||||||
|
if cfg.ServerModel != "" {
|
||||||
|
t.Fatalf("expected empty ServerModel without header, got %q", cfg.ServerModel)
|
||||||
|
}
|
||||||
|
if len(cfg.Rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 rows, got %d", len(cfg.Rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: lot
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if lot_category already exists
|
||||||
|
-- recovery.partial: DROP INDEX IF EXISTS idx_lot_category ON lot; ALTER TABLE lot DROP COLUMN lot_category;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: lot_category column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='lot' AND column_name='lot_category' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Migration: Add lot_category column to lot table
|
-- Migration: Add lot_category column to lot table
|
||||||
-- Run this migration manually on the database
|
-- Run this migration manually on the database
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if custom_price already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN custom_price;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: custom_price column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='custom_price' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add custom_price column to qt_configurations table
|
-- Add custom_price column to qt_configurations table
|
||||||
ALTER TABLE qt_configurations ADD COLUMN custom_price DECIMAL(12,2) NULL COMMENT 'User-defined custom total price';
|
ALTER TABLE qt_configurations ADD COLUMN custom_price DECIMAL(12,2) NULL COMMENT 'User-defined custom total price';
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
|
-- Tables affected: qt_lot_metadata
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if is_hidden already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_lot_metadata DROP COLUMN is_hidden;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: is_hidden column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_lot_metadata' AND column_name='is_hidden' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add is_hidden column to qt_lot_metadata table
|
-- Add is_hidden column to qt_lot_metadata table
|
||||||
ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator';
|
ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator';
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if price_updated_at already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN price_updated_at;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: price_updated_at column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='price_updated_at' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add price_updated_at column to qt_configurations table
|
-- Add price_updated_at column to qt_configurations table
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL
|
ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if owner_username already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN owner_username;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: owner_username column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='owner_username' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Store configuration owner as username (instead of relying on numeric user_id)
|
-- Store configuration owner as username (instead of relying on numeric user_id)
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id,
|
ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id,
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: local_configuration_versions (SQLite), local_configurations (SQLite)
|
||||||
|
-- recovery.not-started: safe to re-run only if table does not exist; fails if table or column already present
|
||||||
|
-- recovery.partial: roll back: DROP TABLE IF EXISTS local_configuration_versions; run SQLite migration recovery
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: local_configuration_versions table missing | SELECT 1 FROM sqlite_master WHERE type='table' AND name='local_configuration_versions' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add full-snapshot versioning for local configurations (SQLite)
|
-- Add full-snapshot versioning for local configurations (SQLite)
|
||||||
-- 1) Create local_configuration_versions
|
-- 1) Create local_configuration_versions
|
||||||
-- 2) Add current_version_id to local_configurations
|
-- 2) Add current_version_id to local_configurations
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: safe to re-run; uses INFORMATION_SCHEMA check before DROP FOREIGN KEY
|
||||||
|
-- recovery.partial: no rollback needed; FK was dropped intentionally
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: user_id column is still NOT NULL | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='user_id' AND is_nullable='NO' HAVING COUNT(*)>0
|
||||||
|
|
||||||
-- Detach qt_configurations from qt_users (ownership is owner_username text)
|
-- Detach qt_configurations from qt_users (ownership is owner_username text)
|
||||||
-- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks.
|
-- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if app_version already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN app_version;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: app_version column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='app_version' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Track application version used for configuration writes (create/update via sync)
|
-- Track application version used for configuration writes (create/update via sync)
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN app_version VARCHAR(64) NULL DEFAULT NULL AFTER owner_username;
|
ADD COLUMN app_version VARCHAR(64) NULL DEFAULT NULL AFTER owner_username;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_projects, qt_configurations
|
||||||
|
-- recovery.not-started: check first; CREATE TABLE and ADD COLUMN fail if already exist
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP FOREIGN KEY fk_qt_configurations_project_uuid; ALTER TABLE qt_configurations DROP COLUMN project_uuid; DROP TABLE IF EXISTS qt_projects;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: qt_projects table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='qt_projects' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add projects and attach configurations to projects
|
-- Add projects and attach configurations to projects
|
||||||
|
|
||||||
CREATE TABLE qt_projects (
|
CREATE TABLE qt_projects (
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_pricelist_sync_status
|
||||||
|
-- recovery.not-started: safe to re-run; CREATE TABLE IF NOT EXISTS
|
||||||
|
-- recovery.partial: DROP TABLE IF EXISTS qt_pricelist_sync_status;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: qt_pricelist_sync_status table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='qt_pricelist_sync_status' HAVING COUNT(*)=0
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
||||||
username VARCHAR(100) NOT NULL,
|
username VARCHAR(100) NOT NULL,
|
||||||
last_sync_at DATETIME NOT NULL,
|
last_sync_at DATETIME NOT NULL,
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if pricelist_id already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN pricelist_id;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: pricelist_id column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='pricelist_id' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add pricelist binding to configurations
|
-- Add pricelist binding to configurations
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN pricelist_id BIGINT UNSIGNED NULL AFTER server_count;
|
ADD COLUMN pricelist_id BIGINT UNSIGNED NULL AFTER server_count;
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
|
-- Tables affected: qt_pricelist_sync_status
|
||||||
|
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
|
||||||
|
-- recovery.partial: ALTER TABLE qt_pricelist_sync_status DROP COLUMN app_version;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: app_version column in qt_pricelist_sync_status missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_pricelist_sync_status' AND column_name='app_version' HAVING COUNT(*)=0
|
||||||
|
|
||||||
ALTER TABLE qt_pricelist_sync_status
|
ALTER TABLE qt_pricelist_sync_status
|
||||||
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL;
|
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_projects
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if tracker_url already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_projects DROP COLUMN tracker_url;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: tracker_url column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='tracker_url' HAVING COUNT(*)=0
|
||||||
|
|
||||||
ALTER TABLE qt_projects
|
ALTER TABLE qt_projects
|
||||||
ADD COLUMN tracker_url VARCHAR(500) NULL AFTER name;
|
ADD COLUMN tracker_url VARCHAR(500) NULL AFTER name;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_pricelists
|
||||||
|
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
|
||||||
|
-- recovery.partial: ALTER TABLE qt_pricelists DROP COLUMN source;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: source column in qt_pricelists missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_pricelists' AND column_name='source' HAVING COUNT(*)=0
|
||||||
|
|
||||||
ALTER TABLE qt_pricelists
|
ALTER TABLE qt_pricelists
|
||||||
ADD COLUMN IF NOT EXISTS source ENUM('estimate', 'warehouse', 'competitor') NOT NULL DEFAULT 'estimate' AFTER id;
|
ADD COLUMN IF NOT EXISTS source ENUM('estimate', 'warehouse', 'competitor') NOT NULL DEFAULT 'estimate' AFTER id;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: stock_log
|
||||||
|
-- recovery.not-started: safe to re-run; CREATE TABLE IF NOT EXISTS
|
||||||
|
-- recovery.partial: DROP TABLE IF EXISTS stock_log;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: stock_log table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='stock_log' HAVING COUNT(*)=0
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stock_log (
|
CREATE TABLE IF NOT EXISTS stock_log (
|
||||||
stock_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
stock_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
lot VARCHAR(255) NOT NULL,
|
lot VARCHAR(255) NOT NULL,
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN warehouse_pricelist_id, DROP COLUMN competitor_pricelist_id;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: warehouse_pricelist_id column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='warehouse_pricelist_id' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add per-source pricelist bindings for configurations
|
-- Add per-source pricelist bindings for configurations
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN IF NOT EXISTS warehouse_pricelist_id BIGINT UNSIGNED NULL AFTER pricelist_id,
|
ADD COLUMN IF NOT EXISTS warehouse_pricelist_id BIGINT UNSIGNED NULL AFTER pricelist_id,
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: stock_ignore_rules
|
||||||
|
-- recovery.not-started: safe to re-run; CREATE TABLE IF NOT EXISTS
|
||||||
|
-- recovery.partial: DROP TABLE IF EXISTS stock_ignore_rules;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: stock_ignore_rules table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='stock_ignore_rules' HAVING COUNT(*)=0
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stock_ignore_rules (
|
CREATE TABLE IF NOT EXISTS stock_ignore_rules (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
target VARCHAR(20) NOT NULL,
|
target VARCHAR(20) NOT NULL,
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
|
-- Tables affected: stock_log
|
||||||
|
-- recovery.not-started: check first; CHANGE COLUMN fails if partnumber already exists
|
||||||
|
-- recovery.partial: ALTER TABLE stock_log CHANGE COLUMN partnumber lot VARCHAR(255) NOT NULL;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: partnumber column in stock_log missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='stock_log' AND column_name='partnumber' HAVING COUNT(*)=0
|
||||||
|
|
||||||
ALTER TABLE stock_log
|
ALTER TABLE stock_log
|
||||||
CHANGE COLUMN lot partnumber VARCHAR(255) NOT NULL;
|
CHANGE COLUMN lot partnumber VARCHAR(255) NOT NULL;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN only_in_stock;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: only_in_stock column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='only_in_stock' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add only_in_stock toggle to configuration settings persisted in MariaDB.
|
-- Add only_in_stock toggle to configuration settings persisted in MariaDB.
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN IF NOT EXISTS only_in_stock BOOLEAN NOT NULL DEFAULT FALSE AFTER disable_price_refresh;
|
ADD COLUMN IF NOT EXISTS only_in_stock BOOLEAN NOT NULL DEFAULT FALSE AFTER disable_price_refresh;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_pricelist_items
|
||||||
|
-- recovery.not-started: safe to re-run; uses INFORMATION_SCHEMA check before adding index
|
||||||
|
-- recovery.partial: DROP INDEX IF EXISTS idx_qt_pricelist_items_pricelist_lot ON qt_pricelist_items;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: composite index on qt_pricelist_items missing | SELECT 1 FROM information_schema.STATISTICS WHERE table_schema=DATABASE() AND table_name='qt_pricelist_items' AND index_name='idx_qt_pricelist_items_pricelist_lot' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Ensure fast lookup for /api/quote/price-levels batched queries:
|
-- Ensure fast lookup for /api/quote/price-levels batched queries:
|
||||||
-- SELECT ... FROM qt_pricelist_items WHERE pricelist_id = ? AND lot_name IN (...)
|
-- SELECT ... FROM qt_pricelist_items WHERE pricelist_id = ? AND lot_name IN (...)
|
||||||
SET @has_idx := (
|
SET @has_idx := (
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN article;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: article column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='article' HAVING COUNT(*)=0
|
||||||
|
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count;
|
ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count;
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN server_model;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: server_model column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='server_model' HAVING COUNT(*)=0
|
||||||
|
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count;
|
ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count;
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN support_code;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: support_code column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='support_code' HAVING COUNT(*)=0
|
||||||
|
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model;
|
ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_projects
|
||||||
|
-- recovery.not-started: check first; idempotent backfill but ADD COLUMN fails if code already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_projects DROP INDEX idx_qt_projects_code; ALTER TABLE qt_projects DROP COLUMN code;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: code column in qt_projects missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='code' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add project code and enforce uniqueness
|
-- Add project code and enforce uniqueness
|
||||||
|
|
||||||
ALTER TABLE qt_projects
|
ALTER TABLE qt_projects
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_projects
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if variant already exists
|
||||||
|
-- recovery.partial: DROP INDEX IF EXISTS idx_qt_projects_code_variant ON qt_projects; ALTER TABLE qt_projects DROP COLUMN variant;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: variant column in qt_projects missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='variant' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add project variant and reset codes from project names
|
-- Add project variant and reset codes from project names
|
||||||
|
|
||||||
ALTER TABLE qt_projects
|
ALTER TABLE qt_projects
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_projects
|
||||||
|
-- recovery.not-started: safe to re-run; MODIFY COLUMN is idempotent
|
||||||
|
-- recovery.partial: ALTER TABLE qt_projects MODIFY COLUMN name VARCHAR(200) NOT NULL;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: name column in qt_projects is still NOT NULL | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='name' AND is_nullable='NO' HAVING COUNT(*)>0
|
||||||
|
|
||||||
-- Allow NULL project names
|
-- Allow NULL project names
|
||||||
|
|
||||||
ALTER TABLE qt_projects
|
ALTER TABLE qt_projects
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN line_no;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: line_no column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='line_no' HAVING COUNT(*)=0
|
||||||
|
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN IF NOT EXISTS line_no INT NULL AFTER only_in_stock;
|
ADD COLUMN IF NOT EXISTS line_no INT NULL AFTER only_in_stock;
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if config_type already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN config_type;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: config_type column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='config_type' HAVING COUNT(*)=0
|
||||||
|
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN config_type VARCHAR(20) NOT NULL DEFAULT 'server';
|
ADD COLUMN config_type VARCHAR(20) NOT NULL DEFAULT 'server';
|
||||||
|
|||||||
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.
|
||||||
@@ -53,45 +53,34 @@ mkdir -p "${RELEASE_DIR}"
|
|||||||
# Create release notes template only when missing.
|
# Create release notes template only when missing.
|
||||||
ensure_release_notes "${RELEASE_DIR}/RELEASE_NOTES.md"
|
ensure_release_notes "${RELEASE_DIR}/RELEASE_NOTES.md"
|
||||||
|
|
||||||
# Build for all platforms
|
# Build binaries
|
||||||
echo -e "${YELLOW}→ Building binaries...${NC}"
|
echo -e "${YELLOW}→ Building binaries...${NC}"
|
||||||
make build-all
|
|
||||||
|
LDFLAGS="-s -w -X main.Version=${VERSION}"
|
||||||
|
|
||||||
|
echo "Building qfs for macOS (Apple Silicon)..."
|
||||||
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o bin/qfs-darwin-arm64 ./cmd/qfs
|
||||||
|
echo "✓ Built: bin/qfs-darwin-arm64"
|
||||||
|
|
||||||
|
echo "Building qfs for Windows..."
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o bin/qfs-windows-amd64.exe ./cmd/qfs
|
||||||
|
echo "✓ Built: bin/qfs-windows-amd64.exe"
|
||||||
|
|
||||||
# Package binaries with checksums
|
# Package binaries with checksums
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}→ Creating release packages...${NC}"
|
echo -e "${YELLOW}→ Creating release packages...${NC}"
|
||||||
|
|
||||||
# Linux AMD64
|
|
||||||
if [ -f "bin/qfs-linux-amd64" ]; then
|
|
||||||
cd bin
|
|
||||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-linux-amd64.tar.gz" qfs-linux-amd64
|
|
||||||
cd ..
|
|
||||||
echo -e "${GREEN} ✓ qfs-${VERSION}-linux-amd64.tar.gz${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# macOS Intel
|
|
||||||
if [ -f "bin/qfs-darwin-amd64" ]; then
|
|
||||||
cd bin
|
|
||||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-amd64.tar.gz" qfs-darwin-amd64
|
|
||||||
cd ..
|
|
||||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-amd64.tar.gz${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# macOS Apple Silicon
|
# macOS Apple Silicon
|
||||||
if [ -f "bin/qfs-darwin-arm64" ]; then
|
cd bin
|
||||||
cd bin
|
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
|
||||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
|
cd ..
|
||||||
cd ..
|
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
|
||||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Windows AMD64
|
# Windows AMD64
|
||||||
if [ -f "bin/qfs-windows-amd64.exe" ]; then
|
cd bin
|
||||||
cd bin
|
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
|
||||||
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
|
cd ..
|
||||||
cd ..
|
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
|
||||||
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Generate checksums
|
# Generate checksums
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -79,6 +79,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Price Diff Modal -->
|
||||||
|
<div id="price-diff-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col">
|
||||||
|
<div class="p-5 border-b border-gray-200 flex-shrink-0 flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Изменение цен</h3>
|
||||||
|
<button onclick="closePriceDiffModal()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="price-diff-modal-body" class="overflow-y-auto flex-1 p-5 space-y-5"></div>
|
||||||
|
<div class="p-4 border-t border-gray-200 flex-shrink-0 flex justify-end">
|
||||||
|
<button onclick="closePriceDiffModal()" class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 text-sm">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sync Info Modal -->
|
<!-- Sync Info Modal -->
|
||||||
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
|
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
|
||||||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
|
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
|
||||||
@@ -517,6 +535,121 @@
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
// ==================== SHARED PRICE REFRESH UTILITIES ====================
|
||||||
|
|
||||||
|
async function fetchLatestEstimatePricelistId() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/pricelists?active_only=true&source=estimate&per_page=1');
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json();
|
||||||
|
const list = data.pricelists || data.items || data;
|
||||||
|
if (Array.isArray(list) && list.length > 0) return Number(list[0].id);
|
||||||
|
} catch(_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fmtMoneyDiff(value) {
|
||||||
|
if (!Number.isFinite(Number(value))) return 'N/A';
|
||||||
|
return '$ ' + Math.round(Number(value)).toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fmtArrow(prev, next) {
|
||||||
|
const diff = next - prev;
|
||||||
|
if (Math.abs(diff) < 0.5) return '';
|
||||||
|
const pct = prev > 0 ? Math.round((diff / prev) * 100) : 0;
|
||||||
|
const sign = diff > 0 ? '+' : '';
|
||||||
|
const color = diff > 0 ? 'text-red-600' : 'text-green-600';
|
||||||
|
return ` <span class="${color} text-xs font-medium">(${sign}${pct}%)</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildDiffRow(lot, qty, prev, next) {
|
||||||
|
const prevLine = prev * qty;
|
||||||
|
const nextLine = next * qty;
|
||||||
|
const delta = next - prev;
|
||||||
|
const arrowColor = delta > 0 ? 'text-red-600' : 'text-green-600';
|
||||||
|
return `<tr class="border-b border-gray-100 last:border-0">
|
||||||
|
<td class="py-1.5 pr-3 text-sm text-gray-700 font-mono">${lot}</td>
|
||||||
|
<td class="py-1.5 px-2 text-sm text-right text-gray-500">${qty}</td>
|
||||||
|
<td class="py-1.5 px-2 text-sm text-right whitespace-nowrap">
|
||||||
|
<span class="text-gray-400 line-through text-xs">${_fmtMoneyDiff(prev)}</span>
|
||||||
|
<span class="${arrowColor} font-medium ml-1">${_fmtMoneyDiff(next)}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-1.5 pl-2 text-sm text-right whitespace-nowrap">
|
||||||
|
<span class="text-gray-400 line-through text-xs">${_fmtMoneyDiff(prevLine)}</span>
|
||||||
|
<span class="${arrowColor} font-medium ml-1">${_fmtMoneyDiff(nextLine)}</span>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPriceDiffModal(results) {
|
||||||
|
const body = document.getElementById('price-diff-modal-body');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
const sections = results.filter(r => !r.skipped);
|
||||||
|
if (sections.length === 0) {
|
||||||
|
body.innerHTML = '<p class="text-gray-500 text-sm text-center py-4">Обновление цен отключено для всех конфигураций</p>';
|
||||||
|
document.getElementById('price-diff-modal').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
let anyChanges = false;
|
||||||
|
|
||||||
|
for (const r of sections) {
|
||||||
|
if (r.error) {
|
||||||
|
html += `<div class="text-sm text-red-600 bg-red-50 rounded px-3 py-2">${r.configName ? `<span class="font-medium">${r.configName}:</span> ` : ''}Ошибка обновления цен</div>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffs = (r.itemDiffs || []).filter(d => Math.abs(d.prevPrice - d.newPrice) > 0.01);
|
||||||
|
const totalDelta = (r.newTotal || 0) - (r.prevTotal || 0);
|
||||||
|
|
||||||
|
if (results.length > 1) {
|
||||||
|
html += `<div class="text-sm font-semibold text-gray-800 mb-1">${r.configName || '—'}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffs.length === 0) {
|
||||||
|
html += `<div class="text-sm text-gray-500 bg-gray-50 rounded px-3 py-2 mb-2">Изменений нет</div>`;
|
||||||
|
} else {
|
||||||
|
anyChanges = true;
|
||||||
|
html += `<div class="overflow-x-auto mb-2">
|
||||||
|
<table class="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase">
|
||||||
|
<th class="pb-1 pr-3 font-medium">Компонент</th>
|
||||||
|
<th class="pb-1 px-2 text-right font-medium">Кол.</th>
|
||||||
|
<th class="pb-1 px-2 text-right font-medium">Цена / шт.</th>
|
||||||
|
<th class="pb-1 pl-2 text-right font-medium">Сумма</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${diffs.map(d => _buildDiffRow(d.lot_name, d.quantity, d.prevPrice, d.newPrice)).join('')}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalColor = totalDelta > 0 ? 'text-red-600' : totalDelta < 0 ? 'text-green-600' : 'text-gray-600';
|
||||||
|
const totalArrow = _fmtArrow(r.prevTotal || 0, r.newTotal || 0);
|
||||||
|
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}
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!anyChanges && sections.every(r => !r.error)) {
|
||||||
|
html = '<div class="text-sm text-gray-500 text-center py-6">Цены актуальны — изменений нет</div>' + html;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = html;
|
||||||
|
document.getElementById('price-diff-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePriceDiffModal() {
|
||||||
|
document.getElementById('price-diff-modal')?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Call functions immediately to ensure they run even before DOMContentLoaded
|
// Call functions immediately to ensure they run even before DOMContentLoaded
|
||||||
// This ensures username and admin link are visible ASAP
|
// This ensures username and admin link are visible ASAP
|
||||||
loadDBUser();
|
loadDBUser();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Ревизии - OFS{{end}}
|
{{define "title"}}QFS Ревизии{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Мои конфигурации - OFS{{end}}
|
{{define "title"}}QFS Мои конфигурации{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -55,12 +55,12 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label>
|
||||||
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
|
<div id="config-type-buttons" class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
|
||||||
<button type="button" id="type-server-btn" onclick="setCreateType('server')"
|
<button type="button" data-type="server" onclick="setCreateType('server')"
|
||||||
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
|
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||||
Сервер
|
Сервер
|
||||||
</button>
|
</button>
|
||||||
<button type="button" id="type-storage-btn" onclick="setCreateType('storage')"
|
<button type="button" data-type="storage" onclick="setCreateType('storage')"
|
||||||
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
|
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
|
||||||
СХД
|
СХД
|
||||||
</button>
|
</button>
|
||||||
@@ -247,7 +247,7 @@ function renderConfigs(configs) {
|
|||||||
|
|
||||||
configs.forEach(c => {
|
configs.forEach(c => {
|
||||||
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
||||||
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
|
const total = c.total_price ? '$' + c.total_price.toLocaleString('ru-RU', {minimumFractionDigits: 2}) : '—';
|
||||||
const serverCount = c.server_count ? c.server_count : 1;
|
const serverCount = c.server_count ? c.server_count : 1;
|
||||||
const author = c.owner_username || (c.user && c.user.username) || '—';
|
const author = c.owner_username || (c.user && c.user.username) || '—';
|
||||||
const projectName = c.project_uuid && projectNameByUUID[c.project_uuid]
|
const projectName = c.project_uuid && projectNameByUUID[c.project_uuid]
|
||||||
@@ -258,7 +258,7 @@ function renderConfigs(configs) {
|
|||||||
let pricePerUnit = '—';
|
let pricePerUnit = '—';
|
||||||
if (c.total_price && serverCount > 0) {
|
if (c.total_price && serverCount > 0) {
|
||||||
const unitPrice = c.total_price / serverCount;
|
const unitPrice = c.total_price / serverCount;
|
||||||
pricePerUnit = '$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2});
|
pricePerUnit = '$' + unitPrice.toLocaleString('ru-RU', {minimumFractionDigits: 2});
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '<tr class="hover:bg-gray-50">';
|
html += '<tr class="hover:bg-gray-50">';
|
||||||
@@ -532,18 +532,51 @@ async function cloneConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let createConfigType = 'server';
|
let createConfigType = 'server';
|
||||||
|
let _cfgSettings = null;
|
||||||
|
|
||||||
|
async function loadCfgSettings() {
|
||||||
|
if (_cfgSettings) return _cfgSettings;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/configurator-settings');
|
||||||
|
if (r.ok) _cfgSettings = await r.json();
|
||||||
|
} catch(e) { /* use hardcoded fallback */ }
|
||||||
|
return _cfgSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConfigTypeButtons(types) {
|
||||||
|
if (!types || !types.length) return;
|
||||||
|
const el = document.getElementById('config-type-buttons');
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = types
|
||||||
|
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0))
|
||||||
|
.map((t, i) => {
|
||||||
|
const borderClass = i > 0 ? 'border-l border-gray-200' : '';
|
||||||
|
return `<button type="button" data-type="${t.code}" onclick="setCreateType('${t.code}')"
|
||||||
|
class="flex-1 py-2 text-sm font-medium ${borderClass} bg-white text-gray-700 hover:bg-gray-50">
|
||||||
|
${t.name_ru || t.code}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
// activate first type
|
||||||
|
const firstCode = types[0].code;
|
||||||
|
createConfigType = firstCode;
|
||||||
|
setCreateType(firstCode);
|
||||||
|
}
|
||||||
|
|
||||||
function setCreateType(type) {
|
function setCreateType(type) {
|
||||||
createConfigType = type;
|
createConfigType = type;
|
||||||
document.getElementById('type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' +
|
document.querySelectorAll('#config-type-buttons button').forEach(btn => {
|
||||||
(type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200');
|
const active = btn.dataset.type === type;
|
||||||
document.getElementById('type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' +
|
btn.className = 'flex-1 py-2 text-sm font-medium ' +
|
||||||
(type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
|
(active
|
||||||
|
? 'bg-blue-600 text-white border-l border-gray-200'
|
||||||
|
: 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
createConfigType = 'server';
|
createConfigType = 'server';
|
||||||
setCreateType('server');
|
setCreateType('server');
|
||||||
|
loadCfgSettings().then(s => renderConfigTypeButtons(s && s.config_types));
|
||||||
document.getElementById('opportunity-number').value = '';
|
document.getElementById('opportunity-number').value = '';
|
||||||
document.getElementById('create-project-input').value = '';
|
document.getElementById('create-project-input').value = '';
|
||||||
document.getElementById('create-modal').classList.remove('hidden');
|
document.getElementById('create-modal').classList.remove('hidden');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}OFS - Конфигуратор{{end}}
|
{{define "title"}}QFS Конфигуратор{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -488,7 +488,7 @@ function updateConfigBreadcrumbs() {
|
|||||||
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||||
configEl.title = fullConfigName;
|
configEl.title = fullConfigName;
|
||||||
versionEl.textContent = 'main';
|
versionEl.textContent = 'main';
|
||||||
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — OFS';
|
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — QFS';
|
||||||
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||||
if (configNameLinkEl && configUUID) {
|
if (configNameLinkEl && configUUID) {
|
||||||
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||||
@@ -504,6 +504,9 @@ let currentTab = 'base';
|
|||||||
let allComponents = [];
|
let allComponents = [];
|
||||||
let cart = [];
|
let cart = [];
|
||||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||||
|
let configTypeCategoryMap = {}; // configTypeCode → Set<UPPER_CODE> of allowed categories (from server)
|
||||||
|
let alwaysVisibleTabsSet = null; // Set<tabKey> — null means use hardcoded fallback
|
||||||
|
let requiredCategoriesMap = {}; // configTypeCode → Set<UPPER_CODE> of required categories
|
||||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||||
let hasUnsavedChanges = false;
|
let hasUnsavedChanges = false;
|
||||||
let exitSaveStarted = false;
|
let exitSaveStarted = false;
|
||||||
@@ -783,6 +786,95 @@ async function loadCategoriesFromAPI() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCfgSettings() {
|
||||||
|
if (typeof _cfgSettings !== 'undefined' && _cfgSettings) return _cfgSettings;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/configurator-settings');
|
||||||
|
if (r.ok) {
|
||||||
|
window._cfgSettings = await r.json();
|
||||||
|
return window._cfgSettings;
|
||||||
|
}
|
||||||
|
} catch(e) { /* fallback to hardcoded */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyServerSettings(settings) {
|
||||||
|
if (!settings) return;
|
||||||
|
|
||||||
|
// config_types → category allowlist map
|
||||||
|
if (Array.isArray(settings.config_types) && settings.config_types.length) {
|
||||||
|
configTypeCategoryMap = {};
|
||||||
|
settings.config_types.forEach(ct => {
|
||||||
|
if (ct.code && Array.isArray(ct.categories)) {
|
||||||
|
configTypeCategoryMap[ct.code] = new Set(ct.categories.map(c => c.toUpperCase()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// tab_config → update TAB_CONFIG (preserve .other)
|
||||||
|
if (Array.isArray(settings.tab_config) && settings.tab_config.length) {
|
||||||
|
const otherTab = TAB_CONFIG.other;
|
||||||
|
TAB_CONFIG = {};
|
||||||
|
settings.tab_config.forEach(tab => {
|
||||||
|
TAB_CONFIG[tab.key] = {
|
||||||
|
categories: Array.isArray(tab.categories) ? tab.categories : [],
|
||||||
|
singleSelect: !!tab.single_select,
|
||||||
|
label: tab.label || tab.key,
|
||||||
|
sections: tab.sections || undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
TAB_CONFIG.other = otherTab || { categories: [], singleSelect: false, label: 'Other' };
|
||||||
|
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG).flatMap(t => t.categories).map(c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// always_visible_tabs
|
||||||
|
if (Array.isArray(settings.always_visible_tabs) && settings.always_visible_tabs.length) {
|
||||||
|
alwaysVisibleTabsSet = new Set(settings.always_visible_tabs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// required_categories
|
||||||
|
if (settings.required_categories && typeof settings.required_categories === 'object') {
|
||||||
|
requiredCategoriesMap = {};
|
||||||
|
Object.entries(settings.required_categories).forEach(([ct, codes]) => {
|
||||||
|
if (Array.isArray(codes)) {
|
||||||
|
requiredCategoriesMap[ct] = new Set(codes.map(c => c.toUpperCase()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyConfigTypeToTabs();
|
||||||
|
updateTabVisibility();
|
||||||
|
updateRequiredCategoryBadges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRequiredCategoryBadges() {
|
||||||
|
const required = requiredCategoriesMap[configType];
|
||||||
|
if (!required || !required.size) return;
|
||||||
|
|
||||||
|
// Build set of categories that have at least one cart item
|
||||||
|
const filledCategories = new Set(
|
||||||
|
cart.map(item => (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// For each tab, check if it contains any required-but-unfilled category
|
||||||
|
Object.entries(TAB_CONFIG).forEach(([tabKey, tabCfg]) => {
|
||||||
|
const btn = document.querySelector(`[data-tab="${tabKey}"]`);
|
||||||
|
if (!btn) return;
|
||||||
|
const tabCategories = (tabCfg.categories || []).map(c => c.toUpperCase());
|
||||||
|
const hasUnfilled = tabCategories.some(cat => required.has(cat) && !filledCategories.has(cat));
|
||||||
|
const badge = btn.querySelector('.required-badge');
|
||||||
|
if (hasUnfilled) {
|
||||||
|
if (!badge) {
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'required-badge inline-block w-1.5 h-1.5 bg-orange-400 rounded-full ml-1 align-middle';
|
||||||
|
btn.appendChild(dot);
|
||||||
|
}
|
||||||
|
} else if (badge) {
|
||||||
|
badge.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
document.addEventListener('DOMContentLoaded', async function() {
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
// RBAC disabled - no token check required
|
// RBAC disabled - no token check required
|
||||||
@@ -791,8 +883,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load categories in background (defaults are usable immediately).
|
// Load categories and configurator settings in background (defaults are usable immediately).
|
||||||
const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
|
const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
|
||||||
|
loadCfgSettings().then(s => applyServerSettings(s)).catch(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/configs/' + configUUID);
|
const resp = await fetch('/api/configs/' + configUUID);
|
||||||
@@ -879,6 +972,12 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save pricing state (ручная цена) on page exit so it survives navigation
|
||||||
|
window.addEventListener('pagehide', saveConfigOnExit);
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') saveConfigOnExit();
|
||||||
|
});
|
||||||
|
|
||||||
// Load vendor spec BOM for this configuration
|
// Load vendor spec BOM for this configuration
|
||||||
if (configUUID) {
|
if (configUUID) {
|
||||||
loadVendorSpec(configUUID);
|
loadVendorSpec(configUUID);
|
||||||
@@ -889,7 +988,7 @@ async function loadAllComponents() {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/components?per_page=5000');
|
const resp = await fetch('/api/components?per_page=5000');
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
allComponents = data.components || [];
|
allComponents = data.items || [];
|
||||||
window._bomAllComponents = allComponents;
|
window._bomAllComponents = allComponents;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('Failed to load components', e);
|
console.error('Failed to load components', e);
|
||||||
@@ -940,7 +1039,7 @@ async function loadActivePricelists(force = false) {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
activePricelistsBySource[source] = data.pricelists || [];
|
activePricelistsBySource[source] = data.items || [];
|
||||||
// Do not reset the stored pricelist — it may be inactive but must be preserved
|
// Do not reset the stored pricelist — it may be inactive but must be preserved
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
activePricelistsBySource[source] = [];
|
activePricelistsBySource[source] = [];
|
||||||
@@ -1154,60 +1253,60 @@ function switchTab(tab) {
|
|||||||
renderTab();
|
renderTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hardcoded fallback constants — used only when server has not provided config_types data
|
||||||
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
|
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
|
||||||
|
|
||||||
// Storage-only categories — hidden for server configs
|
|
||||||
const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC'];
|
const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC'];
|
||||||
// Server-only categories — hidden for storage configs
|
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
|
||||||
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
|
|
||||||
const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID'];
|
const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID'];
|
||||||
const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU'];
|
const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU'];
|
||||||
const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU'];
|
const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU'];
|
||||||
|
|
||||||
|
function isCategoryVisibleForConfigType(code, cfgType) {
|
||||||
|
const allowed = configTypeCategoryMap[cfgType];
|
||||||
|
if (!allowed || allowed.size === 0) return _hardcodedCategoryVisible(code, cfgType);
|
||||||
|
return allowed.has(code.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hardcodedCategoryVisible(code, cfgType) {
|
||||||
|
if (cfgType === 'storage') {
|
||||||
|
if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return true;
|
||||||
|
if (SERVER_ONLY_BASE_CATEGORIES.includes(code)) return false;
|
||||||
|
if (STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(code)) return false;
|
||||||
|
if (STORAGE_HIDDEN_PCI_CATEGORIES.includes(code)) return false;
|
||||||
|
if (STORAGE_HIDDEN_POWER_CATEGORIES.includes(code)) return false;
|
||||||
|
} else {
|
||||||
|
if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _effectiveAlwaysVisibleTabs() {
|
||||||
|
return alwaysVisibleTabsSet || ALWAYS_VISIBLE_TABS;
|
||||||
|
}
|
||||||
|
|
||||||
function applyConfigTypeToTabs() {
|
function applyConfigTypeToTabs() {
|
||||||
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
|
// Filter each tab's categories by visibility for current configType.
|
||||||
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
|
// Uses server-driven allowlists when available; falls back to hardcoded constants.
|
||||||
const storageSections = [
|
Object.keys(TAB_CONFIG).forEach(tabKey => {
|
||||||
{ title: 'RAID Контроллеры', categories: ['RAID'] },
|
if (tabKey === 'other') return;
|
||||||
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
|
const tab = TAB_CONFIG[tabKey];
|
||||||
];
|
if (!tab || !Array.isArray(tab.categories)) return;
|
||||||
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
|
|
||||||
const pciSections = [
|
|
||||||
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
|
||||||
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
|
||||||
{ title: 'HBA', categories: ['HBA'] },
|
|
||||||
{ title: 'HIC', categories: ['HIC'] }
|
|
||||||
];
|
|
||||||
const powerCategories = ['PS', 'PSU'];
|
|
||||||
|
|
||||||
TAB_CONFIG.base.categories = baseCategories.filter(c => {
|
// Snapshot the full category list for this tab (stored in _allCategories if not yet saved)
|
||||||
if (configType === 'storage') {
|
if (!tab._allCategories) tab._allCategories = [...tab.categories];
|
||||||
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
|
|
||||||
}
|
|
||||||
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
|
|
||||||
});
|
|
||||||
|
|
||||||
TAB_CONFIG.storage.categories = storageCategories.filter(c => {
|
tab.categories = tab._allCategories.filter(c => isCategoryVisibleForConfigType(c, configType));
|
||||||
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
|
|
||||||
});
|
|
||||||
TAB_CONFIG.storage.sections = storageSections.filter(section => {
|
|
||||||
if (configType === 'storage') {
|
|
||||||
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
|
if (Array.isArray(tab._allSections || tab.sections)) {
|
||||||
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC';
|
const allSections = tab._allSections || tab.sections;
|
||||||
});
|
if (!tab._allSections) tab._allSections = allSections.map(s => ({ ...s, categories: [...s.categories] }));
|
||||||
TAB_CONFIG.pci.sections = pciSections.filter(section => {
|
tab.sections = tab._allSections
|
||||||
if (configType === 'storage') {
|
.map(section => ({
|
||||||
return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat));
|
...section,
|
||||||
|
categories: section.categories.filter(c => isCategoryVisibleForConfigType(c, configType))
|
||||||
|
}))
|
||||||
|
.filter(section => section.categories.length > 0);
|
||||||
}
|
}
|
||||||
return section.title !== 'HIC';
|
|
||||||
});
|
|
||||||
TAB_CONFIG.power.categories = powerCategories.filter(c => {
|
|
||||||
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rebuild assigned categories index
|
// Rebuild assigned categories index
|
||||||
@@ -1217,8 +1316,9 @@ function applyConfigTypeToTabs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateTabVisibility() {
|
function updateTabVisibility() {
|
||||||
|
const visibleTabs = _effectiveAlwaysVisibleTabs();
|
||||||
for (const tabId of Object.keys(TAB_CONFIG)) {
|
for (const tabId of Object.keys(TAB_CONFIG)) {
|
||||||
if (ALWAYS_VISIBLE_TABS.has(tabId)) continue;
|
if (visibleTabs.has(tabId)) continue;
|
||||||
const btn = document.querySelector(`[data-tab="${tabId}"]`);
|
const btn = document.querySelector(`[data-tab="${tabId}"]`);
|
||||||
if (!btn) continue;
|
if (!btn) continue;
|
||||||
const hasComponents = getComponentsForTab(tabId).length > 0;
|
const hasComponents = getComponentsForTab(tabId).length > 0;
|
||||||
@@ -1656,6 +1756,10 @@ function renderAutocomplete() {
|
|||||||
|
|
||||||
// Build autocomplete items based on mode
|
// Build autocomplete items based on mode
|
||||||
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => {
|
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => {
|
||||||
|
if (comp.isDivider) {
|
||||||
|
return `<div class="px-3 py-1 text-xs text-gray-400 border-t border-gray-200 select-none cursor-default" style="pointer-events:none">── прочие ──</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
let onmousedown;
|
let onmousedown;
|
||||||
|
|
||||||
if (autocompleteMode === 'section') {
|
if (autocompleteMode === 'section') {
|
||||||
@@ -2039,14 +2143,24 @@ function showAutocompleteBOM(rowIdx, input) {
|
|||||||
|
|
||||||
function filterAutocompleteBOM(rowIdx, search) {
|
function filterAutocompleteBOM(rowIdx, search) {
|
||||||
const searchLower = (search || '').toLowerCase();
|
const searchLower = (search || '').toLowerCase();
|
||||||
autocompleteFiltered = (window._bomAllComponents || allComponents).filter(c => {
|
const cartLots = new Set(cart.map(i => i.lot_name));
|
||||||
|
const all = (window._bomAllComponents || allComponents).filter(c => {
|
||||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||||
return text.includes(searchLower);
|
return text.includes(searchLower);
|
||||||
}).sort((a, b) => {
|
|
||||||
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
|
|
||||||
if (popDiff !== 0) return popDiff;
|
|
||||||
return a.lot_name.localeCompare(b.lot_name);
|
|
||||||
});
|
});
|
||||||
|
const inCart = all.filter(c => cartLots.has(c.lot_name))
|
||||||
|
.sort((a, b) => a.lot_name.localeCompare(b.lot_name));
|
||||||
|
const notInCart = all.filter(c => !cartLots.has(c.lot_name))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
|
||||||
|
if (popDiff !== 0) return popDiff;
|
||||||
|
return a.lot_name.localeCompare(b.lot_name);
|
||||||
|
});
|
||||||
|
if (inCart.length && notInCart.length) {
|
||||||
|
autocompleteFiltered = [...inCart, {isDivider: true}, ...notInCart];
|
||||||
|
} else {
|
||||||
|
autocompleteFiltered = [...inCart, ...notInCart];
|
||||||
|
}
|
||||||
renderAutocomplete();
|
renderAutocomplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2071,7 +2185,7 @@ function handleAutocompleteKeyBOM(event, rowIdx) {
|
|||||||
|
|
||||||
function selectAutocompleteItemBOM(index, rowIdx) {
|
function selectAutocompleteItemBOM(index, rowIdx) {
|
||||||
const comp = autocompleteFiltered[index];
|
const comp = autocompleteFiltered[index];
|
||||||
if (!comp) return;
|
if (!comp || comp.isDivider) return;
|
||||||
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
|
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
row.manual_lot = comp.lot_name;
|
row.manual_lot = comp.lot_name;
|
||||||
@@ -2131,6 +2245,7 @@ function removeFromCart(lotName) {
|
|||||||
|
|
||||||
function updateCartUI() {
|
function updateCartUI() {
|
||||||
updateTabVisibility();
|
updateTabVisibility();
|
||||||
|
updateRequiredCategoryBadges();
|
||||||
window._currentCart = cart; // expose for BOM/Pricing tabs
|
window._currentCart = cart; // expose for BOM/Pricing tabs
|
||||||
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||||
document.getElementById('cart-total').textContent = formatMoney(total);
|
document.getElementById('cart-total').textContent = formatMoney(total);
|
||||||
@@ -2426,6 +2541,9 @@ function restoreAutosaveDraftIfAny() {
|
|||||||
customPriceInput.value = '';
|
customPriceInput.value = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (payload.notes) {
|
||||||
|
restorePricingStateFromNotes(payload.notes);
|
||||||
|
}
|
||||||
hasUnsavedChanges = true;
|
hasUnsavedChanges = true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// ignore invalid draft
|
// ignore invalid draft
|
||||||
@@ -2584,7 +2702,7 @@ function formatDiffPercent(baseTotal, compareTotal, compareLabel) {
|
|||||||
}
|
}
|
||||||
const pct = ((baseTotal - compareTotal) / compareTotal) * 100;
|
const pct = ((baseTotal - compareTotal) / compareTotal) * 100;
|
||||||
const sign = pct > 0 ? '+' : '';
|
const sign = pct > 0 ? '+' : '';
|
||||||
return `${sign}${pct.toFixed(1)}% от ${compareLabel}`;
|
return `${sign}${pct.toFixed(1).replace('.', ',')}% от ${compareLabel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTotalClass(current, references) {
|
function getTotalClass(current, references) {
|
||||||
@@ -2709,7 +2827,7 @@ function calculateCustomPrice() {
|
|||||||
|
|
||||||
// Show discount info
|
// Show discount info
|
||||||
discountInfoEl.classList.remove('hidden');
|
discountInfoEl.classList.remove('hidden');
|
||||||
discountPercentEl.textContent = discountPercent.toFixed(1) + '%';
|
discountPercentEl.textContent = discountPercent.toFixed(1).replace('.', ',') + '%';
|
||||||
|
|
||||||
// Update discount color based on value
|
// Update discount color based on value
|
||||||
const discountEl = discountPercentEl;
|
const discountEl = discountPercentEl;
|
||||||
@@ -2817,7 +2935,6 @@ async function exportCSVWithCustomPrice() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshPrices() {
|
async function refreshPrices() {
|
||||||
// RBAC disabled - no token check required
|
|
||||||
if (!configUUID) return;
|
if (!configUUID) return;
|
||||||
if (disablePriceRefresh) {
|
if (disablePriceRefresh) {
|
||||||
showToast('Обновление цен отключено в настройках', 'error');
|
showToast('Обновление цен отключено в настройках', 'error');
|
||||||
@@ -2834,30 +2951,7 @@ async function refreshPrices() {
|
|||||||
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
|
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
|
||||||
}
|
}
|
||||||
|
|
||||||
let serverSyncSkipped = false;
|
await loadActivePricelists(true);
|
||||||
try {
|
|
||||||
const statusResp = await fetch('/api/sync/status');
|
|
||||||
const statusData = statusResp.ok ? await statusResp.json() : null;
|
|
||||||
if (statusData && statusData.is_online) {
|
|
||||||
const componentSyncResp = await fetch('/api/sync/components', { method: 'POST' });
|
|
||||||
if (!componentSyncResp.ok) throw new Error('component sync failed');
|
|
||||||
|
|
||||||
const pricelistSyncResp = await fetch('/api/sync/pricelists', { method: 'POST' });
|
|
||||||
if (!pricelistSyncResp.ok) throw new Error('pricelist sync failed');
|
|
||||||
} else {
|
|
||||||
serverSyncSkipped = true;
|
|
||||||
}
|
|
||||||
} catch(syncErr) {
|
|
||||||
if (syncErr.message === 'component sync failed' || syncErr.message === 'pricelist sync failed') {
|
|
||||||
throw syncErr;
|
|
||||||
}
|
|
||||||
serverSyncSkipped = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
loadActivePricelists(true),
|
|
||||||
loadAllComponents()
|
|
||||||
]);
|
|
||||||
|
|
||||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||||||
const latest = activePricelistsBySource[source]?.[0];
|
const latest = activePricelistsBySource[source]?.[0];
|
||||||
@@ -2871,22 +2965,44 @@ async function refreshPrices() {
|
|||||||
renderPricelistSettingsSummary();
|
renderPricelistSettingsSummary();
|
||||||
persistLocalPriceSettings();
|
persistLocalPriceSettings();
|
||||||
|
|
||||||
|
// Snapshot prices before refresh for diff
|
||||||
|
const beforePricesMap = {};
|
||||||
|
let beforeTotal = 0;
|
||||||
|
for (const item of cart) {
|
||||||
|
const p = getDisplayPrice(item);
|
||||||
|
beforePricesMap[item.lot_name] = { price: p, qty: item.quantity };
|
||||||
|
beforeTotal += p * item.quantity;
|
||||||
|
}
|
||||||
|
beforeTotal *= serverCount;
|
||||||
|
|
||||||
await saveConfig(false);
|
await saveConfig(false);
|
||||||
await refreshPriceLevels({ force: true, noCache: true });
|
await refreshPriceLevels({ force: true, noCache: true });
|
||||||
renderTab();
|
renderTab();
|
||||||
updateCartUI();
|
updateCartUI();
|
||||||
|
|
||||||
|
// Compute diff after refresh
|
||||||
|
const itemDiffs = [];
|
||||||
|
let afterTotal = 0;
|
||||||
|
for (const item of cart) {
|
||||||
|
const newPrice = getDisplayPrice(item);
|
||||||
|
afterTotal += newPrice * item.quantity;
|
||||||
|
const before = beforePricesMap[item.lot_name];
|
||||||
|
if (before && Math.abs(before.price - newPrice) > 0.01) {
|
||||||
|
itemDiffs.push({ lot_name: item.lot_name, quantity: item.quantity, prevPrice: before.price, newPrice });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
afterTotal *= serverCount;
|
||||||
|
|
||||||
if (configUUID) {
|
if (configUUID) {
|
||||||
const configResp = await fetch('/api/configs/' + configUUID);
|
const configResp = await fetch('/api/configs/' + configUUID);
|
||||||
if (configResp.ok) {
|
if (configResp.ok) {
|
||||||
const config = await configResp.json();
|
const config = await configResp.json();
|
||||||
if (config.price_updated_at) {
|
if (config.price_updated_at) updatePriceUpdateDate(config.price_updated_at);
|
||||||
updatePriceUpdateDate(config.price_updated_at);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(serverSyncSkipped ? 'Цены обновлены (без связи с сервером)' : 'Цены обновлены', 'success');
|
showToast('Цены обновлены', 'success');
|
||||||
|
showPriceDiffModal([{ configName: configName || 'Конфигурация', prevTotal: beforeTotal, newTotal: afterTotal, serverCount, itemDiffs }]);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
showToast('Ошибка обновления цен', 'error');
|
showToast('Ошибка обновления цен', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -3056,62 +3172,44 @@ function _normalizeBomRawRows(rows) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _parseInspurBOMText(text) {
|
function _applyParsedBOMRows(parsed) {
|
||||||
const lines = text.split(/\r?\n/);
|
if (!Array.isArray(parsed) || !parsed.length) return false;
|
||||||
const result = [];
|
bomImportRaw = {
|
||||||
for (const raw of lines) {
|
mode: 'raw',
|
||||||
const line = raw.trim();
|
rows: parsed,
|
||||||
if (!line) continue;
|
columnTypes: ['pn', 'qty'],
|
||||||
const clean = line.startsWith('|') ? line.slice(1).trim() : line;
|
ignoredRows: {},
|
||||||
if (!clean) continue;
|
rowErrors: {},
|
||||||
const starIdx = clean.lastIndexOf('*');
|
uiError: ''
|
||||||
if (starIdx > 0) {
|
};
|
||||||
const suffix = clean.slice(starIdx + 1).trim();
|
bomRows = [];
|
||||||
if (/^\d+$/.test(suffix)) {
|
_setBomUIError('');
|
||||||
result.push([clean.slice(0, starIdx).trim(), suffix]);
|
rebuildBOMRowsFromRaw();
|
||||||
continue;
|
renderBOMTable();
|
||||||
}
|
return true;
|
||||||
}
|
|
||||||
result.push([clean, '1']);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _isInspurBOMText(text) {
|
// Detection and parsing of known single-column text BOM formats (Inspur, Russian
|
||||||
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
|
// text BOM) lives only on the server (internal/services/vendor_workspace_import.go),
|
||||||
if (!lines.length) return false;
|
// shared with the vendor file-import path. The paste handler asks the server to
|
||||||
let matches = 0;
|
// parse; an unrecognized payload falls back to the generic Excel column grid below.
|
||||||
for (const line of lines) {
|
async function _serverParseBOMText(text) {
|
||||||
const t = line.trim();
|
try {
|
||||||
const idx = t.lastIndexOf('*');
|
const resp = await fetch('/api/vendor-spec/parse-text', {
|
||||||
if (idx > 0 && /^\d+$/.test(t.slice(idx + 1).trim())) matches++;
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({text})
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!Array.isArray(data.rows) || !data.rows.length) return null;
|
||||||
|
return data.rows.map(r => [r.vendor_partnumber || '', String(r.quantity || '')]);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return matches > 0 && matches >= Math.ceil(lines.length * 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBOMPaste(event) {
|
function _applyGenericBOMPaste(text) {
|
||||||
event.preventDefault();
|
|
||||||
const text = event.clipboardData.getData('text/plain');
|
|
||||||
if (!text || !text.trim()) return;
|
|
||||||
|
|
||||||
if (_isInspurBOMText(text)) {
|
|
||||||
const parsed = _parseInspurBOMText(text);
|
|
||||||
if (!parsed.length) return;
|
|
||||||
bomImportRaw = {
|
|
||||||
mode: 'raw',
|
|
||||||
rows: parsed,
|
|
||||||
columnTypes: ['pn', 'qty'],
|
|
||||||
ignoredRows: {},
|
|
||||||
rowErrors: {},
|
|
||||||
uiError: ''
|
|
||||||
};
|
|
||||||
bomRows = [];
|
|
||||||
_setBomUIError('');
|
|
||||||
rebuildBOMRowsFromRaw();
|
|
||||||
renderBOMTable();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = text.split(/\r?\n/).filter(l => l.length > 0);
|
const lines = text.split(/\r?\n/).filter(l => l.length > 0);
|
||||||
if (!lines.length) return;
|
if (!lines.length) return;
|
||||||
const rows = lines.map(l => l.split('\t').map(c => c.trim()));
|
const rows = lines.map(l => l.split('\t').map(c => c.trim()));
|
||||||
@@ -3131,6 +3229,20 @@ function handleBOMPaste(event) {
|
|||||||
renderBOMTable();
|
renderBOMTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBOMPaste(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = event.clipboardData.getData('text/plain');
|
||||||
|
if (!text || !text.trim()) return;
|
||||||
|
|
||||||
|
// Tabs mean a real spreadsheet table — go straight to the column grid.
|
||||||
|
if (!text.includes('\t')) {
|
||||||
|
const parsed = await _serverParseBOMText(text);
|
||||||
|
if (_applyParsedBOMRows(parsed)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyGenericBOMPaste(text);
|
||||||
|
}
|
||||||
|
|
||||||
function _getBomColumnTypeIndexes() {
|
function _getBomColumnTypeIndexes() {
|
||||||
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null;
|
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null;
|
||||||
const idx = { ignore: [], pn: [], qty: [], price: [], description: [] };
|
const idx = { ignore: [], pn: [], qty: [], price: [], description: [] };
|
||||||
@@ -3215,7 +3327,7 @@ function _bomRawLotCell(rowIdx) {
|
|||||||
cart.forEach(item => { cartMap[item.lot_name] = item.quantity; });
|
cart.forEach(item => { cartMap[item.lot_name] = item.quantity; });
|
||||||
const isUnresolved = !map.resolved_lot || map.resolution_source === 'unresolved';
|
const isUnresolved = !map.resolved_lot || map.resolution_source === 'unresolved';
|
||||||
const cartQty = map.resolved_lot ? (cartMap[map.resolved_lot] ?? null) : null;
|
const cartQty = map.resolved_lot ? (cartMap[map.resolved_lot] ?? null) : null;
|
||||||
const qtyMismatch = cartQty !== null && cartQty !== map.quantity;
|
const qtyMismatch = cartQty !== null && cartQty !== map.quantity * _getRowLotQtyPerPN(map);
|
||||||
const notInCart = map.resolved_lot && cartQty === null;
|
const notInCart = map.resolved_lot && cartQty === null;
|
||||||
|
|
||||||
if (isUnresolved) {
|
if (isUnresolved) {
|
||||||
@@ -3601,7 +3713,7 @@ function _renderBOMParsedTable() {
|
|||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
|
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
|
||||||
const cartQty = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null;
|
const cartQty = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null;
|
||||||
const qtyMismatch = cartQty !== null && cartQty !== row.quantity;
|
const qtyMismatch = cartQty !== null && cartQty !== row.quantity * _getRowLotQtyPerPN(row);
|
||||||
const notInCart = row.resolved_lot && cartQty === null;
|
const notInCart = row.resolved_lot && cartQty === null;
|
||||||
if (isUnresolved) unresolved++;
|
if (isUnresolved) unresolved++;
|
||||||
if (qtyMismatch || notInCart) mismatches++;
|
if (qtyMismatch || notInCart) mismatches++;
|
||||||
@@ -3668,7 +3780,7 @@ function _renderBOMRawTable() {
|
|||||||
else if (parsed) {
|
else if (parsed) {
|
||||||
const isUnresolved = !parsed.resolved_lot || parsed.resolution_source === 'unresolved';
|
const isUnresolved = !parsed.resolved_lot || parsed.resolution_source === 'unresolved';
|
||||||
const cartQty = parsed.resolved_lot ? (cartMap[parsed.resolved_lot] ?? null) : null;
|
const cartQty = parsed.resolved_lot ? (cartMap[parsed.resolved_lot] ?? null) : null;
|
||||||
const qtyMismatch = cartQty !== null && cartQty !== parsed.quantity;
|
const qtyMismatch = cartQty !== null && cartQty !== parsed.quantity * _getRowLotQtyPerPN(parsed);
|
||||||
const notInCart = parsed.resolved_lot && cartQty === null;
|
const notInCart = parsed.resolved_lot && cartQty === null;
|
||||||
if (isUnresolved) unresolved++;
|
if (isUnresolved) unresolved++;
|
||||||
if (qtyMismatch || notInCart) mismatches++;
|
if (qtyMismatch || notInCart) mismatches++;
|
||||||
@@ -3936,6 +4048,9 @@ async function renderPricingTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Collect LOTs to price: from BOM rows (resolved) or from cart
|
// Collect LOTs to price: from BOM rows (resolved) or from cart
|
||||||
|
// Use cart quantity when available (source of truth); fall back to BOM-computed quantity.
|
||||||
|
const _cartQtyMap = {};
|
||||||
|
cart.forEach(item => { if (item?.lot_name) _cartQtyMap[item.lot_name] = item.quantity; });
|
||||||
let itemsForPriceLevels = [];
|
let itemsForPriceLevels = [];
|
||||||
if (bomRows.length) {
|
if (bomRows.length) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -3944,13 +4059,13 @@ async function renderPricingTab() {
|
|||||||
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
||||||
if (baseLot && !seen.has(baseLot)) {
|
if (baseLot && !seen.has(baseLot)) {
|
||||||
seen.add(baseLot);
|
seen.add(baseLot);
|
||||||
itemsForPriceLevels.push({ lot_name: baseLot, quantity: row.quantity * _getRowLotQtyPerPN(row) });
|
itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[baseLot] ?? (row.quantity * _getRowLotQtyPerPN(row)) });
|
||||||
}
|
}
|
||||||
if (allocs.length) {
|
if (allocs.length) {
|
||||||
allocs.forEach(a => {
|
allocs.forEach(a => {
|
||||||
if (!seen.has(a.lot_name)) {
|
if (!seen.has(a.lot_name)) {
|
||||||
seen.add(a.lot_name);
|
seen.add(a.lot_name);
|
||||||
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: row.quantity * a.quantity });
|
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4003,6 +4118,8 @@ async function renderPricingTab() {
|
|||||||
|
|
||||||
// ─── Build shared row data (unit prices for display, totals for math) ────
|
// ─── Build shared row data (unit prices for display, totals for math) ────
|
||||||
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
|
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
|
||||||
|
const cartQtyMap = {};
|
||||||
|
cart.forEach(item => { if (item?.lot_name) cartQtyMap[item.lot_name] = item.quantity; });
|
||||||
const _buildRows = () => {
|
const _buildRows = () => {
|
||||||
const result = [];
|
const result = [];
|
||||||
const coveredLots = new Set();
|
const coveredLots = new Set();
|
||||||
@@ -4026,7 +4143,12 @@ async function renderPricingTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!bomRows.length) {
|
if (!bomRows.length) {
|
||||||
cart.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
|
const sortedByCategory = [...cart].sort((a, b) => {
|
||||||
|
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
|
||||||
|
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
|
||||||
|
return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999);
|
||||||
|
});
|
||||||
|
sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
|
||||||
return { result, coveredLots };
|
return { result, coveredLots };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4047,7 +4169,7 @@ async function renderPricingTab() {
|
|||||||
if (baseLot) {
|
if (baseLot) {
|
||||||
const u = _getUnitPrices(priceMap[baseLot]);
|
const u = _getUnitPrices(priceMap[baseLot]);
|
||||||
const lotQty = _getRowLotQtyPerPN(row);
|
const lotQty = _getRowLotQtyPerPN(row);
|
||||||
const qty = row.quantity * lotQty;
|
const qty = cartQtyMap[baseLot] ?? (row.quantity * lotQty);
|
||||||
subRows.push({
|
subRows.push({
|
||||||
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
|
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
|
||||||
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||||
@@ -4059,7 +4181,7 @@ async function renderPricingTab() {
|
|||||||
}
|
}
|
||||||
allocs.forEach(a => {
|
allocs.forEach(a => {
|
||||||
const u = _getUnitPrices(priceMap[a.lot_name]);
|
const u = _getUnitPrices(priceMap[a.lot_name]);
|
||||||
const qty = row.quantity * a.quantity;
|
const qty = cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity);
|
||||||
subRows.push({
|
subRows.push({
|
||||||
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
|
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
|
||||||
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||||
@@ -4274,7 +4396,7 @@ function applyCustomPrice(table) {
|
|||||||
if (est <= 0) return '';
|
if (est <= 0) return '';
|
||||||
const pct = ((est - custom) / est * 100);
|
const pct = ((est - custom) / est * 100);
|
||||||
const sign = pct >= 0 ? '-' : '+';
|
const sign = pct >= 0 ? '-' : '+';
|
||||||
return ` (${sign}${Math.abs(pct).toFixed(1)}%)`;
|
return ` (${sign}${Math.abs(pct).toFixed(1).replace('.', ',')}%)`;
|
||||||
};
|
};
|
||||||
const _pctClass = (custom, est) => custom <= est ? 'text-green-600' : 'text-red-600';
|
const _pctClass = (custom, est) => custom <= est ? 'text-green-600' : 'text-red-600';
|
||||||
|
|
||||||
@@ -4367,7 +4489,7 @@ function setPricingCustomPriceFromVendor() {
|
|||||||
const totalEl = document.getElementById('pricing-total-buy-vendor');
|
const totalEl = document.getElementById('pricing-total-buy-vendor');
|
||||||
if (hasAny) {
|
if (hasAny) {
|
||||||
document.getElementById('pricing-custom-price-buy').value = total.toFixed(2);
|
document.getElementById('pricing-custom-price-buy').value = total.toFixed(2);
|
||||||
const pct = estimateTotal > 0 ? ` (-${((estimateTotal - total) / estimateTotal * 100).toFixed(1)}%)` : '';
|
const pct = estimateTotal > 0 ? ` (-${((estimateTotal - total) / estimateTotal * 100).toFixed(1).replace('.', ',')}%)` : '';
|
||||||
totalEl.textContent = formatCurrency(total) + pct;
|
totalEl.textContent = formatCurrency(total) + pct;
|
||||||
totalEl.className = totalEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
|
totalEl.className = totalEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
|
||||||
totalEl.classList.add(total <= estimateTotal ? 'text-green-600' : 'text-red-600');
|
totalEl.classList.add(total <= estimateTotal ? 'text-green-600' : 'text-red-600');
|
||||||
@@ -4380,6 +4502,8 @@ function setPricingCustomPriceFromVendor() {
|
|||||||
async function exportPricingCSV(table) {
|
async function exportPricingCSV(table) {
|
||||||
if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; }
|
if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; }
|
||||||
const basis = table === 'sale' ? 'ddp' : 'fob';
|
const basis = table === 'sale' ? 'ddp' : 'fob';
|
||||||
|
const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy';
|
||||||
|
const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || '');
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
|
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -4391,6 +4515,7 @@ async function exportPricingCSV(table) {
|
|||||||
include_stock: true,
|
include_stock: true,
|
||||||
include_competitor: true,
|
include_competitor: true,
|
||||||
basis: basis,
|
basis: basis,
|
||||||
|
manual_price: manualPrice > 0 ? manualPrice : null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }
|
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}OFS - Партномера{{end}}
|
{{define "title"}}QFS Партномера{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -22,20 +22,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
<div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||||
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера.
|
Нет активного листа сопоставлений. Книги загружаются автоматически вместе с прайслистами.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- All books list (collapsed by default) -->
|
<!-- All books list (collapsed by default) -->
|
||||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
<!-- Header row — always visible -->
|
<!-- Header row — always visible -->
|
||||||
<div class="px-4 py-3 flex items-center justify-between">
|
<div class="px-4 py-3">
|
||||||
<button onclick="toggleBooksSection()" class="flex items-center gap-2 text-sm font-semibold text-gray-800 hover:text-gray-600 select-none">
|
<button onclick="toggleBooksSection()" class="flex items-center gap-2 text-sm font-semibold text-gray-800 hover:text-gray-600 select-none">
|
||||||
<svg id="books-chevron" class="w-4 h-4 text-gray-400 transition-transform duration-150" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
<svg id="books-chevron" class="w-4 h-4 text-gray-400 transition-transform duration-150" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||||
Снимки сопоставлений (Partnumber Books)
|
Снимки сопоставлений (Partnumber Books)
|
||||||
</button>
|
</button>
|
||||||
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 text-sm font-medium">
|
|
||||||
Синхронизировать
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Collapsible body -->
|
<!-- Collapsible body -->
|
||||||
<div id="books-section-body" class="hidden border-t">
|
<div id="books-section-body" class="hidden border-t">
|
||||||
@@ -69,7 +66,7 @@
|
|||||||
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
|
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
|
||||||
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
|
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
|
||||||
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
|
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
|
||||||
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
|
<input type="text" id="pn-search" placeholder="Поиск по PN, LOT или описанию..."
|
||||||
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||||
oninput="onItemsSearchInput(this.value)">
|
oninput="onItemsSearchInput(this.value)">
|
||||||
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
|
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
|
||||||
@@ -127,7 +124,7 @@ async function loadBooks() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
allBooks = data.books || [];
|
allBooks = data.items || [];
|
||||||
document.getElementById('books-list-loading').classList.add('hidden');
|
document.getElementById('books-list-loading').classList.add('hidden');
|
||||||
|
|
||||||
if (!allBooks.length) {
|
if (!allBooks.length) {
|
||||||
@@ -213,7 +210,7 @@ async function loadActiveBookItemsPage(page = 1, search = '', book = null) {
|
|||||||
|
|
||||||
activeItems = data.items || [];
|
activeItems = data.items || [];
|
||||||
itemsPage = data.page || page;
|
itemsPage = data.page || page;
|
||||||
itemsTotal = Number(data.total || 0);
|
itemsTotal = Number(data.total_count || 0);
|
||||||
itemsSearch = data.search || search || '';
|
itemsSearch = data.search || search || '';
|
||||||
|
|
||||||
document.getElementById('card-version').textContent = targetBook.version;
|
document.getElementById('card-version').textContent = targetBook.version;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Прайслист - OFS{{end}}
|
{{define "title"}}QFS Прайслист{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
toggleWarehouseColumns();
|
toggleWarehouseColumns();
|
||||||
|
|
||||||
renderItems(data.items || []);
|
renderItems(data.items || []);
|
||||||
renderItemsPagination(data.total, data.page, data.per_page);
|
renderItemsPagination(data.total_count, data.page, data.per_page);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('items-body').innerHTML = `
|
document.getElementById('items-body').innerHTML = `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
const descMax = stock ? 30 : 60;
|
const descMax = stock ? 30 : 60;
|
||||||
|
|
||||||
const html = items.map(item => {
|
const html = items.map(item => {
|
||||||
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
const price = item.price.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
const description = item.lot_description || '-';
|
const description = item.lot_description || '-';
|
||||||
const truncatedDesc = description.length > descMax ? description.substring(0, descMax) + '...' : description;
|
const truncatedDesc = description.length > descMax ? description.substring(0, descMax) + '...' : description;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Прайслисты - OFS{{end}}
|
{{define "title"}}QFS Прайслисты{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -83,8 +83,8 @@
|
|||||||
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
renderPricelists(data.pricelists || []);
|
renderPricelists(data.items || []);
|
||||||
renderPagination(data.total, data.page, data.per_page);
|
renderPagination(data.total_count, data.page, data.per_page);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('pricelists-body').innerHTML = `
|
document.getElementById('pricelists-body').innerHTML = `
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Проект - OFS{{end}}
|
{{define "title"}}QFS Проект{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||||
+ Конфигурация
|
+ Конфигурация
|
||||||
</button>
|
</button>
|
||||||
<button id="refresh-all-prices-btn" onclick="refreshAllPrices()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
<button id="refresh-all-prices-btn" onclick="refreshPrices()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||||
Обновить цены
|
Обновить цены
|
||||||
</button>
|
</button>
|
||||||
<button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium">
|
<button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium">
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||||
<input type="checkbox" id="variant-action-copy" class="rounded border-gray-300" checked>
|
<input type="checkbox" id="variant-action-copy" class="rounded border-gray-300">
|
||||||
Создать копию
|
Создать копию
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||||
<input type="checkbox" id="config-action-copy" class="rounded border-gray-300" checked>
|
<input type="checkbox" id="config-action-copy" class="rounded border-gray-300">
|
||||||
Создать копию
|
Создать копию
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
@@ -339,7 +339,7 @@ function escapeHtml(text) {
|
|||||||
|
|
||||||
function formatMoneyNoDecimals(value) {
|
function formatMoneyNoDecimals(value) {
|
||||||
const safe = Number.isFinite(Number(value)) ? Number(value) : 0;
|
const safe = Number.isFinite(Number(value)) ? Number(value) : 0;
|
||||||
return '$' + Math.round(safe).toLocaleString('en-US');
|
return '$' + Math.round(safe).toLocaleString('ru-RU');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProjectTrackerURL(projectData) {
|
function resolveProjectTrackerURL(projectData) {
|
||||||
@@ -634,7 +634,7 @@ function openVariantActionModal() {
|
|||||||
document.getElementById('variant-action-current-code').value = currentCode;
|
document.getElementById('variant-action-current-code').value = currentCode;
|
||||||
document.getElementById('variant-action-name').value = currentName;
|
document.getElementById('variant-action-name').value = currentName;
|
||||||
document.getElementById('variant-action-code').value = currentCode;
|
document.getElementById('variant-action-code').value = currentCode;
|
||||||
document.getElementById('variant-action-copy').checked = true;
|
document.getElementById('variant-action-copy').checked = false;
|
||||||
document.getElementById('variant-action-modal').classList.remove('hidden');
|
document.getElementById('variant-action-modal').classList.remove('hidden');
|
||||||
document.getElementById('variant-action-modal').classList.add('flex');
|
document.getElementById('variant-action-modal').classList.add('flex');
|
||||||
const nameInput = document.getElementById('variant-action-name');
|
const nameInput = document.getElementById('variant-action-name');
|
||||||
@@ -1105,7 +1105,7 @@ async function openConfigActionModal(uuid, currentName, currentProjectUUID) {
|
|||||||
document.getElementById('config-action-current-name').value = currentName;
|
document.getElementById('config-action-current-name').value = currentName;
|
||||||
document.getElementById('config-action-current-project').value = currentProjectUUID || projectUUID;
|
document.getElementById('config-action-current-project').value = currentProjectUUID || projectUUID;
|
||||||
document.getElementById('config-action-name').value = currentName;
|
document.getElementById('config-action-name').value = currentName;
|
||||||
document.getElementById('config-action-copy').checked = true;
|
document.getElementById('config-action-copy').checked = false;
|
||||||
populateProjectAutocomplete();
|
populateProjectAutocomplete();
|
||||||
const currentProject = projectsCatalog.find(p => p.uuid === (currentProjectUUID || projectUUID));
|
const currentProject = projectsCatalog.find(p => p.uuid === (currentProjectUUID || projectUUID));
|
||||||
if (currentProject) {
|
if (currentProject) {
|
||||||
@@ -1572,10 +1572,10 @@ async function exportProject() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAllPrices() {
|
async function refreshPrices() {
|
||||||
const configs = (allConfigs || []).filter(c => c.is_active !== false);
|
const configs = (allConfigs || []).filter(c => c.is_active !== false);
|
||||||
if (!configs.length) {
|
if (!configs.length) {
|
||||||
if (typeof showToast === 'function') showToast('Нет активных конфигураций', 'error');
|
showToast('Нет активных конфигураций', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1586,87 +1586,76 @@ async function refreshAllPrices() {
|
|||||||
btn.className = 'py-2 bg-gray-300 text-gray-500 rounded-lg cursor-not-allowed font-medium';
|
btn.className = 'py-2 bg-gray-300 text-gray-500 rounded-lg cursor-not-allowed font-medium';
|
||||||
}
|
}
|
||||||
|
|
||||||
let serverSyncSkipped = false;
|
|
||||||
try {
|
try {
|
||||||
const statusResp = await fetch('/api/sync/status');
|
const latestEstimatePricelistId = await fetchLatestEstimatePricelistId();
|
||||||
const statusData = statusResp.ok ? await statusResp.json() : null;
|
|
||||||
if (statusData && statusData.is_online) {
|
|
||||||
const componentSyncResp = await fetch('/api/sync/components', { method: 'POST' });
|
|
||||||
if (!componentSyncResp.ok) throw new Error('component sync failed');
|
|
||||||
const pricelistSyncResp = await fetch('/api/sync/pricelists', { method: 'POST' });
|
|
||||||
if (!pricelistSyncResp.ok) throw new Error('pricelist sync failed');
|
|
||||||
} else {
|
|
||||||
serverSyncSkipped = true;
|
|
||||||
}
|
|
||||||
} catch (syncErr) {
|
|
||||||
if (syncErr.message === 'component sync failed' || syncErr.message === 'pricelist sync failed') {
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Обновить цены';
|
|
||||||
btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium';
|
|
||||||
}
|
|
||||||
if (typeof showToast === 'function') showToast('Ошибка синхронизации прайс-листов', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
serverSyncSkipped = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve latest estimate pricelist ID to pass explicitly, so each config
|
const diffResults = [];
|
||||||
// is updated to the newest pricelist rather than the one stored in the config.
|
for (const cfg of configs) {
|
||||||
let latestEstimatePricelistId = null;
|
if (cfg.disable_price_refresh) {
|
||||||
try {
|
diffResults.push({ configName: cfg.name, skipped: true });
|
||||||
const plResp = await fetch('/api/pricelists?active_only=true&source=estimate&per_page=1');
|
continue;
|
||||||
if (plResp.ok) {
|
|
||||||
const plData = await plResp.json();
|
|
||||||
const list = plData.pricelists || plData.items || plData;
|
|
||||||
if (Array.isArray(list) && list.length > 0 && list[0].id) {
|
|
||||||
latestEstimatePricelistId = Number(list[0].id);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
let failed = 0;
|
const prevTotal = cfg.total_price || 0;
|
||||||
let newTotalSum = 0;
|
const prevItemsMap = {};
|
||||||
for (const cfg of configs) {
|
if (cfg.items) {
|
||||||
try {
|
for (const item of cfg.items) prevItemsMap[item.lot_name] = item.unit_price;
|
||||||
const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined;
|
}
|
||||||
const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
|
|
||||||
method: 'POST',
|
try {
|
||||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined;
|
||||||
body,
|
const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
|
||||||
});
|
method: 'POST',
|
||||||
if (!resp.ok) { failed++; continue; }
|
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||||
const updated = await resp.json();
|
body,
|
||||||
if (updated && updated.total_price != null) {
|
});
|
||||||
cfg.total_price = updated.total_price;
|
if (!resp.ok) {
|
||||||
const totalCell = document.querySelector('[data-total-uuid="' + cfg.uuid + '"]');
|
diffResults.push({ configName: cfg.name, error: true });
|
||||||
if (totalCell) totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
|
continue;
|
||||||
const serverCount = cfg.server_count || 1;
|
|
||||||
const unitPrice = serverCount > 0 ? (updated.total_price / serverCount) : 0;
|
|
||||||
const row = totalCell && totalCell.closest('tr');
|
|
||||||
if (row) {
|
|
||||||
const cells = row.querySelectorAll('td');
|
|
||||||
if (cells[4]) cells[4].textContent = formatMoneyNoDecimals(unitPrice);
|
|
||||||
}
|
}
|
||||||
|
const updated = await resp.json();
|
||||||
|
|
||||||
|
cfg.total_price = updated.total_price ?? cfg.total_price;
|
||||||
|
if (updated.items) cfg.items = updated.items;
|
||||||
|
|
||||||
|
const totalCell = document.querySelector('[data-total-uuid="' + cfg.uuid + '"]');
|
||||||
|
if (totalCell && updated.total_price != null) {
|
||||||
|
totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
|
||||||
|
const sc = cfg.server_count || 1;
|
||||||
|
const row = totalCell.closest('tr');
|
||||||
|
if (row) {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
if (cells[4]) cells[4].textContent = formatMoneyNoDecimals(sc > 0 ? updated.total_price / sc : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemDiffs = [];
|
||||||
|
if (updated.items) {
|
||||||
|
for (const item of updated.items) {
|
||||||
|
const prevPrice = prevItemsMap[item.lot_name];
|
||||||
|
if (prevPrice !== undefined && Math.abs(prevPrice - item.unit_price) > 0.01) {
|
||||||
|
itemDiffs.push({ lot_name: item.lot_name, quantity: item.quantity, prevPrice, newPrice: item.unit_price });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diffResults.push({ configName: cfg.name, prevTotal, newTotal: updated.total_price || 0, serverCount: cfg.server_count || 1, itemDiffs });
|
||||||
|
} catch(_) {
|
||||||
|
diffResults.push({ configName: cfg.name, error: true });
|
||||||
}
|
}
|
||||||
newTotalSum += cfg.total_price || 0;
|
}
|
||||||
} catch { failed++; }
|
|
||||||
}
|
|
||||||
|
|
||||||
const footerTotal = document.querySelector('[data-footer-total="1"]');
|
updateFooterTotal();
|
||||||
if (footerTotal) footerTotal.textContent = formatMoneyNoDecimals(newTotalSum);
|
showToast('Цены обновлены', 'success');
|
||||||
|
showPriceDiffModal(diffResults);
|
||||||
if (btn) {
|
} catch(e) {
|
||||||
btn.disabled = false;
|
showToast('Ошибка обновления цен', 'error');
|
||||||
btn.textContent = 'Обновить цены';
|
} finally {
|
||||||
btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium';
|
if (btn) {
|
||||||
}
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Обновить цены';
|
||||||
if (failed > 0) {
|
btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium';
|
||||||
if (typeof showToast === 'function') showToast('Часть конфигураций не обновилась (' + failed + ')', 'error');
|
}
|
||||||
} else {
|
|
||||||
const msg = serverSyncSkipped ? 'Цены обновлены (без связи с сервером)' : 'Цены обновлены';
|
|
||||||
if (typeof showToast === 'function') showToast(msg, 'success');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Мои проекты - OFS{{end}}
|
{{define "title"}}QFS Мои проекты{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -81,7 +81,7 @@ function escapeHtml(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMoney(v) {
|
function formatMoney(v) {
|
||||||
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2});
|
return '$' + (v || 0).toLocaleString('ru-RU', {minimumFractionDigits: 2});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value) {
|
function formatDateTime(value) {
|
||||||
|
|||||||
Reference in New Issue
Block a user