Compare commits
25 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 |
@@ -116,6 +116,28 @@ Rules:
|
||||
- storage configurations use the same vendor_spec + PN→LOT + pricing flow as server configurations;
|
||||
- storage component categories map to existing tabs: `ENC`/`DKC`/`CTL` → Base, `HIC` → PCI (HIC-карты СХД; `HBA`/`NIC` — серверные, не смешивать), `SSD`/`HDD` → Storage (используют существующие серверные LOT), `ACC` → Accessories (используют существующие серверные LOT), `SW` → SW.
|
||||
- `DKC` = контроллерная полка (модель СХД + тип дисков + кол-во слотов + кол-во контроллеров); `CTL` = контроллер (кэш + встроенные порты); `ENC` = дисковая полка без контроллера.
|
||||
- the available config types and their localized names flow from `qt_settings.config_types` on the server;
|
||||
QF falls back to hardcoded "server/Сервер" and "storage/СХД" when the setting is absent.
|
||||
|
||||
## Server-driven configurator settings (`qt_settings`)
|
||||
|
||||
QF reads four settings from `qt_settings` (MariaDB) and caches them in `local_qt_settings` (SQLite).
|
||||
They are synced during every component sync. See `bible-local/server-contract-qt-settings.md` for the
|
||||
full contract and JSON schemas.
|
||||
|
||||
| Setting key | Effect in QF |
|
||||
|-------------|-------------|
|
||||
| `config_types` | New-config modal buttons; category allowlist per config type |
|
||||
| `tab_config` | Configurator tab structure, sections, singleSelect |
|
||||
| `always_visible_tabs` | Which tabs are shown even when empty |
|
||||
| `required_categories` | Per-config-type badge on tabs with unfilled required categories |
|
||||
|
||||
Rules:
|
||||
- sync runs 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
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ Main tables:
|
||||
| `connection_settings` | encrypted MariaDB connection settings |
|
||||
| `app_settings` | local app state |
|
||||
| `local_schema_migrations` | applied local migration markers |
|
||||
| `local_qt_settings` | server-pushed configurator settings cache (from `qt_settings`) |
|
||||
|
||||
Rules:
|
||||
- cache tables may be rebuilt if local migration recovery requires it;
|
||||
@@ -34,12 +35,13 @@ MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
|
||||
### QuoteForge tables (qt_*)
|
||||
|
||||
Runtime read:
|
||||
- `qt_categories` — pricelist categories
|
||||
- `qt_categories` — pricelist categories (note: `name`/`name_ru` columns being removed; QF does not use them)
|
||||
- `qt_lot_metadata` — component metadata, price settings
|
||||
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
||||
- `qt_pricelist_items` — pricelist rows
|
||||
- `qt_partnumber_books` — partnumber book headers
|
||||
- `qt_partnumber_book_items` — PN→LOT catalog payload
|
||||
- `qt_settings` — server-pushed configurator settings; schema managed by server-side agent (see `bible-local/server-contract-qt-settings.md`)
|
||||
|
||||
Runtime read/write:
|
||||
- `qt_projects` — projects
|
||||
@@ -48,7 +50,7 @@ Runtime read/write:
|
||||
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
|
||||
|
||||
Insert-only tracking:
|
||||
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync
|
||||
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
|
||||
|
||||
Server-side only (not queried by client runtime):
|
||||
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
||||
@@ -91,11 +93,26 @@ Full column reference as of 2026-03-21 (`RFQ_LOG` final schema).
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| code | varchar(20) UNIQUE NOT NULL | |
|
||||
| name | varchar(100) NOT NULL | |
|
||||
| name_ru | varchar(100) | |
|
||||
| name | varchar(100) NOT NULL | being removed; QF does not use at runtime |
|
||||
| name_ru | varchar(100) | being removed; QF does not use at runtime |
|
||||
| display_order | bigint DEFAULT 0 | |
|
||||
| is_required | tinyint(1) DEFAULT 0 | |
|
||||
|
||||
### qt_settings
|
||||
Managed by the server-side agent. QF has SELECT-only access.
|
||||
See `bible-local/server-contract-qt-settings.md` for full schema and value formats.
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| name | varchar(100) PK | setting key |
|
||||
| value | TEXT NOT NULL | JSON-encoded value |
|
||||
|
||||
### local_qt_settings (SQLite)
|
||||
Read-only cache of `qt_settings`. Synced during component sync.
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| name | text PK | setting key |
|
||||
| value | text | JSON value as-is from server |
|
||||
|
||||
### qt_client_schema_state
|
||||
PK: (username, hostname)
|
||||
| Column | Type | Notes |
|
||||
@@ -312,6 +329,7 @@ PK: job_name
|
||||
| ignored_by | varchar(100) | |
|
||||
| created_at | datetime(3) | |
|
||||
| updated_at | datetime(3) | |
|
||||
| lot_suggestion | longtext (JSON) | nullable; set when user manually maps PN → LOT in vendor-spec UI; same format as `qt_partnumber_book_items.lots_json`; see [11-lot-suggestions.md](11-lot-suggestions.md) |
|
||||
|
||||
### stock_ignore_rules
|
||||
| Column | Type | Notes |
|
||||
@@ -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_pricelists 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_book_items TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
|
||||
|
||||
@@ -80,3 +80,81 @@ Rules:
|
||||
- configuration `name` is derived from the uploaded filename (without extension);
|
||||
- lines that do not contain `*<digits>` are skipped;
|
||||
- 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 |
|
||||
| [07-dev.md](07-dev.md) | Development commands and guardrails |
|
||||
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
|
||||
| [10-agent-api-guide.md](10-agent-api-guide.md) | End-to-end API guide for agents pricing servers from a TZ |
|
||||
| [11-lot-suggestions.md](11-lot-suggestions.md) | lot_suggestion column in qt_vendor_partnumber_seen — write/read contract for manual UI mappings |
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
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 (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
@@ -153,7 +153,7 @@ func main() {
|
||||
log.Printf(" Skipped: %d", skipped)
|
||||
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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -79,12 +80,12 @@ func main() {
|
||||
|
||||
printPlan(actions)
|
||||
if len(actions) == 0 {
|
||||
fmt.Println("Nothing to migrate.")
|
||||
slog.Info("Nothing to migrate.")
|
||||
return
|
||||
}
|
||||
|
||||
if !*apply {
|
||||
fmt.Println("\nPreview complete. Re-run with -apply to execute.")
|
||||
slog.Info("Preview complete. Re-run with -apply to execute.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -94,7 +95,7 @@ func main() {
|
||||
log.Fatalf("confirmation failed: %v", confirmErr)
|
||||
}
|
||||
if !ok {
|
||||
fmt.Println("Aborted.")
|
||||
slog.Info("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -103,7 +104,7 @@ func main() {
|
||||
log.Fatalf("migration failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Migration completed successfully.")
|
||||
slog.Info("Migration completed successfully.")
|
||||
}
|
||||
|
||||
func ensureProjectsTable(db *gorm.DB) error {
|
||||
@@ -212,10 +213,8 @@ func printPlan(actions []migrationAction) {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Planned actions: %d\n", len(actions))
|
||||
fmt.Printf("Projects to create: %d\n", createCount)
|
||||
fmt.Printf("Projects to reactivate: %d\n", reactivateCount)
|
||||
fmt.Println("\nDetails:")
|
||||
slog.Info("Plan summary", "actions", len(actions), "create", createCount, "reactivate", reactivateCount)
|
||||
slog.Info("Details:")
|
||||
|
||||
for _, a := range actions {
|
||||
extra := ""
|
||||
|
||||
@@ -2,8 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"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))
|
||||
}
|
||||
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() {
|
||||
// Visible in console output.
|
||||
fmt.Println(startupConsoleWarning)
|
||||
slog.Warn(startupConsoleWarning)
|
||||
// Keep the warning always visible in the console window title when supported.
|
||||
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
|
||||
|
||||
syncService = sync.NewService(connMgr, local)
|
||||
componentService := services.NewComponentService(nil, nil, nil)
|
||||
quoteService := services.NewQuoteService(nil, nil, nil, local, nil)
|
||||
componentService := services.NewComponentService(nil, nil)
|
||||
quoteService := services.NewQuoteService(nil, nil, local, nil)
|
||||
exportService := services.NewExportService(cfg.Export, local)
|
||||
|
||||
// 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)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
|
||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||
vendorSpecHandler := handlers.NewVendorSpecHandler(local)
|
||||
vendorSpecHandler := handlers.NewVendorSpecHandler(local, syncService)
|
||||
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
|
||||
respondError := handlers.RespondError
|
||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||
@@ -920,6 +919,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
|
||||
// Categories (public)
|
||||
api.GET("/categories", componentHandler.GetCategories)
|
||||
api.GET("/configurator-settings", componentHandler.GetConfiguratorSettings)
|
||||
|
||||
// Quote (public)
|
||||
quote := api.Group("/quote")
|
||||
@@ -952,6 +952,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
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)
|
||||
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)
|
||||
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"})
|
||||
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{
|
||||
Components: components,
|
||||
Total: total,
|
||||
Items: components,
|
||||
TotalCount: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalPages: totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,3 +125,102 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, models.DefaultCategories)
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) GetConfiguratorSettings(c *gin.Context) {
|
||||
s, _ := h.localDB.GetConfiguratorSettings()
|
||||
if s == nil {
|
||||
s = &localdb.ConfiguratorSettings{}
|
||||
}
|
||||
|
||||
if len(s.ConfigTypes) == 0 {
|
||||
s.ConfigTypes = defaultConfigTypes()
|
||||
}
|
||||
if len(s.TabConfig) == 0 {
|
||||
s.TabConfig = defaultTabConfig()
|
||||
}
|
||||
if len(s.AlwaysVisibleTabs) == 0 {
|
||||
s.AlwaysVisibleTabs = []string{"base", "storage", "pci"}
|
||||
}
|
||||
if len(s.RequiredCategories) == 0 {
|
||||
s.RequiredCategories = map[string][]string{"server": {"CPU", "MEM", "BB"}}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
func defaultConfigTypes() []localdb.ConfigTypeDef {
|
||||
return []localdb.ConfigTypeDef{
|
||||
{
|
||||
Code: "server",
|
||||
NameRu: "Сервер",
|
||||
DisplayOrder: 10,
|
||||
Categories: []string{
|
||||
"MB", "CPU", "MEM", "RAID",
|
||||
"SSD", "HDD", "M2", "EDSFF", "HHHL",
|
||||
"GPU", "NIC", "HCA", "DPU", "HBA",
|
||||
"PSU", "PS", "ACC", "RISERS", "CARD", "BB",
|
||||
},
|
||||
},
|
||||
{
|
||||
Code: "storage",
|
||||
NameRu: "СХД",
|
||||
DisplayOrder: 20,
|
||||
Categories: []string{
|
||||
"DKC", "CPU", "MEM", "PS",
|
||||
"SSD", "HDD", "M2", "EDSFF", "HHHL",
|
||||
"NIC", "HBA", "HCA", "ACC", "CARD",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultTabConfig() []localdb.TabDef {
|
||||
return []localdb.TabDef{
|
||||
{
|
||||
Key: "base",
|
||||
Label: "Base",
|
||||
SingleSelect: true,
|
||||
Categories: []string{"MB", "CPU", "MEM", "ENC", "DKC", "CTL"},
|
||||
},
|
||||
{
|
||||
Key: "storage",
|
||||
Label: "Storage",
|
||||
SingleSelect: false,
|
||||
Categories: []string{"RAID", "M2", "SSD", "HDD", "EDSFF", "HHHL"},
|
||||
Sections: []localdb.TabSection{
|
||||
{Title: "RAID Контроллеры", Categories: []string{"RAID"}},
|
||||
{Title: "Диски", Categories: []string{"M2", "SSD", "HDD", "EDSFF", "HHHL"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "pci",
|
||||
Label: "PCI",
|
||||
SingleSelect: false,
|
||||
Categories: []string{"GPU", "DPU", "NIC", "HCA", "HBA", "HIC"},
|
||||
Sections: []localdb.TabSection{
|
||||
{Title: "GPU / DPU", Categories: []string{"GPU", "DPU"}},
|
||||
{Title: "NIC / HCA", Categories: []string{"NIC", "HCA"}},
|
||||
{Title: "HBA", Categories: []string{"HBA"}},
|
||||
{Title: "HIC", Categories: []string{"HIC"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "power",
|
||||
Label: "Power",
|
||||
SingleSelect: false,
|
||||
Categories: []string{"PS", "PSU"},
|
||||
},
|
||||
{
|
||||
Key: "accessories",
|
||||
Label: "Accessories",
|
||||
SingleSelect: false,
|
||||
Categories: []string{"ACC", "CARD"},
|
||||
},
|
||||
{
|
||||
Key: "sw",
|
||||
Label: "SW",
|
||||
SingleSelect: false,
|
||||
Categories: []string{"SW"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ type ProjectExportOptionsRequest struct {
|
||||
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
var req ExportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
|
||||
// Validate before streaming (can return JSON error)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
// 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 {
|
||||
RespondError(c, http.StatusNotFound, "resource not found", err)
|
||||
return
|
||||
@@ -160,7 +160,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
|
||||
// Validate before streaming (can return JSON error)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -228,11 +228,11 @@ func (h *ExportHandler) ExportConfigPricingCSV(c *gin.Context) {
|
||||
|
||||
var req ProjectExportOptionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.GetByUUID(uuid, h.dbUsername)
|
||||
config, err := h.configService.GetByUUIDNoAuth(uuid)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusNotFound, "resource not found", err)
|
||||
return
|
||||
@@ -285,7 +285,7 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||
|
||||
var req ProjectExportOptionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*model
|
||||
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) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -124,8 +128,8 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("Expected status 422, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
@@ -158,8 +162,8 @@ func TestExportCSV_EmptyItems(t *testing.T) {
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Should return 400 Bad Request (validation error from gin binding)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
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)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("Expected status 422, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
|
||||
@@ -51,8 +51,11 @@ func (h *PartnumberBooksHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"books": summaries,
|
||||
"total": len(summaries),
|
||||
"items": 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")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"})
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid book ID"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,9 +80,8 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
// Find local book by server_id
|
||||
var book localdb.LocalPartnumberBook
|
||||
if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil {
|
||||
book, err := h.localDB.GetLocalPartnumberBookByServerID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
|
||||
return
|
||||
}
|
||||
@@ -90,15 +92,20 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"book_id": book.ServerID,
|
||||
"version": book.Version,
|
||||
"is_active": book.IsActive,
|
||||
"partnumbers": book.PartnumbersJSON,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"total_count": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"total_pages": totalPages,
|
||||
"search": search,
|
||||
"book_total": bookRepo.CountBookItems(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{
|
||||
"pricelists": summaries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"items": summaries,
|
||||
"total_count": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"total_pages": totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,7 +124,7 @@ func (h *PricelistHandler) Get(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,7 +151,7 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -165,40 +170,21 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||
if perPage < 1 {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
lotNames := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
lotNames[i] = item.LotName
|
||||
}
|
||||
type compRow struct {
|
||||
LotName string
|
||||
LotDescription string
|
||||
}
|
||||
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
|
||||
descMap, err := h.localDB.GetLocalComponentDescriptionsByLotNames(lotNames)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
|
||||
resultItems := make([]gin.H, 0, len(items))
|
||||
@@ -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{
|
||||
"source": localPL.Source,
|
||||
"items": resultItems,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"source": localPL.Source,
|
||||
"items": resultItems,
|
||||
"total_count": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"total_pages": totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -230,7 +218,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -141,21 +141,21 @@ func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Pricelists []struct {
|
||||
Items []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"pricelists"`
|
||||
Total int `json:"total"`
|
||||
} `json:"items"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Total != 1 {
|
||||
t.Fatalf("expected total=1, got %d", resp.Total)
|
||||
if resp.TotalCount != 1 {
|
||||
t.Fatalf("expected total=1, got %d", resp.TotalCount)
|
||||
}
|
||||
if len(resp.Pricelists) != 1 {
|
||||
t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists))
|
||||
if len(resp.Items) != 1 {
|
||||
t.Fatalf("expected 1 pricelist, got %d", len(resp.Items))
|
||||
}
|
||||
if resp.Pricelists[0].ID != 10 {
|
||||
t.Fatalf("expected pricelist id=10, got %d", resp.Pricelists[0].ID)
|
||||
if resp.Items[0].ID != 10 {
|
||||
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) {
|
||||
var req services.QuoteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.quoteService.ValidateAndCalculate(&req)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,13 +34,13 @@ func (h *QuoteHandler) Validate(c *gin.Context) {
|
||||
func (h *QuoteHandler) Calculate(c *gin.Context) {
|
||||
var req services.QuoteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.quoteService.ValidateAndCalculate(&req)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -53,13 +53,13 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
|
||||
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
|
||||
var req services.PriceLevelsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.quoteService.CalculatePriceLevels(&req)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
@@ -15,9 +14,6 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"github.com/gin-gonic/gin"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
gormmysql "gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type SetupHandler struct {
|
||||
@@ -27,8 +23,6 @@ type SetupHandler 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) {
|
||||
funcMap := template.FuncMap{
|
||||
"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)
|
||||
lotCount, canWrite, err := validateMariaDBConnection(dsn)
|
||||
lotCount, canWrite, err := db.ValidateMariaDBConnection(dsn)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -135,7 +129,7 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||
|
||||
// Test connection first
|
||||
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.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
@@ -214,46 +208,3 @@ func buildMySQLDSN(host string, port int, database, user, password string, timeo
|
||||
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"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -39,7 +40,10 @@ func NewSupportBundleHandler(local *localdb.LocalDB, connMgr *db.ConnectionManag
|
||||
// GET /api/support-bundle
|
||||
func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
||||
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-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
|
||||
var migrations []localdb.LocalSchemaMigration
|
||||
_ = h.localDB.DB().Order("applied_at ASC").Find(&migrations).Error
|
||||
migrations, err := h.localDB.GetSchemaMigrations()
|
||||
if err != nil {
|
||||
slog.Warn("support bundle: could not load schema migrations", "err", err)
|
||||
}
|
||||
writeJSON("schema_migrations.json", migrations)
|
||||
|
||||
// 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 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.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{
|
||||
Success: true,
|
||||
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())
|
||||
|
||||
if _, err := h.syncService.PullPartnumberBooks(); err != nil {
|
||||
slog.Warn("partnumber books pull failed after pricelist sync", "error", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
Message: "Pricelists synced successfully",
|
||||
@@ -335,6 +343,10 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds())
|
||||
componentsSynced = compResult.TotalSynced
|
||||
|
||||
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
|
||||
slog.Warn("qt_settings sync failed", "error", err)
|
||||
}
|
||||
|
||||
// Sync pricelists
|
||||
plNow := time.Now()
|
||||
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())
|
||||
|
||||
if _, err := h.syncService.PullPartnumberBooks(); err != nil {
|
||||
slog.Warn("partnumber books pull failed during full sync", "error", err)
|
||||
}
|
||||
|
||||
projectsResult, err := h.syncService.ImportProjectsToLocal()
|
||||
if err != nil {
|
||||
slog.Error("project import failed during full sync", "error", err)
|
||||
@@ -739,7 +755,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -15,12 +17,14 @@ import (
|
||||
type VendorSpecHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
configService *services.LocalConfigurationService
|
||||
syncService *syncsvc.Service // optional; nil = no server push
|
||||
}
|
||||
|
||||
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
||||
func NewVendorSpecHandler(localDB *localdb.LocalDB, syncService *syncsvc.Service) *VendorSpecHandler {
|
||||
return &VendorSpecHandler{
|
||||
localDB: localDB,
|
||||
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
|
||||
syncService: syncService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +40,28 @@ func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfigurati
|
||||
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.
|
||||
// GET /api/configs/:uuid/vendor-spec
|
||||
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
|
||||
@@ -65,7 +91,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -88,9 +114,57 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Push manual lot mappings as suggestions to the server (best-effort, non-blocking).
|
||||
h.pushLotSuggestions(body.VendorSpec)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
||||
}
|
||||
|
||||
// pushLotSuggestions sends manual PN→LOT mappings to qt_vendor_partnumber_seen.lot_suggestion.
|
||||
// Errors are logged and silently dropped — they must not affect the HTTP response.
|
||||
func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
|
||||
if h.syncService == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var items []syncsvc.SeenPartnumber
|
||||
for _, row := range spec {
|
||||
if row.VendorPartnumber == "" || len(row.LotMappings) == 0 {
|
||||
continue
|
||||
}
|
||||
suggestion := make([]syncsvc.LotSuggestionEntry, 0, len(row.LotMappings))
|
||||
for _, m := range row.LotMappings {
|
||||
if m.LotName == "" {
|
||||
continue
|
||||
}
|
||||
qty := m.QuantityPerPN
|
||||
if qty < 1 {
|
||||
qty = 1
|
||||
}
|
||||
suggestion = append(suggestion, syncsvc.LotSuggestionEntry{
|
||||
LotName: m.LotName,
|
||||
Qty: qty,
|
||||
})
|
||||
}
|
||||
if len(suggestion) == 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, syncsvc.SeenPartnumber{
|
||||
Partnumber: row.VendorPartnumber,
|
||||
Description: row.Description,
|
||||
LotSuggestion: suggestion,
|
||||
})
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.syncService.PushPartnumberSeen(items); err != nil {
|
||||
slog.Warn("vendor_spec: failed to push lot suggestions to server", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
@@ -136,7 +210,7 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -149,7 +223,11 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
||||
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)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
@@ -179,7 +257,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,7 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
|
||||
&PendingChange{},
|
||||
&LocalPartnumberBook{},
|
||||
&SyncLogEntry{},
|
||||
&LocalQtSetting{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -497,7 +498,10 @@ func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string
|
||||
// HasSettings returns true if connection settings exist
|
||||
func (l *LocalDB) HasSettings() bool {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1044,14 +1048,18 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
|
||||
// CountConfigurations returns the number of local configurations
|
||||
func (l *LocalDB) CountConfigurations() 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
|
||||
}
|
||||
|
||||
// CountProjects returns the number of local projects
|
||||
func (l *LocalDB) CountProjects() 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
|
||||
}
|
||||
|
||||
@@ -1819,3 +1827,62 @@ func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requi
|
||||
}),
|
||||
}).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)
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import "time"
|
||||
|
||||
// Lot represents existing lot table
|
||||
type Lot struct {
|
||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||
@@ -12,43 +10,3 @@ type Lot struct {
|
||||
func (Lot) TableName() string {
|
||||
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{},
|
||||
&Project{},
|
||||
&Configuration{},
|
||||
&PriceOverride{},
|
||||
&PricingAlert{},
|
||||
&ComponentUsageStats{},
|
||||
&Pricelist{},
|
||||
&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))
|
||||
if search != "" {
|
||||
trimmedSearch := "%" + search + "%"
|
||||
query = query.Where("partnumber LIKE ? OR lots_json LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
|
||||
query = query.Where("partnumber LIKE ? OR CAST(lots_json AS TEXT) LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
|
||||
}
|
||||
|
||||
var total int64
|
||||
|
||||
@@ -177,7 +177,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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 (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
@@ -11,18 +12,15 @@ import (
|
||||
type ComponentService struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
categoryRepo *repository.CategoryRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
}
|
||||
|
||||
func NewComponentService(
|
||||
componentRepo *repository.ComponentRepository,
|
||||
categoryRepo *repository.CategoryRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
) *ComponentService {
|
||||
return &ComponentService{
|
||||
componentRepo: componentRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
statsRepo: statsRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +39,11 @@ func ParsePartNumber(lotName string) (category, model string) {
|
||||
}
|
||||
|
||||
type ComponentListResult struct {
|
||||
Components []ComponentView `json:"components"`
|
||||
Total int64 `json:"total"`
|
||||
Items []ComponentView `json:"items"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
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
|
||||
if s.componentRepo == nil {
|
||||
return &ComponentListResult{
|
||||
Components: []ComponentView{},
|
||||
Total: 0,
|
||||
Items: []ComponentView{},
|
||||
TotalCount: 0,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalPages: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -107,11 +107,16 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
||||
views[i] = view
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
return &ComponentListResult{
|
||||
Components: views,
|
||||
Total: total,
|
||||
Items: views,
|
||||
TotalCount: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -126,8 +131,10 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Track usage
|
||||
_ = s.componentRepo.IncrementRequestCount(lotName)
|
||||
// Track usage (best-effort)
|
||||
if err := s.componentRepo.IncrementRequestCount(lotName); err != nil {
|
||||
slog.Warn("component: could not increment request count", "lot", lotName, "err", err)
|
||||
}
|
||||
|
||||
view := &ComponentView{
|
||||
LotName: c.LotName,
|
||||
|
||||
@@ -18,6 +18,7 @@ var (
|
||||
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
|
||||
type ConfigurationGetter interface {
|
||||
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
|
||||
GetByUUIDNoAuth(uuid string) (*models.Configuration, error)
|
||||
}
|
||||
|
||||
type ConfigurationService struct {
|
||||
@@ -116,9 +117,6 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Record usage stats
|
||||
_ = s.quoteService.RecordUsage(req.Items)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -53,13 +53,14 @@ type ProjectExportData struct {
|
||||
}
|
||||
|
||||
type ProjectPricingExportOptions struct {
|
||||
IncludeLOT bool `json:"include_lot"`
|
||||
IncludeBOM bool `json:"include_bom"`
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
|
||||
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||
IncludeLOT bool `json:"include_lot"`
|
||||
IncludeBOM bool `json:"include_bom"`
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
|
||||
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||
ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows
|
||||
}
|
||||
|
||||
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
|
||||
@@ -87,14 +88,15 @@ type ProjectPricingExportConfig struct {
|
||||
}
|
||||
|
||||
type ProjectPricingExportRow struct {
|
||||
LotDisplay string
|
||||
VendorPN string
|
||||
Description string
|
||||
Quantity int
|
||||
BOMTotal *float64
|
||||
Estimate *float64
|
||||
Stock *float64
|
||||
Competitor *float64
|
||||
LotDisplay string
|
||||
VendorPN string
|
||||
Description string
|
||||
Quantity int
|
||||
BOMTotal *float64
|
||||
Estimate *float64
|
||||
Stock *float64
|
||||
Competitor *float64
|
||||
ManualPrice *float64 // proportional share of the user-defined total price
|
||||
}
|
||||
|
||||
// ToCSV writes project export data in the new structured CSV format.
|
||||
@@ -388,17 +390,37 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
description = componentDescriptions[rowMappings[0].LotName]
|
||||
}
|
||||
|
||||
pricingRow := ProjectPricingExportRow{
|
||||
LotDisplay: formatLotDisplay(rowMappings),
|
||||
VendorPN: row.VendorPartnumber,
|
||||
Description: description,
|
||||
Quantity: exportPositiveInt(row.Quantity, 1),
|
||||
BOMTotal: vendorRowTotal(row),
|
||||
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
|
||||
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
|
||||
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
|
||||
if len(rowMappings) == 0 {
|
||||
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||
LotDisplay: "н/д",
|
||||
VendorPN: row.VendorPartnumber,
|
||||
Description: description,
|
||||
Quantity: exportPositiveInt(row.Quantity, 1),
|
||||
BOMTotal: vendorRowTotal(row),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// One export row per LOT mapping so that bundles (1 PN → N LOTs) appear
|
||||
// as separate lines, matching the frontend pricing table layout.
|
||||
pnQty := exportPositiveInt(row.Quantity, 1)
|
||||
for i, mapping := range rowMappings {
|
||||
lotQty := pnQty * mapping.QuantityPerPN
|
||||
var bomTotal *float64
|
||||
if i == 0 {
|
||||
bomTotal = vendorRowTotal(row)
|
||||
}
|
||||
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||
LotDisplay: mapping.LotName,
|
||||
VendorPN: row.VendorPartnumber,
|
||||
Description: description,
|
||||
Quantity: lotQty,
|
||||
BOMTotal: bomTotal,
|
||||
Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }),
|
||||
Stock: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Stock }),
|
||||
Competitor: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Competitor }),
|
||||
})
|
||||
}
|
||||
block.Rows = append(block.Rows, pricingRow)
|
||||
}
|
||||
|
||||
for _, item := range cfg.Items {
|
||||
@@ -422,10 +444,22 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
catOrder := defaultCategoryOrder()
|
||||
lotNames := make([]string, 0, len(cfg.Items))
|
||||
for _, item := range cfg.Items {
|
||||
if item.LotName != "" {
|
||||
lotNames = append(lotNames, item.LotName)
|
||||
}
|
||||
}
|
||||
itemCategories := s.resolveCategories(cfg.PricelistID, lotNames)
|
||||
sortedItems := sortConfigItemsByCategoryMap(cfg.Items, catOrder, itemCategories)
|
||||
for _, item := range sortedItems {
|
||||
if item.LotName == "" {
|
||||
continue
|
||||
}
|
||||
@@ -444,10 +478,29 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||
}
|
||||
|
||||
return block, nil
|
||||
}
|
||||
|
||||
// sortConfigItemsByCategoryMap returns a copy of items sorted by category display order.
|
||||
// categories maps lot_name → category code; catOrder maps category code → display order.
|
||||
func sortConfigItemsByCategoryMap(items models.ConfigItems, catOrder map[string]int, categories map[string]string) models.ConfigItems {
|
||||
sorted := make(models.ConfigItems, len(items))
|
||||
copy(sorted, items)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
orderI, hasI := categoryDisplayOrder(catOrder, categories[sorted[i].LotName])
|
||||
orderJ, hasJ := categoryDisplayOrder(catOrder, categories[sorted[j].LotName])
|
||||
if hasI && hasJ {
|
||||
return orderI < orderJ
|
||||
}
|
||||
return hasI && !hasJ
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
|
||||
for i := range rows {
|
||||
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
|
||||
@@ -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 {
|
||||
level := pricingLevels{}
|
||||
level.Estimate = s.lookupPricePointer(estimateID, lot)
|
||||
level.Stock = s.lookupPricePointer(warehouseID, lot)
|
||||
level.Competitor = s.lookupPricePointer(competitorID, lot)
|
||||
if p, ok := estimatePrices[lot]; ok {
|
||||
level.Estimate = floatPtr(p)
|
||||
}
|
||||
if p, ok := stockPrices[lot]; ok {
|
||||
level.Stock = floatPtr(p)
|
||||
}
|
||||
if p, ok := competitorPrices[lot]; ok {
|
||||
level.Competitor = floatPtr(p)
|
||||
}
|
||||
result[lot] = level
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 {
|
||||
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" {
|
||||
// batchLookupPrices fetches prices for all lots from a pricelist in a single query.
|
||||
func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string) map[string]float64 {
|
||||
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || len(lots) == 0 {
|
||||
return nil
|
||||
}
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
||||
if err != nil || price <= 0 {
|
||||
prices, err := s.localDB.GetLocalPricesForLots(localPL.ID, lots)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return floatPtr(price)
|
||||
return prices
|
||||
}
|
||||
|
||||
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
|
||||
lots := collectPricingLots(cfg, localCfg, true)
|
||||
result := make(map[string]string, len(lots))
|
||||
if s.localDB == nil {
|
||||
return result
|
||||
if s.localDB == nil || len(lots) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
for _, lot := range lots {
|
||||
component, err := s.localDB.GetLocalComponent(lot)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result[lot] = component.LotDescription
|
||||
descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots)
|
||||
if err != nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
return result
|
||||
return descriptions
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// distributeManualPrice sets ManualPrice on each row proportionally based on the
|
||||
// row's Estimate share. The last row with a price absorbs rounding remainder so
|
||||
// the sum of ManualPrice values always equals manualPrice exactly.
|
||||
func distributeManualPrice(rows []ProjectPricingExportRow, manualPrice float64) {
|
||||
if manualPrice <= 0 || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
totalEstimate := 0.0
|
||||
for _, row := range rows {
|
||||
if row.Estimate != nil && *row.Estimate > 0 {
|
||||
totalEstimate += *row.Estimate
|
||||
}
|
||||
}
|
||||
if totalEstimate <= 0 {
|
||||
return
|
||||
}
|
||||
lastIdx := -1
|
||||
for i, row := range rows {
|
||||
if row.Estimate != nil && *row.Estimate > 0 {
|
||||
lastIdx = i
|
||||
}
|
||||
}
|
||||
assigned := 0.0
|
||||
for i, row := range rows {
|
||||
if row.Estimate == nil || *row.Estimate <= 0 {
|
||||
continue
|
||||
}
|
||||
var share float64
|
||||
if i == lastIdx {
|
||||
share = math.Round((manualPrice-assigned)*100) / 100
|
||||
} else {
|
||||
share = math.Round((*row.Estimate/totalEstimate)*manualPrice*100) / 100
|
||||
assigned += share
|
||||
}
|
||||
rows[i].ManualPrice = floatPtr(share)
|
||||
}
|
||||
}
|
||||
|
||||
func computeSingleLotTotal(priceMap map[string]pricingLevels, lotName string, qty int, selector func(pricingLevels) *float64) *float64 {
|
||||
price := selector(priceMap[lotName])
|
||||
if price == nil || *price <= 0 {
|
||||
return nil
|
||||
}
|
||||
return floatPtr(*price * float64(qty))
|
||||
}
|
||||
|
||||
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
|
||||
if unitPrice == nil || *unitPrice <= 0 {
|
||||
return nil
|
||||
@@ -709,7 +815,7 @@ func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quanti
|
||||
}
|
||||
|
||||
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||
headers := make([]string, 0, 8)
|
||||
headers := make([]string, 0, 9)
|
||||
headers = append(headers, "Line Item")
|
||||
if opts.IncludeLOT {
|
||||
headers = append(headers, "LOT")
|
||||
@@ -727,11 +833,14 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||
if opts.IncludeCompetitor {
|
||||
headers = append(headers, "Конкуренты")
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
headers = append(headers, "Ручная цена")
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
|
||||
record := make([]string, 0, 8)
|
||||
record := make([]string, 0, 9)
|
||||
record = append(record, "")
|
||||
if opts.IncludeLOT {
|
||||
record = append(record, emptyDash(row.LotDisplay))
|
||||
@@ -753,11 +862,14 @@ func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions
|
||||
if opts.IncludeCompetitor {
|
||||
record = append(record, formatMoneyValue(row.Competitor))
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
record = append(record, formatMoneyValue(row.ManualPrice))
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
|
||||
record := make([]string, 0, 8)
|
||||
record := make([]string, 0, 9)
|
||||
record = append(record, fmt.Sprintf("%d", cfg.Line))
|
||||
if opts.IncludeLOT {
|
||||
record = append(record, "")
|
||||
@@ -779,19 +891,12 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
|
||||
if opts.IncludeCompetitor {
|
||||
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
record = append(record, formatMoneyValue(opts.ManualPrice))
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
|
||||
switch len(mappings) {
|
||||
case 0:
|
||||
return "н/д"
|
||||
case 1:
|
||||
return mappings[0].LotName
|
||||
default:
|
||||
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func formatMoneyValue(value *float64) string {
|
||||
if value == nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -118,9 +119,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
}
|
||||
cfg.Line = localCfg.Line
|
||||
|
||||
// Record usage stats
|
||||
_ = s.quoteService.RecordUsage(req.Items)
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -407,7 +405,9 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
|
||||
// Refresh local pricelists when online.
|
||||
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.
|
||||
@@ -791,7 +791,9 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
|
||||
}
|
||||
|
||||
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:
|
||||
|
||||
@@ -19,7 +19,6 @@ var (
|
||||
|
||||
type QuoteService struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
localDB *localdb.LocalDB
|
||||
pricingService priceResolver
|
||||
@@ -34,14 +33,12 @@ type priceResolver interface {
|
||||
|
||||
func NewQuoteService(
|
||||
componentRepo *repository.ComponentRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
pricelistRepo *repository.PricelistRepository,
|
||||
localDB *localdb.LocalDB,
|
||||
pricingService priceResolver,
|
||||
) *QuoteService {
|
||||
return &QuoteService{
|
||||
componentRepo: componentRepo,
|
||||
statsRepo: statsRepo,
|
||||
pricelistRepo: pricelistRepo,
|
||||
localDB: localDB,
|
||||
pricingService: pricingService,
|
||||
@@ -504,18 +501,3 @@ func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string
|
||||
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) {
|
||||
db := newPriceLevelsTestDB(t)
|
||||
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
|
||||
@@ -57,7 +57,7 @@ func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
||||
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
|
||||
db := newPriceLevelsTestDB(t)
|
||||
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)
|
||||
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SeenPartnumber represents an unresolved vendor partnumber to report.
|
||||
type SeenPartnumber struct {
|
||||
Partnumber string
|
||||
Description string
|
||||
Ignored bool
|
||||
Partnumber string
|
||||
Description string
|
||||
Ignored bool
|
||||
LotSuggestion []LotSuggestionEntry // optional; set when user manually mapped PN → LOT in UI
|
||||
}
|
||||
|
||||
// LotSuggestionEntry is one suggested LOT mapping for a vendor partnumber.
|
||||
// JSON shape mirrors qt_partnumber_book_items.lots_json: {"lot_name", "qty"}.
|
||||
type LotSuggestionEntry struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Qty int `json:"qty"`
|
||||
}
|
||||
|
||||
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
||||
// Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
|
||||
// When LotSuggestion is provided the column is updated too; if the column does not exist yet
|
||||
// (migration pending) the write is retried without it and a warning is logged — the app never panics.
|
||||
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
@@ -30,7 +41,43 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
if item.Partnumber == "" {
|
||||
continue
|
||||
}
|
||||
err := mariaDB.Exec(`
|
||||
|
||||
if len(item.LotSuggestion) > 0 {
|
||||
suggJSON, marshalErr := json.Marshal(item.LotSuggestion)
|
||||
if marshalErr != nil {
|
||||
slog.Error("partnumber_seen: failed to marshal lot_suggestion, skipping suggestion",
|
||||
"partnumber", item.Partnumber, "error", marshalErr)
|
||||
suggJSON = nil
|
||||
}
|
||||
|
||||
if suggJSON != nil {
|
||||
err = mariaDB.Exec(`
|
||||
INSERT INTO qt_vendor_partnumber_seen
|
||||
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
|
||||
VALUES
|
||||
('manual', '', ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
lot_suggestion = VALUES(lot_suggestion),
|
||||
last_seen_at = NOW(3)
|
||||
`, item.Partnumber, item.Description, item.Ignored, now, string(suggJSON)).Error
|
||||
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Column not yet migrated — fall through to insert without lot_suggestion.
|
||||
if !isUnknownColumnError(err) {
|
||||
slog.Error("partnumber_seen: failed to upsert with lot_suggestion",
|
||||
"partnumber", item.Partnumber, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Warn("partnumber_seen: lot_suggestion column missing (migration pending), inserting without it",
|
||||
"partnumber", item.Partnumber)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert without lot_suggestion (baseline behaviour or fallback).
|
||||
err = mariaDB.Exec(`
|
||||
INSERT INTO qt_vendor_partnumber_seen
|
||||
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
||||
VALUES
|
||||
@@ -40,10 +87,18 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
||||
if err != nil {
|
||||
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||
// Continue with remaining items
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("partnumber_seen pushed to server", "count", len(items))
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUnknownColumnError returns true when MariaDB reports that a column does not exist.
|
||||
func isUnknownColumnError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unknown column") || strings.Contains(msg, "1054")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -320,14 +321,37 @@ func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
|
||||
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
|
||||
}
|
||||
|
||||
var pending localdb.PendingChange
|
||||
var errored []localdb.PendingChange
|
||||
if err := local.DB().
|
||||
Where("TRIM(COALESCE(last_error, '')) <> ''").
|
||||
Order("id DESC").
|
||||
First(&pending).Error; err == nil {
|
||||
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
|
||||
Limit(20).
|
||||
Find(&errored).Error; err != nil || len(errored) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, nil
|
||||
|
||||
type errorEntry struct {
|
||||
Type string `json:"type"`
|
||||
UUID string `json:"uuid"`
|
||||
Op string `json:"op"`
|
||||
Attempts int `json:"attempts"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
entries := make([]errorEntry, 0, len(errored))
|
||||
for _, ch := range errored {
|
||||
entries = append(entries, errorEntry{
|
||||
Type: ch.EntityType,
|
||||
UUID: ch.EntityUUID,
|
||||
Op: ch.Operation,
|
||||
Attempts: ch.Attempts,
|
||||
Error: strings.TrimSpace(ch.LastError),
|
||||
})
|
||||
}
|
||||
detail, jsonErr := json.Marshal(entries)
|
||||
if jsonErr != nil {
|
||||
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(errored[0].LastError))
|
||||
}
|
||||
return optionalString("PENDING_CHANGE_ERROR"), optionalString(string(detail))
|
||||
}
|
||||
|
||||
func optionalString(value string) *string {
|
||||
|
||||
@@ -851,6 +851,11 @@ func (s *Service) SyncPricelistsIfNeeded() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxPendingChangeAttempts is the number of failed attempts after which a pending change
|
||||
// is considered unrecoverable and removed from the queue. Applies only to changes that
|
||||
// fail with a non-transient error (e.g. corrupt payload, unknown operation).
|
||||
const maxPendingChangeAttempts = 20
|
||||
|
||||
// PushPendingChanges pushes all pending changes to the server
|
||||
func (s *Service) PushPendingChanges() (int, error) {
|
||||
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||
@@ -864,6 +869,14 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
slog.Info("purged orphan configuration pending changes", "removed", removed)
|
||||
}
|
||||
|
||||
// Auto-repair locally-fixable problems (e.g. stale project references)
|
||||
// before attempting to push, so that repaired changes succeed on this cycle.
|
||||
if repaired, _, repairErr := s.localDB.RepairPendingChanges(); repairErr != nil {
|
||||
slog.Warn("auto-repair of errored pending changes failed", "error", repairErr)
|
||||
} else if repaired > 0 {
|
||||
slog.Info("auto-repaired errored pending changes", "repaired", repaired)
|
||||
}
|
||||
|
||||
changes, err := s.localDB.GetPendingChanges()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting pending changes: %w", err)
|
||||
@@ -884,8 +897,14 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
if err != nil {
|
||||
s.markConnectionBroken(err)
|
||||
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
||||
// Increment attempts
|
||||
newAttempts := change.Attempts + 1
|
||||
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
|
||||
if 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
|
||||
}
|
||||
|
||||
@@ -912,7 +931,11 @@ func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
|
||||
case "configuration":
|
||||
return s.pushConfigurationChange(change)
|
||||
default:
|
||||
return fmt.Errorf("unknown entity type: %s", change.EntityType)
|
||||
// Unknown entity type: this change was queued by a newer or different build
|
||||
// and cannot be processed. Remove it from the queue.
|
||||
slog.Warn("dropping pending change with unknown entity type",
|
||||
"id", change.ID, "type", change.EntityType, "op", change.Operation)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,7 +1068,10 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
||||
case "delete":
|
||||
return s.pushConfigurationDelete(change)
|
||||
default:
|
||||
return fmt.Errorf("unknown operation: %s", change.Operation)
|
||||
// Unknown operation: queued by a newer or different build. Drop from queue.
|
||||
slog.Warn("dropping pending change with unknown operation",
|
||||
"id", change.ID, "type", change.EntityType, "op", change.Operation)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1245,24 +1271,30 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
||||
|
||||
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
|
||||
if localErr != nil {
|
||||
return err
|
||||
// Project not found locally either: stale reference (project was deleted).
|
||||
// Fall through to system project so this configuration is not stuck forever.
|
||||
slog.Warn("configuration references missing project, assigning to system project",
|
||||
"cfg_uuid", cfg.UUID,
|
||||
"project_uuid", *cfg.ProjectUUID,
|
||||
)
|
||||
} else {
|
||||
modelProject := localdb.LocalToProject(localProject)
|
||||
if modelProject.OwnerUsername == "" {
|
||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||
}
|
||||
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
if modelProject.ID > 0 {
|
||||
serverID := modelProject.ID
|
||||
localProject.ServerID = &serverID
|
||||
localProject.SyncStatus = "synced"
|
||||
now := time.Now()
|
||||
localProject.SyncedAt = &now
|
||||
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
modelProject := localdb.LocalToProject(localProject)
|
||||
if modelProject.OwnerUsername == "" {
|
||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||
}
|
||||
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
if modelProject.ID > 0 {
|
||||
serverID := modelProject.ID
|
||||
localProject.ServerID = &serverID
|
||||
localProject.SyncStatus = "synced"
|
||||
now := time.Now()
|
||||
localProject.SyncedAt = &now
|
||||
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
systemProject := &models.Project{}
|
||||
|
||||
@@ -100,5 +100,10 @@ func (w *Worker) runSync() {
|
||||
return
|
||||
}
|
||||
|
||||
// Pull partnumber books together with pricelists
|
||||
if _, err := w.service.PullPartnumberBooks(); err != nil {
|
||||
w.logger.Warn("background sync: failed to pull partnumber books", "error", err)
|
||||
}
|
||||
|
||||
w.logger.Info("background sync cycle completed")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -134,6 +135,10 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
||||
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
|
||||
case IsInspurBOM(data):
|
||||
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
|
||||
case IsNxBOM(data):
|
||||
workspace, err = parseNxBOM(data, filepath.Base(sourceFileName))
|
||||
case IsTextBOM(data):
|
||||
workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported vendor export format")
|
||||
}
|
||||
@@ -269,13 +274,17 @@ func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *loca
|
||||
}
|
||||
|
||||
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))
|
||||
for _, lotName := range order {
|
||||
unitPrice := 0.0
|
||||
if estimatePricelist != nil && local != nil {
|
||||
if price, err := local.GetLocalPriceForLot(estimatePricelist.ID, lotName); err == nil && price > 0 {
|
||||
unitPrice = price
|
||||
}
|
||||
if priceMap != nil {
|
||||
unitPrice = priceMap[lotName]
|
||||
}
|
||||
items = append(items, localdb.LocalConfigItem{
|
||||
LotName: lotName,
|
||||
@@ -676,6 +685,211 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
// nxBOMItemLine matches a quantity-first BOM line: "<qty>x <description>"
|
||||
// where the quantity prefix is digits followed immediately by "x" (case-insensitive).
|
||||
// Parentheses, commas, and hyphens inside the description are preserved.
|
||||
var nxBOMItemLine = regexp.MustCompile(`(?i)^(\d+)[xX]\s+(.+\S)\s*$`)
|
||||
|
||||
// IsNxBOM reports whether data looks like a quantity-first "Nx" BOM where each
|
||||
// item line begins with "<qty>x <description>" (e.g. "2x Intel Xeon 8570 ...").
|
||||
func IsNxBOM(data []byte) bool {
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
if nxBOMItemLine.MatchString(strings.TrimSpace(raw)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseNxBOM parses a quantity-first "Nx" BOM into a single configuration.
|
||||
// An optional header line ending with ", в составе:" supplies server_model and name.
|
||||
// Each "<qty>x <description>" line becomes one vendor spec row; description is stored
|
||||
// as both vendor_partnumber and description so rows resolve through the active
|
||||
// partnumber book when matched and otherwise stay unresolved and editable in the UI.
|
||||
func parseNxBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
rows := make([]localdb.VendorSpecItem, 0, len(lines))
|
||||
sortOrder := 10
|
||||
serverModel := ""
|
||||
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
|
||||
if fields := strings.Fields(m[1]); len(fields) > 0 {
|
||||
serverModel = fields[len(fields)-1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
m := nxBOMItemLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
qty, err := strconv.Atoi(m[1])
|
||||
if err != nil || qty <= 0 {
|
||||
continue
|
||||
}
|
||||
description := strings.TrimSpace(m[2])
|
||||
if description == "" {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, localdb.VendorSpecItem{
|
||||
SortOrder: sortOrder,
|
||||
VendorPartnumber: description,
|
||||
Quantity: qty,
|
||||
Description: description,
|
||||
})
|
||||
sortOrder += 10
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("Nx BOM has no importable rows")
|
||||
}
|
||||
|
||||
name := serverModel
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
|
||||
}
|
||||
if name == "" {
|
||||
name = "Nx BOM Import"
|
||||
}
|
||||
|
||||
return &importedWorkspace{
|
||||
SourceFormat: "Nx",
|
||||
SourceFileName: sourceFileName,
|
||||
Configurations: []importedConfiguration{
|
||||
{
|
||||
GroupID: "nx-0",
|
||||
Name: name,
|
||||
Line: 10,
|
||||
ServerCount: 1,
|
||||
ServerModel: serverModel,
|
||||
Rows: rows,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// textBOMItemLine matches a human-readable BOM line of the form
|
||||
// "<description> - <quantity> шт." where the separator may be a hyphen,
|
||||
// en-dash or em-dash and the quantity may have an optional space before "шт".
|
||||
// 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.
|
||||
// The file starts (after optional UTF-8 BOM) with the header line:
|
||||
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."
|
||||
|
||||
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"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
|
||||
-- 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
|
||||
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
|
||||
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
|
||||
ALTER TABLE qt_configurations
|
||||
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)
|
||||
ALTER TABLE qt_configurations
|
||||
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)
|
||||
-- 1) Create local_configuration_versions
|
||||
-- 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)
|
||||
-- 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)
|
||||
ALTER TABLE qt_configurations
|
||||
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
|
||||
|
||||
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 (
|
||||
username VARCHAR(100) 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
|
||||
ALTER TABLE qt_configurations
|
||||
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
|
||||
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
|
||||
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
|
||||
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 (
|
||||
stock_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
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
|
||||
ALTER TABLE qt_configurations
|
||||
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 (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
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
|
||||
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.
|
||||
ALTER TABLE qt_configurations
|
||||
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:
|
||||
-- SELECT ... FROM qt_pricelist_items WHERE pricelist_id = ? AND lot_name IN (...)
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
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.
|
||||
ensure_release_notes "${RELEASE_DIR}/RELEASE_NOTES.md"
|
||||
|
||||
# Build for all platforms
|
||||
# Build binaries
|
||||
echo -e "${YELLOW}→ Building binaries...${NC}"
|
||||
make build-all
|
||||
|
||||
LDFLAGS="-s -w -X main.Version=${VERSION}"
|
||||
|
||||
echo "Building qfs for macOS (Apple Silicon)..."
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o bin/qfs-darwin-arm64 ./cmd/qfs
|
||||
echo "✓ Built: bin/qfs-darwin-arm64"
|
||||
|
||||
echo "Building qfs for Windows..."
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o bin/qfs-windows-amd64.exe ./cmd/qfs
|
||||
echo "✓ Built: bin/qfs-windows-amd64.exe"
|
||||
|
||||
# Package binaries with checksums
|
||||
echo ""
|
||||
echo -e "${YELLOW}→ Creating release packages...${NC}"
|
||||
|
||||
# Linux AMD64
|
||||
if [ -f "bin/qfs-linux-amd64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-linux-amd64.tar.gz" qfs-linux-amd64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-linux-amd64.tar.gz${NC}"
|
||||
fi
|
||||
|
||||
# macOS Intel
|
||||
if [ -f "bin/qfs-darwin-amd64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-amd64.tar.gz" qfs-darwin-amd64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-amd64.tar.gz${NC}"
|
||||
fi
|
||||
|
||||
# macOS Apple Silicon
|
||||
if [ -f "bin/qfs-darwin-arm64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
|
||||
fi
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
|
||||
|
||||
# Windows AMD64
|
||||
if [ -f "bin/qfs-windows-amd64.exe" ]; then
|
||||
cd bin
|
||||
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
|
||||
fi
|
||||
cd bin
|
||||
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
|
||||
|
||||
# Generate checksums
|
||||
echo ""
|
||||
|
||||
@@ -79,6 +79,24 @@
|
||||
</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 -->
|
||||
<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">
|
||||
@@ -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
|
||||
// This ensures username and admin link are visible ASAP
|
||||
loadDBUser();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Ревизии - OFS{{end}}
|
||||
{{define "title"}}QFS Ревизии{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Мои конфигурации - OFS{{end}}
|
||||
{{define "title"}}QFS Мои конфигурации{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -55,12 +55,12 @@
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label>
|
||||
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
|
||||
<button type="button" id="type-server-btn" onclick="setCreateType('server')"
|
||||
<div id="config-type-buttons" class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
|
||||
<button type="button" data-type="server" onclick="setCreateType('server')"
|
||||
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||
Сервер
|
||||
</button>
|
||||
<button type="button" id="type-storage-btn" onclick="setCreateType('storage')"
|
||||
<button type="button" data-type="storage" onclick="setCreateType('storage')"
|
||||
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
|
||||
СХД
|
||||
</button>
|
||||
@@ -247,7 +247,7 @@ function renderConfigs(configs) {
|
||||
|
||||
configs.forEach(c => {
|
||||
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 author = c.owner_username || (c.user && c.user.username) || '—';
|
||||
const projectName = c.project_uuid && projectNameByUUID[c.project_uuid]
|
||||
@@ -258,7 +258,7 @@ function renderConfigs(configs) {
|
||||
let pricePerUnit = '—';
|
||||
if (c.total_price && serverCount > 0) {
|
||||
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">';
|
||||
@@ -532,18 +532,51 @@ async function cloneConfig() {
|
||||
}
|
||||
|
||||
let createConfigType = 'server';
|
||||
let _cfgSettings = null;
|
||||
|
||||
async function loadCfgSettings() {
|
||||
if (_cfgSettings) return _cfgSettings;
|
||||
try {
|
||||
const r = await fetch('/api/configurator-settings');
|
||||
if (r.ok) _cfgSettings = await r.json();
|
||||
} catch(e) { /* use hardcoded fallback */ }
|
||||
return _cfgSettings;
|
||||
}
|
||||
|
||||
function renderConfigTypeButtons(types) {
|
||||
if (!types || !types.length) return;
|
||||
const el = document.getElementById('config-type-buttons');
|
||||
if (!el) return;
|
||||
el.innerHTML = types
|
||||
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0))
|
||||
.map((t, i) => {
|
||||
const borderClass = i > 0 ? 'border-l border-gray-200' : '';
|
||||
return `<button type="button" data-type="${t.code}" onclick="setCreateType('${t.code}')"
|
||||
class="flex-1 py-2 text-sm font-medium ${borderClass} bg-white text-gray-700 hover:bg-gray-50">
|
||||
${t.name_ru || t.code}
|
||||
</button>`;
|
||||
}).join('');
|
||||
// activate first type
|
||||
const firstCode = types[0].code;
|
||||
createConfigType = firstCode;
|
||||
setCreateType(firstCode);
|
||||
}
|
||||
|
||||
function setCreateType(type) {
|
||||
createConfigType = type;
|
||||
document.getElementById('type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' +
|
||||
(type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200');
|
||||
document.getElementById('type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' +
|
||||
(type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
|
||||
document.querySelectorAll('#config-type-buttons button').forEach(btn => {
|
||||
const active = btn.dataset.type === type;
|
||||
btn.className = 'flex-1 py-2 text-sm font-medium ' +
|
||||
(active
|
||||
? 'bg-blue-600 text-white border-l border-gray-200'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200');
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
createConfigType = 'server';
|
||||
setCreateType('server');
|
||||
loadCfgSettings().then(s => renderConfigTypeButtons(s && s.config_types));
|
||||
document.getElementById('opportunity-number').value = '';
|
||||
document.getElementById('create-project-input').value = '';
|
||||
document.getElementById('create-modal').classList.remove('hidden');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}OFS - Конфигуратор{{end}}
|
||||
{{define "title"}}QFS Конфигуратор{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -488,7 +488,7 @@ function updateConfigBreadcrumbs() {
|
||||
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||
configEl.title = fullConfigName;
|
||||
versionEl.textContent = 'main';
|
||||
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — OFS';
|
||||
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — QFS';
|
||||
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||
if (configNameLinkEl && configUUID) {
|
||||
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||
@@ -504,6 +504,9 @@ let currentTab = 'base';
|
||||
let allComponents = [];
|
||||
let cart = [];
|
||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||
let configTypeCategoryMap = {}; // configTypeCode → Set<UPPER_CODE> of allowed categories (from server)
|
||||
let alwaysVisibleTabsSet = null; // Set<tabKey> — null means use hardcoded fallback
|
||||
let requiredCategoriesMap = {}; // configTypeCode → Set<UPPER_CODE> of required categories
|
||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||
let hasUnsavedChanges = false;
|
||||
let exitSaveStarted = false;
|
||||
@@ -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
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
// RBAC disabled - no token check required
|
||||
@@ -791,8 +883,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load categories in background (defaults are usable immediately).
|
||||
// Load categories and configurator settings in background (defaults are usable immediately).
|
||||
const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
|
||||
loadCfgSettings().then(s => applyServerSettings(s)).catch(() => {});
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID);
|
||||
@@ -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
|
||||
if (configUUID) {
|
||||
loadVendorSpec(configUUID);
|
||||
@@ -889,7 +988,7 @@ async function loadAllComponents() {
|
||||
try {
|
||||
const resp = await fetch('/api/components?per_page=5000');
|
||||
const data = await resp.json();
|
||||
allComponents = data.components || [];
|
||||
allComponents = data.items || [];
|
||||
window._bomAllComponents = allComponents;
|
||||
} catch(e) {
|
||||
console.error('Failed to load components', e);
|
||||
@@ -940,7 +1039,7 @@ async function loadActivePricelists(force = false) {
|
||||
try {
|
||||
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
||||
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
|
||||
} catch (e) {
|
||||
activePricelistsBySource[source] = [];
|
||||
@@ -1154,60 +1253,60 @@ function switchTab(tab) {
|
||||
renderTab();
|
||||
}
|
||||
|
||||
// Hardcoded fallback constants — used only when server has not provided config_types data
|
||||
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
|
||||
|
||||
// Storage-only categories — hidden for server configs
|
||||
const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC'];
|
||||
// Server-only categories — hidden for storage configs
|
||||
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
|
||||
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
|
||||
const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID'];
|
||||
const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU'];
|
||||
const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU'];
|
||||
|
||||
function isCategoryVisibleForConfigType(code, cfgType) {
|
||||
const allowed = configTypeCategoryMap[cfgType];
|
||||
if (!allowed || allowed.size === 0) return _hardcodedCategoryVisible(code, cfgType);
|
||||
return allowed.has(code.toUpperCase());
|
||||
}
|
||||
|
||||
function _hardcodedCategoryVisible(code, cfgType) {
|
||||
if (cfgType === 'storage') {
|
||||
if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return true;
|
||||
if (SERVER_ONLY_BASE_CATEGORIES.includes(code)) return false;
|
||||
if (STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(code)) return false;
|
||||
if (STORAGE_HIDDEN_PCI_CATEGORIES.includes(code)) return false;
|
||||
if (STORAGE_HIDDEN_POWER_CATEGORIES.includes(code)) return false;
|
||||
} else {
|
||||
if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _effectiveAlwaysVisibleTabs() {
|
||||
return alwaysVisibleTabsSet || ALWAYS_VISIBLE_TABS;
|
||||
}
|
||||
|
||||
function applyConfigTypeToTabs() {
|
||||
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
|
||||
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
|
||||
const storageSections = [
|
||||
{ title: 'RAID Контроллеры', categories: ['RAID'] },
|
||||
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
|
||||
];
|
||||
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
|
||||
const pciSections = [
|
||||
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
||||
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
||||
{ title: 'HBA', categories: ['HBA'] },
|
||||
{ title: 'HIC', categories: ['HIC'] }
|
||||
];
|
||||
const powerCategories = ['PS', 'PSU'];
|
||||
// Filter each tab's categories by visibility for current configType.
|
||||
// Uses server-driven allowlists when available; falls back to hardcoded constants.
|
||||
Object.keys(TAB_CONFIG).forEach(tabKey => {
|
||||
if (tabKey === 'other') return;
|
||||
const tab = TAB_CONFIG[tabKey];
|
||||
if (!tab || !Array.isArray(tab.categories)) return;
|
||||
|
||||
TAB_CONFIG.base.categories = baseCategories.filter(c => {
|
||||
if (configType === 'storage') {
|
||||
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
|
||||
}
|
||||
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
|
||||
});
|
||||
// Snapshot the full category list for this tab (stored in _allCategories if not yet saved)
|
||||
if (!tab._allCategories) tab._allCategories = [...tab.categories];
|
||||
|
||||
TAB_CONFIG.storage.categories = storageCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
|
||||
});
|
||||
TAB_CONFIG.storage.sections = storageSections.filter(section => {
|
||||
if (configType === 'storage') {
|
||||
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
tab.categories = tab._allCategories.filter(c => isCategoryVisibleForConfigType(c, configType));
|
||||
|
||||
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC';
|
||||
});
|
||||
TAB_CONFIG.pci.sections = pciSections.filter(section => {
|
||||
if (configType === 'storage') {
|
||||
return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat));
|
||||
if (Array.isArray(tab._allSections || tab.sections)) {
|
||||
const allSections = tab._allSections || tab.sections;
|
||||
if (!tab._allSections) tab._allSections = allSections.map(s => ({ ...s, categories: [...s.categories] }));
|
||||
tab.sections = tab._allSections
|
||||
.map(section => ({
|
||||
...section,
|
||||
categories: section.categories.filter(c => isCategoryVisibleForConfigType(c, configType))
|
||||
}))
|
||||
.filter(section => section.categories.length > 0);
|
||||
}
|
||||
return section.title !== 'HIC';
|
||||
});
|
||||
TAB_CONFIG.power.categories = powerCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true;
|
||||
});
|
||||
|
||||
// Rebuild assigned categories index
|
||||
@@ -1217,8 +1316,9 @@ function applyConfigTypeToTabs() {
|
||||
}
|
||||
|
||||
function updateTabVisibility() {
|
||||
const visibleTabs = _effectiveAlwaysVisibleTabs();
|
||||
for (const tabId of Object.keys(TAB_CONFIG)) {
|
||||
if (ALWAYS_VISIBLE_TABS.has(tabId)) continue;
|
||||
if (visibleTabs.has(tabId)) continue;
|
||||
const btn = document.querySelector(`[data-tab="${tabId}"]`);
|
||||
if (!btn) continue;
|
||||
const hasComponents = getComponentsForTab(tabId).length > 0;
|
||||
@@ -1656,6 +1756,10 @@ function renderAutocomplete() {
|
||||
|
||||
// Build autocomplete items based on mode
|
||||
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => {
|
||||
if (comp.isDivider) {
|
||||
return `<div class="px-3 py-1 text-xs text-gray-400 border-t border-gray-200 select-none cursor-default" style="pointer-events:none">── прочие ──</div>`;
|
||||
}
|
||||
|
||||
let onmousedown;
|
||||
|
||||
if (autocompleteMode === 'section') {
|
||||
@@ -2039,14 +2143,24 @@ function showAutocompleteBOM(rowIdx, input) {
|
||||
|
||||
function filterAutocompleteBOM(rowIdx, search) {
|
||||
const searchLower = (search || '').toLowerCase();
|
||||
autocompleteFiltered = (window._bomAllComponents || allComponents).filter(c => {
|
||||
const cartLots = new Set(cart.map(i => i.lot_name));
|
||||
const all = (window._bomAllComponents || allComponents).filter(c => {
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
return text.includes(searchLower);
|
||||
}).sort((a, b) => {
|
||||
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
|
||||
if (popDiff !== 0) return popDiff;
|
||||
return a.lot_name.localeCompare(b.lot_name);
|
||||
});
|
||||
const inCart = all.filter(c => cartLots.has(c.lot_name))
|
||||
.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();
|
||||
}
|
||||
|
||||
@@ -2071,7 +2185,7 @@ function handleAutocompleteKeyBOM(event, rowIdx) {
|
||||
|
||||
function selectAutocompleteItemBOM(index, rowIdx) {
|
||||
const comp = autocompleteFiltered[index];
|
||||
if (!comp) return;
|
||||
if (!comp || comp.isDivider) return;
|
||||
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
|
||||
if (!row) return;
|
||||
row.manual_lot = comp.lot_name;
|
||||
@@ -2131,6 +2245,7 @@ function removeFromCart(lotName) {
|
||||
|
||||
function updateCartUI() {
|
||||
updateTabVisibility();
|
||||
updateRequiredCategoryBadges();
|
||||
window._currentCart = cart; // expose for BOM/Pricing tabs
|
||||
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||
document.getElementById('cart-total').textContent = formatMoney(total);
|
||||
@@ -2426,6 +2541,9 @@ function restoreAutosaveDraftIfAny() {
|
||||
customPriceInput.value = '';
|
||||
}
|
||||
}
|
||||
if (payload.notes) {
|
||||
restorePricingStateFromNotes(payload.notes);
|
||||
}
|
||||
hasUnsavedChanges = true;
|
||||
} catch (_) {
|
||||
// ignore invalid draft
|
||||
@@ -2584,7 +2702,7 @@ function formatDiffPercent(baseTotal, compareTotal, compareLabel) {
|
||||
}
|
||||
const pct = ((baseTotal - compareTotal) / compareTotal) * 100;
|
||||
const sign = pct > 0 ? '+' : '';
|
||||
return `${sign}${pct.toFixed(1)}% от ${compareLabel}`;
|
||||
return `${sign}${pct.toFixed(1).replace('.', ',')}% от ${compareLabel}`;
|
||||
}
|
||||
|
||||
function getTotalClass(current, references) {
|
||||
@@ -2709,7 +2827,7 @@ function calculateCustomPrice() {
|
||||
|
||||
// Show discount info
|
||||
discountInfoEl.classList.remove('hidden');
|
||||
discountPercentEl.textContent = discountPercent.toFixed(1) + '%';
|
||||
discountPercentEl.textContent = discountPercent.toFixed(1).replace('.', ',') + '%';
|
||||
|
||||
// Update discount color based on value
|
||||
const discountEl = discountPercentEl;
|
||||
@@ -2817,7 +2935,6 @@ async function exportCSVWithCustomPrice() {
|
||||
}
|
||||
|
||||
async function refreshPrices() {
|
||||
// RBAC disabled - no token check required
|
||||
if (!configUUID) return;
|
||||
if (disablePriceRefresh) {
|
||||
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';
|
||||
}
|
||||
|
||||
let serverSyncSkipped = false;
|
||||
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()
|
||||
]);
|
||||
await loadActivePricelists(true);
|
||||
|
||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||||
const latest = activePricelistsBySource[source]?.[0];
|
||||
@@ -2871,22 +2965,44 @@ async function refreshPrices() {
|
||||
renderPricelistSettingsSummary();
|
||||
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 refreshPriceLevels({ force: true, noCache: true });
|
||||
renderTab();
|
||||
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) {
|
||||
const configResp = await fetch('/api/configs/' + configUUID);
|
||||
if (configResp.ok) {
|
||||
const config = await configResp.json();
|
||||
if (config.price_updated_at) {
|
||||
updatePriceUpdateDate(config.price_updated_at);
|
||||
}
|
||||
if (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) {
|
||||
showToast('Ошибка обновления цен', 'error');
|
||||
} finally {
|
||||
@@ -3056,62 +3172,44 @@ function _normalizeBomRawRows(rows) {
|
||||
});
|
||||
}
|
||||
|
||||
function _parseInspurBOMText(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const result = [];
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const clean = line.startsWith('|') ? line.slice(1).trim() : line;
|
||||
if (!clean) continue;
|
||||
const starIdx = clean.lastIndexOf('*');
|
||||
if (starIdx > 0) {
|
||||
const suffix = clean.slice(starIdx + 1).trim();
|
||||
if (/^\d+$/.test(suffix)) {
|
||||
result.push([clean.slice(0, starIdx).trim(), suffix]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push([clean, '1']);
|
||||
}
|
||||
return result;
|
||||
function _applyParsedBOMRows(parsed) {
|
||||
if (!Array.isArray(parsed) || !parsed.length) return false;
|
||||
bomImportRaw = {
|
||||
mode: 'raw',
|
||||
rows: parsed,
|
||||
columnTypes: ['pn', 'qty'],
|
||||
ignoredRows: {},
|
||||
rowErrors: {},
|
||||
uiError: ''
|
||||
};
|
||||
bomRows = [];
|
||||
_setBomUIError('');
|
||||
rebuildBOMRowsFromRaw();
|
||||
renderBOMTable();
|
||||
return true;
|
||||
}
|
||||
|
||||
function _isInspurBOMText(text) {
|
||||
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
|
||||
if (!lines.length) return false;
|
||||
let matches = 0;
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
const idx = t.lastIndexOf('*');
|
||||
if (idx > 0 && /^\d+$/.test(t.slice(idx + 1).trim())) matches++;
|
||||
// Detection and parsing of known single-column text BOM formats (Inspur, Russian
|
||||
// text BOM) lives only on the server (internal/services/vendor_workspace_import.go),
|
||||
// shared with the vendor file-import path. The paste handler asks the server to
|
||||
// parse; an unrecognized payload falls back to the generic Excel column grid below.
|
||||
async function _serverParseBOMText(text) {
|
||||
try {
|
||||
const resp = await fetch('/api/vendor-spec/parse-text', {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
function _applyGenericBOMPaste(text) {
|
||||
const lines = text.split(/\r?\n/).filter(l => l.length > 0);
|
||||
if (!lines.length) return;
|
||||
const rows = lines.map(l => l.split('\t').map(c => c.trim()));
|
||||
@@ -3131,6 +3229,20 @@ function handleBOMPaste(event) {
|
||||
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() {
|
||||
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null;
|
||||
const idx = { ignore: [], pn: [], qty: [], price: [], description: [] };
|
||||
@@ -3215,7 +3327,7 @@ function _bomRawLotCell(rowIdx) {
|
||||
cart.forEach(item => { cartMap[item.lot_name] = item.quantity; });
|
||||
const isUnresolved = !map.resolved_lot || map.resolution_source === 'unresolved';
|
||||
const cartQty = map.resolved_lot ? (cartMap[map.resolved_lot] ?? null) : null;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== map.quantity;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== map.quantity * _getRowLotQtyPerPN(map);
|
||||
const notInCart = map.resolved_lot && cartQty === null;
|
||||
|
||||
if (isUnresolved) {
|
||||
@@ -3601,7 +3713,7 @@ function _renderBOMParsedTable() {
|
||||
const tr = document.createElement('tr');
|
||||
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
|
||||
const cartQty = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== row.quantity;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== row.quantity * _getRowLotQtyPerPN(row);
|
||||
const notInCart = row.resolved_lot && cartQty === null;
|
||||
if (isUnresolved) unresolved++;
|
||||
if (qtyMismatch || notInCart) mismatches++;
|
||||
@@ -3668,7 +3780,7 @@ function _renderBOMRawTable() {
|
||||
else if (parsed) {
|
||||
const isUnresolved = !parsed.resolved_lot || parsed.resolution_source === 'unresolved';
|
||||
const cartQty = parsed.resolved_lot ? (cartMap[parsed.resolved_lot] ?? null) : null;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== parsed.quantity;
|
||||
const qtyMismatch = cartQty !== null && cartQty !== parsed.quantity * _getRowLotQtyPerPN(parsed);
|
||||
const notInCart = parsed.resolved_lot && cartQty === null;
|
||||
if (isUnresolved) unresolved++;
|
||||
if (qtyMismatch || notInCart) mismatches++;
|
||||
@@ -3936,6 +4048,9 @@ async function renderPricingTab() {
|
||||
};
|
||||
|
||||
// Collect LOTs to price: from BOM rows (resolved) or from cart
|
||||
// Use cart quantity when available (source of truth); fall back to BOM-computed quantity.
|
||||
const _cartQtyMap = {};
|
||||
cart.forEach(item => { if (item?.lot_name) _cartQtyMap[item.lot_name] = item.quantity; });
|
||||
let itemsForPriceLevels = [];
|
||||
if (bomRows.length) {
|
||||
const seen = new Set();
|
||||
@@ -3944,13 +4059,13 @@ async function renderPricingTab() {
|
||||
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
|
||||
if (baseLot && !seen.has(baseLot)) {
|
||||
seen.add(baseLot);
|
||||
itemsForPriceLevels.push({ lot_name: baseLot, quantity: row.quantity * _getRowLotQtyPerPN(row) });
|
||||
itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[baseLot] ?? (row.quantity * _getRowLotQtyPerPN(row)) });
|
||||
}
|
||||
if (allocs.length) {
|
||||
allocs.forEach(a => {
|
||||
if (!seen.has(a.lot_name)) {
|
||||
seen.add(a.lot_name);
|
||||
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: row.quantity * a.quantity });
|
||||
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4003,6 +4118,8 @@ async function renderPricingTab() {
|
||||
|
||||
// ─── Build shared row data (unit prices for display, totals for math) ────
|
||||
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
|
||||
const cartQtyMap = {};
|
||||
cart.forEach(item => { if (item?.lot_name) cartQtyMap[item.lot_name] = item.quantity; });
|
||||
const _buildRows = () => {
|
||||
const result = [];
|
||||
const coveredLots = new Set();
|
||||
@@ -4026,7 +4143,12 @@ async function renderPricingTab() {
|
||||
};
|
||||
|
||||
if (!bomRows.length) {
|
||||
cart.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
|
||||
const sortedByCategory = [...cart].sort((a, b) => {
|
||||
const catA = (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 };
|
||||
}
|
||||
|
||||
@@ -4047,7 +4169,7 @@ async function renderPricingTab() {
|
||||
if (baseLot) {
|
||||
const u = _getUnitPrices(priceMap[baseLot]);
|
||||
const lotQty = _getRowLotQtyPerPN(row);
|
||||
const qty = row.quantity * lotQty;
|
||||
const qty = cartQtyMap[baseLot] ?? (row.quantity * lotQty);
|
||||
subRows.push({
|
||||
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
|
||||
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||
@@ -4059,7 +4181,7 @@ async function renderPricingTab() {
|
||||
}
|
||||
allocs.forEach(a => {
|
||||
const u = _getUnitPrices(priceMap[a.lot_name]);
|
||||
const qty = row.quantity * a.quantity;
|
||||
const qty = cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity);
|
||||
subRows.push({
|
||||
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
|
||||
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||
@@ -4274,7 +4396,7 @@ function applyCustomPrice(table) {
|
||||
if (est <= 0) return '';
|
||||
const pct = ((est - custom) / est * 100);
|
||||
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';
|
||||
|
||||
@@ -4367,7 +4489,7 @@ function setPricingCustomPriceFromVendor() {
|
||||
const totalEl = document.getElementById('pricing-total-buy-vendor');
|
||||
if (hasAny) {
|
||||
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.className = totalEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
|
||||
totalEl.classList.add(total <= estimateTotal ? 'text-green-600' : 'text-red-600');
|
||||
@@ -4380,6 +4502,8 @@ function setPricingCustomPriceFromVendor() {
|
||||
async function exportPricingCSV(table) {
|
||||
if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; }
|
||||
const basis = table === 'sale' ? 'ddp' : 'fob';
|
||||
const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy';
|
||||
const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || '');
|
||||
try {
|
||||
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
|
||||
method: 'POST',
|
||||
@@ -4391,6 +4515,7 @@ async function exportPricingCSV(table) {
|
||||
include_stock: true,
|
||||
include_competitor: true,
|
||||
basis: basis,
|
||||
manual_price: manualPrice > 0 ? manualPrice : null,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}OFS - Партномера{{end}}
|
||||
{{define "title"}}QFS Партномера{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -22,20 +22,17 @@
|
||||
</div>
|
||||
|
||||
<div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера.
|
||||
Нет активного листа сопоставлений. Книги загружаются автоматически вместе с прайслистами.
|
||||
</div>
|
||||
|
||||
<!-- All books list (collapsed by default) -->
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<!-- Header row — always visible -->
|
||||
<div class="px-4 py-3 flex items-center justify-between">
|
||||
<div class="px-4 py-3">
|
||||
<button onclick="toggleBooksSection()" class="flex items-center gap-2 text-sm font-semibold text-gray-800 hover:text-gray-600 select-none">
|
||||
<svg id="books-chevron" class="w-4 h-4 text-gray-400 transition-transform duration-150" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
Снимки сопоставлений (Partnumber Books)
|
||||
</button>
|
||||
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 text-sm font-medium">
|
||||
Синхронизировать
|
||||
</button>
|
||||
</div>
|
||||
<!-- Collapsible body -->
|
||||
<div id="books-section-body" class="hidden border-t">
|
||||
@@ -69,7 +66,7 @@
|
||||
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
|
||||
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
|
||||
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
|
||||
<input type="text" id="pn-search" placeholder="Поиск по PN, LOT или описанию..."
|
||||
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
oninput="onItemsSearchInput(this.value)">
|
||||
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
|
||||
@@ -127,7 +124,7 @@ async function loadBooks() {
|
||||
return;
|
||||
}
|
||||
|
||||
allBooks = data.books || [];
|
||||
allBooks = data.items || [];
|
||||
document.getElementById('books-list-loading').classList.add('hidden');
|
||||
|
||||
if (!allBooks.length) {
|
||||
@@ -213,7 +210,7 @@ async function loadActiveBookItemsPage(page = 1, search = '', book = null) {
|
||||
|
||||
activeItems = data.items || [];
|
||||
itemsPage = data.page || page;
|
||||
itemsTotal = Number(data.total || 0);
|
||||
itemsTotal = Number(data.total_count || 0);
|
||||
itemsSearch = data.search || search || '';
|
||||
|
||||
document.getElementById('card-version').textContent = targetBook.version;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Прайслист - OFS{{end}}
|
||||
{{define "title"}}QFS Прайслист{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
@@ -137,7 +137,7 @@
|
||||
toggleWarehouseColumns();
|
||||
|
||||
renderItems(data.items || []);
|
||||
renderItemsPagination(data.total, data.page, data.per_page);
|
||||
renderItemsPagination(data.total_count, data.page, data.per_page);
|
||||
} catch (e) {
|
||||
document.getElementById('items-body').innerHTML = `
|
||||
<tr>
|
||||
@@ -243,7 +243,7 @@
|
||||
const descMax = stock ? 30 : 60;
|
||||
|
||||
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 truncatedDesc = description.length > descMax ? description.substring(0, descMax) + '...' : description;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Прайслисты - OFS{{end}}
|
||||
{{define "title"}}QFS Прайслисты{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
@@ -83,8 +83,8 @@
|
||||
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
||||
const data = await resp.json();
|
||||
|
||||
renderPricelists(data.pricelists || []);
|
||||
renderPagination(data.total, data.page, data.per_page);
|
||||
renderPricelists(data.items || []);
|
||||
renderPagination(data.total_count, data.page, data.per_page);
|
||||
} catch (e) {
|
||||
document.getElementById('pricelists-body').innerHTML = `
|
||||
<tr>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Проект - OFS{{end}}
|
||||
{{define "title"}}QFS Проект{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<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>
|
||||
<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 onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium">
|
||||
@@ -339,7 +339,7 @@ function escapeHtml(text) {
|
||||
|
||||
function formatMoneyNoDecimals(value) {
|
||||
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) {
|
||||
@@ -1572,10 +1572,10 @@ async function exportProject() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAllPrices() {
|
||||
async function refreshPrices() {
|
||||
const configs = (allConfigs || []).filter(c => c.is_active !== false);
|
||||
if (!configs.length) {
|
||||
if (typeof showToast === 'function') showToast('Нет активных конфигураций', 'error');
|
||||
showToast('Нет активных конфигураций', 'error');
|
||||
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';
|
||||
}
|
||||
|
||||
let serverSyncSkipped = false;
|
||||
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') {
|
||||
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;
|
||||
}
|
||||
const latestEstimatePricelistId = await fetchLatestEstimatePricelistId();
|
||||
|
||||
// Resolve latest estimate pricelist ID to pass explicitly, so each config
|
||||
// is updated to the newest pricelist rather than the one stored in the config.
|
||||
let latestEstimatePricelistId = null;
|
||||
try {
|
||||
const plResp = await fetch('/api/pricelists?active_only=true&source=estimate&per_page=1');
|
||||
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);
|
||||
const diffResults = [];
|
||||
for (const cfg of configs) {
|
||||
if (cfg.disable_price_refresh) {
|
||||
diffResults.push({ configName: cfg.name, skipped: true });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
let failed = 0;
|
||||
let newTotalSum = 0;
|
||||
for (const cfg of configs) {
|
||||
try {
|
||||
const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined;
|
||||
const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) { failed++; continue; }
|
||||
const updated = await resp.json();
|
||||
if (updated && updated.total_price != null) {
|
||||
cfg.total_price = updated.total_price;
|
||||
const totalCell = document.querySelector('[data-total-uuid="' + cfg.uuid + '"]');
|
||||
if (totalCell) totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
|
||||
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 prevTotal = cfg.total_price || 0;
|
||||
const prevItemsMap = {};
|
||||
if (cfg.items) {
|
||||
for (const item of cfg.items) prevItemsMap[item.lot_name] = item.unit_price;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined;
|
||||
const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
diffResults.push({ configName: cfg.name, error: true });
|
||||
continue;
|
||||
}
|
||||
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"]');
|
||||
if (footerTotal) footerTotal.textContent = formatMoneyNoDecimals(newTotalSum);
|
||||
|
||||
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 (failed > 0) {
|
||||
if (typeof showToast === 'function') showToast('Часть конфигураций не обновилась (' + failed + ')', 'error');
|
||||
} else {
|
||||
const msg = serverSyncSkipped ? 'Цены обновлены (без связи с сервером)' : 'Цены обновлены';
|
||||
if (typeof showToast === 'function') showToast(msg, 'success');
|
||||
updateFooterTotal();
|
||||
showToast('Цены обновлены', 'success');
|
||||
showPriceDiffModal(diffResults);
|
||||
} catch(e) {
|
||||
showToast('Ошибка обновления цен', 'error');
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Обновить цены';
|
||||
btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Мои проекты - OFS{{end}}
|
||||
{{define "title"}}QFS Мои проекты{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -81,7 +81,7 @@ function escapeHtml(text) {
|
||||
}
|
||||
|
||||
function formatMoney(v) {
|
||||
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||
return '$' + (v || 0).toLocaleString('ru-RU', {minimumFractionDigits: 2});
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
|
||||
Reference in New Issue
Block a user