Compare commits
190 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f6be786a8 | ||
|
|
a360992a01 | ||
|
|
1ea21ece33 | ||
|
|
7ae804d2d3 | ||
|
|
da5414c708 | ||
|
|
7a69c1513d | ||
|
|
f448111e77 | ||
|
|
a5dafd37d3 | ||
|
|
3661e345b1 | ||
|
|
f915866f83 | ||
|
|
c34a42aaf5 | ||
|
|
7de0f359b6 | ||
|
|
a8d8d7dfa9 | ||
|
|
20ce0124be | ||
|
|
b0a106415f | ||
|
|
a054fc7564 | ||
|
|
68cd087356 | ||
|
|
579ff46a7f | ||
|
|
35c5600b36 | ||
|
|
c599897142 | ||
|
|
c964d66e64 | ||
|
|
f0e6bba7e9 | ||
|
|
61d7e493bd | ||
|
|
f930c79b34 | ||
|
|
a0a57e0969 | ||
|
|
b3003c4858 | ||
|
|
e2da8b4253 | ||
|
|
06397a6bd1 | ||
|
|
4e977737ee | ||
|
|
7c3752f110 | ||
|
|
08ecfd0826 | ||
|
|
42458455f7 | ||
|
|
8663a87d28 | ||
| 2f0957ae4e | |||
| 65db9b37ea | |||
| ed0ef04d10 | |||
| 2e0faf4aec | |||
| 4b0879779a | |||
| 2b175a3d1e | |||
| 5732c75b85 | |||
| eb7c3739ce | |||
|
|
6e0335af7c | ||
|
|
a42a80beb8 | ||
|
|
586114c79c | ||
|
|
e9230c0e58 | ||
|
|
aa65fc8156 | ||
|
|
b22e961656 | ||
|
|
af83818564 | ||
|
|
8a138327a3 | ||
|
|
d1f65f6684 | ||
|
|
7b371add10 | ||
|
|
8d7fab39b4 | ||
|
|
1906a74759 | ||
| d0400b18a3 | |||
| d3f1a838eb | |||
| c6086ac03a | |||
| a127ebea82 | |||
| 347599e06b | |||
| 4a44d48366 | |||
| 23882637b5 | |||
| 5e56f386cc | |||
| e5b6902c9e | |||
|
|
3c46cd7bf0 | ||
|
|
7f8491d197 | ||
|
|
3fd7a2231a | ||
|
|
c295b60dd8 | ||
| cc9b846c31 | |||
| 87cb12906d | |||
| 075fc709dd | |||
| cbaeafa9c8 | |||
| 71f73e2f1d | |||
| 2e973b6d78 | |||
| 8508ee2921 | |||
| b153afbf51 | |||
|
|
9b5d57902d | ||
|
|
4e1a46bd71 | ||
|
|
857ec7a0e5 | ||
|
|
01f21fa5ac | ||
|
|
a1edca3be9 | ||
|
|
7fbf813952 | ||
|
|
e58fd35ee4 | ||
|
|
e3559035f7 | ||
|
|
5edffe822b | ||
|
|
99fd80bca7 | ||
|
|
d8edd5d5f0 | ||
|
|
9cb17ee03f | ||
|
|
8f596cec68 | ||
|
|
8fd27d11a7 | ||
|
|
600f842b82 | ||
|
|
acf7c8a4da | ||
|
|
5984a57a8b | ||
|
|
84dda8cf0a | ||
|
|
abeb26d82d | ||
|
|
29edd73744 | ||
|
|
e8d0e28415 | ||
|
|
08feda9af6 | ||
|
|
af79b6f3bf | ||
|
|
bca82f9dc0 | ||
| 17969277e6 | |||
| 0dbfe45353 | |||
| f609d2ce35 | |||
| 593280de99 | |||
| eb8555c11a | |||
| 7523a7d887 | |||
| 95b5f8bf65 | |||
| b629af9742 | |||
| 72ff842f5d | |||
|
|
5f2969a85a | ||
|
|
eb8ac34d83 | ||
|
|
104a26d907 | ||
|
|
b965c6bb95 | ||
|
|
29035ddc5a | ||
|
|
2f0ac2f6d2 | ||
|
|
8a8ea10dc2 | ||
|
|
51e2d1fc83 | ||
|
|
3d5ab63970 | ||
|
|
c02a7eac73 | ||
|
|
651427e0dd | ||
|
|
f665e9b08c | ||
|
|
994eec53e7 | ||
|
|
2f3c20fea6 | ||
|
|
80ec7bc6b8 | ||
|
|
8e5c4f5a7c | ||
|
|
1744e6a3b8 | ||
|
|
726dccb07c | ||
|
|
38d7332a38 | ||
|
|
c0beed021c | ||
|
|
08b95c293c | ||
|
|
c418d6cfc3 | ||
|
|
548a256d04 | ||
|
|
77c00de97a | ||
|
|
0c190efda4 | ||
|
|
41c0a47f54 | ||
|
|
f4f92dea66 | ||
|
|
f42b850734 | ||
|
|
d094d39427 | ||
|
|
4509e93864 | ||
|
|
e2800b06f9 | ||
|
|
7c606af2bb | ||
|
|
fabd30650d | ||
|
|
40ade651b0 | ||
|
|
1b87c53609 | ||
| a3dc264efd | |||
| 20056f3593 | |||
|
|
8a37542929 | ||
|
|
0eb6730a55 | ||
|
|
e2d056e7cb | ||
|
|
1bce8086d6 | ||
|
|
0bdd163728 | ||
|
|
fa0f5e321d | ||
|
|
502832ac9a | ||
|
|
8d84484412 | ||
| 2510d9e36e | |||
| d7285fc730 | |||
| e33a3f2c88 | |||
| 4735e2b9bb | |||
| cdf5cef2cf | |||
| 7f030e7db7 | |||
| 3d222b7f14 | |||
| c024b96de7 | |||
| 2c75a7ccb8 | |||
|
|
f25477a25e | ||
|
|
0bde12a39d | ||
|
|
e0404186ad | ||
|
|
eda0e7cb47 | ||
|
|
693c1d05d7 | ||
|
|
7fb9dd0267 | ||
|
|
61646bea46 | ||
|
|
9495f929aa | ||
|
|
b80bde7dac | ||
|
|
e307a2765d | ||
|
|
6f1feb942a | ||
|
|
236e37376e | ||
|
|
ded6e09b5e | ||
|
|
96bbe0a510 | ||
|
|
b672cbf27d | ||
|
|
e206531364 | ||
|
|
9bd2acd4f7 | ||
| ec3c16f3fc | |||
| 1f739a3ab2 | |||
| be77256d4e | |||
| 143d217397 | |||
| 8b8d2f18f9 | |||
| 8c1c8ccace | |||
| f31ae69233 | |||
| 3132ab2fa2 | |||
| 73acc5410f | |||
| 68d0e9a540 | |||
| 8309a5dc0e | |||
|
|
48921c699d |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,10 +1,5 @@
|
||||
# QuoteForge
|
||||
config.yaml
|
||||
|
||||
# Data exports and imports with real supplier/pricing data
|
||||
*_import.sql
|
||||
*_export.csv
|
||||
test_export.csv
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
|
||||
33
acc_lot_log_import.sql
Normal file
33
acc_lot_log_import.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Generated from /Users/mchusavitin/Downloads/acc.csv
|
||||
-- Unambiguous rows only. Rows from headers without a date were skipped.
|
||||
INSERT INTO lot_log (`lot`, `supplier`, `date`, `price`, `quality`, `comments`) VALUES
|
||||
('ACC_RMK_L_Type', '', '2024-04-01', 19, NULL, 'header supplier missing in source (45383)'),
|
||||
('ACC_RMK_SLIDE', '', '2024-04-01', 31, NULL, 'header supplier missing in source (45383)'),
|
||||
('NVLINK_2S_Bridge', '', '2023-01-01', 431, NULL, 'header supplier missing in source (44927)'),
|
||||
('NVLINK_2S_Bridge', 'Jevy Yang', '2025-01-15', 139, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Wendy', '2025-01-15', 143, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'HONCH (Darian)', '2025-05-06', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'HONCH (Sunny)', '2025-06-17', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Wendy', '2025-07-02', 145, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Honch (Sunny)', '2025-07-10', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Honch (Yan)', '2025-08-07', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Jevy', '2025-09-09', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Honch (Darian)', '2025-11-17', 102, NULL, NULL),
|
||||
('NVLINK_2W_Bridge(H200)', '', '2023-01-01', 405, NULL, 'header supplier missing in source (44927)'),
|
||||
('NVLINK_2W_Bridge(H200)', 'network logic / Stephen', '2025-02-10', 305, NULL, NULL),
|
||||
('NVLINK_2W_Bridge(H200)', 'JEVY', '2025-02-18', 411, NULL, NULL),
|
||||
('NVLINK_4W_Bridge(H200)', '', '2023-01-01', 820, NULL, 'header supplier missing in source (44927)'),
|
||||
('NVLINK_4W_Bridge(H200)', 'network logic / Stephen', '2025-02-10', 610, NULL, NULL),
|
||||
('NVLINK_4W_Bridge(H200)', 'JEVY', '2025-02-18', 754, NULL, NULL),
|
||||
('25G_SFP28_MMA2P00-AS', 'HONCH (Doris)', '2025-02-19', 65, NULL, NULL),
|
||||
('ACC_SuperCap', '', '2024-04-01', 59, NULL, 'header supplier missing in source (45383)'),
|
||||
('ACC_SuperCap', 'Chiphome', '2025-02-28', 48, NULL, NULL);
|
||||
|
||||
-- Skipped source values due to missing date in header:
|
||||
-- lot=ACC_RMK_L_Type; header=FOB; price=19; reason=header has supplier but no date
|
||||
-- lot=ACC_RMK_SLIDE; header=FOB; price=31; reason=header has supplier but no date
|
||||
-- lot=NVLINK_2S_Bridge; header=FOB; price=155; reason=header has supplier but no date
|
||||
-- lot=NVLINK_2W_Bridge(H200); header=FOB; price=405; reason=header has supplier but no date
|
||||
-- lot=NVLINK_4W_Bridge(H200); header=FOB; price=754; reason=header has supplier but no date
|
||||
-- lot=25G_SFP28_MMA2P00-AS; header=FOB; price=65; reason=header has supplier but no date
|
||||
-- lot=ACC_SuperCap; header=FOB; price=48; reason=header has supplier but no date
|
||||
2
bible
2
bible
Submodule bible updated: 1977730d93...52444350c1
@@ -106,17 +106,6 @@ Rules:
|
||||
- copy checkboxes and copy modals must prefill `_копия`, not ` (копия)`;
|
||||
- the literal variant name `main` is reserved and must not be allowed for non-main variants.
|
||||
|
||||
## Configuration types
|
||||
|
||||
Configurations have a `config_type` field: `"server"` (default) or `"storage"`.
|
||||
|
||||
Rules:
|
||||
- `config_type` defaults to `"server"` for all existing and new configurations unless explicitly set;
|
||||
- the configurator page is shared for both types; the SW tab is always visible regardless of type;
|
||||
- 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` = дисковая полка без контроллера.
|
||||
|
||||
## Vendor BOM contract
|
||||
|
||||
Vendor BOM is stored in `vendor_spec` on the configuration row.
|
||||
|
||||
@@ -29,15 +29,17 @@ Rules:
|
||||
|
||||
## MariaDB
|
||||
|
||||
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
|
||||
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-03-21.
|
||||
|
||||
### QuoteForge tables (qt_*)
|
||||
### QuoteForge tables (qt_* and stock_*)
|
||||
|
||||
Runtime read:
|
||||
- `qt_categories` — pricelist categories
|
||||
- `qt_lot_metadata` — component metadata, price settings
|
||||
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
||||
- `qt_pricelist_items` — pricelist rows
|
||||
- `stock_log` — raw supplier price log, source for pricelist generation
|
||||
- `stock_ignore_rules` — patterns to skip during stock import
|
||||
- `qt_partnumber_books` — partnumber book headers
|
||||
- `qt_partnumber_book_items` — PN→LOT catalog payload
|
||||
|
||||
@@ -67,20 +69,18 @@ QuoteForge references competitor pricelists only via `qt_pricelists` (source='co
|
||||
### Legacy RFQ tables (pre-QuoteForge, no Go code references)
|
||||
|
||||
- `lot` — original component registry (data preserved; superseded by `qt_lot_metadata`)
|
||||
- `lot_log` — original supplier price log
|
||||
- `lot_log` — original supplier price log (superseded by `stock_log`)
|
||||
- `supplier` — supplier registry (FK target for lot_log and machine_log)
|
||||
- `machine` — device model registry
|
||||
- `machine_log` — device price/quote log
|
||||
- `parts_log` — supplier partnumber log used by server-side import/pricing workflows, not by QuoteForge runtime
|
||||
|
||||
These tables are retained for historical data. QuoteForge does not read or write them at runtime.
|
||||
|
||||
Rules:
|
||||
- QuoteForge runtime must not depend on any legacy RFQ tables;
|
||||
- QuoteForge sync reads prices and categories from `qt_pricelists` / `qt_pricelist_items` only;
|
||||
- QuoteForge does not enrich local pricelist rows from `parts_log` or any other raw supplier log table;
|
||||
- stock enrichment happens during sync and is persisted into SQLite;
|
||||
- normal UI requests must not query MariaDB tables directly;
|
||||
- `qt_client_local_migrations` exists in the 2026-04-15 schema dump, but runtime sync does not depend on it.
|
||||
- `qt_client_local_migrations` was removed from the schema on 2026-03-21 (was in earlier drafts).
|
||||
|
||||
## MariaDB Table Structures
|
||||
|
||||
@@ -370,6 +370,8 @@ 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'@'%';
|
||||
@@ -378,6 +380,7 @@ GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_projects TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
@@ -62,21 +62,3 @@ Imported configuration fields:
|
||||
- `article` or `support_code` from `ProprietaryProductIdentifier`
|
||||
|
||||
Imported BOM rows become `vendor_spec` rows and are resolved through the active local partnumber book when possible.
|
||||
|
||||
## Inspur BOM import
|
||||
|
||||
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts Inspur text BOM exports.
|
||||
|
||||
Format: one component per line, `<partnumber>*<quantity>`. A leading `|` character is optional and stripped. Trailing whitespace around `*` is normalised.
|
||||
|
||||
Example:
|
||||
```
|
||||
|CPU_AMD_9535-EPYC2.4_64C_256M_300W*1
|
||||
|PowerSupply_1300W_Titanium_220VACor240VDC_GaN*2
|
||||
```
|
||||
|
||||
Rules:
|
||||
- the entire file becomes a single configuration (`server_count = 1`);
|
||||
- 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.
|
||||
|
||||
@@ -1,563 +0,0 @@
|
||||
# 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}'
|
||||
```
|
||||
@@ -14,7 +14,6 @@ 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 |
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,86 +0,0 @@
|
||||
# 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.
|
||||
@@ -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)
|
||||
|
||||
slog.Info("Done! You can now run the server with: go run ./cmd/qfs")
|
||||
fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs")
|
||||
}
|
||||
|
||||
func derefUint(v *uint) uint {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -80,12 +79,12 @@ func main() {
|
||||
|
||||
printPlan(actions)
|
||||
if len(actions) == 0 {
|
||||
slog.Info("Nothing to migrate.")
|
||||
fmt.Println("Nothing to migrate.")
|
||||
return
|
||||
}
|
||||
|
||||
if !*apply {
|
||||
slog.Info("Preview complete. Re-run with -apply to execute.")
|
||||
fmt.Println("\nPreview complete. Re-run with -apply to execute.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -95,7 +94,7 @@ func main() {
|
||||
log.Fatalf("confirmation failed: %v", confirmErr)
|
||||
}
|
||||
if !ok {
|
||||
slog.Info("Aborted.")
|
||||
fmt.Println("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -104,7 +103,7 @@ func main() {
|
||||
log.Fatalf("migration failed: %v", err)
|
||||
}
|
||||
|
||||
slog.Info("Migration completed successfully.")
|
||||
fmt.Println("Migration completed successfully.")
|
||||
}
|
||||
|
||||
func ensureProjectsTable(db *gorm.DB) error {
|
||||
@@ -213,8 +212,10 @@ func printPlan(actions []migrationAction) {
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("Plan summary", "actions", len(actions), "create", createCount, "reactivate", reactivateCount)
|
||||
slog.Info("Details:")
|
||||
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:")
|
||||
|
||||
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 {
|
||||
slog.Info("Re-run with -apply to write server updated_at into local SQLite.")
|
||||
fmt.Println("Re-run with -apply to write server updated_at into local SQLite.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -289,7 +289,8 @@ func main() {
|
||||
}
|
||||
|
||||
func showStartupConsoleWarning() {
|
||||
slog.Warn(startupConsoleWarning)
|
||||
// Visible in console output.
|
||||
fmt.Println(startupConsoleWarning)
|
||||
// Keep the warning always visible in the console window title when supported.
|
||||
fmt.Printf("\033]0;%s\007", startupConsoleWarning)
|
||||
}
|
||||
@@ -331,9 +332,7 @@ func setConfigDefaults(cfg *config.Config) {
|
||||
cfg.Server.ReadTimeout = 30 * time.Second
|
||||
}
|
||||
if cfg.Server.WriteTimeout == 0 {
|
||||
// Sync operations (pricelist download over slow VPN) can take several minutes.
|
||||
// Loopback-only binding means there is no risk of holding connections from external clients.
|
||||
cfg.Server.WriteTimeout = 10 * time.Minute
|
||||
cfg.Server.WriteTimeout = 30 * time.Second
|
||||
}
|
||||
if cfg.Backup.Time == "" {
|
||||
cfg.Backup.Time = "00:00"
|
||||
@@ -395,7 +394,7 @@ func ensureDefaultConfigFile(configPath string) error {
|
||||
port: 8080
|
||||
mode: "release"
|
||||
read_timeout: 30s
|
||||
write_timeout: 10m
|
||||
write_timeout: 30s
|
||||
|
||||
backup:
|
||||
time: "00:00"
|
||||
@@ -679,7 +678,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
syncService = sync.NewService(connMgr, local)
|
||||
componentService := services.NewComponentService(nil, nil, nil)
|
||||
quoteService := services.NewQuoteService(nil, nil, nil, local, nil)
|
||||
exportService := services.NewExportService(cfg.Export, local)
|
||||
exportService := services.NewExportService(cfg.Export, nil, local)
|
||||
|
||||
// isOnline function for local-first architecture
|
||||
isOnline := func() bool {
|
||||
@@ -787,8 +786,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||
}
|
||||
|
||||
supportBundleHandler := handlers.NewSupportBundleHandler(local, connMgr, syncService, cfg.Logging.FilePath)
|
||||
|
||||
// Setup handler (for reconfiguration)
|
||||
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig)
|
||||
if err != nil {
|
||||
@@ -908,8 +905,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
||||
})
|
||||
|
||||
api.GET("/support-bundle", supportBundleHandler.DownloadBundle)
|
||||
|
||||
// Components (public read)
|
||||
components := api.Group("/components")
|
||||
{
|
||||
@@ -1275,7 +1270,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
})
|
||||
|
||||
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
||||
configs.POST("/:uuid/export/pricing", exportHandler.ExportConfigPricingCSV)
|
||||
|
||||
// Vendor spec (BOM) endpoints
|
||||
configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec)
|
||||
@@ -1725,7 +1719,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) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
# Руководство по составлению каталога лотов СХД
|
||||
|
||||
## Что такое LOT и зачем он нужен
|
||||
|
||||
LOT — это внутренний идентификатор типа компонента в системе QuoteForge.
|
||||
|
||||
Каждый LOT представляет одну рыночную позицию и хранит **средневзвешенную рыночную цену**, рассчитанную по историческим данным от поставщиков. Это позволяет получать актуальную оценку стоимости независимо от конкретного поставщика или прайс-листа.
|
||||
|
||||
Партномера вендора (Part Number, Feature Code) сами по себе не имеют цены в системе — они **переводятся в LOT** через книгу партномеров. Именно через LOT происходит расценка конфигурации.
|
||||
|
||||
**Пример:** Feature Code `B4B9` и Part Number `4C57A14368` — это два разных обозначения одной и той же HIC-карты от Lenovo. Оба маппируются на один LOT `HIC_4pFC32`, у которого есть рыночная цена.
|
||||
|
||||
---
|
||||
|
||||
## Категории и вкладки конфигуратора
|
||||
|
||||
Категория LOT определяет, в какой вкладке конфигуратора он появится.
|
||||
|
||||
| Код категории | Название | Вкладка | Что сюда относится |
|
||||
|---|---|---|---|
|
||||
| `ENC` | Storage Enclosure | **Base** | Дисковая полка без контроллера |
|
||||
| `DKC` | Disk/Controller Enclosure | **Base** | Контроллерная полка: модель СХД + тип дисков + кол-во слотов + кол-во контроллеров |
|
||||
| `CTL` | Storage Controller | **Base** | Контроллер СХД: объём кэша + встроенные хост-порты |
|
||||
| `HIC` | Host Interface Card | **PCI** | HIC-карты СХД: интерфейсы подключения (FC, iSCSI, SAS) |
|
||||
| `HDD` | HDD | **Storage** | Жёсткие диски (HDD) |
|
||||
| `SSD` | SSD | **Storage** | Твердотельные диски (SSD, NVMe) |
|
||||
| `ACC` | Accessories | **Accessories** | Кабели подключения, кабели питания |
|
||||
| `SW` | Software | **SW** | Программные лицензии |
|
||||
| *(прочее)* | — | **Other** | Гарантийные опции, инсталляция |
|
||||
|
||||
---
|
||||
|
||||
## Правила именования LOT
|
||||
|
||||
Формат: `КАТЕГОРИЯ_МОДЕЛЬСХД_СПЕЦИФИКА`
|
||||
|
||||
- только латиница, цифры и знак `_`
|
||||
- регистр — ВЕРХНИЙ
|
||||
- без пробелов, дефисов, точек
|
||||
- каждый LOT уникален — два разных компонента не могут иметь одинаковое имя
|
||||
|
||||
### DKC — контроллерная полка
|
||||
|
||||
Специфика: `ТИПДИСКА_СЛОТЫ_NCTRL`
|
||||
|
||||
| Пример | Расшифровка |
|
||||
|---|---|
|
||||
| `DKC_DE4000H_SFF_24_2CTRL` | DE4000H, 24 слота SFF (2.5"), 2 контроллера |
|
||||
| `DKC_DE4000H_LFF_12_2CTRL` | DE4000H, 12 слотов LFF (3.5"), 2 контроллера |
|
||||
| `DKC_DE4000H_SFF_24_1CTRL` | DE4000H, 24 слота SFF, 1 контроллер (симплекс) |
|
||||
|
||||
Обозначения типа диска: `SFF` — 2.5", `LFF` — 3.5", `NVMe` — U.2/U.3.
|
||||
|
||||
### CTL — контроллер
|
||||
|
||||
Специфика: `КЭШГБ_ПОРТЫТИП` (если встроенные порты есть) или `КЭШГБ_BASE` (если без портов, добавляются через HIC)
|
||||
|
||||
| Пример | Расшифровка |
|
||||
|---|---|
|
||||
| `CTL_DE4000H_32GB_BASE` | 32GB кэш, без встроенных хост-портов |
|
||||
| `CTL_DE4000H_8GB_BASE` | 8GB кэш, без встроенных хост-портов |
|
||||
| `CTL_MSA2060_8GB_ISCSI10G_4P` | 8GB кэш, встроенные 4× iSCSI 10GbE |
|
||||
|
||||
### HIC — HIC-карты (интерфейс подключения)
|
||||
|
||||
Специфика: `NpПРОТОКОЛ` — без привязки к модели СХД, по аналогии с серверными `HBA_2pFC16`, `HBA_4pFC32_Gen6`.
|
||||
|
||||
| Пример | Расшифровка |
|
||||
|---|---|
|
||||
| `HIC_4pFC32` | 4 порта FC 32Gb |
|
||||
| `HIC_4pFC16` | 4 порта FC 16G/10GbE |
|
||||
| `HIC_4p25G_iSCSI` | 4 порта 25G iSCSI |
|
||||
| `HIC_4p12G_SAS` | 4 порта SAS 12Gb |
|
||||
| `HIC_2p10G_BaseT` | 2 порта 10G Base-T |
|
||||
|
||||
### HDD / SSD / NVMe — диски
|
||||
|
||||
Диски **не привязываются к модели СХД** — используются существующие LOT из серверного каталога (`HDD_...`, `SSD_...`, `NVME_...`). Новые LOT для дисков СХД не создаются; партномера дисков маппируются на уже существующие серверные LOT.
|
||||
|
||||
### ACC — кабели
|
||||
|
||||
Кабели **не привязываются к модели СХД**. Формат: `ACC_CABLE_{ТИП}_{ДЛИНА}` — универсальные LOT, одинаковые для серверов и СХД.
|
||||
|
||||
| Пример | Расшифровка |
|
||||
|---|---|
|
||||
| `ACC_CABLE_CAT6_10M` | Кабель CAT6 10м |
|
||||
| `ACC_CABLE_FC_OM4_3M` | Кабель FC LC-LC OM4 до 3м |
|
||||
| `ACC_CABLE_PWR_C13C14_15M` | Кабель питания C13–C14 1.5м |
|
||||
|
||||
### SW — программные лицензии
|
||||
|
||||
Специфика: краткое название функции.
|
||||
|
||||
| Пример | Расшифровка |
|
||||
|---|---|
|
||||
| `SW_DE4000H_ASYNC_MIRROR` | Async Mirroring |
|
||||
| `SW_DE4000H_SNAPSHOT_512` | Snapshot 512 |
|
||||
|
||||
---
|
||||
|
||||
## Таблица лотов: DE4000H (пример заполнения)
|
||||
|
||||
### DKC — контроллерная полка
|
||||
|
||||
| lot_name | vendor | model | description | disk_slots | disk_type | controllers |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DKC_DE4000H_SFF_24_2CTRL` | Lenovo | DE4000H 2U24 | DE4000H, 24× SFF, 2 контроллера | 24 | SFF | 2 |
|
||||
| `DKC_DE4000H_LFF_12_2CTRL` | Lenovo | DE4000H 2U12 | DE4000H, 12× LFF, 2 контроллера | 12 | LFF | 2 |
|
||||
|
||||
### CTL — контроллер
|
||||
|
||||
| lot_name | vendor | model | description | cache_gb | host_ports |
|
||||
|---|---|---|---|---|---|
|
||||
| `CTL_DE4000H_32GB_BASE` | Lenovo | DE4000 Controller 32GB Gen2 | Контроллер DE4000, 32GB кэш, без встроенных портов | 32 | — |
|
||||
| `CTL_DE4000H_8GB_BASE` | Lenovo | DE4000 Controller 8GB Gen2 | Контроллер DE4000, 8GB кэш, без встроенных портов | 8 | — |
|
||||
|
||||
### HIC — HIC-карты
|
||||
|
||||
| lot_name | vendor | model | description |
|
||||
|---|---|---|---|
|
||||
| `HIC_2p10G_BaseT` | Lenovo | HIC 10GBASE-T 2-Ports | HIC 10GBASE-T, 2 порта |
|
||||
| `HIC_4p25G_iSCSI` | Lenovo | HIC 10/25GbE iSCSI 4-ports | HIC iSCSI 10/25GbE, 4 порта |
|
||||
| `HIC_4p12G_SAS` | Lenovo | HIC 12Gb SAS 4-ports | HIC SAS 12Gb, 4 порта |
|
||||
| `HIC_4pFC32` | Lenovo | HIC 32Gb FC 4-ports | HIC FC 32Gb, 4 порта |
|
||||
| `HIC_4pFC16` | Lenovo | HIC 16G FC/10GbE 4-ports | HIC FC 16G/10GbE, 4 порта |
|
||||
|
||||
### HDD / SSD / NVMe / ACC — диски и кабели
|
||||
|
||||
Для дисков и кабелей новые LOT не создаются. Партномера маппируются на существующие серверные LOT из каталога.
|
||||
|
||||
### SW — программные лицензии
|
||||
|
||||
| lot_name | vendor | model | description |
|
||||
|---|---|---|---|
|
||||
| `SW_DE4000H_ASYNC_MIRROR` | Lenovo | DE4000H Asynchronous Mirroring | Лицензия Async Mirroring |
|
||||
| `SW_DE4000H_SNAPSHOT_512` | Lenovo | DE4000H Snapshot Upgrade 512 | Лицензия Snapshot 512 |
|
||||
| `SW_DE4000H_SYNC_MIRROR` | Lenovo | DE4000 Synchronous Mirroring | Лицензия Sync Mirroring |
|
||||
|
||||
---
|
||||
|
||||
## Таблица партномеров: DE4000H (пример заполнения)
|
||||
|
||||
Каждый Feature Code и Part Number должен быть привязан к своему LOT.
|
||||
Если у компонента есть оба — добавить две строки.
|
||||
|
||||
| partnumber | lot_name | описание |
|
||||
|---|---|---|
|
||||
| `BEY7` | `ENC_2U24_CHASSIS` | Lenovo ThinkSystem Storage 2U24 Chassis |
|
||||
| `BQA0` | `CTL_DE4000H_32GB_BASE` | DE4000 Controller 32GB Gen2 |
|
||||
| `BQ9Z` | `CTL_DE4000H_8GB_BASE` | DE4000 Controller 8GB Gen2 |
|
||||
| `B4B1` | `HIC_2p10G_BaseT` | HIC 10GBASE-T 2-Ports |
|
||||
| `4C57A14376` | `HIC_2p10G_BaseT` | HIC 10GBASE-T 2-Ports |
|
||||
| `B4BA` | `HIC_4p25G_iSCSI` | HIC 10/25GbE iSCSI 4-ports |
|
||||
| `4C57A14369` | `HIC_4p25G_iSCSI` | HIC 10/25GbE iSCSI 4-ports |
|
||||
| `B4B8` | `HIC_4p12G_SAS` | HIC 12Gb SAS 4-ports |
|
||||
| `4C57A14367` | `HIC_4p12G_SAS` | HIC 12Gb SAS 4-ports |
|
||||
| `B4B9` | `HIC_4pFC32` | HIC 32Gb FC 4-ports |
|
||||
| `4C57A14368` | `HIC_4pFC32` | HIC 32Gb FC 4-ports |
|
||||
| `B4B7` | `HIC_4pFC16` | HIC 16G FC/10GbE 4-ports |
|
||||
| `4C57A14366` | `HIC_4pFC16` | HIC 16G FC/10GbE 4-ports |
|
||||
| `BW12` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD 2U24 |
|
||||
| `4XB7A88046` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD 2U24 |
|
||||
| `B4C0` | `HDD_SAS_01.8TB` | 1.8TB 10K 2.5" HDD SED FIPS |
|
||||
| `4XB7A14114` | `HDD_SAS_01.8TB` | 1.8TB 10K 2.5" HDD SED FIPS |
|
||||
| `BW13` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD FIPS |
|
||||
| `4XB7A88048` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD FIPS |
|
||||
| `BKUQ` | `SSD_SAS_0.960T` | 960GB 1DWD 2.5" SSD |
|
||||
| `4XB7A74948` | `SSD_SAS_0.960T` | 960GB 1DWD 2.5" SSD |
|
||||
| `BKUT` | `SSD_SAS_01.92T` | 1.92TB 1DWD 2.5" SSD |
|
||||
| `4XB7A74951` | `SSD_SAS_01.92T` | 1.92TB 1DWD 2.5" SSD |
|
||||
| `BKUK` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD |
|
||||
| `4XB7A74955` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD |
|
||||
| `B4RY` | `SSD_SAS_07.68T` | 7.68TB 1DWD 2.5" SSD |
|
||||
| `4XB7A14176` | `SSD_SAS_07.68T` | 7.68TB 1DWD 2.5" SSD |
|
||||
| `B4CD` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD |
|
||||
| `4XB7A14110` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD |
|
||||
| `BWCJ` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD FIPS |
|
||||
| `4XB7A88469` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD FIPS |
|
||||
| `BW2B` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD SED |
|
||||
| `4XB7A88466` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD SED |
|
||||
| `AVFW` | `ACC_CABLE_CAT6_1M` | CAT6 0.75-1.5m |
|
||||
| `A1MT` | `ACC_CABLE_CAT6_10M` | CAT6 10m |
|
||||
| `90Y3718` | `ACC_CABLE_CAT6_10M` | CAT6 10m |
|
||||
| `A1MW` | `ACC_CABLE_CAT6_25M` | CAT6 25m |
|
||||
| `90Y3727` | `ACC_CABLE_CAT6_25M` | CAT6 25m |
|
||||
| `39Y7937` | `ACC_CABLE_PWR_C13C14_15M` | C13–C14 1.5m |
|
||||
| `39Y7938` | `ACC_CABLE_PWR_C13C20_28M` | C13–C20 2.8m |
|
||||
| `4L67A08371` | `ACC_CABLE_PWR_C13C14_43M` | C13–C14 4.3m |
|
||||
| `C932` | `SW_DE4000H_ASYNC_MIRROR` | DE4000H Asynchronous Mirroring |
|
||||
| `00WE123` | `SW_DE4000H_ASYNC_MIRROR` | DE4000H Asynchronous Mirroring |
|
||||
| `C930` | `SW_DE4000H_SNAPSHOT_512` | DE4000H Snapshot Upgrade 512 |
|
||||
| `C931` | `SW_DE4000H_SYNC_MIRROR` | DE4000 Synchronous Mirroring |
|
||||
|
||||
---
|
||||
|
||||
## Шаблон для новых моделей СХД
|
||||
|
||||
```
|
||||
DKC_МОДЕЛЬ_ТИПДИСКА_СЛОТЫ_NCTRL — контроллерная полка
|
||||
CTL_МОДЕЛЬ_КЭШГБ_ПОРТЫ — контроллер
|
||||
HIC_МОДЕЛЬ_ПРОТОКОЛ_СКОРОСТЬ_ПОРТЫ — HIC-карта (интерфейс подключения)
|
||||
SW_МОДЕЛЬ_ФУНКЦИЯ — лицензия
|
||||
```
|
||||
|
||||
Диски (HDD/SSD/NVMe) и кабели (ACC) — маппируются на существующие серверные LOT, новые не создаются.
|
||||
|
||||
Пример для HPE MSA 2060:
|
||||
```
|
||||
DKC_MSA2060_SFF_24_2CTRL
|
||||
CTL_MSA2060_8GB_ISCSI10G_4P
|
||||
HIC_MSA2060_FC32G_2P
|
||||
SW_MSA2060_REMOTE_SNAP
|
||||
```
|
||||
@@ -22,29 +22,24 @@ type BuildResult struct {
|
||||
}
|
||||
|
||||
var (
|
||||
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
|
||||
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
|
||||
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
|
||||
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
|
||||
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
|
||||
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
|
||||
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
|
||||
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
|
||||
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
|
||||
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
|
||||
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
|
||||
)
|
||||
|
||||
type namedSeg struct {
|
||||
group string // "MODEL","CPU","MEM","GPU","DISK","NET","PSU","SUPPORT"
|
||||
value string
|
||||
}
|
||||
|
||||
func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) {
|
||||
segs := make([]namedSeg, 0, 8)
|
||||
segments := make([]string, 0, 8)
|
||||
warnings := make([]string, 0)
|
||||
|
||||
model := NormalizeServerModel(opts.ServerModel)
|
||||
if model == "" {
|
||||
return BuildResult{}, fmt.Errorf("server_model required")
|
||||
}
|
||||
segs = append(segs, namedSeg{"MODEL", model})
|
||||
segments = append(segments, model)
|
||||
|
||||
lotNames := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
@@ -60,39 +55,41 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
|
||||
return BuildResult{}, err
|
||||
}
|
||||
|
||||
if cpuSeg := buildCPUSegment(items, cats); cpuSeg != "" {
|
||||
segs = append(segs, namedSeg{"CPU", cpuSeg})
|
||||
cpuSeg := buildCPUSegment(items, cats)
|
||||
if cpuSeg != "" {
|
||||
segments = append(segments, cpuSeg)
|
||||
}
|
||||
memSeg, memWarn := buildMemSegment(items, cats)
|
||||
if memWarn != "" {
|
||||
warnings = append(warnings, memWarn)
|
||||
}
|
||||
if memSeg != "" {
|
||||
segs = append(segs, namedSeg{"MEM", memSeg})
|
||||
segments = append(segments, memSeg)
|
||||
}
|
||||
if gpuSeg := buildGPUSegment(items, cats); gpuSeg != "" {
|
||||
segs = append(segs, namedSeg{"GPU", gpuSeg})
|
||||
gpuSeg := buildGPUSegment(items, cats)
|
||||
if gpuSeg != "" {
|
||||
segments = append(segments, gpuSeg)
|
||||
}
|
||||
diskSeg, diskWarn := buildDiskSegment(items, cats)
|
||||
if diskWarn != "" {
|
||||
warnings = append(warnings, diskWarn)
|
||||
}
|
||||
if diskSeg != "" {
|
||||
segs = append(segs, namedSeg{"DISK", diskSeg})
|
||||
segments = append(segments, diskSeg)
|
||||
}
|
||||
netSeg, netWarn := buildNetSegment(items, cats)
|
||||
if netWarn != "" {
|
||||
warnings = append(warnings, netWarn)
|
||||
}
|
||||
if netSeg != "" {
|
||||
segs = append(segs, namedSeg{"NET", netSeg})
|
||||
segments = append(segments, netSeg)
|
||||
}
|
||||
psuSeg, psuWarn := buildPSUSegment(items, cats)
|
||||
if psuWarn != "" {
|
||||
warnings = append(warnings, psuWarn)
|
||||
}
|
||||
if psuSeg != "" {
|
||||
segs = append(segs, namedSeg{"PSU", psuSeg})
|
||||
segments = append(segments, psuSeg)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(opts.SupportCode) != "" {
|
||||
@@ -100,12 +97,12 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
|
||||
if !isSupportCodeValid(code) {
|
||||
return BuildResult{}, fmt.Errorf("invalid_support_code")
|
||||
}
|
||||
segs = append(segs, namedSeg{"SUPPORT", code})
|
||||
segments = append(segments, code)
|
||||
}
|
||||
|
||||
article := strings.Join(namedSegsValues(segs), "-")
|
||||
article := strings.Join(segments, "-")
|
||||
if len([]rune(article)) > 80 {
|
||||
article = compressArticle(segs)
|
||||
article = compressArticle(segments)
|
||||
warnings = append(warnings, "compressed")
|
||||
}
|
||||
if len([]rune(article)) > 80 {
|
||||
@@ -115,23 +112,6 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
|
||||
return BuildResult{Article: article, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func namedSegsValues(segs []namedSeg) []string {
|
||||
out := make([]string, len(segs))
|
||||
for i, s := range segs {
|
||||
out[i] = s.value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findSegGroup(segs []namedSeg, group string) int {
|
||||
for i, s := range segs {
|
||||
if s.group == group {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func isSupportCodeValid(code string) bool {
|
||||
if len(code) < 3 {
|
||||
return false
|
||||
@@ -349,60 +329,33 @@ func parseGPUModel(lotName string) string {
|
||||
}
|
||||
parts := strings.Split(upper, "_")
|
||||
model := ""
|
||||
numSuffix := ""
|
||||
mem := ""
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
switch p {
|
||||
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX", "SFF", "LOVELACE":
|
||||
continue
|
||||
case "ADA", "AMPERE", "HOPPER", "BLACKWELL":
|
||||
if model != "" {
|
||||
archAbbr := map[string]string{
|
||||
"ADA": "ADA", "AMPERE": "AMP", "HOPPER": "HOP", "BLACKWELL": "BWL",
|
||||
}
|
||||
numSuffix += archAbbr[p]
|
||||
}
|
||||
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
||||
continue
|
||||
default:
|
||||
if strings.Contains(p, "GB") {
|
||||
mem = p
|
||||
continue
|
||||
}
|
||||
if model == "" && i > 0 {
|
||||
if model == "" && (i > 0) {
|
||||
model = p
|
||||
} else if model != "" && numSuffix == "" && isNumeric(p) {
|
||||
numSuffix = p
|
||||
}
|
||||
}
|
||||
}
|
||||
full := model
|
||||
if numSuffix != "" {
|
||||
full = model + numSuffix
|
||||
if model != "" && mem != "" {
|
||||
return model + "_" + mem
|
||||
}
|
||||
if full != "" && mem != "" {
|
||||
return full + "_" + mem
|
||||
}
|
||||
if full != "" {
|
||||
return full
|
||||
if model != "" {
|
||||
return model
|
||||
}
|
||||
return normalizeModelToken(lotName)
|
||||
}
|
||||
|
||||
func isNumeric(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseMemGiB(lotName string) int {
|
||||
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
|
||||
return atoi(m[1]) * 1024
|
||||
@@ -504,50 +457,60 @@ func atoi(v string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
func compressArticle(segs []namedSeg) string {
|
||||
if len(segs) == 0 {
|
||||
func compressArticle(segments []string) string {
|
||||
if len(segments) == 0 {
|
||||
return ""
|
||||
}
|
||||
for i, s := range segs {
|
||||
segs[i].value = strings.ReplaceAll(s.value, "GbE", "G")
|
||||
normalized := make([]string, 0, len(segments))
|
||||
for _, s := range segments {
|
||||
normalized = append(normalized, strings.ReplaceAll(s, "GbE", "G"))
|
||||
}
|
||||
article := strings.Join(namedSegsValues(segs), "-")
|
||||
segments = normalized
|
||||
article := strings.Join(segments, "-")
|
||||
if len([]rune(article)) <= 80 {
|
||||
return article
|
||||
}
|
||||
|
||||
// segment order: model, cpu, mem, gpu, disk, net, psu, support
|
||||
index := func(i int) (int, bool) {
|
||||
if i >= 0 && i < len(segments) {
|
||||
return i, true
|
||||
}
|
||||
return -1, false
|
||||
}
|
||||
|
||||
// 1) remove PSU
|
||||
if i := findSegGroup(segs, "PSU"); i >= 0 {
|
||||
segs = append(segs[:i], segs[i+1:]...)
|
||||
article = strings.Join(namedSegsValues(segs), "-")
|
||||
if i, ok := index(6); ok {
|
||||
segments = append(segments[:i], segments[i+1:]...)
|
||||
article = strings.Join(segments, "-")
|
||||
if len([]rune(article)) <= 80 {
|
||||
return article
|
||||
}
|
||||
}
|
||||
|
||||
// 2) compress NET/HBA/HCA
|
||||
if i := findSegGroup(segs, "NET"); i >= 0 {
|
||||
segs[i].value = compressNetSegment(segs[i].value)
|
||||
article = strings.Join(namedSegsValues(segs), "-")
|
||||
if i, ok := index(5); ok {
|
||||
segments[i] = compressNetSegment(segments[i])
|
||||
article = strings.Join(segments, "-")
|
||||
if len([]rune(article)) <= 80 {
|
||||
return article
|
||||
}
|
||||
}
|
||||
|
||||
// 3) compress DISK
|
||||
if i := findSegGroup(segs, "DISK"); i >= 0 {
|
||||
segs[i].value = compressDiskSegment(segs[i].value)
|
||||
article = strings.Join(namedSegsValues(segs), "-")
|
||||
if i, ok := index(4); ok {
|
||||
segments[i] = compressDiskSegment(segments[i])
|
||||
article = strings.Join(segments, "-")
|
||||
if len([]rune(article)) <= 80 {
|
||||
return article
|
||||
}
|
||||
}
|
||||
|
||||
// 4) compress GPU to vendor only (GPU_NV)
|
||||
if i := findSegGroup(segs, "GPU"); i >= 0 {
|
||||
segs[i].value = compressGPUSegment(segs[i].value)
|
||||
if i, ok := index(3); ok {
|
||||
segments[i] = compressGPUSegment(segments[i])
|
||||
}
|
||||
return strings.Join(namedSegsValues(segs), "-")
|
||||
return strings.Join(segments, "-")
|
||||
}
|
||||
|
||||
func compressNetSegment(seg string) string {
|
||||
|
||||
@@ -61,79 +61,6 @@ func TestBuild_ParsesNetAndPSU(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuild_CompressArticle_NoGPU_PSUNotNIC reproduces the bug where 2 PSUs produced
|
||||
// "2xNIC" in the article because compressArticle used hard-coded indices that assumed
|
||||
// GPU was always present.
|
||||
func TestBuild_CompressArticle_NoGPU_PSUNotNIC(t *testing.T) {
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 2,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-05-19-001",
|
||||
Name: "test",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(2)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: localPL.ID, LotName: "CPU_INTEL_8358", LotCategory: "CPU", Price: 1},
|
||||
{PricelistID: localPL.ID, LotName: "MEM_DDR4_64G_3200", LotCategory: "MEM", Price: 1},
|
||||
{PricelistID: localPL.ID, LotName: "SSD_SATA_0.4T", LotCategory: "SSD", Price: 1},
|
||||
{PricelistID: localPL.ID, LotName: "SSD_SATA_0.9T", LotCategory: "SSD", Price: 1},
|
||||
{PricelistID: localPL.ID, LotName: "HDD_SATA_16T", LotCategory: "HDD", Price: 1},
|
||||
{PricelistID: localPL.ID, LotName: "NIC_2p25G_MCX512A", LotCategory: "NIC", Price: 1},
|
||||
{PricelistID: localPL.ID, LotName: "HBA_2pFC32_Gen6", LotCategory: "HBA", Price: 1},
|
||||
{PricelistID: localPL.ID, LotName: "NIC_4p1G_I350", LotCategory: "NIC", Price: 1},
|
||||
{PricelistID: localPL.ID, LotName: "PS_1500W_Platinum", LotCategory: "PS", Price: 1},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local items: %v", err)
|
||||
}
|
||||
|
||||
// PS_1500W → "2x1.5kW" (7 chars) brings uncompressed article to 81 chars, triggering
|
||||
// compressArticle. Before the fix, compressArticle used hard-coded index 5 for NET, but
|
||||
// without GPU the PSU sits at index 5, so compressNetSegment("2x1.5kW") returned "2xNIC".
|
||||
items := models.ConfigItems{
|
||||
{LotName: "CPU_INTEL_8358", Quantity: 2},
|
||||
{LotName: "MEM_DDR4_64G_3200", Quantity: 16}, // 1024 GiB = 1T
|
||||
{LotName: "SSD_SATA_0.4T", Quantity: 2},
|
||||
{LotName: "SSD_SATA_0.9T", Quantity: 4},
|
||||
{LotName: "HDD_SATA_16T", Quantity: 6},
|
||||
{LotName: "NIC_2p25G_MCX512A", Quantity: 1},
|
||||
{LotName: "HBA_2pFC32_Gen6", Quantity: 1},
|
||||
{LotName: "NIC_4p1G_I350", Quantity: 1},
|
||||
{LotName: "PS_1500W_Platinum", Quantity: 2},
|
||||
}
|
||||
result, err := Build(local, items, BuildOptions{
|
||||
ServerModel: "NF5280M6",
|
||||
ServerPricelist: &localPL.ServerID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build article: %v", err)
|
||||
}
|
||||
if len([]rune(result.Article)) > 80 {
|
||||
t.Fatalf("article too long (%d): %s", len([]rune(result.Article)), result.Article)
|
||||
}
|
||||
// PSU segment must not be mis-labeled as NIC during compression
|
||||
// The correct behaviour: PSU is dropped, NET stays as-is or compressed to HBA/NIC labels
|
||||
// Before the fix: article ended with "-2xNIC" (PSU turned into NIC)
|
||||
// After the fix: article must not contain a standalone "NIC" that came from PSU wattage
|
||||
if strings.HasSuffix(result.Article, "-2xNIC") {
|
||||
t.Fatalf("PSU mis-labeled as NIC in article: %s", result.Article)
|
||||
}
|
||||
t.Logf("article: %s (warnings: %v)", result.Article, result.Warnings)
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return strings.Contains(s, sub)
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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,16 +64,11 @@ 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{
|
||||
Items: components,
|
||||
TotalCount: total,
|
||||
Components: components,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalPages: totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,12 +90,6 @@ func (h *ComponentHandler) Get(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
||||
// Build display_order lookup from the canonical list.
|
||||
orderMap := make(map[string]int, len(models.DefaultCategories))
|
||||
for _, cat := range models.DefaultCategories {
|
||||
orderMap[strings.ToUpper(cat.Code)] = cat.DisplayOrder
|
||||
}
|
||||
|
||||
codes, err := h.localDB.GetLocalComponentCategories()
|
||||
if err == nil && len(codes) > 0 {
|
||||
categories := make([]models.Category, 0, len(codes))
|
||||
@@ -109,15 +98,7 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
order := orderMap[strings.ToUpper(trimmed)]
|
||||
if order == 0 {
|
||||
order = models.MaxKnownDisplayOrder + 1
|
||||
}
|
||||
categories = append(categories, models.Category{
|
||||
Code: trimmed,
|
||||
Name: trimmed,
|
||||
DisplayOrder: order,
|
||||
})
|
||||
categories = append(categories, models.Category{Code: trimmed, Name: trimmed})
|
||||
}
|
||||
c.JSON(http.StatusOK, categories)
|
||||
return
|
||||
|
||||
@@ -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.StatusUnprocessableEntity, "invalid request", err)
|
||||
RespondError(c, http.StatusBadRequest, "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.StatusUnprocessableEntity, gin.H{"error": "no items to export"})
|
||||
c.JSON(http.StatusBadRequest, 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.GetByUUIDNoAuth(uuid)
|
||||
config, err := h.configService.GetByUUID(uuid, h.dbUsername)
|
||||
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.StatusUnprocessableEntity, gin.H{"error": "no items to export"})
|
||||
c.JSON(http.StatusBadRequest, 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.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -223,69 +223,12 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportConfigPricingCSV(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
var req ProjectExportOptionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.GetByUUIDNoAuth(uuid)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusNotFound, "resource not found", err)
|
||||
return
|
||||
}
|
||||
|
||||
opts := services.ProjectPricingExportOptions{
|
||||
IncludeLOT: req.IncludeLOT,
|
||||
IncludeBOM: req.IncludeBOM,
|
||||
IncludeEstimate: req.IncludeEstimate,
|
||||
IncludeStock: req.IncludeStock,
|
||||
IncludeCompetitor: req.IncludeCompetitor,
|
||||
Basis: req.Basis,
|
||||
SaleMarkup: req.SaleMarkup,
|
||||
}
|
||||
|
||||
data, err := h.exportService.ConfigToPricingExportData(config, opts)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
|
||||
basisLabel := "FOB"
|
||||
if strings.EqualFold(strings.TrimSpace(req.Basis), "ddp") {
|
||||
basisLabel = "DDP"
|
||||
}
|
||||
|
||||
projectCode := config.Name
|
||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, h.dbUsername); err == nil && project != nil {
|
||||
projectCode = project.Code
|
||||
}
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s (%s) %s %s SPEC.csv",
|
||||
time.Now().Format("2006-01-02"),
|
||||
projectCode,
|
||||
config.Name,
|
||||
basisLabel,
|
||||
)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
if err := h.exportService.ToPricingCSV(c.Writer, data, opts); err != nil {
|
||||
c.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||
projectUUID := c.Param("uuid")
|
||||
|
||||
var req ProjectExportOptionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -301,7 +244,7 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if len(result.Configs) == 0 {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -26,15 +26,11 @@ 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)
|
||||
|
||||
// Create handler with mocks
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
@@ -109,7 +105,7 @@ func TestExportCSV_Success(t *testing.T) {
|
||||
func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
@@ -128,8 +124,8 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("Expected status 422, got %d", w.Code)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
@@ -143,7 +139,7 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||
func TestExportCSV_EmptyItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
@@ -162,8 +158,8 @@ func TestExportCSV_EmptyItems(t *testing.T) {
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Should return 400 Bad Request (validation error from gin binding)
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Logf("Status code: %d (expected 422 for empty items)", w.Code)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +181,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
@@ -232,7 +228,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
||||
func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{err: errors.New("config not found")},
|
||||
@@ -275,7 +271,7 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
@@ -294,8 +290,8 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("Expected status 422, got %d", w.Code)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
|
||||
@@ -51,11 +51,8 @@ func (h *PartnumberBooksHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": summaries,
|
||||
"total_count": len(summaries),
|
||||
"page": 1,
|
||||
"per_page": len(summaries),
|
||||
"total_pages": 1,
|
||||
"books": summaries,
|
||||
"total": len(summaries),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,7 +62,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.StatusUnprocessableEntity, gin.H{"error": "invalid book ID"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,8 +77,9 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
book, err := h.localDB.GetLocalPartnumberBookByServerID(uint(id))
|
||||
if err != nil {
|
||||
// Find local book by server_id
|
||||
var book localdb.LocalPartnumberBook
|
||||
if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
|
||||
return
|
||||
}
|
||||
@@ -92,20 +90,15 @@ 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_count": total,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"total_pages": totalPages,
|
||||
"search": search,
|
||||
"book_total": bookRepo.CountBookItems(book.ID),
|
||||
"lot_count": bookRepo.CountDistinctLots(book.ID),
|
||||
|
||||
@@ -106,16 +106,11 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
totalPages := (total + perPage - 1) / perPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": summaries,
|
||||
"total_count": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"total_pages": totalPages,
|
||||
"pricelists": summaries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,7 +119,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.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -151,7 +146,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.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -170,21 +165,40 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
items, total, err := h.localDB.GetLocalPricelistItemsPage(localPL.ID, strings.TrimSpace(search), page, perPage)
|
||||
if err != nil {
|
||||
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 {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
lotNames := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
lotNames[i] = item.LotName
|
||||
}
|
||||
descMap, err := h.localDB.GetLocalComponentDescriptionsByLotNames(lotNames)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
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
|
||||
}
|
||||
|
||||
resultItems := make([]gin.H, 0, len(items))
|
||||
@@ -203,14 +217,12 @@ 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_count": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"total_pages": totalPages,
|
||||
"source": localPL.Source,
|
||||
"items": resultItems,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,7 +230,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.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -141,21 +141,21 @@ func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Items []struct {
|
||||
Pricelists []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"items"`
|
||||
TotalCount int `json:"total_count"`
|
||||
} `json:"pricelists"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp.TotalCount != 1 {
|
||||
t.Fatalf("expected total=1, got %d", resp.TotalCount)
|
||||
if resp.Total != 1 {
|
||||
t.Fatalf("expected total=1, got %d", resp.Total)
|
||||
}
|
||||
if len(resp.Items) != 1 {
|
||||
t.Fatalf("expected 1 pricelist, got %d", len(resp.Items))
|
||||
if len(resp.Pricelists) != 1 {
|
||||
t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists))
|
||||
}
|
||||
if resp.Items[0].ID != 10 {
|
||||
t.Fatalf("expected pricelist id=10, got %d", resp.Items[0].ID)
|
||||
if resp.Pricelists[0].ID != 10 {
|
||||
t.Fatalf("expected pricelist id=10, got %d", resp.Pricelists[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.StatusUnprocessableEntity, "invalid request", err)
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.quoteService.ValidateAndCalculate(&req)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
RespondError(c, http.StatusBadRequest, "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.StatusUnprocessableEntity, "invalid request", err)
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.quoteService.ValidateAndCalculate(&req)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
RespondError(c, http.StatusBadRequest, "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.StatusUnprocessableEntity, "invalid request", err)
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.quoteService.CalculatePriceLevels(&req)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
@@ -14,6 +15,9 @@ 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 {
|
||||
@@ -23,6 +27,8 @@ 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 },
|
||||
@@ -87,7 +93,7 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
|
||||
}
|
||||
|
||||
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
||||
lotCount, canWrite, err := db.ValidateMariaDBConnection(dsn)
|
||||
lotCount, canWrite, err := validateMariaDBConnection(dsn)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -129,7 +135,7 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||
|
||||
// Test connection first
|
||||
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
||||
if _, _, err := db.ValidateMariaDBConnection(dsn); err != nil {
|
||||
if _, _, err := validateMariaDBConnection(dsn); err != nil {
|
||||
_ = c.Error(err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
@@ -208,3 +214,46 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SupportBundleHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
connMgr *db.ConnectionManager
|
||||
syncService *syncsvc.Service
|
||||
logFilePath string
|
||||
}
|
||||
|
||||
func NewSupportBundleHandler(local *localdb.LocalDB, connMgr *db.ConnectionManager, svc *syncsvc.Service, logFilePath string) *SupportBundleHandler {
|
||||
return &SupportBundleHandler{
|
||||
localDB: local,
|
||||
connMgr: connMgr,
|
||||
syncService: svc,
|
||||
logFilePath: logFilePath,
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadBundle collects diagnostic data and streams a ZIP archive.
|
||||
// GET /api/support-bundle
|
||||
func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
||||
now := time.Now().UTC()
|
||||
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")))
|
||||
|
||||
zw := zip.NewWriter(c.Writer)
|
||||
defer zw.Close()
|
||||
|
||||
writeJSON := func(name string, v any) {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
// app_info.json
|
||||
writeJSON("app_info.json", map[string]any{
|
||||
"app_version": appmeta.Version(),
|
||||
"go_version": runtime.Version(),
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"hostname": hostname,
|
||||
"db_user": h.localDB.GetDBUser(),
|
||||
"collected_at": now.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
// local_db_stats.json
|
||||
writeJSON("local_db_stats.json", map[string]any{
|
||||
"components": h.localDB.CountLocalComponents(),
|
||||
"configurations": h.localDB.CountConfigurations(),
|
||||
"projects": h.localDB.CountProjects(),
|
||||
"pricelists": h.localDB.CountLocalPricelists(),
|
||||
"pending_changes": h.localDB.GetPendingCount(),
|
||||
"db_size_bytes": h.localDB.DBFileSizeBytes(),
|
||||
"last_pricelist_sync_time": h.localDB.GetLastSyncTime(),
|
||||
"last_pricelist_attempt": h.localDB.GetLastPricelistSyncAttemptAt(),
|
||||
"last_pricelist_status": h.localDB.GetLastPricelistSyncStatus(),
|
||||
"last_pricelist_error": h.localDB.GetLastPricelistSyncError(),
|
||||
"last_component_sync_attempt": h.localDB.GetLastComponentSyncAttemptAt(),
|
||||
"last_component_sync_status": h.localDB.GetLastComponentSyncStatus(),
|
||||
"last_component_sync_error": h.localDB.GetLastComponentSyncError(),
|
||||
})
|
||||
|
||||
// db_connection.json — includes TCP ping to DB host
|
||||
connStatus := h.connMgr.GetStatus()
|
||||
dbConnDoc := map[string]any{
|
||||
"is_connected": connStatus.IsConnected,
|
||||
"last_error": connStatus.LastError,
|
||||
}
|
||||
if settings, err := h.localDB.GetSettings(); err == nil && settings.Host != "" {
|
||||
addr := net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
|
||||
start := time.Now()
|
||||
conn, dialErr := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||
pingMs := time.Since(start).Milliseconds()
|
||||
if dialErr == nil {
|
||||
conn.Close()
|
||||
dbConnDoc["tcp_ping_ms"] = pingMs
|
||||
dbConnDoc["tcp_ping_addr"] = addr
|
||||
} else {
|
||||
dbConnDoc["tcp_ping_error"] = dialErr.Error()
|
||||
dbConnDoc["tcp_ping_addr"] = addr
|
||||
}
|
||||
}
|
||||
writeJSON("db_connection.json", dbConnDoc)
|
||||
|
||||
// sync_readiness.json
|
||||
if h.syncService != nil {
|
||||
readiness, err := h.syncService.GetReadiness()
|
||||
if err != nil {
|
||||
writeJSON("sync_readiness.json", map[string]any{"error": err.Error()})
|
||||
} else {
|
||||
writeJSON("sync_readiness.json", readiness)
|
||||
}
|
||||
}
|
||||
|
||||
// system_metrics.json
|
||||
writeJSON("system_metrics.json", collectSystemMetrics())
|
||||
|
||||
// sync_log.json — history of sync operations
|
||||
if entries, err := h.localDB.GetSyncLog(200); err == nil {
|
||||
writeJSON("sync_log.json", entries)
|
||||
}
|
||||
|
||||
// pricelists.json — downloaded pricelists grouped by source
|
||||
if pricelists, err := h.localDB.GetLocalPricelists(); err == nil {
|
||||
type plEntry struct {
|
||||
ServerID uint `json:"server_id"`
|
||||
Source string `json:"source"`
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
IsUsed bool `json:"is_used"`
|
||||
}
|
||||
bySource := map[string][]plEntry{}
|
||||
for _, pl := range pricelists {
|
||||
e := plEntry{
|
||||
ServerID: pl.ServerID,
|
||||
Source: pl.Source,
|
||||
Version: pl.Version,
|
||||
Name: pl.Name,
|
||||
CreatedAt: pl.CreatedAt,
|
||||
SyncedAt: pl.SyncedAt,
|
||||
IsUsed: pl.IsUsed,
|
||||
}
|
||||
bySource[pl.Source] = append(bySource[pl.Source], e)
|
||||
}
|
||||
writeJSON("pricelists.json", bySource)
|
||||
}
|
||||
|
||||
// schema_migrations.json
|
||||
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)
|
||||
if h.logFilePath != "" {
|
||||
if f, err := os.Open(h.logFilePath); err == nil {
|
||||
defer f.Close()
|
||||
if info, err := f.Stat(); err == nil {
|
||||
const maxLog = 5 << 20
|
||||
offset := int64(0)
|
||||
if info.Size() > maxLog {
|
||||
offset = info.Size() - maxLog
|
||||
}
|
||||
if _, err := f.Seek(offset, io.SeekStart); err == nil {
|
||||
if w, err := zw.Create("app.log"); err == nil {
|
||||
if _, err := io.Copy(w, f); err != nil {
|
||||
slog.Warn("support bundle: error copying log file", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func collectSystemMetrics() map[string]any {
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
|
||||
m := map[string]any{
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"cpu_count": runtime.NumCPU(),
|
||||
"heap_alloc_bytes": ms.HeapAlloc,
|
||||
"heap_sys_bytes": ms.HeapSys,
|
||||
"heap_inuse_bytes": ms.HeapInuse,
|
||||
"stack_inuse_bytes": ms.StackInuse,
|
||||
"gc_cycles": ms.NumGC,
|
||||
"next_gc_bytes": ms.NextGC,
|
||||
}
|
||||
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
if info := diskUsage(wd); info != nil {
|
||||
m["disk"] = info
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package handlers
|
||||
|
||||
import "syscall"
|
||||
|
||||
func diskUsage(path string) map[string]any {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return nil
|
||||
}
|
||||
total := stat.Blocks * uint64(stat.Bsize)
|
||||
free := stat.Bfree * uint64(stat.Bsize)
|
||||
return map[string]any{
|
||||
"total_bytes": total,
|
||||
"free_bytes": free,
|
||||
"used_bytes": total - free,
|
||||
"path": path,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package handlers
|
||||
|
||||
func diskUsage(_ string) map[string]any {
|
||||
return nil
|
||||
}
|
||||
@@ -187,11 +187,8 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
result, err := h.localDB.SyncComponents(mariaDB)
|
||||
if err != nil {
|
||||
_ = h.localDB.SetComponentSyncResult("error", err.Error(), now)
|
||||
h.localDB.AppendSyncLog("components", "error", err.Error(), 0, now, time.Since(now).Milliseconds())
|
||||
slog.Error("component sync failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
@@ -200,8 +197,6 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
_ = h.localDB.SetComponentSyncResult("ok", "", now)
|
||||
h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds())
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
@@ -221,7 +216,6 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
startTime := time.Now()
|
||||
synced, err := h.syncService.SyncPricelists()
|
||||
if err != nil {
|
||||
h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, startTime, time.Since(startTime).Milliseconds())
|
||||
slog.Error("pricelist sync failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
@@ -230,7 +224,6 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
h.localDB.AppendSyncLog("pricelists", "ok", "", synced, startTime, time.Since(startTime).Milliseconds())
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
@@ -238,6 +231,7 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
Synced: synced,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite.
|
||||
@@ -265,6 +259,7 @@ func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
|
||||
Synced: pulled,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// SyncAllResponse represents result of full sync
|
||||
@@ -318,11 +313,8 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
compNow := time.Now()
|
||||
compResult, err := h.localDB.SyncComponents(mariaDB)
|
||||
if err != nil {
|
||||
_ = h.localDB.SetComponentSyncResult("error", err.Error(), compNow)
|
||||
h.localDB.AppendSyncLog("components", "error", err.Error(), 0, compNow, time.Since(compNow).Milliseconds())
|
||||
slog.Error("component sync failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
@@ -331,15 +323,11 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
_ = h.localDB.SetComponentSyncResult("ok", "", compNow)
|
||||
h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds())
|
||||
componentsSynced = compResult.TotalSynced
|
||||
|
||||
// Sync pricelists
|
||||
plNow := time.Now()
|
||||
pricelistsSynced, err = h.syncService.SyncPricelists()
|
||||
if err != nil {
|
||||
h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, plNow, time.Since(plNow).Milliseconds())
|
||||
slog.Error("pricelist sync failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
@@ -350,7 +338,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
h.localDB.AppendSyncLog("pricelists", "ok", "", pricelistsSynced, plNow, time.Since(plNow).Milliseconds())
|
||||
|
||||
projectsResult, err := h.syncService.ImportProjectsToLocal()
|
||||
if err != nil {
|
||||
@@ -397,6 +384,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
ConfigurationsSkipped: configsResult.Skipped,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// checkOnline checks if MariaDB is accessible
|
||||
@@ -429,6 +417,7 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
|
||||
Synced: pushed,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// GetPendingCount returns the number of pending changes
|
||||
@@ -617,6 +606,9 @@ func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Keep current client heartbeat fresh so app version is available in the table.
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
|
||||
users, err := h.syncService.ListUserSyncStatuses(threshold)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
@@ -705,13 +697,6 @@ func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OFFLINE_UNVERIFIED_SCHEMA is only valid while actually offline.
|
||||
// Suppress it when the connection manager reports online so the stale
|
||||
// blocked state from a previous disconnection doesn't linger in the UI.
|
||||
if state.ReasonCode == "OFFLINE_UNVERIFIED_SCHEMA" && h.checkOnline() {
|
||||
return nil
|
||||
}
|
||||
|
||||
readiness := &sync.SyncReadiness{
|
||||
Status: state.Status,
|
||||
Blocked: state.Status == sync.ReadinessBlocked,
|
||||
@@ -739,7 +724,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -66,7 +65,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -137,7 +136,7 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -150,11 +149,7 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
book, err := bookRepo.GetActiveBook()
|
||||
if err != nil {
|
||||
slog.Warn("vendor spec resolve: no active partnumber book", "err", err)
|
||||
book = nil
|
||||
}
|
||||
book, _ := bookRepo.GetActiveBook()
|
||||
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
@@ -184,7 +179,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,8 @@ type ComponentSyncResult struct {
|
||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Build the component catalog from every runtime source of LOT names.
|
||||
// Storage lots may exist in qt_lot_metadata / qt_pricelist_items before they appear in lot,
|
||||
// so the sync cannot start from lot alone.
|
||||
// Query to join lot with qt_lot_metadata (metadata only, no pricing)
|
||||
// Use LEFT JOIN to include lots without metadata
|
||||
type componentRow struct {
|
||||
LotName string
|
||||
LotDescription string
|
||||
@@ -41,29 +40,15 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
||||
var rows []componentRow
|
||||
err := mariaDB.Raw(`
|
||||
SELECT
|
||||
src.lot_name,
|
||||
COALESCE(MAX(NULLIF(TRIM(l.lot_description), '')), '') AS lot_description,
|
||||
COALESCE(
|
||||
MAX(NULLIF(TRIM(c.code), '')),
|
||||
MAX(NULLIF(TRIM(l.lot_category), '')),
|
||||
SUBSTRING_INDEX(src.lot_name, '_', 1)
|
||||
) AS category,
|
||||
MAX(NULLIF(TRIM(m.model), '')) AS model
|
||||
FROM (
|
||||
SELECT lot_name FROM lot
|
||||
UNION
|
||||
SELECT lot_name FROM qt_lot_metadata
|
||||
WHERE is_hidden = FALSE OR is_hidden IS NULL
|
||||
UNION
|
||||
SELECT lot_name FROM qt_pricelist_items
|
||||
) src
|
||||
LEFT JOIN lot l ON l.lot_name = src.lot_name
|
||||
LEFT JOIN qt_lot_metadata m
|
||||
ON m.lot_name = src.lot_name
|
||||
AND (m.is_hidden = FALSE OR m.is_hidden IS NULL)
|
||||
l.lot_name,
|
||||
l.lot_description,
|
||||
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
||||
m.model
|
||||
FROM lot l
|
||||
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
||||
LEFT JOIN qt_categories c ON m.category_id = c.id
|
||||
GROUP BY src.lot_name
|
||||
ORDER BY src.lot_name
|
||||
WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL
|
||||
ORDER BY l.lot_name
|
||||
`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
|
||||
@@ -86,25 +71,18 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
||||
existingMap[c.LotName] = true
|
||||
}
|
||||
|
||||
// Prepare components for batch insert/update.
|
||||
// Source joins may duplicate the same lot_name, so collapse them before insert.
|
||||
// Prepare components for batch insert/update
|
||||
syncTime := time.Now()
|
||||
components := make([]LocalComponent, 0, len(rows))
|
||||
componentIndex := make(map[string]int, len(rows))
|
||||
newCount := 0
|
||||
|
||||
for _, row := range rows {
|
||||
lotName := strings.TrimSpace(row.LotName)
|
||||
if lotName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
category := ""
|
||||
if row.Category != nil {
|
||||
category = strings.TrimSpace(*row.Category)
|
||||
category = *row.Category
|
||||
} else {
|
||||
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||
parts := strings.SplitN(lotName, "_", 2)
|
||||
parts := strings.SplitN(row.LotName, "_", 2)
|
||||
if len(parts) >= 1 {
|
||||
category = parts[0]
|
||||
}
|
||||
@@ -112,34 +90,18 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
||||
|
||||
model := ""
|
||||
if row.Model != nil {
|
||||
model = strings.TrimSpace(*row.Model)
|
||||
model = *row.Model
|
||||
}
|
||||
|
||||
comp := LocalComponent{
|
||||
LotName: lotName,
|
||||
LotDescription: strings.TrimSpace(row.LotDescription),
|
||||
LotName: row.LotName,
|
||||
LotDescription: row.LotDescription,
|
||||
Category: category,
|
||||
Model: model,
|
||||
}
|
||||
|
||||
if idx, exists := componentIndex[lotName]; exists {
|
||||
// Keep the first row, but fill any missing metadata from duplicates.
|
||||
if components[idx].LotDescription == "" && comp.LotDescription != "" {
|
||||
components[idx].LotDescription = comp.LotDescription
|
||||
}
|
||||
if components[idx].Category == "" && comp.Category != "" {
|
||||
components[idx].Category = comp.Category
|
||||
}
|
||||
if components[idx].Model == "" && comp.Model != "" {
|
||||
components[idx].Model = comp.Model
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
componentIndex[lotName] = len(components)
|
||||
components = append(components, comp)
|
||||
|
||||
if !existingMap[lotName] {
|
||||
if !existingMap[row.LotName] {
|
||||
newCount++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
PricelistID: cfg.PricelistID,
|
||||
WarehousePricelistID: cfg.WarehousePricelistID,
|
||||
CompetitorPricelistID: cfg.CompetitorPricelistID,
|
||||
ConfigType: cfg.ConfigType,
|
||||
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
|
||||
DisablePriceRefresh: cfg.DisablePriceRefresh,
|
||||
OnlyInStock: cfg.OnlyInStock,
|
||||
@@ -83,7 +82,6 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
PricelistID: local.PricelistID,
|
||||
WarehousePricelistID: local.WarehousePricelistID,
|
||||
CompetitorPricelistID: local.CompetitorPricelistID,
|
||||
ConfigType: local.ConfigType,
|
||||
VendorSpec: localVendorSpecToModel(local.VendorSpec),
|
||||
DisablePriceRefresh: local.DisablePriceRefresh,
|
||||
OnlyInStock: local.OnlyInStock,
|
||||
|
||||
@@ -229,7 +229,6 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
|
||||
&LocalSyncGuardState{},
|
||||
&PendingChange{},
|
||||
&LocalPartnumberBook{},
|
||||
&SyncLogEntry{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -497,10 +496,7 @@ func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string
|
||||
// HasSettings returns true if connection settings exist
|
||||
func (l *LocalDB) HasSettings() bool {
|
||||
var count int64
|
||||
if err := l.db.Model(&ConnectionSettings{}).Count(&count).Error; err != nil {
|
||||
slog.Error("localdb: HasSettings count failed", "err", err)
|
||||
return false
|
||||
}
|
||||
l.db.Model(&ConnectionSettings{}).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
@@ -1047,18 +1043,14 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
|
||||
// CountConfigurations returns the number of local configurations
|
||||
func (l *LocalDB) CountConfigurations() int64 {
|
||||
var count int64
|
||||
if err := l.db.Model(&LocalConfiguration{}).Count(&count).Error; err != nil {
|
||||
slog.Error("localdb: CountConfigurations failed", "err", err)
|
||||
}
|
||||
l.db.Model(&LocalConfiguration{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// CountProjects returns the number of local projects
|
||||
func (l *LocalDB) CountProjects() int64 {
|
||||
var count int64
|
||||
if err := l.db.Model(&LocalProject{}).Count(&count).Error; err != nil {
|
||||
slog.Error("localdb: CountProjects failed", "err", err)
|
||||
}
|
||||
l.db.Model(&LocalProject{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -1161,85 +1153,6 @@ func (l *LocalDB) SetPricelistSyncResult(status, errorText string, attemptedAt t
|
||||
})
|
||||
}
|
||||
|
||||
const syncLogMaxPerType = 100
|
||||
|
||||
// AppendSyncLog writes a sync result and prunes old entries beyond the per-type cap.
|
||||
func (l *LocalDB) AppendSyncLog(syncType, status, errorText string, syncedCount int, startedAt time.Time, durationMs int64) {
|
||||
entry := SyncLogEntry{
|
||||
SyncType: syncType,
|
||||
Status: status,
|
||||
ErrorText: errorText,
|
||||
SyncedCount: syncedCount,
|
||||
StartedAt: startedAt,
|
||||
DurationMs: durationMs,
|
||||
}
|
||||
if err := l.db.Create(&entry).Error; err != nil {
|
||||
return
|
||||
}
|
||||
// Prune: keep only the most recent N entries for this sync_type
|
||||
l.db.Exec(`
|
||||
DELETE FROM sync_log
|
||||
WHERE sync_type = ? AND id NOT IN (
|
||||
SELECT id FROM sync_log WHERE sync_type = ? ORDER BY started_at DESC LIMIT ?
|
||||
)
|
||||
`, syncType, syncType, syncLogMaxPerType)
|
||||
}
|
||||
|
||||
// GetSyncLog returns the most recent sync log entries, newest first.
|
||||
func (l *LocalDB) GetSyncLog(limit int) ([]SyncLogEntry, error) {
|
||||
var entries []SyncLogEntry
|
||||
err := l.db.Order("started_at DESC").Limit(limit).Find(&entries).Error
|
||||
return entries, err
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetLastComponentSyncAttemptAt() *time.Time {
|
||||
value, ok := l.getAppSettingValue("last_component_sync_attempt_at")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetLastComponentSyncStatus() string {
|
||||
value, ok := l.getAppSettingValue("last_component_sync_status")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetLastComponentSyncError() string {
|
||||
value, ok := l.getAppSettingValue("last_component_sync_error")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func (l *LocalDB) SetComponentSyncResult(status, errorText string, attemptedAt time.Time) error {
|
||||
status = strings.TrimSpace(status)
|
||||
errorText = strings.TrimSpace(errorText)
|
||||
if status == "" {
|
||||
status = "unknown"
|
||||
}
|
||||
return l.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := l.upsertAppSetting(tx, "last_component_sync_status", status, attemptedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := l.upsertAppSetting(tx, "last_component_sync_error", errorText, attemptedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := l.upsertAppSetting(tx, "last_component_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CountLocalPricelists returns the number of local pricelists
|
||||
func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
var count int64
|
||||
@@ -1826,62 +1739,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ type LocalConfiguration struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
ConfigType string `gorm:"default:server" json:"config_type"` // "server" | "storage"
|
||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
||||
@@ -317,19 +316,6 @@ type VendorSpecLotMapping struct {
|
||||
QuantityPerPN int `json:"quantity_per_pn"`
|
||||
}
|
||||
|
||||
// SyncLogEntry records the outcome of a single sync operation for diagnostics.
|
||||
type SyncLogEntry struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
SyncType string `gorm:"not null;index;size:32" json:"sync_type"` // components | pricelists | push | full
|
||||
Status string `gorm:"not null;size:16" json:"status"` // ok | error | skipped
|
||||
ErrorText string `gorm:"size:1000" json:"error_text,omitempty"`
|
||||
SyncedCount int `gorm:"default:0" json:"synced_count"`
|
||||
StartedAt time.Time `gorm:"not null;index" json:"started_at"`
|
||||
DurationMs int64 `gorm:"default:0" json:"duration_ms"`
|
||||
}
|
||||
|
||||
func (SyncLogEntry) TableName() string { return "sync_log" }
|
||||
|
||||
// VendorSpec is a JSON-encodable slice of VendorSpecItem
|
||||
type VendorSpec []VendorSpecItem
|
||||
|
||||
|
||||
@@ -13,32 +13,32 @@ func (Category) TableName() string {
|
||||
return "qt_categories"
|
||||
}
|
||||
|
||||
// DefaultCategories defines the standard categories with display order.
|
||||
// Canonical order: MB, CPU, MEM, RAID, storage drives, PCIe GPU, PCIe NICs, HBA, PSU, accessories, other.
|
||||
// Display orders use gaps of 10 to allow future insertions without renumbering.
|
||||
// DefaultCategories defines the standard categories with display order
|
||||
// Order: BB, CPU, MEM, GPU, SSD, RAID, HBA, NIC, PSU, RISERS, ACC, and others
|
||||
var DefaultCategories = []Category{
|
||||
{Code: "MB", Name: "Motherboard", NameRu: "Материнская плата", DisplayOrder: 10},
|
||||
{Code: "CPU", Name: "Processor", NameRu: "Процессор", DisplayOrder: 20, IsRequired: true},
|
||||
{Code: "MEM", Name: "Memory", NameRu: "Оперативная память", DisplayOrder: 30, IsRequired: true},
|
||||
{Code: "RAID", Name: "RAID Controller", NameRu: "RAID контроллер", DisplayOrder: 40},
|
||||
{Code: "SSD", Name: "SSD Storage", NameRu: "SSD накопитель", DisplayOrder: 50},
|
||||
{Code: "HDD", Name: "HDD Storage", NameRu: "HDD накопитель", DisplayOrder: 51},
|
||||
{Code: "M2", Name: "M.2 Storage", NameRu: "M.2 накопитель", DisplayOrder: 52},
|
||||
{Code: "EDSFF", Name: "EDSFF Storage", NameRu: "EDSFF накопитель", DisplayOrder: 53},
|
||||
{Code: "HHHL", Name: "HHHL Storage", NameRu: "HHHL накопитель", DisplayOrder: 54},
|
||||
{Code: "GPU", Name: "Graphics Card", NameRu: "Видеокарта", DisplayOrder: 60},
|
||||
{Code: "NIC", Name: "Network Card", NameRu: "Сетевая карта", DisplayOrder: 70},
|
||||
{Code: "HCA", Name: "HCA Adapter", NameRu: "HCA адаптер", DisplayOrder: 71},
|
||||
{Code: "DPU", Name: "DPU", NameRu: "DPU", DisplayOrder: 72},
|
||||
{Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 80},
|
||||
{Code: "PSU", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 90},
|
||||
{Code: "PS", Name: "Power Supply (Legacy)", NameRu: "Блок питания", DisplayOrder: 91},
|
||||
{Code: "ACC", Name: "Accessories", NameRu: "Аксессуары", DisplayOrder: 100},
|
||||
{Code: "RISERS", Name: "Risers", NameRu: "Райзеры", DisplayOrder: 101},
|
||||
{Code: "CARD", Name: "Cards", NameRu: "Карты", DisplayOrder: 110},
|
||||
{Code: "BB", Name: "Barebone", NameRu: "Шасси", DisplayOrder: 120, IsRequired: true},
|
||||
{Code: "BB", Name: "Barebone", NameRu: "Шасси", DisplayOrder: 1, IsRequired: true},
|
||||
{Code: "CPU", Name: "Processor", NameRu: "Процессор", DisplayOrder: 2, IsRequired: true},
|
||||
{Code: "MEM", Name: "Memory", NameRu: "Оперативная память", DisplayOrder: 3, IsRequired: true},
|
||||
{Code: "GPU", Name: "Graphics Card", NameRu: "Видеокарта", DisplayOrder: 4},
|
||||
{Code: "SSD", Name: "SSD Storage", NameRu: "SSD накопитель", DisplayOrder: 5},
|
||||
{Code: "RAID", Name: "RAID Controller", NameRu: "RAID контроллер", DisplayOrder: 6},
|
||||
{Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 7},
|
||||
{Code: "NIC", Name: "Network Card", NameRu: "Сетевая карта", DisplayOrder: 8},
|
||||
{Code: "PSU", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 9},
|
||||
{Code: "RISERS", Name: "Risers", NameRu: "Райзеры", DisplayOrder: 10},
|
||||
{Code: "ACC", Name: "Accessories", NameRu: "Аксессуары", DisplayOrder: 11},
|
||||
// Additional categories
|
||||
{Code: "MB", Name: "Motherboard", NameRu: "Материнская плата", DisplayOrder: 12},
|
||||
{Code: "HDD", Name: "HDD Storage", NameRu: "HDD накопитель", DisplayOrder: 13},
|
||||
{Code: "HCA", Name: "HCA Adapter", NameRu: "HCA адаптер", DisplayOrder: 14},
|
||||
{Code: "DPU", Name: "DPU", NameRu: "DPU", DisplayOrder: 15},
|
||||
{Code: "M2", Name: "M.2 Storage", NameRu: "M.2 накопитель", DisplayOrder: 16},
|
||||
{Code: "EDSFF", Name: "EDSFF Storage", NameRu: "EDSFF накопитель", DisplayOrder: 17},
|
||||
{Code: "HHHL", Name: "HHHL Storage", NameRu: "HHHL накопитель", DisplayOrder: 18},
|
||||
{Code: "PS", Name: "Power Supply (Legacy)", NameRu: "Блок питания", DisplayOrder: 19},
|
||||
{Code: "CARD", Name: "Cards", NameRu: "Карты", DisplayOrder: 20},
|
||||
}
|
||||
|
||||
// MaxKnownDisplayOrder is the highest display order for known categories.
|
||||
// New categories will get display order starting from this + 1.
|
||||
const MaxKnownDisplayOrder = 200
|
||||
// MaxKnownDisplayOrder is the highest display order for known categories
|
||||
// New categories will get display order starting from this + 1
|
||||
const MaxKnownDisplayOrder = 100
|
||||
|
||||
@@ -111,7 +111,6 @@ type Configuration struct {
|
||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||
VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,omitempty"`
|
||||
ConfigType string `gorm:"size:20;default:server" json:"config_type"` // "server" | "storage"
|
||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
Line int `gorm:"column:line_no;index" json:"line"`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Lot represents existing lot table
|
||||
type Lot struct {
|
||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||
@@ -10,3 +12,58 @@ type Lot struct {
|
||||
func (Lot) TableName() string {
|
||||
return "lot"
|
||||
}
|
||||
|
||||
// LotLog represents existing lot_log table (READ-ONLY)
|
||||
type LotLog struct {
|
||||
LotLogID uint `gorm:"column:lot_log_id;primaryKey;autoIncrement"`
|
||||
Lot string `gorm:"column:lot;size:255;not null"`
|
||||
Supplier string `gorm:"column:supplier;size:255;not null"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (LotLog) TableName() string {
|
||||
return "lot_log"
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
@@ -31,9 +31,7 @@ func Migrate(db *gorm.DB) error {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Can't DROP") ||
|
||||
strings.Contains(errStr, "Duplicate key name") ||
|
||||
strings.Contains(errStr, "check that it exists") ||
|
||||
strings.Contains(errStr, "Cannot change column") ||
|
||||
strings.Contains(errStr, "used in a foreign key constraint") {
|
||||
strings.Contains(errStr, "check that it exists") {
|
||||
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
|
||||
continue
|
||||
}
|
||||
@@ -43,18 +41,12 @@ func Migrate(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedCategories upserts default categories, updating display_order on existing rows.
|
||||
// SeedCategories inserts default categories if not exist
|
||||
func SeedCategories(db *gorm.DB) error {
|
||||
for _, cat := range DefaultCategories {
|
||||
var existing Category
|
||||
if err := db.Where("code = ?", cat.Code).First(&existing).Error; err != nil {
|
||||
if err := db.Create(&cat).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := db.Model(&existing).Update("display_order", cat.DisplayOrder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := db.Where("code = ?", cat.Code).FirstOrCreate(&cat)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -63,6 +63,11 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
|
||||
Order("current_price " + sortDir)
|
||||
case "lot_name":
|
||||
query = query.Order("lot_name " + sortDir)
|
||||
case "quote_count":
|
||||
// Sort by quote count from lot_log table
|
||||
query = query.
|
||||
Select("qt_lot_metadata.*, (SELECT COUNT(*) FROM lot_log WHERE lot_log.lot = qt_lot_metadata.lot_name) as quote_count_sort").
|
||||
Order("quote_count_sort " + sortDir)
|
||||
default:
|
||||
// Default: sort by popularity, no price goes last
|
||||
query = query.
|
||||
|
||||
124
internal/repository/price.go
Normal file
124
internal/repository/price.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PriceRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPriceRepository(db *gorm.DB) *PriceRepository {
|
||||
return &PriceRepository{db: db}
|
||||
}
|
||||
|
||||
type PricePoint struct {
|
||||
Price float64
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
// GetPriceHistory returns price history from lot_log for a component
|
||||
func (r *PriceRepository) GetPriceHistory(lotName string, periodDays int) ([]PricePoint, error) {
|
||||
var points []PricePoint
|
||||
since := time.Now().AddDate(0, 0, -periodDays)
|
||||
|
||||
err := r.db.Model(&models.LotLog{}).
|
||||
Select("price, date").
|
||||
Where("lot = ? AND date >= ?", lotName, since).
|
||||
Order("date DESC").
|
||||
Scan(&points).Error
|
||||
|
||||
return points, err
|
||||
}
|
||||
|
||||
// GetLatestPrice returns the most recent price for a component
|
||||
func (r *PriceRepository) GetLatestPrice(lotName string) (*PricePoint, error) {
|
||||
var point PricePoint
|
||||
err := r.db.Model(&models.LotLog{}).
|
||||
Select("price, date").
|
||||
Where("lot = ?", lotName).
|
||||
Order("date DESC").
|
||||
First(&point).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &point, nil
|
||||
}
|
||||
|
||||
// GetPriceOverride returns active override for a component
|
||||
func (r *PriceRepository) GetPriceOverride(lotName string) (*models.PriceOverride, error) {
|
||||
var override models.PriceOverride
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
|
||||
err := r.db.
|
||||
Where("lot_name = ?", lotName).
|
||||
Where("valid_from <= ?", today).
|
||||
Where("valid_until IS NULL OR valid_until >= ?", today).
|
||||
Order("valid_from DESC").
|
||||
First(&override).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &override, nil
|
||||
}
|
||||
|
||||
// CreatePriceOverride creates a new price override
|
||||
func (r *PriceRepository) CreatePriceOverride(override *models.PriceOverride) error {
|
||||
return r.db.Create(override).Error
|
||||
}
|
||||
|
||||
// GetPriceOverrides returns all overrides for a component
|
||||
func (r *PriceRepository) GetPriceOverrides(lotName string) ([]models.PriceOverride, error) {
|
||||
var overrides []models.PriceOverride
|
||||
err := r.db.
|
||||
Where("lot_name = ?", lotName).
|
||||
Order("valid_from DESC").
|
||||
Find(&overrides).Error
|
||||
return overrides, err
|
||||
}
|
||||
|
||||
// DeletePriceOverride deletes an override
|
||||
func (r *PriceRepository) DeletePriceOverride(id uint) error {
|
||||
return r.db.Delete(&models.PriceOverride{}, id).Error
|
||||
}
|
||||
|
||||
// GetQuoteCount returns the number of quotes in lot_log for a period
|
||||
func (r *PriceRepository) GetQuoteCount(lotName string, periodDays int) (int64, error) {
|
||||
var count int64
|
||||
since := time.Now().AddDate(0, 0, -periodDays)
|
||||
|
||||
err := r.db.Model(&models.LotLog{}).
|
||||
Where("lot = ? AND date >= ?", lotName, since).
|
||||
Count(&count).Error
|
||||
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetQuoteCounts returns quote counts for multiple lot names
|
||||
func (r *PriceRepository) GetQuoteCounts(lotNames []string) (map[string]int64, error) {
|
||||
type Result struct {
|
||||
Lot string
|
||||
Count int64
|
||||
}
|
||||
var results []Result
|
||||
|
||||
err := r.db.Model(&models.LotLog{}).
|
||||
Select("lot, COUNT(*) as count").
|
||||
Where("lot IN ?", lotNames).
|
||||
Group("lot").
|
||||
Scan(&results).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
counts := make(map[string]int64)
|
||||
for _, r := range results {
|
||||
counts[r.Lot] = r.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
@@ -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{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.StockLog{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return NewPricelistRepository(db)
|
||||
|
||||
@@ -91,3 +91,25 @@ func (r *StatsRepository) ResetMonthlyCounters() error {
|
||||
Update("quotes_last_30d", 0).Error
|
||||
}
|
||||
|
||||
// UpdatePopularityScores recalculates popularity_score in qt_lot_metadata
|
||||
// based on supplier quotes from lot_log table
|
||||
func (r *StatsRepository) UpdatePopularityScores() error {
|
||||
// Formula: popularity_score = quotes_last_30d * 3 + quotes_last_90d * 1 + quotes_total * 0.1
|
||||
// This gives more weight to recent supplier activity
|
||||
return r.db.Exec(`
|
||||
UPDATE qt_lot_metadata m
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
lot,
|
||||
COUNT(*) as quotes_total,
|
||||
SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as quotes_last_30d,
|
||||
SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 90 DAY) THEN 1 ELSE 0 END) as quotes_last_90d
|
||||
FROM lot_log
|
||||
GROUP BY lot
|
||||
) s ON m.lot_name = s.lot
|
||||
SET m.popularity_score = COALESCE(
|
||||
s.quotes_last_30d * 3 + s.quotes_last_90d * 1 + s.quotes_total * 0.1,
|
||||
0
|
||||
)
|
||||
`).Error
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
@@ -42,11 +41,10 @@ func ParsePartNumber(lotName string) (category, model string) {
|
||||
}
|
||||
|
||||
type ComponentListResult struct {
|
||||
Items []ComponentView `json:"items"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
Components []ComponentView `json:"components"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type ComponentView struct {
|
||||
@@ -65,11 +63,10 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
||||
// Components should be loaded via /api/sync/components first
|
||||
if s.componentRepo == nil {
|
||||
return &ComponentListResult{
|
||||
Items: []ComponentView{},
|
||||
TotalCount: 0,
|
||||
Components: []ComponentView{},
|
||||
Total: 0,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalPages: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -110,16 +107,11 @@ 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{
|
||||
Items: views,
|
||||
TotalCount: total,
|
||||
Components: views,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -134,10 +126,8 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Track usage (best-effort)
|
||||
if err := s.componentRepo.IncrementRequestCount(lotName); err != nil {
|
||||
slog.Warn("component: could not increment request count", "lot", lotName, "err", err)
|
||||
}
|
||||
// Track usage
|
||||
_ = s.componentRepo.IncrementRequestCount(lotName)
|
||||
|
||||
view := &ComponentView{
|
||||
LotName: c.LotName,
|
||||
|
||||
@@ -2,7 +2,6 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
@@ -19,7 +18,6 @@ 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 {
|
||||
@@ -60,7 +58,6 @@ type CreateConfigRequest struct {
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
|
||||
ConfigType string `json:"config_type,omitempty"` // "server" | "storage"
|
||||
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
}
|
||||
@@ -106,22 +103,16 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
||||
PricelistID: pricelistID,
|
||||
WarehousePricelistID: req.WarehousePricelistID,
|
||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||
ConfigType: req.ConfigType,
|
||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
}
|
||||
if config.ConfigType == "" {
|
||||
config.ConfigType = "server"
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Record usage stats (best-effort)
|
||||
if err := s.quoteService.RecordUsage(req.Items); err != nil {
|
||||
slog.Warn("configuration: could not record usage stats", "err", err)
|
||||
}
|
||||
// Record usage stats
|
||||
_ = s.quoteService.RecordUsage(req.Items)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
@@ -13,17 +13,20 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
type ExportService struct {
|
||||
config config.ExportConfig
|
||||
localDB *localdb.LocalDB
|
||||
config config.ExportConfig
|
||||
categoryRepo *repository.CategoryRepository
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewExportService(cfg config.ExportConfig, local *localdb.LocalDB) *ExportService {
|
||||
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository, local *localdb.LocalDB) *ExportService {
|
||||
return &ExportService{
|
||||
config: cfg,
|
||||
localDB: local,
|
||||
config: cfg,
|
||||
categoryRepo: categoryRepo,
|
||||
localDB: local,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +127,16 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
|
||||
return fmt.Errorf("failed to write header: %w", err)
|
||||
}
|
||||
|
||||
categoryOrder := defaultCategoryOrder()
|
||||
// Get category hierarchy for sorting
|
||||
categoryOrder := make(map[string]int)
|
||||
if s.categoryRepo != nil {
|
||||
categories, err := s.categoryRepo.GetAll()
|
||||
if err == nil {
|
||||
for _, cat := range categories {
|
||||
categoryOrder[cat.Code] = cat.DisplayOrder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, block := range data.Configs {
|
||||
lineNo := block.Line
|
||||
@@ -201,30 +213,27 @@ func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func sortConfigsByLine(configs []models.Configuration) []models.Configuration {
|
||||
sorted := make([]models.Configuration, len(configs))
|
||||
copy(sorted, configs)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
li, lj := sorted[i].Line, sorted[j].Line
|
||||
if li <= 0 {
|
||||
li = int(^uint(0) >> 1)
|
||||
}
|
||||
if lj <= 0 {
|
||||
lj = int(^uint(0) >> 1)
|
||||
}
|
||||
if li != lj {
|
||||
return li < lj
|
||||
}
|
||||
if !sorted[i].CreatedAt.Equal(sorted[j].CreatedAt) {
|
||||
return sorted[i].CreatedAt.After(sorted[j].CreatedAt)
|
||||
}
|
||||
return sorted[i].UUID > sorted[j].UUID
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
|
||||
sortedConfigs := sortConfigsByLine(configs)
|
||||
sortedConfigs := make([]models.Configuration, len(configs))
|
||||
copy(sortedConfigs, configs)
|
||||
sort.Slice(sortedConfigs, func(i, j int) bool {
|
||||
leftLine := sortedConfigs[i].Line
|
||||
rightLine := sortedConfigs[j].Line
|
||||
|
||||
if leftLine <= 0 {
|
||||
leftLine = int(^uint(0) >> 1)
|
||||
}
|
||||
if rightLine <= 0 {
|
||||
rightLine = int(^uint(0) >> 1)
|
||||
}
|
||||
if leftLine != rightLine {
|
||||
return leftLine < rightLine
|
||||
}
|
||||
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
|
||||
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
|
||||
}
|
||||
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
|
||||
})
|
||||
|
||||
blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs))
|
||||
for i := range sortedConfigs {
|
||||
@@ -287,7 +296,26 @@ func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectEx
|
||||
|
||||
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
||||
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
||||
sortedConfigs := sortConfigsByLine(configs)
|
||||
sortedConfigs := make([]models.Configuration, len(configs))
|
||||
copy(sortedConfigs, configs)
|
||||
sort.Slice(sortedConfigs, func(i, j int) bool {
|
||||
leftLine := sortedConfigs[i].Line
|
||||
rightLine := sortedConfigs[j].Line
|
||||
|
||||
if leftLine <= 0 {
|
||||
leftLine = int(^uint(0) >> 1)
|
||||
}
|
||||
if rightLine <= 0 {
|
||||
rightLine = int(^uint(0) >> 1)
|
||||
}
|
||||
if leftLine != rightLine {
|
||||
return leftLine < rightLine
|
||||
}
|
||||
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
|
||||
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
|
||||
}
|
||||
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
|
||||
})
|
||||
|
||||
blocks := make([]ConfigExportBlock, 0, len(configs))
|
||||
for i := range sortedConfigs {
|
||||
@@ -299,18 +327,6 @@ func (s *ExportService) ProjectToExportData(configs []models.Configuration) *Pro
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigToPricingExportData is a single-config variant of ProjectToPricingExportData.
|
||||
func (s *ExportService) ConfigToPricingExportData(cfg *models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
|
||||
block, err := s.buildPricingExportBlock(cfg, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProjectPricingExportData{
|
||||
Configs: []ProjectPricingExportConfig{block},
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock {
|
||||
// Batch-fetch categories from local data (pricelist items → local_components fallback)
|
||||
lotNames := make([]string, len(cfg.Items))
|
||||
@@ -504,30 +520,20 @@ func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string)
|
||||
return categories
|
||||
}
|
||||
|
||||
// defaultCategoryOrder returns an uppercase category code → display_order map from models.DefaultCategories.
|
||||
func defaultCategoryOrder() map[string]int {
|
||||
m := make(map[string]int, len(models.DefaultCategories))
|
||||
for _, cat := range models.DefaultCategories {
|
||||
m[strings.ToUpper(cat.Code)] = cat.DisplayOrder
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func categoryDisplayOrder(categoryOrder map[string]int, category string) (int, bool) {
|
||||
order, ok := categoryOrder[strings.ToUpper(strings.TrimSpace(category))]
|
||||
return order, ok
|
||||
}
|
||||
|
||||
// sortItemsByCategory sorts items by category display order (items without category go to the end).
|
||||
func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
orderI, hasI := categoryDisplayOrder(categoryOrder, items[i].Category)
|
||||
orderJ, hasJ := categoryDisplayOrder(categoryOrder, items[j].Category)
|
||||
if hasI && hasJ {
|
||||
return orderI < orderJ
|
||||
for i := 0; i < len(items)-1; i++ {
|
||||
for j := i + 1; j < len(items); j++ {
|
||||
orderI, hasI := categoryOrder[items[i].Category]
|
||||
orderJ, hasJ := categoryOrder[items[j].Category]
|
||||
|
||||
if !hasI && hasJ {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
} else if hasI && hasJ && orderI > orderJ {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
}
|
||||
}
|
||||
return hasI && !hasJ
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type pricingLevels struct {
|
||||
@@ -567,52 +573,45 @@ 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{}
|
||||
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)
|
||||
}
|
||||
level.Estimate = s.lookupPricePointer(estimateID, lot)
|
||||
level.Stock = s.lookupPricePointer(warehouseID, lot)
|
||||
level.Competitor = s.lookupPricePointer(competitorID, lot)
|
||||
result[lot] = level
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 {
|
||||
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" {
|
||||
return nil
|
||||
}
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
prices, err := s.localDB.GetLocalPricesForLots(localPL.ID, lots)
|
||||
if err != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
||||
if err != nil || price <= 0 {
|
||||
return nil
|
||||
}
|
||||
return prices
|
||||
return floatPtr(price)
|
||||
}
|
||||
|
||||
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
|
||||
lots := collectPricingLots(cfg, localCfg, true)
|
||||
if s.localDB == nil || len(lots) == 0 {
|
||||
return map[string]string{}
|
||||
result := make(map[string]string, len(lots))
|
||||
if s.localDB == nil {
|
||||
return result
|
||||
}
|
||||
descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots)
|
||||
if err != nil {
|
||||
return map[string]string{}
|
||||
for _, lot := range lots {
|
||||
component, err := s.localDB.GetLocalComponent(lot)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result[lot] = component.LotDescription
|
||||
}
|
||||
return descriptions
|
||||
return result
|
||||
}
|
||||
|
||||
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
||||
|
||||
@@ -33,7 +33,7 @@ func newTestProjectData(items []ExportItem, article string, serverCount int) *Pr
|
||||
}
|
||||
|
||||
func TestToCSV_UTF8BOM(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{
|
||||
@@ -63,7 +63,7 @@ func TestToCSV_UTF8BOM(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{
|
||||
@@ -130,7 +130,7 @@ func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToCSV_ServerRow(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
@@ -175,7 +175,7 @@ func TestToCSV_ServerRow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToCSV_CategorySorting(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT-A", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
@@ -214,7 +214,7 @@ func TestToCSV_CategorySorting(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToCSV_EmptyData(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{},
|
||||
@@ -247,7 +247,7 @@ func TestToCSV_EmptyData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
@@ -270,7 +270,7 @@ func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToCSV_WriterError(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
@@ -284,7 +284,7 @@ func TestToCSV_WriterError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToCSV_MultipleBlocks(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{
|
||||
@@ -359,7 +359,7 @@ func TestToCSV_MultipleBlocks(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProjectToExportData_SortsByLine(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
configs := []models.Configuration{
|
||||
{
|
||||
@@ -445,7 +445,7 @@ func TestFormatPriceComma(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
data := &ProjectPricingExportData{
|
||||
Configs: []ProjectPricingExportConfig{
|
||||
{
|
||||
@@ -499,7 +499,7 @@ func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read summary row: %v", err)
|
||||
}
|
||||
expectedSummary := []string{"10", "", "ART-1", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
|
||||
expectedSummary := []string{"10", "", "", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
|
||||
for i, want := range expectedSummary {
|
||||
if summary[i] != want {
|
||||
t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i])
|
||||
@@ -519,7 +519,7 @@ func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProjectToPricingExportData_UsesCartRowsWithoutBOM(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil)
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
configs := []models.Configuration{
|
||||
{
|
||||
UUID: "cfg-1",
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -102,14 +101,10 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
PricelistID: pricelistID,
|
||||
WarehousePricelistID: req.WarehousePricelistID,
|
||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||
ConfigType: req.ConfigType,
|
||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if cfg.ConfigType == "" {
|
||||
cfg.ConfigType = "server"
|
||||
}
|
||||
|
||||
// Convert to local model
|
||||
localCfg := localdb.ConfigurationToLocal(cfg)
|
||||
@@ -119,10 +114,8 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
}
|
||||
cfg.Line = localCfg.Line
|
||||
|
||||
// Record usage stats (best-effort)
|
||||
if err := s.quoteService.RecordUsage(req.Items); err != nil {
|
||||
slog.Warn("local configuration: could not record usage stats", "err", err)
|
||||
}
|
||||
// Record usage stats
|
||||
_ = s.quoteService.RecordUsage(req.Items)
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -410,9 +403,7 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
|
||||
// Refresh local pricelists when online.
|
||||
if s.isOnline() {
|
||||
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||
slog.Warn("local configuration: background pricelist sync failed", "err", err)
|
||||
}
|
||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
||||
}
|
||||
|
||||
// Use the pricelist stored in the config; fall back to latest if unavailable.
|
||||
@@ -796,9 +787,7 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
|
||||
}
|
||||
|
||||
if s.isOnline() {
|
||||
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||
slog.Warn("local configuration: background pricelist sync failed", "err", err)
|
||||
}
|
||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
||||
}
|
||||
|
||||
// Resolve which pricelist to use:
|
||||
|
||||
@@ -76,11 +76,6 @@ func (s *Service) GetReadiness() (*SyncReadiness, error) {
|
||||
)
|
||||
}
|
||||
|
||||
s.schemaOnce.Do(func() {
|
||||
if err := ensureClientSchemaStateTable(mariaDB); err != nil {
|
||||
slog.Warn("qt_client_schema_state migration skipped (no DDL rights — run server migrate)", "error", err)
|
||||
}
|
||||
})
|
||||
if err := s.reportClientSchemaState(mariaDB, now); err != nil {
|
||||
slog.Warn("failed to report client schema state", "error", err)
|
||||
}
|
||||
@@ -146,51 +141,39 @@ func ensureClientSchemaStateTable(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
if tableExists(db, "qt_client_schema_state") {
|
||||
// Each ALTER is guarded by a column existence check so users without DDL
|
||||
// rights don't get a permission error on every sync cycle — the server
|
||||
// migration tool is the authoritative path for schema changes.
|
||||
if !columnExists(db, "qt_client_schema_state", "hostname") {
|
||||
if err := db.Exec(`
|
||||
ALTER TABLE qt_client_schema_state
|
||||
ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER username
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("add qt_client_schema_state.hostname: %w", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
ALTER TABLE qt_client_schema_state
|
||||
DROP PRIMARY KEY,
|
||||
ADD PRIMARY KEY (username, hostname)
|
||||
`).Error; err != nil && !isDuplicatePrimaryKeyDefinition(err) {
|
||||
return fmt.Errorf("set qt_client_schema_state primary key: %w", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
ALTER TABLE qt_client_schema_state
|
||||
ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER username
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("add qt_client_schema_state.hostname: %w", err)
|
||||
}
|
||||
|
||||
type colMigration struct {
|
||||
column string
|
||||
stmt string
|
||||
if err := db.Exec(`
|
||||
ALTER TABLE qt_client_schema_state
|
||||
DROP PRIMARY KEY,
|
||||
ADD PRIMARY KEY (username, hostname)
|
||||
`).Error; err != nil && !isDuplicatePrimaryKeyDefinition(err) {
|
||||
return fmt.Errorf("set qt_client_schema_state primary key: %w", err)
|
||||
}
|
||||
migrations := []colMigration{
|
||||
{"last_sync_at", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version"},
|
||||
{"last_sync_status", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_status VARCHAR(32) NULL AFTER last_sync_at"},
|
||||
{"pending_changes_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_changes_count INT NOT NULL DEFAULT 0 AFTER last_sync_status"},
|
||||
{"pending_errors_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_errors_count INT NOT NULL DEFAULT 0 AFTER pending_changes_count"},
|
||||
{"configurations_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_count"},
|
||||
{"projects_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS projects_count INT NOT NULL DEFAULT 0 AFTER configurations_count"},
|
||||
{"estimate_pricelist_version", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS estimate_pricelist_version VARCHAR(128) NULL AFTER projects_count"},
|
||||
{"warehouse_pricelist_version", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS warehouse_pricelist_version VARCHAR(128) NULL AFTER estimate_pricelist_version"},
|
||||
{"competitor_pricelist_version", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version"},
|
||||
{"last_sync_error_code", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version"},
|
||||
{"last_sync_error_text", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code"},
|
||||
{"local_pricelist_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS local_pricelist_count INT NOT NULL DEFAULT 0 AFTER last_sync_error_text"},
|
||||
{"pricelist_items_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pricelist_items_count INT NOT NULL DEFAULT 0 AFTER local_pricelist_count"},
|
||||
{"components_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS components_count INT NOT NULL DEFAULT 0 AFTER pricelist_items_count"},
|
||||
{"db_size_bytes", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS db_size_bytes BIGINT NOT NULL DEFAULT 0 AFTER components_count"},
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if columnExists(db, "qt_client_schema_state", m.column) {
|
||||
continue
|
||||
}
|
||||
if err := db.Exec(m.stmt).Error; err != nil {
|
||||
|
||||
for _, stmt := range []string{
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_status VARCHAR(32) NULL AFTER last_sync_at",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_changes_count INT NOT NULL DEFAULT 0 AFTER last_sync_status",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_errors_count INT NOT NULL DEFAULT 0 AFTER pending_changes_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS projects_count INT NOT NULL DEFAULT 0 AFTER configurations_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS estimate_pricelist_version VARCHAR(128) NULL AFTER projects_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS warehouse_pricelist_version VARCHAR(128) NULL AFTER estimate_pricelist_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS local_pricelist_count INT NOT NULL DEFAULT 0 AFTER last_sync_error_text",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pricelist_items_count INT NOT NULL DEFAULT 0 AFTER local_pricelist_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS components_count INT NOT NULL DEFAULT 0 AFTER pricelist_items_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS db_size_bytes BIGINT NOT NULL DEFAULT 0 AFTER components_count",
|
||||
} {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -198,17 +181,6 @@ func ensureClientSchemaStateTable(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func columnExists(db *gorm.DB, tableName, columnName string) bool {
|
||||
var count int64
|
||||
if err := db.Raw(`
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
|
||||
`, tableName, columnName).Scan(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func tableExists(db *gorm.DB, tableName string) bool {
|
||||
var count int64
|
||||
// For MariaDB/MySQL, check information_schema
|
||||
@@ -225,6 +197,9 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
|
||||
return nil
|
||||
}
|
||||
if err := ensureClientSchemaStateTable(mariaDB); err != nil {
|
||||
return err
|
||||
}
|
||||
username := strings.TrimSpace(s.localDB.GetDBUser())
|
||||
if username == "" {
|
||||
return nil
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var ErrOffline = errors.New("database is offline")
|
||||
@@ -28,7 +27,6 @@ type Service struct {
|
||||
localDB *localdb.LocalDB
|
||||
directDB *gorm.DB
|
||||
pricelistMu sync.Mutex // prevents concurrent pricelist syncs
|
||||
schemaOnce sync.Once // ensures ensureClientSchemaStateTable runs at most once per process
|
||||
}
|
||||
|
||||
// NewService creates a new sync service
|
||||
@@ -274,56 +272,60 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
func (s *Service) NeedSync() (bool, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
|
||||
// If never synced, always need sync.
|
||||
// If never synced, need sync
|
||||
if lastSync == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// When online, compare actual server versions regardless of elapsed time.
|
||||
// This prevents a stale "failed" status from the past from triggering
|
||||
// endless sync retries when all pricelists are already up to date.
|
||||
connStatus := s.getConnectionStatus()
|
||||
if connStatus.IsConnected {
|
||||
mariaDB, err := s.getDB()
|
||||
if err == nil {
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
sources := []models.PricelistSource{
|
||||
models.PricelistSourceEstimate,
|
||||
models.PricelistSourceWarehouse,
|
||||
models.PricelistSourceCompetitor,
|
||||
}
|
||||
allMatch := true
|
||||
for _, source := range sources {
|
||||
latestServer, err := pricelistRepo.GetLatestActiveBySource(string(source))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
latestLocal, err := s.localDB.GetLatestLocalPricelistBySource(string(source))
|
||||
if err != nil {
|
||||
return true, nil
|
||||
}
|
||||
if latestServer.ID != latestLocal.ServerID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if allMatch {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Offline fallback: suggest sync if last successful sync was more than 1 hour ago.
|
||||
// If last sync was more than 1 hour ago, suggest sync
|
||||
if time.Since(*lastSync) > time.Hour {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check if there are new pricelists on server (only if already connected)
|
||||
connStatus := s.getConnectionStatus()
|
||||
if !connStatus.IsConnected {
|
||||
// If offline, can't check server, no need to sync
|
||||
return false, nil
|
||||
}
|
||||
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
// If offline, can't check server, no need to sync
|
||||
return false, nil
|
||||
}
|
||||
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
sources := []models.PricelistSource{
|
||||
models.PricelistSourceEstimate,
|
||||
models.PricelistSourceWarehouse,
|
||||
models.PricelistSourceCompetitor,
|
||||
}
|
||||
for _, source := range sources {
|
||||
latestServer, err := pricelistRepo.GetLatestActiveBySource(string(source))
|
||||
if err != nil {
|
||||
// No active pricelist for this source yet.
|
||||
continue
|
||||
}
|
||||
|
||||
latestLocal, err := s.localDB.GetLatestLocalPricelistBySource(string(source))
|
||||
if err != nil {
|
||||
// No local pricelist for an existing source on server.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If server has newer pricelist for this source, need sync.
|
||||
if latestServer.ID != latestLocal.ServerID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
||||
func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Info("starting pricelist sync")
|
||||
plSyncStart := time.Now()
|
||||
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -332,7 +334,6 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
s.recordPricelistSyncFailure(err)
|
||||
s.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, plSyncStart, time.Since(plSyncStart).Milliseconds())
|
||||
return 0, fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
@@ -356,18 +357,6 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
// Check if pricelist already exists locally
|
||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||
if existing != nil {
|
||||
existing.Source = pl.Source
|
||||
existing.Version = pl.Version
|
||||
existing.Name = pl.Notification
|
||||
existing.CreatedAt = pl.CreatedAt
|
||||
existing.SyncedAt = time.Now()
|
||||
if err := s.localDB.SaveLocalPricelist(existing); err != nil {
|
||||
if syncErr == nil {
|
||||
syncErr = fmt.Errorf("refresh existing pricelist %s: %w", pl.Version, err)
|
||||
}
|
||||
slog.Warn("failed to refresh existing local pricelist header", "version", pl.Version, "error", err)
|
||||
continue
|
||||
}
|
||||
// Backfill items for legacy/partial local caches where only pricelist metadata exists.
|
||||
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
|
||||
itemCount, err := s.SyncPricelistItems(existing.ID)
|
||||
@@ -419,7 +408,6 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
|
||||
if syncErr != nil {
|
||||
s.recordPricelistSyncFailure(syncErr)
|
||||
s.localDB.AppendSyncLog("pricelists", "error", syncErr.Error(), synced, plSyncStart, time.Since(plSyncStart).Milliseconds())
|
||||
return synced, syncErr
|
||||
}
|
||||
|
||||
@@ -427,7 +415,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
now := time.Now()
|
||||
s.localDB.SetLastSyncTime(now)
|
||||
s.recordPricelistSyncSuccess(now)
|
||||
s.localDB.AppendSyncLog("pricelists", "ok", "", synced, plSyncStart, time.Since(plSyncStart).Milliseconds())
|
||||
s.RecordSyncHeartbeat()
|
||||
|
||||
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
||||
return synced, nil
|
||||
@@ -480,29 +468,24 @@ func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int
|
||||
}
|
||||
|
||||
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "server_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"source": localPL.Source,
|
||||
"version": localPL.Version,
|
||||
"name": localPL.Name,
|
||||
"created_at": localPL.CreatedAt,
|
||||
"synced_at": localPL.SyncedAt,
|
||||
"is_used": localPL.IsUsed,
|
||||
}),
|
||||
}).Create(localPL).Error; err != nil {
|
||||
if err := tx.Create(localPL).Error; err != nil {
|
||||
return fmt.Errorf("save local pricelist: %w", err)
|
||||
}
|
||||
if localPL.ID == 0 {
|
||||
if err := tx.Where("server_id = ?", localPL.ServerID).First(localPL).Error; err != nil {
|
||||
return fmt.Errorf("reload local pricelist: %w", err)
|
||||
}
|
||||
if len(localItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range localItems {
|
||||
localItems[i].PricelistID = localPL.ID
|
||||
}
|
||||
if err := replaceLocalPricelistItemsTx(tx, localPL.ID, localItems); err != nil {
|
||||
return fmt.Errorf("save local pricelist items: %w", err)
|
||||
batchSize := 500
|
||||
for i := 0; i < len(localItems); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(localItems) {
|
||||
end = len(localItems)
|
||||
}
|
||||
if err := tx.CreateInBatches(localItems[i:end], batchSize).Error; err != nil {
|
||||
return fmt.Errorf("save local pricelist items: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -513,27 +496,6 @@ func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int
|
||||
return len(localItems), nil
|
||||
}
|
||||
|
||||
func replaceLocalPricelistItemsTx(tx *gorm.DB, pricelistID uint, items []localdb.LocalPricelistItem) error {
|
||||
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&localdb.LocalPricelistItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
batchSize := 500
|
||||
for i := 0; i < len(items); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
if err := tx.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
|
||||
if s.localDB == nil || pricelistRepo == nil {
|
||||
return
|
||||
@@ -611,29 +573,58 @@ func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.
|
||||
}
|
||||
}
|
||||
|
||||
// ListUserSyncStatuses returns users who have recorded a client schema state check.
|
||||
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
|
||||
// Only users with write rights are expected to be able to update this table.
|
||||
func (s *Service) RecordSyncHeartbeat() {
|
||||
username := strings.TrimSpace(s.localDB.GetDBUser())
|
||||
if username == "" {
|
||||
return
|
||||
}
|
||||
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil || mariaDB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
|
||||
slog.Warn("sync heartbeat: failed to ensure table", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := mariaDB.Exec(`
|
||||
INSERT INTO qt_pricelist_sync_status (username, last_sync_at, updated_at, app_version)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_sync_at = VALUES(last_sync_at),
|
||||
updated_at = VALUES(updated_at),
|
||||
app_version = VALUES(app_version)
|
||||
`, username, now, now, appmeta.Version()).Error; err != nil {
|
||||
slog.Debug("sync heartbeat: skipped", "username", username, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ListUserSyncStatuses returns users who have recorded sync heartbeat.
|
||||
func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) {
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil || mariaDB == nil {
|
||||
return nil, ErrOffline
|
||||
}
|
||||
|
||||
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
|
||||
return nil, fmt.Errorf("ensure sync status table: %w", err)
|
||||
}
|
||||
|
||||
type row struct {
|
||||
Username string `gorm:"column:username"`
|
||||
LastCheckedAt time.Time `gorm:"column:last_checked_at"`
|
||||
AppVersion string `gorm:"column:app_version"`
|
||||
Username string `gorm:"column:username"`
|
||||
LastSyncAt time.Time `gorm:"column:last_sync_at"`
|
||||
AppVersion string `gorm:"column:app_version"`
|
||||
}
|
||||
var rows []row
|
||||
if err := mariaDB.Raw(`
|
||||
SELECT s.username, s.last_checked_at, COALESCE(s.app_version, '') AS app_version
|
||||
FROM qt_client_schema_state s
|
||||
INNER JOIN (
|
||||
SELECT username, MAX(last_checked_at) AS max_checked
|
||||
FROM qt_client_schema_state
|
||||
GROUP BY username
|
||||
) latest ON s.username = latest.username AND s.last_checked_at = latest.max_checked
|
||||
GROUP BY s.username
|
||||
ORDER BY s.last_checked_at DESC, s.username ASC
|
||||
SELECT username, last_sync_at, COALESCE(app_version, '') AS app_version
|
||||
FROM qt_pricelist_sync_status
|
||||
ORDER BY last_sync_at DESC, username ASC
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("load sync status rows: %w", err)
|
||||
}
|
||||
@@ -653,7 +644,7 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn
|
||||
continue
|
||||
}
|
||||
|
||||
isOnline := now.Sub(r.LastCheckedAt) <= onlineThreshold
|
||||
isOnline := now.Sub(r.LastSyncAt) <= onlineThreshold
|
||||
if _, connected := activeUsers[username]; connected {
|
||||
isOnline = true
|
||||
delete(activeUsers, username)
|
||||
@@ -663,7 +654,7 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn
|
||||
|
||||
result = append(result, UserSyncStatus{
|
||||
Username: username,
|
||||
LastSyncAt: r.LastCheckedAt,
|
||||
LastSyncAt: r.LastSyncAt,
|
||||
AppVersion: appVersion,
|
||||
IsOnline: isOnline,
|
||||
})
|
||||
@@ -717,6 +708,36 @@ func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, e
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func ensureUserSyncStatusTable(db *gorm.DB) error {
|
||||
// Check if table exists instead of trying to create (avoids permission issues)
|
||||
if !tableExists(db, "qt_pricelist_sync_status") {
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
||||
username VARCHAR(100) NOT NULL,
|
||||
last_sync_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
app_version VARCHAR(64) NULL,
|
||||
PRIMARY KEY (username),
|
||||
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("create qt_pricelist_sync_status table: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility for environments where table was created without app_version.
|
||||
// Only try to add column if table exists.
|
||||
if tableExists(db, "qt_pricelist_sync_status") {
|
||||
if err := db.Exec(`
|
||||
ALTER TABLE qt_pricelist_sync_status
|
||||
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
|
||||
`).Error; err != nil {
|
||||
// Log but don't fail if alter fails (column might already exist)
|
||||
slog.Debug("failed to add app_version column", "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncPricelistItems synchronizes items for a specific pricelist
|
||||
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
@@ -768,6 +789,9 @@ func (s *Service) fetchServerPricelistItems(serverPricelistID uint) ([]localdb.L
|
||||
for i, item := range serverItems {
|
||||
localItems[i] = *localdb.PricelistItemToLocal(&item, 0)
|
||||
}
|
||||
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
|
||||
slog.Warn("pricelist stock enrichment skipped", "server_pricelist_id", serverPricelistID, "error", err)
|
||||
}
|
||||
|
||||
return localItems, nil
|
||||
}
|
||||
@@ -781,6 +805,111 @@ func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, err
|
||||
return s.SyncPricelistItems(localPL.ID)
|
||||
}
|
||||
|
||||
func (s *Service) enrichLocalPricelistItemsWithStock(mariaDB *gorm.DB, items []localdb.LocalPricelistItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
|
||||
book, err := bookRepo.GetActiveBook()
|
||||
if err != nil || book == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
bookItems, err := bookRepo.GetBookItems(book.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(bookItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
partnumberToLots := make(map[string][]string, len(bookItems))
|
||||
for _, item := range bookItems {
|
||||
pn := strings.TrimSpace(item.Partnumber)
|
||||
if pn == "" {
|
||||
continue
|
||||
}
|
||||
seenLots := make(map[string]struct{}, len(item.LotsJSON))
|
||||
for _, lot := range item.LotsJSON {
|
||||
lotName := strings.TrimSpace(lot.LotName)
|
||||
if lotName == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(lotName)
|
||||
if _, exists := seenLots[key]; exists {
|
||||
continue
|
||||
}
|
||||
seenLots[key] = struct{}{}
|
||||
partnumberToLots[pn] = append(partnumberToLots[pn], lotName)
|
||||
}
|
||||
}
|
||||
if len(partnumberToLots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type stockRow struct {
|
||||
Partnumber string `gorm:"column:partnumber"`
|
||||
Qty *float64 `gorm:"column:qty"`
|
||||
}
|
||||
rows := make([]stockRow, 0)
|
||||
if err := mariaDB.Raw(`
|
||||
SELECT s.partnumber, s.qty
|
||||
FROM stock_log s
|
||||
INNER JOIN (
|
||||
SELECT partnumber, MAX(date) AS max_date
|
||||
FROM stock_log
|
||||
GROUP BY partnumber
|
||||
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
|
||||
WHERE s.qty IS NOT NULL
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lotTotals := make(map[string]float64, len(items))
|
||||
lotPartnumbers := make(map[string][]string, len(items))
|
||||
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
|
||||
|
||||
for _, row := range rows {
|
||||
pn := strings.TrimSpace(row.Partnumber)
|
||||
if pn == "" || row.Qty == nil {
|
||||
continue
|
||||
}
|
||||
lots := partnumberToLots[pn]
|
||||
if len(lots) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, lotName := range lots {
|
||||
lotTotals[lotName] += *row.Qty
|
||||
if _, ok := seenPartnumbers[lotName]; !ok {
|
||||
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
|
||||
}
|
||||
key := strings.ToLower(pn)
|
||||
if _, exists := seenPartnumbers[lotName][key]; exists {
|
||||
continue
|
||||
}
|
||||
seenPartnumbers[lotName][key] = struct{}{}
|
||||
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
lotName := strings.TrimSpace(items[i].LotName)
|
||||
if qty, ok := lotTotals[lotName]; ok {
|
||||
qtyCopy := qty
|
||||
items[i].AvailableQty = &qtyCopy
|
||||
}
|
||||
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
|
||||
sort.Slice(partnumbers, func(a, b int) bool {
|
||||
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
|
||||
})
|
||||
items[i].Partnumbers = append(localdb.LocalStringList{}, partnumbers...)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
||||
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
||||
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
||||
@@ -829,16 +958,6 @@ func (s *Service) SyncPricelistsIfNeeded() error {
|
||||
|
||||
if !needSync {
|
||||
slog.Debug("pricelists are up to date, no sync needed")
|
||||
// Clear stale "failed" status: if NeedSync confirmed all active server pricelists
|
||||
// are present locally, any lingering failure flag is outdated.
|
||||
if strings.EqualFold(s.localDB.GetLastPricelistSyncStatus(), "failed") {
|
||||
now := time.Now()
|
||||
if err := s.localDB.SetPricelistSyncResult("success", "", now); err != nil {
|
||||
slog.Warn("failed to clear stale pricelist sync failure flag", "error", err)
|
||||
} else {
|
||||
s.localDB.SetLastSyncTime(now)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1573,25 +1692,3 @@ func (s *Service) getConnectionStatus() db.ConnectionStatus {
|
||||
}
|
||||
return s.connMgr.GetStatus()
|
||||
}
|
||||
|
||||
// SyncComponentsIfEmpty syncs components from MariaDB when local_components is empty.
|
||||
// Used by the background worker on first run to populate the catalog for new users.
|
||||
func (s *Service) SyncComponentsIfEmpty() error {
|
||||
if s.localDB.CountComponents() > 0 {
|
||||
return nil
|
||||
}
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
_ = s.localDB.SetComponentSyncResult("error", err.Error(), time.Now())
|
||||
return err
|
||||
}
|
||||
result, err := s.localDB.SyncComponents(mariaDB)
|
||||
now := time.Now()
|
||||
if err != nil {
|
||||
_ = s.localDB.SetComponentSyncResult("error", err.Error(), now)
|
||||
return err
|
||||
}
|
||||
_ = s.localDB.SetComponentSyncResult("ok", "", now)
|
||||
slog.Info("background sync: initial component sync completed", "synced", result.TotalSynced)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
|
||||
&models.Pricelist{},
|
||||
&models.PricelistItem{},
|
||||
&models.Lot{},
|
||||
&models.StockLog{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate server tables: %v", err)
|
||||
}
|
||||
@@ -102,3 +103,103 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
|
||||
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPricelistItems_EnrichesStockFromLocalPartnumberBook(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
|
||||
if err := serverDB.AutoMigrate(
|
||||
&models.Pricelist{},
|
||||
&models.PricelistItem{},
|
||||
&models.Lot{},
|
||||
&models.StockLog{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate server tables: %v", err)
|
||||
}
|
||||
|
||||
serverPL := models.Pricelist{
|
||||
Source: "warehouse",
|
||||
Version: "2026-03-07-001",
|
||||
Notification: "server",
|
||||
CreatedBy: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
if err := serverDB.Create(&serverPL).Error; err != nil {
|
||||
t.Fatalf("create server pricelist: %v", err)
|
||||
}
|
||||
if err := serverDB.Create(&models.PricelistItem{
|
||||
PricelistID: serverPL.ID,
|
||||
LotName: "CPU_A",
|
||||
LotCategory: "CPU",
|
||||
Price: 10,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create server pricelist item: %v", err)
|
||||
}
|
||||
qty := 7.0
|
||||
if err := serverDB.Create(&models.StockLog{
|
||||
Partnumber: "CPU-PN-1",
|
||||
Date: time.Now(),
|
||||
Price: 100,
|
||||
Qty: &qty,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create stock log: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: serverPL.ID,
|
||||
Source: serverPL.Source,
|
||||
Version: serverPL.Version,
|
||||
Name: serverPL.Notification,
|
||||
CreatedAt: serverPL.CreatedAt,
|
||||
SyncedAt: time.Now(),
|
||||
IsUsed: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(serverPL.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Create(&localdb.LocalPartnumberBook{
|
||||
ServerID: 1,
|
||||
Version: "2026-03-07-001",
|
||||
CreatedAt: time.Now(),
|
||||
IsActive: true,
|
||||
PartnumbersJSON: localdb.LocalStringList{"CPU-PN-1"},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create local partnumber book: %v", err)
|
||||
}
|
||||
if err := local.DB().Create(&localdb.LocalPartnumberBookItem{
|
||||
Partnumber: "CPU-PN-1",
|
||||
LotsJSON: localdb.LocalPartnumberBookLots{
|
||||
{LotName: "CPU_A", Qty: 1},
|
||||
},
|
||||
Description: "CPU PN",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create local partnumber book item: %v", err)
|
||||
}
|
||||
|
||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
if _, err := svc.SyncPricelistItems(localPL.ID); err != nil {
|
||||
t.Fatalf("sync pricelist items: %v", err)
|
||||
}
|
||||
|
||||
items, err := local.GetLocalPricelistItems(localPL.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load local items: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 local item, got %d", len(items))
|
||||
}
|
||||
if items[0].AvailableQty == nil {
|
||||
t.Fatalf("expected available_qty to be set")
|
||||
}
|
||||
if *items[0].AvailableQty != 7 {
|
||||
t.Fatalf("expected available_qty=7, got %v", *items[0].AvailableQty)
|
||||
}
|
||||
if len(items[0].Partnumbers) != 1 || items[0].Partnumbers[0] != "CPU-PN-1" {
|
||||
t.Fatalf("expected partnumbers [CPU-PN-1], got %v", items[0].Partnumbers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSyncNewPricelistSnapshotUpsertsExistingServerID(t *testing.T) {
|
||||
local := newLocalDBForUpsertTest(t)
|
||||
serverDB := newServerDBForUpsertTest(t)
|
||||
if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
|
||||
t.Fatalf("migrate server pricelist tables: %v", err)
|
||||
}
|
||||
|
||||
serverPL := models.Pricelist{
|
||||
Source: "estimate",
|
||||
Version: "B-2026-04-28-001",
|
||||
Notification: "server-current",
|
||||
CreatedBy: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
if err := serverDB.Create(&serverPL).Error; err != nil {
|
||||
t.Fatalf("create server pricelist: %v", err)
|
||||
}
|
||||
if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil {
|
||||
t.Fatalf("create server pricelist item: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: serverPL.ID,
|
||||
Source: "estimate",
|
||||
Version: "old-version",
|
||||
Name: "stale-local",
|
||||
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||
SyncedAt: time.Now().Add(-24 * time.Hour),
|
||||
IsUsed: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed stale local pricelist: %v", err)
|
||||
}
|
||||
staleLocal, err := local.GetLocalPricelistByServerID(serverPL.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get stale local pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: staleLocal.ID, LotName: "OLD_LOT", Price: 99},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed stale local pricelist items: %v", err)
|
||||
}
|
||||
|
||||
svc := NewServiceWithDB(serverDB, local)
|
||||
localPL := &localdb.LocalPricelist{
|
||||
ServerID: serverPL.ID,
|
||||
Source: serverPL.Source,
|
||||
Version: serverPL.Version,
|
||||
Name: serverPL.Notification,
|
||||
CreatedAt: serverPL.CreatedAt,
|
||||
SyncedAt: time.Now(),
|
||||
IsUsed: false,
|
||||
}
|
||||
|
||||
itemCount, err := svc.syncNewPricelistSnapshot(localPL)
|
||||
if err != nil {
|
||||
t.Fatalf("sync new pricelist snapshot: %v", err)
|
||||
}
|
||||
if itemCount != 1 {
|
||||
t.Fatalf("expected 1 synced item, got %d", itemCount)
|
||||
}
|
||||
|
||||
refreshed, err := local.GetLocalPricelistByServerID(serverPL.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get refreshed local pricelist: %v", err)
|
||||
}
|
||||
if refreshed.Version != serverPL.Version {
|
||||
t.Fatalf("expected local version %q, got %q", serverPL.Version, refreshed.Version)
|
||||
}
|
||||
if refreshed.Name != serverPL.Notification {
|
||||
t.Fatalf("expected local name %q, got %q", serverPL.Notification, refreshed.Name)
|
||||
}
|
||||
|
||||
items, err := local.GetLocalPricelistItems(refreshed.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load refreshed local items: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 local item after refresh, got %d", len(items))
|
||||
}
|
||||
if items[0].LotName != "CPU_A" {
|
||||
t.Fatalf("expected refreshed item CPU_A, got %q", items[0].LotName)
|
||||
}
|
||||
}
|
||||
|
||||
func newLocalDBForUpsertTest(t *testing.T) *localdb.LocalDB {
|
||||
t.Helper()
|
||||
localPath := filepath.Join(t.TempDir(), "local.db")
|
||||
local, err := localdb.New(localPath)
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
return local
|
||||
}
|
||||
|
||||
func newServerDBForUpsertTest(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
serverPath := filepath.Join(t.TempDir(), "server.db")
|
||||
db, err := gorm.Open(sqlite.Open(serverPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open server sqlite: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
@@ -434,14 +434,54 @@ func newServerDBForSyncTest(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("open server sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&models.Project{},
|
||||
&models.Configuration{},
|
||||
&models.Pricelist{},
|
||||
&models.PricelistItem{},
|
||||
&models.Lot{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate server test schema: %v", err)
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE qt_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
owner_username TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
variant TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
tracker_url TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create qt_projects: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);`).Error; err != nil {
|
||||
t.Fatalf("create qt_projects index: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE qt_configurations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER NULL,
|
||||
owner_username TEXT NOT NULL,
|
||||
project_uuid TEXT NULL,
|
||||
app_version TEXT NULL,
|
||||
name TEXT NOT NULL,
|
||||
items TEXT NOT NULL,
|
||||
total_price REAL NULL,
|
||||
custom_price REAL NULL,
|
||||
notes TEXT NULL,
|
||||
is_template INTEGER NOT NULL DEFAULT 0,
|
||||
server_count INTEGER NOT NULL DEFAULT 1,
|
||||
server_model TEXT NULL,
|
||||
support_code TEXT NULL,
|
||||
article TEXT NULL,
|
||||
pricelist_id INTEGER NULL,
|
||||
warehouse_pricelist_id INTEGER NULL,
|
||||
competitor_pricelist_id INTEGER NULL,
|
||||
disable_price_refresh INTEGER NOT NULL DEFAULT 0,
|
||||
only_in_stock INTEGER NOT NULL DEFAULT 0,
|
||||
line_no INTEGER NULL,
|
||||
price_updated_at DATETIME NULL,
|
||||
vendor_spec TEXT NULL,
|
||||
created_at DATETIME
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create qt_configurations: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -80,11 +80,6 @@ func (w *Worker) runSync() {
|
||||
return
|
||||
}
|
||||
|
||||
// Populate component catalog on first run (empty local_components)
|
||||
if err := w.service.SyncComponentsIfEmpty(); err != nil {
|
||||
w.logger.Warn("background sync: initial component sync failed", "error", err)
|
||||
}
|
||||
|
||||
// Push pending changes first
|
||||
pushed, err := w.service.PushPendingChanges()
|
||||
if err != nil {
|
||||
@@ -100,5 +95,8 @@ func (w *Worker) runSync() {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark user's sync heartbeat (used for online/offline status in UI).
|
||||
w.service.RecordSyncHeartbeat()
|
||||
|
||||
w.logger.Info("background sync cycle completed")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
@@ -48,8 +47,7 @@ type importedConfiguration struct {
|
||||
ServerModel string
|
||||
Article string
|
||||
CurrencyCode string
|
||||
Rows []localdb.VendorSpecItem // vendor BOM formats (CFXML, Inspur)
|
||||
DirectItems localdb.LocalConfigItems // direct LOT formats (QuoteForge CSV)
|
||||
Rows []localdb.VendorSpecItem
|
||||
TotalPrice *float64
|
||||
}
|
||||
|
||||
@@ -126,17 +124,7 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
|
||||
var workspace *importedWorkspace
|
||||
switch {
|
||||
case IsCFXMLWorkspace(data):
|
||||
workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
|
||||
case IsQuoteForgeCSV(data):
|
||||
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
|
||||
case IsInspurBOM(data):
|
||||
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported vendor export format")
|
||||
}
|
||||
workspace, err := parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -152,28 +140,10 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
||||
for _, imported := range workspace.Configurations {
|
||||
now := time.Now()
|
||||
cfgUUID := uuid.NewString()
|
||||
|
||||
var groupRows localdb.VendorSpec
|
||||
var items localdb.LocalConfigItems
|
||||
var totalPrice *float64
|
||||
var estimatePricelistID *uint
|
||||
|
||||
if len(imported.DirectItems) > 0 {
|
||||
items = imported.DirectItems
|
||||
estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate")
|
||||
if estimatePricelist != nil {
|
||||
estimatePricelistID = &estimatePricelist.ServerID
|
||||
}
|
||||
val := items.Total() * float64(maxInt(imported.ServerCount, 1))
|
||||
totalPrice = &val
|
||||
} else {
|
||||
var prepErr error
|
||||
groupRows, items, totalPrice, estimatePricelistID, prepErr = s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
|
||||
if prepErr != nil {
|
||||
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, prepErr)
|
||||
}
|
||||
groupRows, items, totalPrice, estimatePricelistID, err := s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, err)
|
||||
}
|
||||
|
||||
localCfg := &localdb.LocalConfiguration{
|
||||
UUID: cfgUUID,
|
||||
ProjectUUID: &projectUUID,
|
||||
@@ -269,17 +239,13 @@ 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 priceMap != nil {
|
||||
unitPrice = priceMap[lotName]
|
||||
if estimatePricelist != nil && local != nil {
|
||||
if price, err := local.GetLocalPriceForLot(estimatePricelist.ID, lotName); err == nil && price > 0 {
|
||||
unitPrice = price
|
||||
}
|
||||
}
|
||||
items = append(items, localdb.LocalConfigItem{
|
||||
LotName: lotName,
|
||||
@@ -592,222 +558,3 @@ func normalizeTopLevelQuantity(raw string, serverCount int) int {
|
||||
func IsCFXMLWorkspace(data []byte) bool {
|
||||
return bytes.Contains(data, []byte("<CFXML>")) || bytes.Contains(data, []byte("<CFXML "))
|
||||
}
|
||||
|
||||
func IsInspurBOM(data []byte) bool {
|
||||
for _, line := range bytes.Split(data, []byte("\n")) {
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if len(trimmed) == 0 {
|
||||
continue
|
||||
}
|
||||
idx := bytes.LastIndexByte(trimmed, '*')
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
suffix := bytes.TrimSpace(trimmed[idx+1:])
|
||||
if len(suffix) > 0 && allDigits(suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func allDigits(b []byte) bool {
|
||||
if len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range b {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
rows := make([]localdb.VendorSpecItem, 0, len(lines))
|
||||
sortOrder := 10
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "|")
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
pn := line
|
||||
qty := 1
|
||||
if idx := strings.LastIndex(line, "*"); idx > 0 {
|
||||
suffix := strings.TrimSpace(line[idx+1:])
|
||||
if n, err := strconv.Atoi(suffix); err == nil && n > 0 {
|
||||
pn = strings.TrimSpace(line[:idx])
|
||||
qty = n
|
||||
}
|
||||
}
|
||||
if pn == "" {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, localdb.VendorSpecItem{
|
||||
SortOrder: sortOrder,
|
||||
VendorPartnumber: pn,
|
||||
Quantity: qty,
|
||||
})
|
||||
sortOrder += 10
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("Inspur BOM has no importable rows")
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
|
||||
if name == "" {
|
||||
name = "Inspur Import"
|
||||
}
|
||||
|
||||
return &importedWorkspace{
|
||||
SourceFormat: "Inspur",
|
||||
SourceFileName: sourceFileName,
|
||||
Configurations: []importedConfiguration{
|
||||
{
|
||||
GroupID: "inspur-0",
|
||||
Name: name,
|
||||
Line: 10,
|
||||
ServerCount: 1,
|
||||
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);..."
|
||||
func IsQuoteForgeCSV(data []byte) bool {
|
||||
trimmed := bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||
firstLine := trimmed
|
||||
if idx := bytes.IndexByte(trimmed, '\n'); idx >= 0 {
|
||||
firstLine = trimmed[:idx]
|
||||
}
|
||||
return bytes.HasPrefix(bytes.TrimSpace(firstLine), []byte("Line;Type;p/n;"))
|
||||
}
|
||||
|
||||
// parseQuoteForgeCSV parses a QuoteForge own CSV export back into importable configurations.
|
||||
// Each server block (row where Line column is non-empty) becomes one importedConfiguration
|
||||
// with DirectItems populated from the component rows that follow it.
|
||||
func parseQuoteForgeCSV(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||||
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||
|
||||
r := csv.NewReader(bytes.NewReader(data))
|
||||
r.Comma = ';'
|
||||
r.FieldsPerRecord = -1
|
||||
r.LazyQuotes = true
|
||||
|
||||
records, err := r.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse QuoteForge CSV: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, fmt.Errorf("QuoteForge CSV is empty")
|
||||
}
|
||||
|
||||
// Skip header row (first row whose first cell is "Line")
|
||||
startIdx := 0
|
||||
if len(records[0]) > 0 && strings.EqualFold(strings.TrimSpace(records[0][0]), "line") {
|
||||
startIdx = 1
|
||||
}
|
||||
|
||||
var configs []importedConfiguration
|
||||
var current *importedConfiguration
|
||||
blockIdx := 0
|
||||
|
||||
for _, record := range records[startIdx:] {
|
||||
if csvAllEmpty(record) {
|
||||
continue
|
||||
}
|
||||
lineCol := strings.TrimSpace(csvCol(record, 0))
|
||||
pn := strings.TrimSpace(csvCol(record, 2))
|
||||
|
||||
if lineCol != "" {
|
||||
// New server block
|
||||
if current != nil {
|
||||
configs = append(configs, *current)
|
||||
}
|
||||
blockIdx++
|
||||
serverCount := maxInt(parseInt(strings.TrimSpace(csvCol(record, 5))), 1)
|
||||
article := pn
|
||||
name := article
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("Config %d", blockIdx)
|
||||
}
|
||||
current = &importedConfiguration{
|
||||
GroupID: fmt.Sprintf("qfcsv-%d", blockIdx),
|
||||
Name: name,
|
||||
Line: blockIdx * 10,
|
||||
ServerCount: serverCount,
|
||||
Article: article,
|
||||
DirectItems: make(localdb.LocalConfigItems, 0),
|
||||
}
|
||||
} else if pn != "" && current != nil {
|
||||
// Component row
|
||||
qty := maxInt(parseInt(strings.TrimSpace(csvCol(record, 4))), 1)
|
||||
unitPrice := parseCSVPrice(strings.TrimSpace(csvCol(record, 6)))
|
||||
current.DirectItems = append(current.DirectItems, localdb.LocalConfigItem{
|
||||
LotName: pn,
|
||||
Quantity: qty,
|
||||
UnitPrice: unitPrice,
|
||||
})
|
||||
}
|
||||
}
|
||||
if current != nil {
|
||||
configs = append(configs, *current)
|
||||
}
|
||||
|
||||
if len(configs) == 0 {
|
||||
return nil, fmt.Errorf("QuoteForge CSV has no importable configurations")
|
||||
}
|
||||
|
||||
return &importedWorkspace{
|
||||
SourceFormat: "QuoteForgeCSV",
|
||||
SourceFileName: sourceFileName,
|
||||
Configurations: configs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// csvCol returns record[idx] or "" when idx is out of range.
|
||||
func csvCol(record []string, idx int) string {
|
||||
if idx < len(record) {
|
||||
return record[idx]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// csvAllEmpty reports whether every cell in the record is blank.
|
||||
func csvAllEmpty(record []string) bool {
|
||||
for _, cell := range record {
|
||||
if strings.TrimSpace(cell) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parseCSVPrice parses a price string in QuoteForge CSV format:
|
||||
// comma as decimal separator, optional space as thousands separator.
|
||||
// Returns 0 on any parse failure.
|
||||
func parseCSVPrice(s string) float64 {
|
||||
if s == "" || s == "—" {
|
||||
return 0
|
||||
}
|
||||
// Remove thousands separators (space, non-breaking space)
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
// Replace comma decimal separator with dot
|
||||
s = strings.ReplaceAll(s, ",", ".")
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -358,233 +358,3 @@ func TestImportVendorWorkspaceToProject_AutoResolvesAndAppliesEstimate(t *testin
|
||||
t.Fatalf("expected resolved rows for CPU and LIC in vendor spec")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInspurBOM(t *testing.T) {
|
||||
const sample = `|CPU_AMD_9535-EPYC2.4_64C_256M_300W*1
|
||||
|Mem_64G_DDR5-6400MHz_ECC-RDIMM*1
|
||||
|2.5 NVMe Bays*4
|
||||
|2.5 or 3.5 SATA Bays*8
|
||||
|FrontHDModuleBackPlane_4SAS or 4U.2_3.5x4_GEN5*1
|
||||
|FrontHDModuleBackPlane_4SAS or 4U.2_3.5x4_GEN5*2
|
||||
|RAID_IAG_2RO_9230_N_M.2_PCIE2_HS *1
|
||||
|SSD_SA_480M2TD_MZNL3480HCLR_T2_6_PM893*2
|
||||
|NIC_100Gbps_2Port_LC_Nvidia_CX6DX_PCIe_GEN4*1
|
||||
|Riser_X16+X8+X8_G5-J4J6-A*1
|
||||
|PowerSupply_1300W_Titanium_220VACor240VDC_GaN*2
|
||||
|PowerCord_1.5m_C14_C13_CN+CNHK+CNTW+US+UK+EU+AU+SG+ZA+RU+KR*2
|
||||
|Rail_Slider-Drop-in_760mm_2U-EN*1
|
||||
|PKACCY_470x285x63_Box-Blankspace_General*1
|
||||
|Chassis_3.5x12_6PCIE*1
|
||||
|MB_AMD_Non*1
|
||||
|Fan_23000rpm_6056*6
|
||||
|Software-KSManage*1
|
||||
|TPM_2.0_NON-MainLand_SPI-INF*1
|
||||
|【CA&SA】KR2180E3-A0 3 years RTV HK Service*1
|
||||
|【CA&SA】KR2180E3-A0 3 years Data Media Retention Service*1`
|
||||
|
||||
workspace, err := parseInspurBOM([]byte(sample), "KR2180E3-A0.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if workspace.SourceFormat != "Inspur" {
|
||||
t.Fatalf("expected SourceFormat Inspur, 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 != "KR2180E3-A0" {
|
||||
t.Fatalf("expected name KR2180E3-A0, got %q", cfg.Name)
|
||||
}
|
||||
if cfg.ServerCount != 1 {
|
||||
t.Fatalf("expected ServerCount 1, got %d", cfg.ServerCount)
|
||||
}
|
||||
const wantRows = 21
|
||||
if len(cfg.Rows) != wantRows {
|
||||
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
|
||||
}
|
||||
|
||||
rowsByPN := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
|
||||
for _, r := range cfg.Rows {
|
||||
rowsByPN[r.VendorPartnumber] = r
|
||||
}
|
||||
|
||||
cpu, ok := rowsByPN["CPU_AMD_9535-EPYC2.4_64C_256M_300W"]
|
||||
if !ok {
|
||||
t.Fatal("expected CPU row not found")
|
||||
}
|
||||
if cpu.Quantity != 1 {
|
||||
t.Fatalf("CPU: expected qty 1, got %d", cpu.Quantity)
|
||||
}
|
||||
|
||||
psu, ok := rowsByPN["PowerSupply_1300W_Titanium_220VACor240VDC_GaN"]
|
||||
if !ok {
|
||||
t.Fatal("expected PSU row not found")
|
||||
}
|
||||
if psu.Quantity != 2 {
|
||||
t.Fatalf("PSU: expected qty 2, got %d", psu.Quantity)
|
||||
}
|
||||
|
||||
fan, ok := rowsByPN["Fan_23000rpm_6056"]
|
||||
if !ok {
|
||||
t.Fatal("expected Fan row not found")
|
||||
}
|
||||
if fan.Quantity != 6 {
|
||||
t.Fatalf("Fan: expected qty 6, got %d", fan.Quantity)
|
||||
}
|
||||
|
||||
// RAID partnumber has trailing space before *, must be trimmed
|
||||
raid, ok := rowsByPN["RAID_IAG_2RO_9230_N_M.2_PCIE2_HS"]
|
||||
if !ok {
|
||||
t.Fatal("expected RAID row not found (check whitespace trimming)")
|
||||
}
|
||||
if raid.Quantity != 1 {
|
||||
t.Fatalf("RAID: expected qty 1, got %d", raid.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInspurBOMWithoutPipe(t *testing.T) {
|
||||
const sample = `CPU_AMD_9535*2
|
||||
Mem_64G_DDR5*4
|
||||
PowerSupply_1300W*2`
|
||||
|
||||
workspace, err := parseInspurBOM([]byte(sample), "config.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(workspace.Configurations[0].Rows) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d", len(workspace.Configurations[0].Rows))
|
||||
}
|
||||
if workspace.Configurations[0].Rows[0].VendorPartnumber != "CPU_AMD_9535" {
|
||||
t.Fatalf("unexpected pn: %q", workspace.Configurations[0].Rows[0].VendorPartnumber)
|
||||
}
|
||||
if workspace.Configurations[0].Rows[0].Quantity != 2 {
|
||||
t.Fatalf("unexpected qty: %d", workspace.Configurations[0].Rows[0].Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQuoteForgeCSV(t *testing.T) {
|
||||
// Format mirrors ToCSV output: col[0]=Line, col[1]=Type, col[2]=p/n,
|
||||
// col[3]=Description, col[4]=Qty(1pcs), col[5]=Qty(total), col[6]=Price(1pcs), col[7]=Price(total)
|
||||
const sample = "\xEF\xBB\xBF" + // UTF-8 BOM
|
||||
"Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)\n" +
|
||||
"10;;DL380-ARTICLE;;;2;10470;20 940\n" +
|
||||
";MEMORY;MB_INTEL_A1;;1;;2074,5;\n" +
|
||||
";CPU;CPU_XEON_X;;2;;5100;\n" +
|
||||
"\n" +
|
||||
"20;;DL380-ARTICLE-2;;;1;8000;8 000\n" +
|
||||
";STORAGE;SSD_NVMe;;4;;1200;\n"
|
||||
|
||||
workspace, err := parseQuoteForgeCSV([]byte(sample), "project.csv")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if workspace.SourceFormat != "QuoteForgeCSV" {
|
||||
t.Fatalf("expected SourceFormat QuoteForgeCSV, got %q", workspace.SourceFormat)
|
||||
}
|
||||
if len(workspace.Configurations) != 2 {
|
||||
t.Fatalf("expected 2 configurations, got %d", len(workspace.Configurations))
|
||||
}
|
||||
|
||||
cfg1 := workspace.Configurations[0]
|
||||
if cfg1.Article != "DL380-ARTICLE" {
|
||||
t.Fatalf("cfg1 article: want DL380-ARTICLE, got %q", cfg1.Article)
|
||||
}
|
||||
if cfg1.ServerCount != 2 {
|
||||
t.Fatalf("cfg1 server_count: want 2, got %d", cfg1.ServerCount)
|
||||
}
|
||||
if len(cfg1.DirectItems) != 2 {
|
||||
t.Fatalf("cfg1 items: want 2, got %d", len(cfg1.DirectItems))
|
||||
}
|
||||
if cfg1.DirectItems[0].LotName != "MB_INTEL_A1" || cfg1.DirectItems[0].Quantity != 1 {
|
||||
t.Fatalf("cfg1 item[0]: %+v", cfg1.DirectItems[0])
|
||||
}
|
||||
if cfg1.DirectItems[1].LotName != "CPU_XEON_X" || cfg1.DirectItems[1].Quantity != 2 {
|
||||
t.Fatalf("cfg1 item[1]: %+v", cfg1.DirectItems[1])
|
||||
}
|
||||
if cfg1.DirectItems[1].UnitPrice != 5100 {
|
||||
t.Fatalf("cfg1 item[1] price: want 5100, got %v", cfg1.DirectItems[1].UnitPrice)
|
||||
}
|
||||
|
||||
cfg2 := workspace.Configurations[1]
|
||||
if cfg2.Article != "DL380-ARTICLE-2" {
|
||||
t.Fatalf("cfg2 article: want DL380-ARTICLE-2, got %q", cfg2.Article)
|
||||
}
|
||||
if cfg2.ServerCount != 1 {
|
||||
t.Fatalf("cfg2 server_count: want 1, got %d", cfg2.ServerCount)
|
||||
}
|
||||
if len(cfg2.DirectItems) != 1 || cfg2.DirectItems[0].LotName != "SSD_NVMe" {
|
||||
t.Fatalf("cfg2 items: %+v", cfg2.DirectItems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQuoteForgeCSV(t *testing.T) {
|
||||
withBOM := "\xEF\xBB\xBFLine;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)\n10;;ART;;1;;100;\n"
|
||||
noBOM := "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)\n"
|
||||
|
||||
cases := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{withBOM, true},
|
||||
{noBOM, true},
|
||||
{"<CFXML>\n</CFXML>", false},
|
||||
{"|CPU*1\n|PSU*2", false},
|
||||
{"", false},
|
||||
{"Line;other;columns\n", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := IsQuoteForgeCSV([]byte(tc.input))
|
||||
if got != tc.want {
|
||||
t.Errorf("IsQuoteForgeCSV(%q) = %v, want %v", tc.input[:min(len(tc.input), 40)], got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVPrice(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want float64
|
||||
}{
|
||||
{"2074,5", 2074.5},
|
||||
{"5100", 5100},
|
||||
{"104 700", 104700},
|
||||
{"20 940", 20940},
|
||||
{"—", 0},
|
||||
{"", 0},
|
||||
{"abc", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := parseCSVPrice(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("parseCSVPrice(%q) = %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestIsInspurBOM(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"|CPU_AMD*1\n|PSU*2", true},
|
||||
{"CPU_AMD*1", true},
|
||||
{"<CFXML>\n</CFXML>", false},
|
||||
{"just text\nno stars", false},
|
||||
{"pn*abc", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := IsInspurBOM([]byte(tc.input))
|
||||
if got != tc.want {
|
||||
t.Errorf("IsInspurBOM(%q) = %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
-- 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,8 +1,2 @@
|
||||
-- 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,8 +1,2 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,8 +1,2 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,8 +1,2 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,8 +1,2 @@
|
||||
-- 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,8 +1,2 @@
|
||||
-- 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,8 +1,2 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,9 +1,3 @@
|
||||
-- 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,8 +0,0 @@
|
||||
-- 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';
|
||||
@@ -1,38 +0,0 @@
|
||||
# QuoteForge v1.10
|
||||
|
||||
Дата релиза: 2026-06-02
|
||||
Тег: `v1.10`
|
||||
|
||||
## Что нового
|
||||
|
||||
### Новые возможности
|
||||
|
||||
- **Support Bundle** — кнопка-иконка в шапке рядом с именем пользователя скачивает ZIP-архив с диагностикой: версия приложения, статистика локальной БД, статус подключения с TCP-пингом до сервера, история синхронизаций (`sync_log`), список скачанных прайслистов, системные метрики (память, диск), состояние readiness-блокировки и лог-файл приложения.
|
||||
- **Автосинхронизация компонентов** — при первом запуске с пустой таблицей компонентов фоновый воркер автоматически скачивает каталог из MariaDB. Новые пользователи сразу видят все вкладки и автокомплит артикулов без ручной синхронизации.
|
||||
- **Импорт собственного CSV QuoteForge** — файлы, экспортированные из QuoteForge, можно импортировать обратно в конфигурацию.
|
||||
- **Кнопка «Обновить цены» на странице варианта проекта** — обновление цен доступно прямо со страницы проекта.
|
||||
- **Импорт BOM Inspur в формате PN×qty** — поддержка формата `артикул × количество` при импорте Inspur-спецификаций.
|
||||
|
||||
### Исправления
|
||||
|
||||
- **«Не докачано» исчезает когда всё на месте** — исправлена логика `NeedSync`: при наличии подключения всегда сравниваем реальные версии с сервером, не ориентируясь на давность последнего синка. Устраняет ситуацию когда бейдж висел вечно после сетевого сбоя, хотя все прайслисты уже были скачаны.
|
||||
- **Синхронизация прайслистов через медленное соединение** — увеличен `WriteTimeout` с 30 с до 10 мин. При высокой задержке до сервера (VPN, >300 мс) кнопка «Синхронизировать» больше не зависает без обратной связи.
|
||||
- **ALTER-спам в логах** — устранены повторяющиеся WARN `ALTER command denied` каждые 5 минут. DDL на `qt_client_schema_state` выполняется не более одного раза за жизнь процесса и только если колонки реально отсутствуют.
|
||||
- **Галочка «Создать копию»** включена по умолчанию в обоих диалогах клонирования.
|
||||
- **Сортировка категорий в CSV-экспорте** — исправлена сортировка без учёта регистра и правильный порядок (MB → CPU → MEM → RAID → диски → GPU → NIC → HBA → PSU → ACC).
|
||||
- **`/api/categories` возвращал `display_order: 0`** для всех категорий — исправлено.
|
||||
|
||||
## Для администраторов
|
||||
|
||||
При обновлении с v1.9 выполните миграцию на сервере:
|
||||
|
||||
```bash
|
||||
go run ./cmd/qfs -migrate
|
||||
```
|
||||
|
||||
Это добавит колонку `hostname` в `qt_client_schema_state`, после чего клиентские приложения перестанут получать ошибку `ALTER command denied`.
|
||||
|
||||
## Запуск на macOS
|
||||
|
||||
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
|
||||
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||
@@ -1,13 +0,0 @@
|
||||
# QuoteForge v1.7
|
||||
|
||||
Дата релиза: 2026-04-23
|
||||
Тег: `v1.7`
|
||||
|
||||
Предыдущий релиз: `v1.6.2`
|
||||
|
||||
## Ключевые изменения
|
||||
|
||||
- все вкладки estimate (storage, pci, power, accessories, sw, other) теперь используют редактируемый autocomplete-input для существующих позиций — поведение идентично вкладке base;
|
||||
- LOT-поля в BOM-таблицах переведены на общий autocomplete dropdown вместо datalist;
|
||||
- кнопка ✕ в BOM снимает сопоставление BOM→LOT вместо удаления строки;
|
||||
- кнопка «Пересчитать эстимейт» переименована в «Перенести в эстимейт».
|
||||
@@ -1,13 +0,0 @@
|
||||
# QuoteForge v1.8
|
||||
|
||||
Дата релиза: 2026-04-28
|
||||
Тег: `v1.8`
|
||||
|
||||
Предыдущий релиз: `v1.7`
|
||||
|
||||
## Ключевые изменения
|
||||
|
||||
- исправлен sync прайслистов при конфликте `local_pricelists.server_id`: сохранение локального снапшота стало idempotent через upsert;
|
||||
- сохранение нового локального снапшота прайслиста теперь атомарно заменяет строки внутри одной транзакции;
|
||||
- sync обновляет метаданные уже существующих локальных прайслистов;
|
||||
- устаревшие sync/export тесты приведены к актуальному контракту, `go test ./...` проходит полностью.
|
||||
@@ -35,16 +35,6 @@
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
<span id="db-user" class="text-sm text-gray-600"></span>
|
||||
<a href="/api/support-bundle"
|
||||
title="Скачать Support Bundle"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
download>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,43 +50,6 @@
|
||||
|
||||
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
||||
|
||||
<!-- Dead-man's switch overlay: shown when backend process stops responding -->
|
||||
<div id="backend-offline-overlay" class="hidden fixed inset-0 z-[9999] bg-black/80 flex items-center justify-center p-6">
|
||||
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-8 text-center">
|
||||
<div class="text-red-500 mb-4">
|
||||
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-3">Приложение остановлено</h2>
|
||||
<p class="text-gray-600 mb-4">Консольное окно QuoteForge было закрыто — без него программа не работает.</p>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6 text-sm text-amber-800">
|
||||
Запустите программу заново и нажмите «Обновить страницу».
|
||||
</div>
|
||||
<button onclick="window.location.reload()" class="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
Обновить страницу
|
||||
</button>
|
||||
</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">
|
||||
@@ -535,155 +488,11 @@
|
||||
// }
|
||||
// }
|
||||
|
||||
// ==================== 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();
|
||||
checkWritePermission();
|
||||
|
||||
// Dead-man's switch: detect if the backend process has stopped
|
||||
(function() {
|
||||
const POLL_MS = 5000;
|
||||
const FAIL_THRESHOLD = 2;
|
||||
let failCount = 0;
|
||||
|
||||
async function checkBackend() {
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 3000);
|
||||
const resp = await fetch('/health', { signal: ctrl.signal });
|
||||
clearTimeout(timer);
|
||||
if (resp.ok) {
|
||||
failCount = 0;
|
||||
document.getElementById('backend-offline-overlay').classList.add('hidden');
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} catch (_) {
|
||||
failCount++;
|
||||
}
|
||||
if (failCount >= FAIL_THRESHOLD) {
|
||||
document.getElementById('backend-offline-overlay').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(checkBackend, POLL_MS);
|
||||
})();
|
||||
|
||||
// Load last sync time - removed since dropdown is gone
|
||||
// document.addEventListener('DOMContentLoaded', loadLastSyncTime);
|
||||
</script>
|
||||
|
||||
@@ -53,19 +53,6 @@
|
||||
<h2 class="text-xl font-semibold mb-4">Новая конфигурация</h2>
|
||||
|
||||
<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')"
|
||||
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')"
|
||||
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
|
||||
СХД
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
|
||||
<input type="text" id="opportunity-number" placeholder="Например: Сервер для проекта X"
|
||||
@@ -247,7 +234,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('ru-RU', {minimumFractionDigits: 2}) : '—';
|
||||
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {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 +245,7 @@ function renderConfigs(configs) {
|
||||
let pricePerUnit = '—';
|
||||
if (c.total_price && serverCount > 0) {
|
||||
const unitPrice = c.total_price / serverCount;
|
||||
pricePerUnit = '$' + unitPrice.toLocaleString('ru-RU', {minimumFractionDigits: 2});
|
||||
pricePerUnit = '$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||
}
|
||||
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
@@ -531,19 +518,7 @@ async function cloneConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
let createConfigType = 'server';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
createConfigType = 'server';
|
||||
setCreateType('server');
|
||||
document.getElementById('opportunity-number').value = '';
|
||||
document.getElementById('create-project-input').value = '';
|
||||
document.getElementById('create-modal').classList.remove('hidden');
|
||||
@@ -598,8 +573,7 @@ async function createConfigWithProject(name, projectUUID) {
|
||||
items: [],
|
||||
notes: '',
|
||||
server_count: 1,
|
||||
project_uuid: projectUUID || null,
|
||||
config_type: createConfigType
|
||||
project_uuid: projectUUID || null
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -108,10 +108,6 @@
|
||||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||||
Accessories
|
||||
</button>
|
||||
<button onclick="switchTab('sw')" data-tab="sw"
|
||||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||||
SW
|
||||
</button>
|
||||
<button onclick="switchTab('other')" data-tab="other"
|
||||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||||
Other
|
||||
@@ -194,7 +190,7 @@
|
||||
Сохранить BOM
|
||||
</button>
|
||||
<button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700">
|
||||
Перенести в эстимейт
|
||||
Пересчитать эстимейт
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,7 +365,7 @@
|
||||
// Tab configuration - will be populated dynamically
|
||||
let TAB_CONFIG = {
|
||||
base: {
|
||||
categories: ['MB', 'CPU', 'MEM', 'ENC', 'DKC', 'CTL'],
|
||||
categories: ['MB', 'CPU', 'MEM'],
|
||||
singleSelect: true,
|
||||
label: 'Base'
|
||||
},
|
||||
@@ -383,14 +379,13 @@ let TAB_CONFIG = {
|
||||
]
|
||||
},
|
||||
pci: {
|
||||
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'],
|
||||
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA'],
|
||||
singleSelect: false,
|
||||
label: 'PCI',
|
||||
sections: [
|
||||
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
||||
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
||||
{ title: 'HBA', categories: ['HBA'] },
|
||||
{ title: 'HIC', categories: ['HIC'] }
|
||||
{ title: 'HBA', categories: ['HBA'] }
|
||||
]
|
||||
},
|
||||
power: {
|
||||
@@ -403,11 +398,6 @@ let TAB_CONFIG = {
|
||||
singleSelect: false,
|
||||
label: 'Accessories'
|
||||
},
|
||||
sw: {
|
||||
categories: ['SW'],
|
||||
singleSelect: false,
|
||||
label: 'SW'
|
||||
},
|
||||
other: {
|
||||
categories: [],
|
||||
singleSelect: false,
|
||||
@@ -421,7 +411,6 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
|
||||
// State
|
||||
let configUUID = '{{.ConfigUUID}}';
|
||||
let configType = 'server';
|
||||
let configName = '';
|
||||
let currentVersionNo = 1;
|
||||
let projectUUID = '';
|
||||
@@ -542,8 +531,7 @@ let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks o
|
||||
// Autocomplete state
|
||||
let autocompleteInput = null;
|
||||
let autocompleteCategory = null;
|
||||
let autocompleteMode = null; // 'single', 'multi', 'section', 'edit-item'
|
||||
let autocompleteEditCategories = null;
|
||||
let autocompleteMode = null; // 'single', 'multi', 'section'
|
||||
let autocompleteIndex = -1;
|
||||
let autocompleteFiltered = [];
|
||||
|
||||
@@ -805,8 +793,6 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
|
||||
const config = await resp.json();
|
||||
configName = config.name;
|
||||
configType = config.config_type || 'server';
|
||||
applyConfigTypeToTabs();
|
||||
currentVersionNo = config.current_version_no || 1;
|
||||
projectUUID = config.project_uuid || '';
|
||||
await loadProjectIndex();
|
||||
@@ -838,7 +824,6 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
serverModelForQuote = config.server_model || '';
|
||||
supportCode = config.support_code || '';
|
||||
currentArticle = config.article || '';
|
||||
restorePricingStateFromNotes(config.notes || '');
|
||||
|
||||
// Restore custom price if saved
|
||||
if (config.custom_price) {
|
||||
@@ -889,7 +874,7 @@ async function loadAllComponents() {
|
||||
try {
|
||||
const resp = await fetch('/api/components?per_page=5000');
|
||||
const data = await resp.json();
|
||||
allComponents = data.items || [];
|
||||
allComponents = data.components || [];
|
||||
window._bomAllComponents = allComponents;
|
||||
} catch(e) {
|
||||
console.error('Failed to load components', e);
|
||||
@@ -940,7 +925,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.items || [];
|
||||
activePricelistsBySource[source] = data.pricelists || [];
|
||||
// Do not reset the stored pricelist — it may be inactive but must be preserved
|
||||
} catch (e) {
|
||||
activePricelistsBySource[source] = [];
|
||||
@@ -1154,87 +1139,6 @@ function switchTab(tab) {
|
||||
renderTab();
|
||||
}
|
||||
|
||||
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 STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID'];
|
||||
const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU'];
|
||||
const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU'];
|
||||
|
||||
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'];
|
||||
|
||||
TAB_CONFIG.base.categories = baseCategories.filter(c => {
|
||||
if (configType === 'storage') {
|
||||
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
|
||||
}
|
||||
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
|
||||
});
|
||||
|
||||
TAB_CONFIG.storage.categories = storageCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
|
||||
});
|
||||
TAB_CONFIG.storage.sections = storageSections.filter(section => {
|
||||
if (configType === 'storage') {
|
||||
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC';
|
||||
});
|
||||
TAB_CONFIG.pci.sections = pciSections.filter(section => {
|
||||
if (configType === 'storage') {
|
||||
return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat));
|
||||
}
|
||||
return section.title !== 'HIC';
|
||||
});
|
||||
TAB_CONFIG.power.categories = powerCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true;
|
||||
});
|
||||
|
||||
// Rebuild assigned categories index
|
||||
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
.flatMap(t => t.categories)
|
||||
.map(c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function updateTabVisibility() {
|
||||
for (const tabId of Object.keys(TAB_CONFIG)) {
|
||||
if (ALWAYS_VISIBLE_TABS.has(tabId)) continue;
|
||||
const btn = document.querySelector(`[data-tab="${tabId}"]`);
|
||||
if (!btn) continue;
|
||||
const hasComponents = getComponentsForTab(tabId).length > 0;
|
||||
const hasCartItems = cart.some(item => {
|
||||
const cat = (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase();
|
||||
return getTabForCategory(cat) === tabId;
|
||||
});
|
||||
const visible = hasComponents || hasCartItems;
|
||||
btn.classList.toggle('hidden', !visible);
|
||||
// If the current tab just got hidden, fall back to base
|
||||
if (!visible && currentTab === tabId) {
|
||||
switchTab('base');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getComponentsForTab(tab) {
|
||||
const config = TAB_CONFIG[tab];
|
||||
return allComponents.filter(comp => {
|
||||
@@ -1270,7 +1174,7 @@ function renderSingleSelectTab(categories) {
|
||||
if (currentTab === 'base') {
|
||||
html += `
|
||||
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель системы для партномера:</label>
|
||||
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label>
|
||||
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
|
||||
</div>
|
||||
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||
@@ -1390,16 +1294,7 @@ function renderMultiSelectTab(components) {
|
||||
|
||||
html += `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2 min-w-48">
|
||||
<div class="autocomplete-wrapper relative">
|
||||
<input type="text"
|
||||
value="${escapeHtml(item.lot_name)}"
|
||||
class="w-full px-2 py-1 border rounded text-sm font-mono"
|
||||
onfocus="showAutocompleteEditItem('${item.lot_name}', this)"
|
||||
oninput="filterAutocompleteEditItem(this.value)"
|
||||
onkeydown="handleAutocompleteKeyEditItem(event)">
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
@@ -1493,10 +1388,6 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
<tbody class="divide-y">
|
||||
`;
|
||||
|
||||
// Add empty row for new item in this section
|
||||
const sectionId = section.categories.join('-');
|
||||
const categoriesStr = section.categories.join(',');
|
||||
|
||||
// Render existing cart items for this section
|
||||
sectionItems.forEach((item) => {
|
||||
const comp = allComponents.find(c => c.lot_name === item.lot_name);
|
||||
@@ -1504,17 +1395,7 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
|
||||
html += `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2 min-w-48">
|
||||
<div class="autocomplete-wrapper relative">
|
||||
<input type="text"
|
||||
value="${escapeHtml(item.lot_name)}"
|
||||
data-categories="${categoriesStr}"
|
||||
class="w-full px-2 py-1 border rounded text-sm font-mono"
|
||||
onfocus="showAutocompleteEditItem('${item.lot_name}', this)"
|
||||
oninput="filterAutocompleteEditItem(this.value)"
|
||||
onkeydown="handleAutocompleteKeyEditItem(event)">
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
@@ -1535,6 +1416,8 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
});
|
||||
|
||||
// Add empty row for new item in this section
|
||||
const sectionId = section.categories.join('-');
|
||||
const categoriesStr = section.categories.join(',');
|
||||
html += `
|
||||
<tr class="hover:bg-gray-50 bg-gray-50">
|
||||
<td class="px-3 py-2" colspan="2">
|
||||
@@ -1662,10 +1545,6 @@ function renderAutocomplete() {
|
||||
onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`;
|
||||
} else if (autocompleteMode === 'multi') {
|
||||
onmousedown = `selectAutocompleteItemMulti(${idx})`;
|
||||
} else if (autocompleteMode === 'bom') {
|
||||
onmousedown = `selectAutocompleteItemBOM(${idx}, ${autocompleteCategory})`;
|
||||
} else if (autocompleteMode === 'edit-item') {
|
||||
onmousedown = `selectAutocompleteEditItem(${idx})`;
|
||||
} else {
|
||||
// single mode
|
||||
onmousedown = `selectAutocompleteItem(${idx})`;
|
||||
@@ -1947,138 +1826,6 @@ function selectAutocompleteItemSection(index, sectionId) {
|
||||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||
}
|
||||
|
||||
// Autocomplete for editing an existing cart item's LOT (multi/section tabs)
|
||||
async function showAutocompleteEditItem(lotName, input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = lotName;
|
||||
autocompleteMode = 'edit-item';
|
||||
autocompleteIndex = -1;
|
||||
autocompleteEditCategories = input.dataset.categories
|
||||
? input.dataset.categories.split(',').map(c => c.trim().toUpperCase())
|
||||
: null;
|
||||
const components = getComponentsForTab(currentTab);
|
||||
await ensurePricesLoaded(components);
|
||||
filterAutocompleteEditItem(input.value);
|
||||
}
|
||||
|
||||
function filterAutocompleteEditItem(search) {
|
||||
const searchLower = (search || '').toLowerCase();
|
||||
const components = autocompleteEditCategories
|
||||
? allComponents.filter(c => autocompleteEditCategories.includes(getComponentCategory(c)))
|
||||
: getComponentsForTab(currentTab);
|
||||
autocompleteFiltered = components.filter(c => {
|
||||
if (!hasComponentPrice(c.lot_name)) return false;
|
||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||
return (c.lot_name + ' ' + (c.description || '')).toLowerCase().includes(searchLower);
|
||||
}).sort((a, b) => {
|
||||
const d = (b.popularity_score || 0) - (a.popularity_score || 0);
|
||||
return d !== 0 ? d : a.lot_name.localeCompare(b.lot_name);
|
||||
});
|
||||
renderAutocomplete();
|
||||
}
|
||||
|
||||
function handleAutocompleteKeyEditItem(event) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
|
||||
renderAutocomplete();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
|
||||
renderAutocomplete();
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
|
||||
selectAutocompleteEditItem(autocompleteIndex);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
hideAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
function selectAutocompleteEditItem(index) {
|
||||
const comp = autocompleteFiltered[index];
|
||||
if (!comp) return;
|
||||
const lotName = autocompleteCategory;
|
||||
const oldItem = cart.find(i => i.lot_name === lotName);
|
||||
const qty = oldItem?.quantity || 1;
|
||||
cart = cart.filter(i => i.lot_name !== lotName);
|
||||
const price = componentPricesCache[comp.lot_name] || 0;
|
||||
cart.push({
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: price,
|
||||
estimate_price: price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
delta_wh_estimate_abs: null,
|
||||
delta_wh_estimate_pct: null,
|
||||
delta_comp_estimate_abs: null,
|
||||
delta_comp_estimate_pct: null,
|
||||
delta_comp_wh_abs: null,
|
||||
delta_comp_wh_pct: null,
|
||||
price_missing: ['warehouse', 'competitor'],
|
||||
description: comp.description || '',
|
||||
category: getComponentCategory(comp)
|
||||
});
|
||||
hideAutocomplete();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||
}
|
||||
|
||||
// Autocomplete for BOM LOT mapping
|
||||
function showAutocompleteBOM(rowIdx, input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = rowIdx;
|
||||
autocompleteMode = 'bom';
|
||||
autocompleteIndex = -1;
|
||||
filterAutocompleteBOM(rowIdx, input.value);
|
||||
}
|
||||
|
||||
function filterAutocompleteBOM(rowIdx, search) {
|
||||
const searchLower = (search || '').toLowerCase();
|
||||
autocompleteFiltered = (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);
|
||||
});
|
||||
renderAutocomplete();
|
||||
}
|
||||
|
||||
function handleAutocompleteKeyBOM(event, rowIdx) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
|
||||
renderAutocomplete();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
|
||||
renderAutocomplete();
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
|
||||
selectAutocompleteItemBOM(autocompleteIndex, rowIdx);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
hideAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
function selectAutocompleteItemBOM(index, rowIdx) {
|
||||
const comp = autocompleteFiltered[index];
|
||||
if (!comp) return;
|
||||
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
|
||||
if (!row) return;
|
||||
row.manual_lot = comp.lot_name;
|
||||
hideAutocomplete();
|
||||
resolveBOM();
|
||||
}
|
||||
|
||||
function clearSingleSelect(category) {
|
||||
cart = cart.filter(item =>
|
||||
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
|
||||
@@ -2123,14 +1870,12 @@ function updateMultiQuantity(lotName, value) {
|
||||
|
||||
function removeFromCart(lotName) {
|
||||
cart = cart.filter(i => i.lot_name !== lotName);
|
||||
updateTabVisibility();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function updateCartUI() {
|
||||
updateTabVisibility();
|
||||
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);
|
||||
@@ -2280,58 +2025,6 @@ function getCurrentArticle() {
|
||||
return currentArticle || '';
|
||||
}
|
||||
|
||||
function buildPricingState() {
|
||||
const buyCustom = parseDecimalInput(document.getElementById('pricing-custom-price-buy')?.value || '');
|
||||
const saleUplift = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
|
||||
const saleCustom = parseDecimalInput(document.getElementById('pricing-custom-price-sale')?.value || '');
|
||||
|
||||
return {
|
||||
buy_custom_price: buyCustom > 0 ? buyCustom : null,
|
||||
sale_uplift: saleUplift > 0 ? saleUplift : null,
|
||||
sale_custom_price: saleCustom > 0 ? saleCustom : null,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeConfigNotes() {
|
||||
return JSON.stringify({
|
||||
pricing_ui: buildPricingState()
|
||||
});
|
||||
}
|
||||
|
||||
function restorePricingStateFromNotes(notesRaw) {
|
||||
if (!notesRaw) return;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(notesRaw);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pricing = parsed?.pricing_ui;
|
||||
if (!pricing || typeof pricing !== 'object') return;
|
||||
|
||||
const buyInput = document.getElementById('pricing-custom-price-buy');
|
||||
if (buyInput) {
|
||||
buyInput.value = typeof pricing.buy_custom_price === 'number' && pricing.buy_custom_price > 0
|
||||
? pricing.buy_custom_price.toFixed(2)
|
||||
: '';
|
||||
}
|
||||
|
||||
const upliftInput = document.getElementById('pricing-uplift-sale');
|
||||
if (upliftInput) {
|
||||
upliftInput.value = typeof pricing.sale_uplift === 'number' && pricing.sale_uplift > 0
|
||||
? formatUpliftInput(pricing.sale_uplift)
|
||||
: '';
|
||||
}
|
||||
|
||||
const saleInput = document.getElementById('pricing-custom-price-sale');
|
||||
if (saleInput) {
|
||||
saleInput.value = typeof pricing.sale_custom_price === 'number' && pricing.sale_custom_price > 0
|
||||
? pricing.sale_custom_price.toFixed(2)
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
function getAutosaveStorageKey() {
|
||||
return `qf_config_autosave_${configUUID || 'default'}`;
|
||||
}
|
||||
@@ -2345,7 +2038,7 @@ function buildSavePayload() {
|
||||
name: configName,
|
||||
items: cart,
|
||||
custom_price: customPrice,
|
||||
notes: serializeConfigNotes(),
|
||||
notes: '',
|
||||
server_count: serverCount,
|
||||
server_model: serverModelForQuote,
|
||||
support_code: supportCode,
|
||||
@@ -2584,7 +2277,7 @@ function formatDiffPercent(baseTotal, compareTotal, compareLabel) {
|
||||
}
|
||||
const pct = ((baseTotal - compareTotal) / compareTotal) * 100;
|
||||
const sign = pct > 0 ? '+' : '';
|
||||
return `${sign}${pct.toFixed(1).replace('.', ',')}% от ${compareLabel}`;
|
||||
return `${sign}${pct.toFixed(1)}% от ${compareLabel}`;
|
||||
}
|
||||
|
||||
function getTotalClass(current, references) {
|
||||
@@ -2709,7 +2402,7 @@ function calculateCustomPrice() {
|
||||
|
||||
// Show discount info
|
||||
discountInfoEl.classList.remove('hidden');
|
||||
discountPercentEl.textContent = discountPercent.toFixed(1).replace('.', ',') + '%';
|
||||
discountPercentEl.textContent = discountPercent.toFixed(1) + '%';
|
||||
|
||||
// Update discount color based on value
|
||||
const discountEl = discountPercentEl;
|
||||
@@ -2817,82 +2510,73 @@ async function exportCSVWithCustomPrice() {
|
||||
}
|
||||
|
||||
async function refreshPrices() {
|
||||
// RBAC disabled - no token check required
|
||||
if (!configUUID) return;
|
||||
if (disablePriceRefresh) {
|
||||
showToast('Обновление цен отключено в настройках', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshBtn = document.getElementById('refresh-prices-btn');
|
||||
const previousLabel = refreshBtn ? refreshBtn.textContent : '';
|
||||
|
||||
try {
|
||||
if (refreshBtn) {
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.textContent = 'Обновление...';
|
||||
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
|
||||
const refreshPayload = {};
|
||||
if (selectedPricelistIds.estimate) {
|
||||
refreshPayload.pricelist_id = selectedPricelistIds.estimate;
|
||||
}
|
||||
|
||||
await loadActivePricelists(true);
|
||||
|
||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||||
const latest = activePricelistsBySource[source]?.[0];
|
||||
if (latest && latest.id) {
|
||||
selectedPricelistIds[source] = Number(latest.id);
|
||||
resolvedAutoPricelistIds[source] = null;
|
||||
}
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(refreshPayload)
|
||||
});
|
||||
|
||||
syncPriceSettingsControls();
|
||||
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;
|
||||
if (!resp.ok) {
|
||||
showToast('Ошибка обновления цен', 'error');
|
||||
return;
|
||||
}
|
||||
beforeTotal *= serverCount;
|
||||
|
||||
await saveConfig(false);
|
||||
const config = await resp.json();
|
||||
|
||||
// Update cart with new prices
|
||||
if (config.items && config.items.length > 0) {
|
||||
cart = config.items.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
estimate_price: item.unit_price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
description: item.description || '',
|
||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||
}));
|
||||
}
|
||||
|
||||
// Update price update date
|
||||
if (config.price_updated_at) {
|
||||
updatePriceUpdateDate(config.price_updated_at);
|
||||
}
|
||||
if (config.pricelist_id) {
|
||||
if (selectedPricelistIds.estimate) {
|
||||
selectedPricelistIds.estimate = config.pricelist_id;
|
||||
} else {
|
||||
resolvedAutoPricelistIds.estimate = Number(config.pricelist_id);
|
||||
}
|
||||
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
||||
await loadActivePricelists();
|
||||
}
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
if (selectedPricelistIds.estimate) {
|
||||
persistLocalPriceSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render UI
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
showToast('Цены обновлены', 'success');
|
||||
showPriceDiffModal([{ configName: configName || 'Конфигурация', prevTotal: beforeTotal, newTotal: afterTotal, serverCount, itemDiffs }]);
|
||||
} catch(e) {
|
||||
showToast('Ошибка обновления цен', 'error');
|
||||
} finally {
|
||||
if (refreshBtn) {
|
||||
refreshBtn.disabled = false;
|
||||
refreshBtn.textContent = previousLabel || 'Обновить цены';
|
||||
updateRefreshPricesButtonState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3054,62 +2738,11 @@ 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 _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++;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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()));
|
||||
@@ -3185,18 +2818,6 @@ function deleteBOMRawRow(rowIdx) {
|
||||
rebuildBOMRowsFromRaw();
|
||||
}
|
||||
|
||||
function clearBOMLotMapping(rowIdx) {
|
||||
const row = bomRows.find(r => r.source_row_index === rowIdx);
|
||||
if (!row) return;
|
||||
row.manual_lot = '';
|
||||
row.resolved_lot = '';
|
||||
row.resolution_source = 'unresolved';
|
||||
row.lot_allocations = [];
|
||||
row.bundle_enabled = false;
|
||||
renderBOMTable();
|
||||
debouncedResolveBOM();
|
||||
}
|
||||
|
||||
function _bomRawLotCell(rowIdx) {
|
||||
if (!bomImportRaw || bomImportRaw.mode !== 'raw') return '—';
|
||||
if (bomImportRaw.ignoredRows?.[rowIdx]) return '<span class="text-gray-400">—</span>';
|
||||
@@ -3218,12 +2839,13 @@ function _bomRawLotCell(rowIdx) {
|
||||
|
||||
if (isUnresolved) {
|
||||
const val = map.manual_lot || '';
|
||||
return `<div class="autocomplete-wrapper relative"><input type="text" placeholder="Введите артикул..."
|
||||
value="${escapeHtml(val)}"
|
||||
class="w-full min-w-28 px-2 py-1 border rounded text-xs font-mono"
|
||||
onfocus="showAutocompleteBOM(${rowIdx}, this)"
|
||||
oninput="filterAutocompleteBOM(${rowIdx}, this.value); setBOMManualLotDraft(${rowIdx}, this.value, this)"
|
||||
onkeydown="handleAutocompleteKeyBOM(event, ${rowIdx})"></div>
|
||||
const invalid = val && !_bomLotValid(val);
|
||||
return `<input type="text" placeholder="LOT..." value="${escapeHtml(val)}"
|
||||
class="w-full min-w-28 px-2 py-1 border rounded text-xs ${invalid ? 'border-red-400 bg-red-50' : ''}"
|
||||
list="lot-autocomplete-list"
|
||||
oninput="setBOMManualLotDraft(${rowIdx}, this.value, this)"
|
||||
onchange="commitBOMManualLot(${rowIdx}, this)"
|
||||
onblur="commitBOMManualLot(${rowIdx}, this)">
|
||||
${renderBOMLotAllocationsEditor(rowIdx)}`;
|
||||
}
|
||||
let suffix = '';
|
||||
@@ -3607,12 +3229,12 @@ function _renderBOMParsedTable() {
|
||||
|
||||
let lotCell = '';
|
||||
if (isUnresolved) {
|
||||
lotCell = `<div class="autocomplete-wrapper relative"><input type="text" placeholder="Введите артикул..."
|
||||
value="${escapeHtml(row.manual_lot || '')}"
|
||||
class="w-full px-2 py-1 border rounded text-sm font-mono focus:ring-1 focus:ring-blue-400"
|
||||
onfocus="showAutocompleteBOM(${row.source_row_index}, this)"
|
||||
oninput="filterAutocompleteBOM(${row.source_row_index}, this.value); bomRows.find(r=>r.source_row_index===${row.source_row_index}).manual_lot=this.value;"
|
||||
onkeydown="handleAutocompleteKeyBOM(event, ${row.source_row_index})"></div>${renderBOMLotAllocationsEditor(idx)}`;
|
||||
lotCell = `<input type="text" placeholder="Введите LOT..." value="${escapeHtml(row.manual_lot || '')}"
|
||||
class="w-full px-2 py-1 border rounded text-sm focus:ring-1 focus:ring-blue-400"
|
||||
oninput="bomRows[${idx}].manual_lot = this.value; this.classList.toggle('border-red-400', this.value && !_bomLotValid(this.value));"
|
||||
onchange="if(_bomLotValid(this.value)){bomRows[${idx}].manual_lot=this.value;resolveBOM(); this.classList.remove('border-red-400');}else{this.value=bomRows[${idx}].manual_lot||'';}"
|
||||
onblur="if(this.value && !_bomLotValid(this.value)){this.value=bomRows[${idx}].manual_lot||'';}"
|
||||
list="lot-autocomplete-list">${renderBOMLotAllocationsEditor(idx)}`;
|
||||
} else {
|
||||
let suffix = '';
|
||||
if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`;
|
||||
@@ -3702,7 +3324,7 @@ function _renderBOMRawTable() {
|
||||
<td class="w-12 px-1 py-1 border-b text-center align-top whitespace-nowrap">
|
||||
<button type="button" title="Добавить LOT в bundle" onclick="addBOMAllocation(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-blue-600">+</button>
|
||||
<button type="button" title="${ignored ? 'Не игнорировать' : 'Игнорировать'}" onclick="toggleBOMRawRowIgnored(${rowIdx})" class="inline-block text-xs px-1 ${ignored ? 'text-blue-600' : 'text-gray-400 hover:text-gray-700'}">${ignored ? '◉' : '○'}</button>
|
||||
<button type="button" title="Снять сопоставление" onclick="clearBOMLotMapping(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button>
|
||||
<button type="button" title="Удалить строку" onclick="deleteBOMRawRow(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button>
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
@@ -4272,7 +3894,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).replace('.', ',')}%)`;
|
||||
return ` (${sign}${Math.abs(pct).toFixed(1)}%)`;
|
||||
};
|
||||
const _pctClass = (custom, est) => custom <= est ? 'text-green-600' : 'text-red-600';
|
||||
|
||||
@@ -4326,17 +3948,14 @@ function applyCustomPrice(table) {
|
||||
|
||||
function onBuyCustomPriceInput() {
|
||||
applyCustomPrice('buy');
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function onSaleCustomPriceInput() {
|
||||
applyCustomPrice('sale');
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function onSaleMarkupInput() {
|
||||
renderPricingTab();
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function setPricingCustomPriceFromVendor() {
|
||||
@@ -4365,7 +3984,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).replace('.', ',')}%)` : '';
|
||||
const pct = estimateTotal > 0 ? ` (-${((estimateTotal - total) / estimateTotal * 100).toFixed(1)}%)` : '';
|
||||
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');
|
||||
@@ -4375,33 +3994,72 @@ function setPricingCustomPriceFromVendor() {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPricingCSV(table) {
|
||||
if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; }
|
||||
const basis = table === 'sale' ? 'ddp' : 'fob';
|
||||
try {
|
||||
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
include_lot: true,
|
||||
include_bom: true,
|
||||
include_estimate: true,
|
||||
include_stock: true,
|
||||
include_competitor: true,
|
||||
basis: basis,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = getFilenameFromResponse(resp) || `${configName || 'config'} SPEC-${basis.toUpperCase()}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch(e) {
|
||||
showToast('Ошибка экспорта', 'error');
|
||||
}
|
||||
function exportPricingCSV(table) {
|
||||
const bodyId = table === 'sale' ? 'pricing-body-sale' : 'pricing-body-buy';
|
||||
const rowClass = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy';
|
||||
const totalIds = table === 'sale'
|
||||
? { est: 'pricing-total-sale-estimate', wh: 'pricing-total-sale-warehouse', comp: 'pricing-total-sale-competitor', vendor: 'pricing-total-sale-vendor' }
|
||||
: { est: 'pricing-total-buy-estimate', wh: 'pricing-total-buy-warehouse', comp: 'pricing-total-buy-competitor', vendor: 'pricing-total-buy-vendor' };
|
||||
|
||||
const rows = document.querySelectorAll(`#${bodyId} tr.${rowClass}`);
|
||||
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
|
||||
|
||||
const csvDelimiter = ';';
|
||||
const cleanExportCell = value => {
|
||||
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text || text === '—') return text || '';
|
||||
return text
|
||||
.replace(/\s*\(.*\)$/, '')
|
||||
.replace(/\s*\*+\s*$/, '')
|
||||
.trim();
|
||||
};
|
||||
const csvEscape = v => {
|
||||
if (v == null) return '';
|
||||
const s = String(v).replace(/"/g, '""');
|
||||
return /[;"\n\r]/.test(s) ? `"${s}"` : s;
|
||||
};
|
||||
|
||||
const headers = ['PN вендора', 'Описание', 'LOT', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
|
||||
const lines = [headers.map(csvEscape).join(csvDelimiter)];
|
||||
|
||||
rows.forEach(tr => {
|
||||
// PN вендора, Описание, LOT are stored in dataset to handle rowspan correctly
|
||||
const pn = cleanExportCell(tr.dataset.vendorPn || '');
|
||||
const desc = cleanExportCell(tr.dataset.desc || '');
|
||||
const lot = cleanExportCell(tr.dataset.lot || '');
|
||||
// Qty..Ручная цена: cells at offset 2 for group-start rows, offset 0 for sub-rows
|
||||
const isGroupStart = tr.dataset.groupStart === 'true';
|
||||
const cells = tr.querySelectorAll('td');
|
||||
const o = isGroupStart ? 2 : 0;
|
||||
const cols = [pn, desc, lot,
|
||||
cleanExportCell(cells[o]?.textContent),
|
||||
cleanExportCell(cells[o+1]?.textContent),
|
||||
cleanExportCell(cells[o+2]?.textContent),
|
||||
cleanExportCell(cells[o+3]?.textContent),
|
||||
cleanExportCell(cells[o+4]?.textContent),
|
||||
];
|
||||
lines.push(cols.map(csvEscape).join(csvDelimiter));
|
||||
});
|
||||
|
||||
// Totals row
|
||||
const tEst = cleanExportCell(document.getElementById(totalIds.est)?.textContent);
|
||||
const tWh = cleanExportCell(document.getElementById(totalIds.wh)?.textContent);
|
||||
const tComp = cleanExportCell(document.getElementById(totalIds.comp)?.textContent);
|
||||
const tVendor = cleanExportCell(document.getElementById(totalIds.vendor)?.textContent);
|
||||
lines.push(['', '', '', 'Итого:', tEst, tWh, tComp, tVendor].map(csvEscape).join(csvDelimiter));
|
||||
|
||||
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const today = new Date();
|
||||
const datePart = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
|
||||
const codePart = (projectCode || 'NO-PROJECT').trim();
|
||||
const namePart = (configName || 'config').trim();
|
||||
const suffix = table === 'sale' ? 'SALE' : 'BUY';
|
||||
a.download = `${datePart} (${codePart}) ${namePart} SPEC-${suffix}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
|
||||
@@ -127,7 +127,7 @@ async function loadBooks() {
|
||||
return;
|
||||
}
|
||||
|
||||
allBooks = data.items || [];
|
||||
allBooks = data.books || [];
|
||||
document.getElementById('books-list-loading').classList.add('hidden');
|
||||
|
||||
if (!allBooks.length) {
|
||||
@@ -213,7 +213,7 @@ async function loadActiveBookItemsPage(page = 1, search = '', book = null) {
|
||||
|
||||
activeItems = data.items || [];
|
||||
itemsPage = data.page || page;
|
||||
itemsTotal = Number(data.total_count || 0);
|
||||
itemsTotal = Number(data.total || 0);
|
||||
itemsSearch = data.search || search || '';
|
||||
|
||||
document.getElementById('card-version').textContent = targetBook.version;
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
toggleWarehouseColumns();
|
||||
|
||||
renderItems(data.items || []);
|
||||
renderItemsPagination(data.total_count, data.page, data.per_page);
|
||||
renderItemsPagination(data.total, 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('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const description = item.lot_description || '-';
|
||||
const truncatedDesc = description.length > descMax ? description.substring(0, descMax) + '...' : description;
|
||||
|
||||
|
||||
@@ -83,8 +83,8 @@
|
||||
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
||||
const data = await resp.json();
|
||||
|
||||
renderPricelists(data.items || []);
|
||||
renderPagination(data.total_count, data.page, data.per_page);
|
||||
renderPricelists(data.pricelists || []);
|
||||
renderPagination(data.total, data.page, data.per_page);
|
||||
} catch (e) {
|
||||
document.getElementById('pricelists-body').innerHTML = `
|
||||
<tr>
|
||||
|
||||
@@ -40,9 +40,6 @@
|
||||
<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="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">
|
||||
Импорт
|
||||
</button>
|
||||
@@ -80,19 +77,6 @@
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Новая конфигурация в проекте</h2>
|
||||
<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="pd-type-server-btn" onclick="pdSetCreateType('server')"
|
||||
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||
Сервер
|
||||
</button>
|
||||
<button type="button" id="pd-type-storage-btn" onclick="pdSetCreateType('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>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
|
||||
<input type="text" id="create-name" placeholder="Например: OPP-2026-001"
|
||||
@@ -108,14 +92,14 @@
|
||||
|
||||
<div id="vendor-import-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Импорт конфигураций</h2>
|
||||
<h2 class="text-xl font-semibold mb-4">Импорт выгрузки вендора</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
Поддерживаемые форматы: CFXML-выгрузка вендора (.xml), собственный CSV-экспорт QuoteForge (.csv), текстовый BOM Inspur (.txt).
|
||||
Загружает `CFXML`-выгрузку в текущий проект и создаёт несколько конфигураций, если они есть в файле.
|
||||
</div>
|
||||
<div>
|
||||
<label for="vendor-import-file" class="block text-sm font-medium text-gray-700 mb-1">Файл</label>
|
||||
<input id="vendor-import-file" type="file" accept=".xml,.csv,.txt,text/xml,application/xml,text/csv,text/plain"
|
||||
<label for="vendor-import-file" class="block text-sm font-medium text-gray-700 mb-1">Файл выгрузки</label>
|
||||
<input id="vendor-import-file" type="file" accept=".xml,text/xml,application/xml"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-amber-500 focus:border-amber-500">
|
||||
</div>
|
||||
<div id="vendor-import-status" class="hidden text-sm rounded border px-3 py-2"></div>
|
||||
@@ -339,7 +323,7 @@ function escapeHtml(text) {
|
||||
|
||||
function formatMoneyNoDecimals(value) {
|
||||
const safe = Number.isFinite(Number(value)) ? Number(value) : 0;
|
||||
return '$' + Math.round(safe).toLocaleString('ru-RU');
|
||||
return '$' + Math.round(safe).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function resolveProjectTrackerURL(projectData) {
|
||||
@@ -592,19 +576,7 @@ async function loadConfigs() {
|
||||
}
|
||||
}
|
||||
|
||||
let pdCreateConfigType = 'server';
|
||||
|
||||
function pdSetCreateType(type) {
|
||||
pdCreateConfigType = type;
|
||||
document.getElementById('pd-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');
|
||||
document.getElementById('pd-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');
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
pdCreateConfigType = 'server';
|
||||
pdSetCreateType('server');
|
||||
document.getElementById('create-name').value = '';
|
||||
document.getElementById('create-modal').classList.remove('hidden');
|
||||
document.getElementById('create-modal').classList.add('flex');
|
||||
@@ -911,7 +883,7 @@ async function importVendorWorkspace() {
|
||||
const input = document.getElementById('vendor-import-file');
|
||||
const submit = document.getElementById('vendor-import-submit');
|
||||
if (!input || !input.files || !input.files[0]) {
|
||||
setVendorImportStatus('Выберите файл для импорта', 'error');
|
||||
setVendorImportStatus('Выберите XML-файл выгрузки', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -962,7 +934,7 @@ async function createConfig() {
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1, config_type: pdCreateConfigType})
|
||||
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось создать конфигурацию');
|
||||
@@ -1572,93 +1544,6 @@ async function exportProject() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPrices() {
|
||||
const configs = (allConfigs || []).filter(c => c.is_active !== false);
|
||||
if (!configs.length) {
|
||||
showToast('Нет активных конфигураций', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('refresh-all-prices-btn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Обновление...';
|
||||
btn.className = 'py-2 bg-gray-300 text-gray-500 rounded-lg cursor-not-allowed font-medium';
|
||||
}
|
||||
|
||||
try {
|
||||
const latestEstimatePricelistId = await fetchLatestEstimatePricelistId();
|
||||
|
||||
const diffResults = [];
|
||||
for (const cfg of configs) {
|
||||
if (cfg.disable_price_refresh) {
|
||||
diffResults.push({ configName: cfg.name, skipped: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
applyStatusModeUI();
|
||||
const ok = await loadProject();
|
||||
|
||||
@@ -81,7 +81,7 @@ function escapeHtml(text) {
|
||||
}
|
||||
|
||||
function formatMoney(v) {
|
||||
return '$' + (v || 0).toLocaleString('ru-RU', {minimumFractionDigits: 2});
|
||||
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
|
||||
@@ -16,13 +16,6 @@
|
||||
<p class="text-gray-600 mt-2">Настройка подключения к базе данных</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber-50 border border-amber-300 rounded-md p-3 mb-4 flex items-start gap-2">
|
||||
<svg class="w-5 h-5 text-amber-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-amber-800"><span class="font-semibold">Важно:</span> не закрывайте консольное окно приложения — без него программа не работает.</p>
|
||||
</div>
|
||||
|
||||
<form id="setup-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Хост сервера</label>
|
||||
@@ -92,28 +85,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dead-man's switch overlay -->
|
||||
<div id="backend-offline-overlay" class="hidden fixed inset-0 z-[9999] bg-black/80 flex items-center justify-center p-6">
|
||||
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-8 text-center">
|
||||
<div class="text-red-500 mb-4">
|
||||
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-3">Приложение остановлено</h2>
|
||||
<p class="text-gray-600 mb-4">Консольное окно QuoteForge было закрыто — без него программа не работает.</p>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6 text-sm text-amber-800">
|
||||
Запустите программу заново и нажмите «Обновить страницу».
|
||||
</div>
|
||||
<button onclick="window.location.reload()" class="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
Обновить страницу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let awaitingRestart = false;
|
||||
|
||||
function showStatus(message, type) {
|
||||
const status = document.getElementById('status');
|
||||
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800', 'bg-yellow-100', 'text-yellow-800');
|
||||
@@ -188,7 +160,6 @@
|
||||
}
|
||||
|
||||
async function requestRestartAndWait() {
|
||||
awaitingRestart = true;
|
||||
showStatus('Перезапуск приложения...', 'info');
|
||||
try {
|
||||
await fetch('/api/restart', { method: 'POST' });
|
||||
@@ -234,35 +205,6 @@
|
||||
showStatus('Ошибка сети: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
// Dead-man's switch: detect if the backend process has stopped
|
||||
(function() {
|
||||
const POLL_MS = 5000;
|
||||
const FAIL_THRESHOLD = 2;
|
||||
let failCount = 0;
|
||||
|
||||
async function checkBackend() {
|
||||
if (awaitingRestart) return;
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 3000);
|
||||
const resp = await fetch('/health', { signal: ctrl.signal });
|
||||
clearTimeout(timer);
|
||||
if (resp.ok) {
|
||||
failCount = 0;
|
||||
document.getElementById('backend-offline-overlay').classList.add('hidden');
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} catch (_) {
|
||||
failCount++;
|
||||
}
|
||||
if (failCount >= FAIL_THRESHOLD) {
|
||||
document.getElementById('backend-offline-overlay').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(checkBackend, POLL_MS);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user