Compare commits
173 Commits
v1.14
...
579ff46a7f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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: 52444350c1...5a69e0bba8
@@ -35,8 +35,6 @@ Readiness guard:
|
||||
- blocked sync returns `423 Locked` with a machine-readable reason;
|
||||
- local work continues even when sync is blocked.
|
||||
- sync metadata updates must preserve project `updated_at`; sync time belongs in `synced_at`, not in the user-facing last-modified timestamp.
|
||||
- pricelist pull must persist a new local snapshot atomically: header and items appear together, and `last_pricelist_sync` advances only after item download succeeds.
|
||||
- UI sync status must distinguish "last sync failed" from "up to date"; if the app can prove newer server pricelist data exists, the indicator must say local cache is incomplete.
|
||||
|
||||
## Pricing contract
|
||||
|
||||
@@ -48,55 +46,16 @@ Rules:
|
||||
- latest pricelist selection ignores snapshots without items;
|
||||
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
|
||||
|
||||
## Pricing tab layout
|
||||
|
||||
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
|
||||
|
||||
Column order (both tables):
|
||||
|
||||
```
|
||||
PN вендора | Описание | LOT | Кол-во | Estimate | Склад | Конкуренты | Ручная цена
|
||||
```
|
||||
|
||||
Per-LOT row expansion rules:
|
||||
- each `lot_mappings` entry in a BOM row becomes its own table row with its own quantity and prices;
|
||||
- `baseLot` (resolved LOT without an explicit mapping) is treated as the first sub-row with `quantity_per_pn` from `_getRowLotQtyPerPN`;
|
||||
- when one vendor PN expands into N LOT sub-rows, PN вендора and Описание cells use `rowspan="N"` and appear only on the first sub-row;
|
||||
- a visual top border (`border-t border-gray-200`) separates each vendor PN group.
|
||||
|
||||
Vendor price attachment:
|
||||
- `vendorOrig` and `vendorOrigUnit` (BOM unit/total price) are attached to the first LOT sub-row only;
|
||||
- subsequent sub-rows carry empty `data-vendor-orig` so `setPricingCustomPriceFromVendor` counts each vendor PN exactly once.
|
||||
|
||||
Controls terminology:
|
||||
- custom price input is labeled **Ручная цена** (not "Своя цена");
|
||||
- the button that fills custom price from BOM totals is labeled **BOM Цена** (not "Проставить цены BOM").
|
||||
|
||||
CSV export reads PN вендора, Описание, and LOT from `data-vendor-pn`, `data-desc`, `data-lot` row attributes to bypass the rowspan cell offset problem.
|
||||
|
||||
## Configuration versioning
|
||||
|
||||
Configuration revisions are append-only snapshots stored in `local_configuration_versions`.
|
||||
|
||||
Rules:
|
||||
- the editable working configuration is always the implicit head named `main`; UI must not switch the user to a numbered revision after save;
|
||||
- create a new revision when spec, BOM, or pricing content changes;
|
||||
- revision history is retrospective: the revisions page shows past snapshots, not the current `main` state;
|
||||
- create a new revision only when spec or price content changes;
|
||||
- rollback creates a new head revision from an old snapshot;
|
||||
- rename, reorder, project move, and similar operational edits do not create a new revision snapshot;
|
||||
- revision deduplication includes `items`, `server_count`, `total_price`, `custom_price`, `vendor_spec`, pricelist selectors, `disable_price_refresh`, and `only_in_stock`;
|
||||
- BOM updates must use version-aware save flow, not a direct SQL field update;
|
||||
- current revision pointer must be recoverable if legacy or damaged rows are found locally.
|
||||
|
||||
## Sync UX
|
||||
|
||||
UI-facing sync status must never block on live MariaDB calls.
|
||||
|
||||
Rules:
|
||||
- navbar sync indicator and sync info modal read only local cached state from SQLite/app settings;
|
||||
- background/manual sync may talk to MariaDB, but polling endpoints must stay fast even on slow or broken connections;
|
||||
- any MariaDB timeout/invalid-connection during sync must invalidate the cached remote handle immediately so UI stops treating the connection as healthy.
|
||||
|
||||
## Naming collisions
|
||||
|
||||
UI-driven rename and copy flows use one suffix convention for conflicts.
|
||||
@@ -106,17 +65,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,366 +29,31 @@ Rules:
|
||||
|
||||
## MariaDB
|
||||
|
||||
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
|
||||
MariaDB is the central sync database.
|
||||
|
||||
### QuoteForge tables (qt_*)
|
||||
Runtime read permissions:
|
||||
- `lot`
|
||||
- `qt_lot_metadata`
|
||||
- `qt_categories`
|
||||
- `qt_pricelists`
|
||||
- `qt_pricelist_items`
|
||||
- `stock_log`
|
||||
- `qt_partnumber_books`
|
||||
- `qt_partnumber_book_items`
|
||||
|
||||
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
|
||||
- `qt_partnumber_books` — partnumber book headers
|
||||
- `qt_partnumber_book_items` — PN→LOT catalog payload
|
||||
|
||||
Runtime read/write:
|
||||
- `qt_projects` — projects
|
||||
- `qt_configurations` — configurations
|
||||
- `qt_client_schema_state` — per-client sync status and version tracking
|
||||
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
|
||||
Runtime read/write permissions:
|
||||
- `qt_projects`
|
||||
- `qt_configurations`
|
||||
- `qt_client_schema_state`
|
||||
- `qt_pricelist_sync_status`
|
||||
|
||||
Insert-only tracking:
|
||||
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync
|
||||
|
||||
Server-side only (not queried by client runtime):
|
||||
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
||||
- `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime)
|
||||
- `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`)
|
||||
- `qt_scheduler_runs` — server background job tracking (no Go code references it in this repo)
|
||||
|
||||
### Competitor subsystem (server-side only, not used by QuoteForge Go code)
|
||||
|
||||
- `qt_competitors` — competitor registry
|
||||
- `partnumber_log_competitors` — competitor price log (FK → qt_competitors)
|
||||
|
||||
These tables exist in the schema and are maintained by another tool or workflow.
|
||||
QuoteForge references competitor pricelists only via `qt_pricelists` (source='competitor').
|
||||
|
||||
### 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
|
||||
- `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.
|
||||
- `qt_vendor_partnumber_seen`
|
||||
|
||||
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;
|
||||
- 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.
|
||||
|
||||
## MariaDB Table Structures
|
||||
|
||||
Full column reference as of 2026-03-21 (`RFQ_LOG` final schema).
|
||||
|
||||
### qt_categories
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| code | varchar(20) UNIQUE NOT NULL | |
|
||||
| name | varchar(100) NOT NULL | |
|
||||
| name_ru | varchar(100) | |
|
||||
| display_order | bigint DEFAULT 0 | |
|
||||
| is_required | tinyint(1) DEFAULT 0 | |
|
||||
|
||||
### qt_client_schema_state
|
||||
PK: (username, hostname)
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| username | varchar(100) | |
|
||||
| hostname | varchar(255) DEFAULT '' | |
|
||||
| last_applied_migration_id | varchar(128) | |
|
||||
| app_version | varchar(64) | |
|
||||
| last_sync_at | datetime | |
|
||||
| last_sync_status | varchar(32) | |
|
||||
| pending_changes_count | int DEFAULT 0 | |
|
||||
| pending_errors_count | int DEFAULT 0 | |
|
||||
| configurations_count | int DEFAULT 0 | |
|
||||
| projects_count | int DEFAULT 0 | |
|
||||
| estimate_pricelist_version | varchar(128) | |
|
||||
| warehouse_pricelist_version | varchar(128) | |
|
||||
| competitor_pricelist_version | varchar(128) | |
|
||||
| last_sync_error_code | varchar(128) | |
|
||||
| last_sync_error_text | text | |
|
||||
| last_checked_at | datetime NOT NULL | |
|
||||
| updated_at | datetime NOT NULL | |
|
||||
|
||||
### qt_component_usage_stats
|
||||
PK: lot_name
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| lot_name | varchar(255) | |
|
||||
| quotes_total | bigint DEFAULT 0 | |
|
||||
| quotes_last30d | bigint DEFAULT 0 | |
|
||||
| quotes_last7d | bigint DEFAULT 0 | |
|
||||
| total_quantity | bigint DEFAULT 0 | |
|
||||
| total_revenue | decimal(14,2) DEFAULT 0 | |
|
||||
| trend_direction | enum('up','stable','down') DEFAULT 'stable' | |
|
||||
| trend_percent | decimal(5,2) DEFAULT 0 | |
|
||||
| last_used_at | datetime(3) | |
|
||||
|
||||
### qt_competitors
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| name | varchar(255) NOT NULL | |
|
||||
| code | varchar(100) UNIQUE NOT NULL | |
|
||||
| delivery_basis | varchar(50) DEFAULT 'DDP' | |
|
||||
| currency | varchar(10) DEFAULT 'USD' | |
|
||||
| column_mapping | longtext JSON | |
|
||||
| is_active | tinyint(1) DEFAULT 1 | |
|
||||
| created_at | timestamp | |
|
||||
| updated_at | timestamp ON UPDATE | |
|
||||
| price_uplift | decimal(8,4) DEFAULT 1.3 | effective_price = price / price_uplift |
|
||||
|
||||
### qt_configurations
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| uuid | varchar(36) UNIQUE NOT NULL | |
|
||||
| user_id | bigint UNSIGNED | |
|
||||
| owner_username | varchar(100) NOT NULL | |
|
||||
| app_version | varchar(64) | |
|
||||
| project_uuid | char(36) | FK → qt_projects.uuid ON DELETE SET NULL |
|
||||
| name | varchar(200) NOT NULL | |
|
||||
| items | longtext JSON NOT NULL | component list |
|
||||
| total_price | decimal(12,2) | |
|
||||
| notes | text | |
|
||||
| is_template | tinyint(1) DEFAULT 0 | |
|
||||
| created_at | datetime(3) | |
|
||||
| custom_price | decimal(12,2) | |
|
||||
| server_count | bigint DEFAULT 1 | |
|
||||
| server_model | varchar(100) | |
|
||||
| support_code | varchar(20) | |
|
||||
| article | varchar(80) | |
|
||||
| pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
|
||||
| warehouse_pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
|
||||
| competitor_pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
|
||||
| disable_price_refresh | tinyint(1) DEFAULT 0 | |
|
||||
| only_in_stock | tinyint(1) DEFAULT 0 | |
|
||||
| line_no | int | position within project |
|
||||
| price_updated_at | timestamp | |
|
||||
| vendor_spec | longtext JSON | |
|
||||
|
||||
### qt_lot_metadata
|
||||
PK: lot_name
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| lot_name | varchar(255) | |
|
||||
| category_id | bigint UNSIGNED | FK → qt_categories.id |
|
||||
| vendor | varchar(50) | |
|
||||
| model | varchar(100) | |
|
||||
| specs | longtext JSON | |
|
||||
| current_price | decimal(12,2) | cached computed price |
|
||||
| price_method | enum('manual','median','average','weighted_median') DEFAULT 'median' | |
|
||||
| price_period_days | bigint DEFAULT 90 | |
|
||||
| price_updated_at | datetime(3) | |
|
||||
| request_count | bigint DEFAULT 0 | |
|
||||
| last_request_date | date | |
|
||||
| popularity_score | decimal(10,4) DEFAULT 0 | |
|
||||
| price_coefficient | decimal(5,2) DEFAULT 0 | markup % |
|
||||
| manual_price | decimal(12,2) | |
|
||||
| meta_prices | varchar(1000) | raw price samples JSON |
|
||||
| meta_method | varchar(20) | method used for last compute |
|
||||
| meta_period_days | bigint DEFAULT 90 | |
|
||||
| is_hidden | tinyint(1) DEFAULT 0 | |
|
||||
|
||||
### qt_partnumber_books
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| version | varchar(30) UNIQUE NOT NULL | |
|
||||
| created_at | timestamp | |
|
||||
| created_by | varchar(100) | |
|
||||
| is_active | tinyint(1) DEFAULT 0 | only one active at a time |
|
||||
| partnumbers_json | longtext DEFAULT '[]' | flat list of partnumbers |
|
||||
|
||||
### qt_partnumber_book_items
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| partnumber | varchar(255) UNIQUE NOT NULL | |
|
||||
| lots_json | longtext NOT NULL | JSON array of lot_names |
|
||||
| description | varchar(10000) | |
|
||||
|
||||
### qt_pricelists
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| source | varchar(20) DEFAULT 'estimate' | 'estimate' / 'warehouse' / 'competitor' |
|
||||
| version | varchar(20) NOT NULL | UNIQUE with source |
|
||||
| created_at | datetime(3) | |
|
||||
| created_by | varchar(100) | |
|
||||
| is_active | tinyint(1) DEFAULT 1 | |
|
||||
| usage_count | bigint DEFAULT 0 | |
|
||||
| expires_at | datetime(3) | |
|
||||
| notification | varchar(500) | shown to clients on sync |
|
||||
|
||||
### qt_pricelist_items
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| pricelist_id | bigint UNSIGNED NOT NULL | FK → qt_pricelists.id |
|
||||
| lot_name | varchar(255) NOT NULL | INDEX with pricelist_id |
|
||||
| lot_category | varchar(50) | |
|
||||
| price | decimal(12,2) NOT NULL | |
|
||||
| price_method | varchar(20) | |
|
||||
| price_period_days | bigint DEFAULT 90 | |
|
||||
| price_coefficient | decimal(5,2) DEFAULT 0 | |
|
||||
| manual_price | decimal(12,2) | |
|
||||
| meta_prices | varchar(1000) | |
|
||||
|
||||
### qt_pricelist_sync_status
|
||||
PK: username
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| username | varchar(100) | |
|
||||
| last_sync_at | datetime NOT NULL | |
|
||||
| updated_at | datetime NOT NULL | |
|
||||
| app_version | varchar(64) | |
|
||||
|
||||
### qt_pricing_alerts
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| lot_name | varchar(255) NOT NULL | |
|
||||
| alert_type | enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price') | |
|
||||
| severity | enum('low','medium','high','critical') DEFAULT 'medium' | |
|
||||
| message | text NOT NULL | |
|
||||
| details | longtext JSON | |
|
||||
| status | enum('new','acknowledged','resolved','ignored') DEFAULT 'new' | |
|
||||
| created_at | datetime(3) | |
|
||||
|
||||
### qt_projects
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| uuid | char(36) UNIQUE NOT NULL | |
|
||||
| owner_username | varchar(100) NOT NULL | |
|
||||
| code | varchar(100) NOT NULL | UNIQUE with variant |
|
||||
| variant | varchar(100) DEFAULT '' | UNIQUE with code |
|
||||
| name | varchar(200) | |
|
||||
| tracker_url | varchar(500) | |
|
||||
| is_active | tinyint(1) DEFAULT 1 | |
|
||||
| is_system | tinyint(1) DEFAULT 0 | |
|
||||
| created_at | timestamp | |
|
||||
| updated_at | timestamp ON UPDATE | |
|
||||
|
||||
### qt_schema_migrations
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| filename | varchar(255) UNIQUE NOT NULL | |
|
||||
| applied_at | datetime(3) | |
|
||||
|
||||
### qt_scheduler_runs
|
||||
PK: job_name
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| job_name | varchar(100) | |
|
||||
| last_started_at | datetime | |
|
||||
| last_finished_at | datetime | |
|
||||
| last_status | varchar(20) DEFAULT 'idle' | |
|
||||
| last_error | text | |
|
||||
| updated_at | timestamp ON UPDATE | |
|
||||
|
||||
### qt_vendor_partnumber_seen
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| source_type | varchar(32) NOT NULL | |
|
||||
| vendor | varchar(255) DEFAULT '' | |
|
||||
| partnumber | varchar(255) UNIQUE NOT NULL | |
|
||||
| description | varchar(10000) | |
|
||||
| last_seen_at | datetime(3) NOT NULL | |
|
||||
| is_ignored | tinyint(1) DEFAULT 0 | |
|
||||
| is_pattern | tinyint(1) DEFAULT 0 | |
|
||||
| ignored_at | datetime(3) | |
|
||||
| ignored_by | varchar(100) | |
|
||||
| created_at | datetime(3) | |
|
||||
| updated_at | datetime(3) | |
|
||||
|
||||
### stock_ignore_rules
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| target | varchar(20) NOT NULL | UNIQUE with match_type+pattern |
|
||||
| match_type | varchar(20) NOT NULL | |
|
||||
| pattern | varchar(500) NOT NULL | |
|
||||
| created_at | timestamp | |
|
||||
|
||||
### stock_log
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| stock_log_id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| partnumber | varchar(255) NOT NULL | INDEX with date |
|
||||
| supplier | varchar(255) | |
|
||||
| date | date NOT NULL | |
|
||||
| price | decimal(12,2) NOT NULL | |
|
||||
| quality | varchar(255) | |
|
||||
| comments | text | |
|
||||
| vendor | varchar(255) | INDEX |
|
||||
| qty | decimal(14,3) | |
|
||||
|
||||
### partnumber_log_competitors
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| competitor_id | bigint UNSIGNED NOT NULL | FK → qt_competitors.id |
|
||||
| partnumber | varchar(255) NOT NULL | |
|
||||
| description | varchar(500) | |
|
||||
| vendor | varchar(255) | |
|
||||
| price | decimal(12,2) NOT NULL | |
|
||||
| price_loccur | decimal(12,2) | local currency price |
|
||||
| currency | varchar(10) | |
|
||||
| qty | decimal(12,4) DEFAULT 1 | |
|
||||
| date | date NOT NULL | |
|
||||
| created_at | timestamp | |
|
||||
|
||||
### Legacy tables (lot / lot_log / machine / machine_log / supplier)
|
||||
|
||||
Retained for historical data only. Not queried by QuoteForge.
|
||||
|
||||
**lot**: lot_name (PK, char 255), lot_category, lot_description
|
||||
**lot_log**: lot_log_id AUTO_INCREMENT, lot (FK→lot), supplier (FK→supplier), date, price double, quality, comments
|
||||
**supplier**: supplier_name (PK, char 255), supplier_comment
|
||||
**machine**: machine_name (PK, char 255), machine_description
|
||||
**machine_log**: machine_log_id AUTO_INCREMENT, date, supplier (FK→supplier), country, opty, type, machine (FK→machine), customer_requirement, variant, price_gpl, price_estimate, qty, quality, carepack, lead_time_weeks, prepayment_percent, price_got, Comment
|
||||
|
||||
## MariaDB User Permissions
|
||||
|
||||
The application user needs read-only access to reference tables and read/write access to runtime tables.
|
||||
|
||||
```sql
|
||||
-- Read-only: reference and pricing data
|
||||
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.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'@'%';
|
||||
|
||||
-- Read/write: runtime sync and user data
|
||||
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_vendor_partnumber_seen TO 'qfs_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `qt_client_schema_state` requires INSERT + UPDATE for sync status tracking (uses `ON DUPLICATE KEY UPDATE`);
|
||||
- `qt_vendor_partnumber_seen` requires INSERT + UPDATE (vendor PN discovery during sync);
|
||||
- no DELETE is needed on sync/tracking tables — rows are never removed by the client;
|
||||
- `lot` SELECT is required for the connection validation probe in `/setup`;
|
||||
- the setup page shows `can_write: true` only when `qt_client_schema_state` INSERT succeeds.
|
||||
- QuoteForge runtime must not depend on any removed legacy BOM tables;
|
||||
- stock enrichment happens during sync and is persisted into SQLite;
|
||||
- normal UI requests must not query MariaDB tables directly.
|
||||
|
||||
## Migrations
|
||||
|
||||
|
||||
@@ -62,64 +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.
|
||||
|
||||
## Text BOM import
|
||||
|
||||
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a human-readable Russian text BOM.
|
||||
|
||||
Format: an optional header line ending with `, в составе:` followed by one component per line as
|
||||
`<description> - <quantity> шт.`. The separator may be a hyphen, en-dash, or em-dash; the space before
|
||||
`шт` is optional; a trailing `.` after `шт` is optional. Quantities are anchored to the end of the line,
|
||||
so hyphens, commas, and digits inside the description (e.g. `8-GPU-2304GB`, `RAID0,1,10`) are preserved.
|
||||
|
||||
Example:
|
||||
```
|
||||
Вычислительный GPU сервер G5500V7, в составе:
|
||||
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
|
||||
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
|
||||
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
|
||||
NVIDIA twin port transceiver, 800Gbps, OSFP - 8шт.
|
||||
```
|
||||
|
||||
Rules:
|
||||
- the entire file becomes a single configuration (`server_count = 1`);
|
||||
- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the
|
||||
last whitespace-separated token before the comma (so both `Сервер X3` and `Вычислительный GPU сервер X3`
|
||||
resolve to `X3`);
|
||||
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
|
||||
- each line is trimmed, so leading/trailing whitespace never enters `vendor_partnumber`;
|
||||
- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and
|
||||
`description`, so rows resolve through the active partnumber book when matched and otherwise stay
|
||||
unresolved and editable in the UI;
|
||||
- lines that do not match `<description> - <quantity> шт.` are skipped;
|
||||
- no price data is present in the format; `unit_price` and `total_price` are left nil.
|
||||
|
||||
## Pasted BOM text parsing
|
||||
|
||||
`POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM
|
||||
(Inspur and Russian text BOM) into rows. Request body: `{"text": "..."}`. Response:
|
||||
`{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`.
|
||||
|
||||
This shares the exact detectors and parsers used by the file-import path
|
||||
(`ParsePastedBOMText` → `IsInspurBOM`/`parseInspurBOM`, `IsTextBOM`/`parseTextBOM`), so paste and upload
|
||||
behave identically — there is no second parser in the frontend. The configurator's BOM paste box calls
|
||||
this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real spreadsheet table)
|
||||
falls back to the manual column-mapping grid.
|
||||
|
||||
@@ -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"
|
||||
@@ -677,9 +676,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
var projectService *services.ProjectService
|
||||
|
||||
syncService = sync.NewService(connMgr, local)
|
||||
componentService := services.NewComponentService(nil, nil)
|
||||
quoteService := services.NewQuoteService(nil, nil, local, nil)
|
||||
exportService := services.NewExportService(cfg.Export, local)
|
||||
componentService := services.NewComponentService(nil, nil, nil)
|
||||
quoteService := services.NewQuoteService(nil, nil, nil, local, nil)
|
||||
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")
|
||||
{
|
||||
@@ -951,9 +946,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
|
||||
}
|
||||
|
||||
// Stateless BOM text parsing shared by paste and file-import paths.
|
||||
api.POST("/vendor-spec/parse-text", vendorSpecHandler.ParseText)
|
||||
|
||||
// Configurations (public - RBAC disabled)
|
||||
configs := api.Group("/configs")
|
||||
{
|
||||
@@ -1134,12 +1126,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
|
||||
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
var req struct {
|
||||
PricelistID *uint `json:"pricelist_id"`
|
||||
}
|
||||
// Ignore bind error — pricelist_id is optional
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
config, err := configService.RefreshPricesNoAuth(uuid, req.PricelistID)
|
||||
config, err := configService.RefreshPricesNoAuth(uuid)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
@@ -1278,7 +1265,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)
|
||||
@@ -1553,8 +1539,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrReservedMainVariant),
|
||||
errors.Is(err, services.ErrCannotRenameMainVariant):
|
||||
case errors.Is(err, services.ErrReservedMainVariant):
|
||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||
@@ -1728,7 +1713,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) && !services.IsTextBOM(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)
|
||||
}
|
||||
|
||||
@@ -238,22 +238,6 @@ func (cm *ConnectionManager) Disconnect() {
|
||||
cm.lastError = nil
|
||||
}
|
||||
|
||||
// MarkOffline closes the current connection and preserves the last observed error.
|
||||
func (cm *ConnectionManager) MarkOffline(err error) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
if cm.db != nil {
|
||||
sqlDB, dbErr := cm.db.DB()
|
||||
if dbErr == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
cm.db = nil
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
}
|
||||
|
||||
// GetLastError returns the last connection error (thread-safe)
|
||||
func (cm *ConnectionManager) GetLastError() error {
|
||||
cm.mu.RLock()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,19 +48,17 @@ type ExportRequest struct {
|
||||
}
|
||||
|
||||
type ProjectExportOptionsRequest struct {
|
||||
IncludeLOT bool `json:"include_lot"`
|
||||
IncludeBOM bool `json:"include_bom"`
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
Basis string `json:"basis"` // "fob" or "ddp"
|
||||
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||
IncludeLOT bool `json:"include_lot"`
|
||||
IncludeBOM bool `json:"include_bom"`
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
}
|
||||
|
||||
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 +66,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 +148,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 +158,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 +204,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 +221,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 +242,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
|
||||
}
|
||||
|
||||
@@ -311,8 +252,6 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||
IncludeEstimate: req.IncludeEstimate,
|
||||
IncludeStock: req.IncludeStock,
|
||||
IncludeCompetitor: req.IncludeCompetitor,
|
||||
Basis: req.Basis,
|
||||
SaleMarkup: req.SaleMarkup,
|
||||
}
|
||||
|
||||
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
|
||||
@@ -321,15 +260,7 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
basisLabel := "FOB"
|
||||
if strings.EqualFold(strings.TrimSpace(req.Basis), "ddp") {
|
||||
basisLabel = "DDP"
|
||||
}
|
||||
variantLabel := strings.TrimSpace(project.Variant)
|
||||
if variantLabel == "" {
|
||||
variantLabel = "main"
|
||||
}
|
||||
filename := fmt.Sprintf("%s (%s) %s %s.csv", time.Now().Format("2006-01-02"), project.Code, basisLabel, variantLabel)
|
||||
filename := fmt.Sprintf("%s (%s) pricing.csv", time.Now().Format("2006-01-02"), project.Code)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
stdsync "sync"
|
||||
"time"
|
||||
|
||||
@@ -50,20 +49,15 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
||||
|
||||
// SyncStatusResponse represents the sync status
|
||||
type SyncStatusResponse struct {
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||||
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||||
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
|
||||
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
|
||||
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_sync"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_sync"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||||
}
|
||||
|
||||
type SyncReadinessResponse struct {
|
||||
@@ -78,34 +72,42 @@ type SyncReadinessResponse struct {
|
||||
// GetStatus returns current sync status
|
||||
// GET /api/sync/status
|
||||
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
connStatus := h.connMgr.GetStatus()
|
||||
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||||
// Check online status by pinging MariaDB
|
||||
isOnline := h.checkOnline()
|
||||
|
||||
// Get sync times
|
||||
lastComponentSync := h.localDB.GetComponentSyncTime()
|
||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||
|
||||
// Get counts
|
||||
componentsCount := h.localDB.CountLocalComponents()
|
||||
pricelistsCount := h.localDB.CountLocalPricelists()
|
||||
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||
|
||||
// Get server pricelist count if online
|
||||
serverPricelists := 0
|
||||
needPricelistSync := false
|
||||
if isOnline {
|
||||
status, err := h.syncService.GetStatus()
|
||||
if err == nil {
|
||||
serverPricelists = status.ServerPricelists
|
||||
needPricelistSync = status.NeedsSync
|
||||
}
|
||||
}
|
||||
|
||||
// Check if component sync is needed (older than 24 hours)
|
||||
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||
readiness := h.getReadinessLocal()
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
|
||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||
LastComponentSync: lastComponentSync,
|
||||
LastPricelistSync: lastPricelistSync,
|
||||
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||
LastPricelistSyncError: lastPricelistSyncError,
|
||||
HasIncompleteServerSync: hasFailedSync,
|
||||
KnownServerChangesMiss: hasFailedSync,
|
||||
IsOnline: isOnline,
|
||||
ComponentsCount: componentsCount,
|
||||
PricelistsCount: pricelistsCount,
|
||||
ServerPricelists: 0,
|
||||
NeedComponentSync: needComponentSync,
|
||||
NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
|
||||
Readiness: readiness,
|
||||
LastComponentSync: lastComponentSync,
|
||||
LastPricelistSync: lastPricelistSync,
|
||||
IsOnline: isOnline,
|
||||
ComponentsCount: componentsCount,
|
||||
PricelistsCount: pricelistsCount,
|
||||
ServerPricelists: serverPricelists,
|
||||
NeedComponentSync: needComponentSync,
|
||||
NeedPricelistSync: needPricelistSync,
|
||||
Readiness: readiness,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,11 +189,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 +199,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 +218,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 +226,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 +233,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 +261,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 +315,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 +325,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 +340,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 +386,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 +419,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
|
||||
@@ -483,13 +474,8 @@ type SyncInfoResponse struct {
|
||||
DBName string `json:"db_name"`
|
||||
|
||||
// Status
|
||||
IsOnline bool `json:"is_online"`
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||||
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||||
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
|
||||
// Statistics
|
||||
LotCount int64 `json:"lot_count"`
|
||||
@@ -525,8 +511,8 @@ type SyncError struct {
|
||||
// GetInfo returns sync information for modal
|
||||
// GET /api/sync/info
|
||||
func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
connStatus := h.connMgr.GetStatus()
|
||||
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||||
// Check online status by pinging MariaDB
|
||||
isOnline := h.checkOnline()
|
||||
|
||||
// Get DB connection info
|
||||
var dbHost, dbUser, dbName string
|
||||
@@ -538,12 +524,6 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
|
||||
// Get sync times
|
||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||
needPricelistSync := lastPricelistSync == nil || hasFailedSync
|
||||
hasIncompleteServerSync := hasFailedSync
|
||||
|
||||
// Get local counts
|
||||
configCount := h.localDB.CountConfigurations()
|
||||
@@ -576,27 +556,22 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
syncErrors = syncErrors[:10]
|
||||
}
|
||||
|
||||
readiness := h.getReadinessLocal()
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
|
||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||
DBHost: dbHost,
|
||||
DBUser: dbUser,
|
||||
DBName: dbName,
|
||||
IsOnline: isOnline,
|
||||
LastSyncAt: lastPricelistSync,
|
||||
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||
LastPricelistSyncError: lastPricelistSyncError,
|
||||
NeedPricelistSync: needPricelistSync,
|
||||
HasIncompleteServerSync: hasIncompleteServerSync,
|
||||
LotCount: componentCount,
|
||||
LotLogCount: pricelistCount,
|
||||
ConfigCount: configCount,
|
||||
ProjectCount: projectCount,
|
||||
PendingChanges: changes,
|
||||
ErrorCount: errorCount,
|
||||
Errors: syncErrors,
|
||||
Readiness: readiness,
|
||||
DBHost: dbHost,
|
||||
DBUser: dbUser,
|
||||
DBName: dbName,
|
||||
IsOnline: isOnline,
|
||||
LastSyncAt: lastPricelistSync,
|
||||
LotCount: componentCount,
|
||||
LotLogCount: pricelistCount,
|
||||
ConfigCount: configCount,
|
||||
ProjectCount: projectCount,
|
||||
PendingChanges: changes,
|
||||
ErrorCount: errorCount,
|
||||
Errors: syncErrors,
|
||||
Readiness: readiness,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -617,6 +592,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)
|
||||
@@ -648,33 +626,15 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||
|
||||
// Get pending count
|
||||
pendingCount := h.localDB.GetPendingCount()
|
||||
readiness := h.getReadinessLocal()
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
isBlocked := readiness != nil && readiness.Blocked
|
||||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||
hasIncompleteServerSync := hasFailedSync
|
||||
|
||||
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
|
||||
|
||||
data := gin.H{
|
||||
"IsOffline": isOffline,
|
||||
"PendingCount": pendingCount,
|
||||
"IsBlocked": isBlocked,
|
||||
"HasFailedSync": hasFailedSync,
|
||||
"HasIncompleteServerSync": hasIncompleteServerSync,
|
||||
"SyncIssueTitle": func() string {
|
||||
if hasIncompleteServerSync {
|
||||
return "Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые не загружены локально."
|
||||
}
|
||||
if hasFailedSync {
|
||||
if lastPricelistSyncError != "" {
|
||||
return lastPricelistSyncError
|
||||
}
|
||||
return "Последняя синхронизация прайслистов завершилась ошибкой."
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
"IsOffline": isOffline,
|
||||
"PendingCount": pendingCount,
|
||||
"IsBlocked": isBlocked,
|
||||
"BlockedReason": func() string {
|
||||
if readiness == nil {
|
||||
return ""
|
||||
@@ -691,36 +651,20 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness {
|
||||
func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness {
|
||||
h.readinessMu.Lock()
|
||||
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < 10*time.Second {
|
||||
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
|
||||
cached := *h.readinessCached
|
||||
h.readinessMu.Unlock()
|
||||
return &cached
|
||||
}
|
||||
h.readinessMu.Unlock()
|
||||
|
||||
state, err := h.localDB.GetSyncGuardState()
|
||||
if err != nil || state == nil {
|
||||
readiness, err := h.syncService.GetReadiness()
|
||||
if err != nil && readiness == nil {
|
||||
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,
|
||||
ReasonCode: state.ReasonCode,
|
||||
ReasonText: state.ReasonText,
|
||||
RequiredMinAppVersion: state.RequiredMinAppVersion,
|
||||
LastCheckedAt: state.LastCheckedAt,
|
||||
}
|
||||
|
||||
h.readinessMu.Lock()
|
||||
h.readinessCached = readiness
|
||||
h.readinessCachedAt = time.Now()
|
||||
@@ -739,7 +683,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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -14,15 +14,11 @@ import (
|
||||
|
||||
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
|
||||
type VendorSpecHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
configService *services.LocalConfigurationService
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
||||
return &VendorSpecHandler{
|
||||
localDB: localDB,
|
||||
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
|
||||
}
|
||||
return &VendorSpecHandler{localDB: localDB}
|
||||
}
|
||||
|
||||
// lookupConfig finds an active configuration by UUID using the standard localDB method.
|
||||
@@ -37,28 +33,6 @@ func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfigurati
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ParseText parses a pasted single-column text BOM (Inspur or Russian text BOM)
|
||||
// using the same parsers as the vendor file-import path. It is stateless: no
|
||||
// configuration is required. Returns the parsed rows and the detected format, or
|
||||
// an empty result when the text is not a recognized single-column format (the
|
||||
// client then falls back to manual column mapping).
|
||||
// POST /api/vendor-spec/parse-text
|
||||
func (h *VendorSpecHandler) ParseText(c *gin.Context) {
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
rows, format := services.ParsePastedBOMText(body.Text)
|
||||
if rows == nil {
|
||||
rows = []localdb.VendorSpecItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rows": rows, "format": format})
|
||||
}
|
||||
|
||||
// GetVendorSpec returns the vendor spec (BOM) for a configuration.
|
||||
// GET /api/configs/:uuid/vendor-spec
|
||||
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
|
||||
@@ -88,7 +62,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
|
||||
}
|
||||
|
||||
@@ -106,7 +80,12 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||
}
|
||||
|
||||
spec := localdb.VendorSpec(body.VendorSpec)
|
||||
if _, err := h.configService.UpdateVendorSpecNoAuth(cfg.UUID, spec); err != nil {
|
||||
specJSON, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
@@ -159,7 +138,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
|
||||
}
|
||||
|
||||
@@ -172,11 +151,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)
|
||||
@@ -206,7 +181,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
|
||||
}
|
||||
|
||||
@@ -219,7 +194,13 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := h.configService.ApplyVendorSpecItemsNoAuth(cfg.UUID, newItems); err != nil {
|
||||
itemsJSON, err := json.Marshal(newItems)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -113,7 +112,6 @@ func NewWebHandler(_ string, localDB *localdb.LocalDB) (*WebHandler, error) {
|
||||
}
|
||||
|
||||
func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
||||
data["AppVersion"] = appmeta.Version()
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl, ok := h.templates[name]
|
||||
if !ok {
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,60 +95,3 @@ func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
|
||||
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationFingerprintIncludesPricingSelectorsAndVendorSpec(t *testing.T) {
|
||||
estimateID := uint(11)
|
||||
warehouseID := uint(22)
|
||||
competitorID := uint(33)
|
||||
|
||||
base := &LocalConfiguration{
|
||||
UUID: "cfg-1",
|
||||
Name: "Config",
|
||||
ServerCount: 1,
|
||||
Items: LocalConfigItems{{LotName: "LOT_A", Quantity: 1, UnitPrice: 100}},
|
||||
PricelistID: &estimateID,
|
||||
WarehousePricelistID: &warehouseID,
|
||||
CompetitorPricelistID: &competitorID,
|
||||
DisablePriceRefresh: true,
|
||||
OnlyInStock: true,
|
||||
VendorSpec: VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "PN-1",
|
||||
Quantity: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
baseFingerprint, err := BuildConfigurationSpecPriceFingerprint(base)
|
||||
if err != nil {
|
||||
t.Fatalf("base fingerprint: %v", err)
|
||||
}
|
||||
|
||||
changedPricelist := *base
|
||||
newEstimateID := uint(44)
|
||||
changedPricelist.PricelistID = &newEstimateID
|
||||
pricelistFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedPricelist)
|
||||
if err != nil {
|
||||
t.Fatalf("pricelist fingerprint: %v", err)
|
||||
}
|
||||
if pricelistFingerprint == baseFingerprint {
|
||||
t.Fatalf("expected pricelist selector to affect fingerprint")
|
||||
}
|
||||
|
||||
changedVendorSpec := *base
|
||||
changedVendorSpec.VendorSpec = VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "PN-2",
|
||||
Quantity: 1,
|
||||
},
|
||||
}
|
||||
vendorFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedVendorSpec)
|
||||
if err != nil {
|
||||
t.Fatalf("vendor fingerprint: %v", err)
|
||||
}
|
||||
if vendorFingerprint == baseFingerprint {
|
||||
t.Fatalf("expected vendor spec to affect fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -116,14 +116,6 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
||||
}
|
||||
|
||||
// Enable WAL mode so background sync writes never block UI reads.
|
||||
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
|
||||
slog.Warn("failed to enable WAL mode", "error", err)
|
||||
}
|
||||
if err := db.Exec("PRAGMA synchronous=NORMAL").Error; err != nil {
|
||||
slog.Warn("failed to set synchronous=NORMAL", "error", err)
|
||||
}
|
||||
|
||||
if err := ensureLocalProjectsTable(db); err != nil {
|
||||
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
||||
}
|
||||
@@ -229,7 +221,6 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
|
||||
&LocalSyncGuardState{},
|
||||
&PendingChange{},
|
||||
&LocalPartnumberBook{},
|
||||
&SyncLogEntry{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -497,10 +488,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 +1035,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
|
||||
}
|
||||
|
||||
@@ -1082,26 +1066,6 @@ func (l *LocalDB) GetLastSyncTime() *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
func (l *LocalDB) getAppSettingValue(key string) (string, bool) {
|
||||
var setting struct {
|
||||
Value string
|
||||
}
|
||||
if err := l.db.Table("app_settings").
|
||||
Where("key = ?", key).
|
||||
First(&setting).Error; err != nil {
|
||||
return "", false
|
||||
}
|
||||
return setting.Value, true
|
||||
}
|
||||
|
||||
func (l *LocalDB) upsertAppSetting(tx *gorm.DB, key, value string, updatedAt time.Time) error {
|
||||
return tx.Exec(`
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
`, key, value, updatedAt.Format(time.RFC3339)).Error
|
||||
}
|
||||
|
||||
// SetLastSyncTime sets the last sync timestamp
|
||||
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
||||
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
|
||||
@@ -1112,134 +1076,6 @@ func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
||||
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetLastPricelistSyncAttemptAt() *time.Time {
|
||||
value, ok := l.getAppSettingValue("last_pricelist_sync_attempt_at")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetLastPricelistSyncStatus() string {
|
||||
value, ok := l.getAppSettingValue("last_pricelist_sync_status")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetLastPricelistSyncError() string {
|
||||
value, ok := l.getAppSettingValue("last_pricelist_sync_error")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func (l *LocalDB) SetPricelistSyncResult(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_pricelist_sync_status", status, attemptedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := l.upsertAppSetting(tx, "last_pricelist_sync_error", errorText, attemptedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := l.upsertAppSetting(tx, "last_pricelist_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1247,29 +1083,6 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
return count
|
||||
}
|
||||
|
||||
// CountAllPricelistItems returns total rows across all local_pricelist_items.
|
||||
func (l *LocalDB) CountAllPricelistItems() int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalPricelistItem{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// CountComponents returns the number of rows in local_components.
|
||||
func (l *LocalDB) CountComponents() int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalComponent{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// DBFileSizeBytes returns the size of the SQLite database file in bytes.
|
||||
func (l *LocalDB) DBFileSizeBytes() int64 {
|
||||
info, err := os.Stat(l.path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
@@ -1437,32 +1250,6 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
|
||||
return item.Price, nil
|
||||
}
|
||||
|
||||
// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query.
|
||||
// Uses the composite index (pricelist_id, lot_name). Missing lots are omitted from the result.
|
||||
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
|
||||
result := make(map[string]float64, len(lotNames))
|
||||
if len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
type row struct {
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
var rows []row
|
||||
if err := l.db.Model(&LocalPricelistItem{}).
|
||||
Select("lot_name, price").
|
||||
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Price > 0 {
|
||||
result[r.LotName] = r.Price
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||
@@ -1826,62 +1613,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
|
||||
|
||||
|
||||
@@ -112,16 +112,10 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
}
|
||||
|
||||
type configurationSpecPriceFingerprint struct {
|
||||
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
||||
ServerCount int `json:"server_count"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
CustomPrice *float64 `json:"custom_price,omitempty"`
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
|
||||
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
VendorSpec VendorSpec `json:"vendor_spec,omitempty"`
|
||||
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
||||
ServerCount int `json:"server_count"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
CustomPrice *float64 `json:"custom_price,omitempty"`
|
||||
}
|
||||
|
||||
type configurationSpecPriceFingerprintItem struct {
|
||||
@@ -152,16 +146,10 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin
|
||||
})
|
||||
|
||||
payload := configurationSpecPriceFingerprint{
|
||||
Items: items,
|
||||
ServerCount: localCfg.ServerCount,
|
||||
TotalPrice: localCfg.TotalPrice,
|
||||
CustomPrice: localCfg.CustomPrice,
|
||||
PricelistID: localCfg.PricelistID,
|
||||
WarehousePricelistID: localCfg.WarehousePricelistID,
|
||||
CompetitorPricelistID: localCfg.CompetitorPricelistID,
|
||||
DisablePriceRefresh: localCfg.DisablePriceRefresh,
|
||||
OnlyInStock: localCfg.OnlyInStock,
|
||||
VendorSpec: localCfg.VendorSpec,
|
||||
Items: items,
|
||||
ServerCount: localCfg.ServerCount,
|
||||
TotalPrice: localCfg.TotalPrice,
|
||||
CustomPrice: localCfg.CustomPrice,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(payload)
|
||||
|
||||
93
internal/models/alert.go
Normal file
93
internal/models/alert.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AlertType string
|
||||
|
||||
const (
|
||||
AlertHighDemandStalePrice AlertType = "high_demand_stale_price"
|
||||
AlertPriceSpike AlertType = "price_spike"
|
||||
AlertPriceDrop AlertType = "price_drop"
|
||||
AlertNoRecentQuotes AlertType = "no_recent_quotes"
|
||||
AlertTrendingNoPrice AlertType = "trending_no_price"
|
||||
)
|
||||
|
||||
type AlertSeverity string
|
||||
|
||||
const (
|
||||
SeverityLow AlertSeverity = "low"
|
||||
SeverityMedium AlertSeverity = "medium"
|
||||
SeverityHigh AlertSeverity = "high"
|
||||
SeverityCritical AlertSeverity = "critical"
|
||||
)
|
||||
|
||||
type AlertStatus string
|
||||
|
||||
const (
|
||||
AlertStatusNew AlertStatus = "new"
|
||||
AlertStatusAcknowledged AlertStatus = "acknowledged"
|
||||
AlertStatusResolved AlertStatus = "resolved"
|
||||
AlertStatusIgnored AlertStatus = "ignored"
|
||||
)
|
||||
|
||||
type AlertDetails map[string]interface{}
|
||||
|
||||
func (d AlertDetails) Value() (driver.Value, error) {
|
||||
return json.Marshal(d)
|
||||
}
|
||||
|
||||
func (d *AlertDetails) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*d = make(AlertDetails)
|
||||
return nil
|
||||
}
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New("type assertion to []byte failed")
|
||||
}
|
||||
return json.Unmarshal(bytes, d)
|
||||
}
|
||||
|
||||
type PricingAlert struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
LotName string `gorm:"size:255;not null" json:"lot_name"`
|
||||
AlertType AlertType `gorm:"type:enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price');not null" json:"alert_type"`
|
||||
Severity AlertSeverity `gorm:"type:enum('low','medium','high','critical');default:'medium'" json:"severity"`
|
||||
Message string `gorm:"type:text;not null" json:"message"`
|
||||
Details AlertDetails `gorm:"type:json" json:"details"`
|
||||
Status AlertStatus `gorm:"type:enum('new','acknowledged','resolved','ignored');default:'new'" json:"status"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (PricingAlert) TableName() string {
|
||||
return "qt_pricing_alerts"
|
||||
}
|
||||
|
||||
type TrendDirection string
|
||||
|
||||
const (
|
||||
TrendUp TrendDirection = "up"
|
||||
TrendStable TrendDirection = "stable"
|
||||
TrendDown TrendDirection = "down"
|
||||
)
|
||||
|
||||
type ComponentUsageStats struct {
|
||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||
QuotesTotal int `gorm:"default:0" json:"quotes_total"`
|
||||
QuotesLast30d int `gorm:"default:0" json:"quotes_last_30d"`
|
||||
QuotesLast7d int `gorm:"default:0" json:"quotes_last_7d"`
|
||||
TotalQuantity int `gorm:"default:0" json:"total_quantity"`
|
||||
TotalRevenue float64 `gorm:"type:decimal(14,2);default:0" json:"total_revenue"`
|
||||
TrendDirection TrendDirection `gorm:"type:enum('up','stable','down');default:'stable'" json:"trend_direction"`
|
||||
TrendPercent float64 `gorm:"type:decimal(5,2);default:0" json:"trend_percent"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
}
|
||||
|
||||
func (ComponentUsageStats) TableName() string {
|
||||
return "qt_component_usage_stats"
|
||||
}
|
||||
@@ -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"`
|
||||
@@ -124,3 +123,16 @@ func (Configuration) TableName() string {
|
||||
return "qt_configurations"
|
||||
}
|
||||
|
||||
type PriceOverride struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
LotName string `gorm:"size:255;not null" json:"lot_name"`
|
||||
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
||||
ValidFrom time.Time `gorm:"type:date;not null" json:"valid_from"`
|
||||
ValidUntil *time.Time `gorm:"type:date" json:"valid_until"`
|
||||
Reason string `gorm:"type:text" json:"reason"`
|
||||
CreatedBy uint `gorm:"not null" json:"created_by"`
|
||||
}
|
||||
|
||||
func (PriceOverride) TableName() string {
|
||||
return "qt_price_overrides"
|
||||
}
|
||||
|
||||
@@ -1,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"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ func AllModels() []interface{} {
|
||||
&LotMetadata{},
|
||||
&Project{},
|
||||
&Configuration{},
|
||||
&PriceOverride{},
|
||||
&PricingAlert{},
|
||||
&ComponentUsageStats{},
|
||||
&Pricelist{},
|
||||
&PricelistItem{},
|
||||
}
|
||||
@@ -28,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
|
||||
}
|
||||
@@ -40,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
|
||||
|
||||
91
internal/repository/alert.go
Normal file
91
internal/repository/alert.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AlertRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAlertRepository(db *gorm.DB) *AlertRepository {
|
||||
return &AlertRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *AlertRepository) Create(alert *models.PricingAlert) error {
|
||||
return r.db.Create(alert).Error
|
||||
}
|
||||
|
||||
func (r *AlertRepository) GetByID(id uint) (*models.PricingAlert, error) {
|
||||
var alert models.PricingAlert
|
||||
err := r.db.First(&alert, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &alert, nil
|
||||
}
|
||||
|
||||
func (r *AlertRepository) Update(alert *models.PricingAlert) error {
|
||||
return r.db.Save(alert).Error
|
||||
}
|
||||
|
||||
type AlertFilter struct {
|
||||
Status models.AlertStatus
|
||||
Severity models.AlertSeverity
|
||||
Type models.AlertType
|
||||
LotName string
|
||||
}
|
||||
|
||||
func (r *AlertRepository) List(filter AlertFilter, offset, limit int) ([]models.PricingAlert, int64, error) {
|
||||
var alerts []models.PricingAlert
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.PricingAlert{})
|
||||
|
||||
if filter.Status != "" {
|
||||
query = query.Where("status = ?", filter.Status)
|
||||
}
|
||||
if filter.Severity != "" {
|
||||
query = query.Where("severity = ?", filter.Severity)
|
||||
}
|
||||
if filter.Type != "" {
|
||||
query = query.Where("alert_type = ?", filter.Type)
|
||||
}
|
||||
if filter.LotName != "" {
|
||||
query = query.Where("lot_name = ?", filter.LotName)
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
|
||||
err := query.
|
||||
Order("FIELD(severity, 'critical', 'high', 'medium', 'low')").
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&alerts).Error
|
||||
|
||||
return alerts, total, err
|
||||
}
|
||||
|
||||
func (r *AlertRepository) CountByStatus(status models.AlertStatus) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.PricingAlert{}).
|
||||
Where("status = ?", status).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *AlertRepository) UpdateStatus(id uint, status models.AlertStatus) error {
|
||||
return r.db.Model(&models.PricingAlert{}).
|
||||
Where("id = ?", id).
|
||||
Update("status", status).Error
|
||||
}
|
||||
|
||||
func (r *AlertRepository) ExistsByLotAndType(lotName string, alertType models.AlertType) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.PricingAlert{}).
|
||||
Where("lot_name = ? AND alert_type = ? AND status IN ('new', 'acknowledged')", lotName, alertType).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
115
internal/repository/stats.go
Normal file
115
internal/repository/stats.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type StatsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewStatsRepository(db *gorm.DB) *StatsRepository {
|
||||
return &StatsRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *StatsRepository) GetByLotName(lotName string) (*models.ComponentUsageStats, error) {
|
||||
var stats models.ComponentUsageStats
|
||||
err := r.db.Where("lot_name = ?", lotName).First(&stats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
func (r *StatsRepository) Upsert(stats *models.ComponentUsageStats) error {
|
||||
return r.db.Save(stats).Error
|
||||
}
|
||||
|
||||
func (r *StatsRepository) IncrementUsage(lotName string, quantity int, revenue float64) error {
|
||||
now := time.Now()
|
||||
|
||||
result := r.db.Model(&models.ComponentUsageStats{}).
|
||||
Where("lot_name = ?", lotName).
|
||||
Updates(map[string]interface{}{
|
||||
"quotes_total": gorm.Expr("quotes_total + 1"),
|
||||
"quotes_last_30d": gorm.Expr("quotes_last_30d + 1"),
|
||||
"quotes_last_7d": gorm.Expr("quotes_last_7d + 1"),
|
||||
"total_quantity": gorm.Expr("total_quantity + ?", quantity),
|
||||
"total_revenue": gorm.Expr("total_revenue + ?", revenue),
|
||||
"last_used_at": now,
|
||||
})
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
stats := &models.ComponentUsageStats{
|
||||
LotName: lotName,
|
||||
QuotesTotal: 1,
|
||||
QuotesLast30d: 1,
|
||||
QuotesLast7d: 1,
|
||||
TotalQuantity: quantity,
|
||||
TotalRevenue: revenue,
|
||||
LastUsedAt: &now,
|
||||
}
|
||||
return r.db.Create(stats).Error
|
||||
}
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func (r *StatsRepository) GetTopComponents(limit int) ([]models.ComponentUsageStats, error) {
|
||||
var stats []models.ComponentUsageStats
|
||||
err := r.db.
|
||||
Order("quotes_last_30d DESC").
|
||||
Limit(limit).
|
||||
Find(&stats).Error
|
||||
return stats, err
|
||||
}
|
||||
|
||||
func (r *StatsRepository) GetTrendingComponents(limit int) ([]models.ComponentUsageStats, error) {
|
||||
var stats []models.ComponentUsageStats
|
||||
err := r.db.
|
||||
Where("trend_direction = ? AND trend_percent > ?", models.TrendUp, 20).
|
||||
Order("trend_percent DESC").
|
||||
Limit(limit).
|
||||
Find(&stats).Error
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// ResetWeeklyCounters resets quotes_last_7d (run weekly via cron)
|
||||
func (r *StatsRepository) ResetWeeklyCounters() error {
|
||||
return r.db.Model(&models.ComponentUsageStats{}).
|
||||
Where("1 = 1").
|
||||
Update("quotes_last_7d", 0).Error
|
||||
}
|
||||
|
||||
// ResetMonthlyCounters resets quotes_last_30d (run monthly via cron)
|
||||
func (r *StatsRepository) ResetMonthlyCounters() error {
|
||||
return r.db.Model(&models.ComponentUsageStats{}).
|
||||
Where("1 = 1").
|
||||
Update("quotes_last_30d", 0).Error
|
||||
}
|
||||
|
||||
// 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"
|
||||
@@ -12,15 +11,18 @@ import (
|
||||
type ComponentService struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
categoryRepo *repository.CategoryRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
}
|
||||
|
||||
func NewComponentService(
|
||||
componentRepo *repository.ComponentRepository,
|
||||
categoryRepo *repository.CategoryRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
) *ComponentService {
|
||||
return &ComponentService{
|
||||
componentRepo: componentRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
statsRepo: statsRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,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 {
|
||||
@@ -62,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
|
||||
}
|
||||
|
||||
@@ -107,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
|
||||
}
|
||||
|
||||
@@ -131,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,
|
||||
|
||||
@@ -18,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 {
|
||||
@@ -59,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"`
|
||||
}
|
||||
@@ -105,18 +103,17 @@ 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
|
||||
_ = 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,24 +56,11 @@ type ProjectExportData struct {
|
||||
}
|
||||
|
||||
type ProjectPricingExportOptions struct {
|
||||
IncludeLOT bool `json:"include_lot"`
|
||||
IncludeBOM bool `json:"include_bom"`
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
|
||||
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||
}
|
||||
|
||||
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
|
||||
if o.SaleMarkup > 0 {
|
||||
return o.SaleMarkup
|
||||
}
|
||||
return 1.3
|
||||
}
|
||||
|
||||
func (o ProjectPricingExportOptions) isDDP() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(o.Basis), "ddp")
|
||||
IncludeLOT bool `json:"include_lot"`
|
||||
IncludeBOM bool `json:"include_bom"`
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
}
|
||||
|
||||
type ProjectPricingExportData struct {
|
||||
@@ -124,7 +114,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 +200,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 {
|
||||
@@ -255,16 +251,18 @@ func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData
|
||||
return fmt.Errorf("failed to write pricing header: %w", err)
|
||||
}
|
||||
|
||||
writeRows := opts.IncludeLOT || opts.IncludeBOM
|
||||
for _, cfg := range data.Configs {
|
||||
for idx, cfg := range data.Configs {
|
||||
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
|
||||
return fmt.Errorf("failed to write config summary row: %w", err)
|
||||
}
|
||||
if writeRows {
|
||||
for _, row := range cfg.Rows {
|
||||
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
||||
return fmt.Errorf("failed to write pricing row: %w", err)
|
||||
}
|
||||
for _, row := range cfg.Rows {
|
||||
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
||||
return fmt.Errorf("failed to write pricing row: %w", err)
|
||||
}
|
||||
}
|
||||
if idx < len(data.Configs)-1 {
|
||||
if err := csvWriter.Write([]string{}); err != nil {
|
||||
return fmt.Errorf("failed to write separator row: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,7 +285,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 +316,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))
|
||||
@@ -419,9 +424,6 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
||||
})
|
||||
}
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
@@ -441,29 +443,9 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
})
|
||||
}
|
||||
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||
}
|
||||
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
|
||||
for i := range rows {
|
||||
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
|
||||
rows[i].Stock = scaleFloatPtr(rows[i].Stock, factor)
|
||||
rows[i].Competitor = scaleFloatPtr(rows[i].Competitor, factor)
|
||||
}
|
||||
}
|
||||
|
||||
func scaleFloatPtr(v *float64, factor float64) *float64 {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
result := *v * factor
|
||||
return &result
|
||||
}
|
||||
|
||||
// resolveCategories returns lot_name → category map.
|
||||
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
||||
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
||||
@@ -504,30 +486,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 +539,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 {
|
||||
@@ -770,7 +735,7 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
|
||||
record = append(record, "")
|
||||
}
|
||||
record = append(record,
|
||||
emptyDash(cfg.Article),
|
||||
"",
|
||||
emptyDash(cfg.Name),
|
||||
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -50,13 +49,11 @@ func NewLocalConfigurationService(
|
||||
|
||||
// Create creates a new configuration in local SQLite and queues it for sync
|
||||
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
// If online, trigger pricelist sync in the background — do not block config creation
|
||||
// If online, check for new pricelists first
|
||||
if s.isOnline() {
|
||||
go func() {
|
||||
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||
// Log but don't fail - we can still use local pricelists
|
||||
}
|
||||
}()
|
||||
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||
// Log but don't fail - we can still use local pricelists
|
||||
}
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
@@ -102,14 +99,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,6 +112,9 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
}
|
||||
cfg.Line = localCfg.Line
|
||||
|
||||
// Record usage stats
|
||||
_ = s.quoteService.RecordUsage(req.Items)
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -403,31 +399,17 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
// Refresh local pricelists when online.
|
||||
// Refresh local pricelists when online and use latest active/local pricelist for recalculation.
|
||||
if s.isOnline() {
|
||||
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||
slog.Warn("local configuration: background pricelist sync failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Use the pricelist stored in the config; fall back to latest if unavailable.
|
||||
var pricelist *localdb.LocalPricelist
|
||||
if localCfg.PricelistID != nil && *localCfg.PricelistID > 0 {
|
||||
if pl, err := s.localDB.GetLocalPricelistByServerID(*localCfg.PricelistID); err == nil {
|
||||
pricelist = pl
|
||||
}
|
||||
}
|
||||
if pricelist == nil {
|
||||
if pl, err := s.localDB.GetLatestLocalPricelist(); err == nil {
|
||||
pricelist = pl
|
||||
}
|
||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
||||
}
|
||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
||||
|
||||
// Update prices for all items from pricelist
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
if pricelist != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName)
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
||||
if err == nil && price > 0 {
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
@@ -452,8 +434,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
}
|
||||
|
||||
localCfg.TotalPrice = &total
|
||||
if pricelist != nil {
|
||||
localCfg.PricelistID = &pricelist.ServerID
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
localCfg.PricelistID = &latestPricelist.ServerID
|
||||
}
|
||||
|
||||
// Set price update timestamp and mark for sync
|
||||
@@ -780,10 +762,8 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
|
||||
return templates[start:end], total, nil
|
||||
}
|
||||
|
||||
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check.
|
||||
// pricelistServerID optionally specifies which pricelist to use; if nil, the config's stored
|
||||
// pricelist is used; if that is also absent, the latest local pricelist is used as a fallback.
|
||||
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistServerID *uint) (*models.Configuration, error) {
|
||||
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check
|
||||
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||
// Get configuration from local SQLite
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
@@ -791,40 +771,15 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve which pricelist to use:
|
||||
// 1. Explicitly requested pricelist (from UI selection)
|
||||
// 2. Pricelist stored in the configuration
|
||||
// 3. Latest local pricelist as last-resort fallback
|
||||
var targetServerID *uint
|
||||
if pricelistServerID != nil && *pricelistServerID > 0 {
|
||||
targetServerID = pricelistServerID
|
||||
} else if localCfg.PricelistID != nil && *localCfg.PricelistID > 0 {
|
||||
targetServerID = localCfg.PricelistID
|
||||
}
|
||||
|
||||
var pricelist *localdb.LocalPricelist
|
||||
if targetServerID != nil {
|
||||
if pl, err := s.localDB.GetLocalPricelistByServerID(*targetServerID); err == nil {
|
||||
pricelist = pl
|
||||
}
|
||||
}
|
||||
if pricelist == nil {
|
||||
// Fallback: use latest local pricelist
|
||||
if pl, err := s.localDB.GetLatestLocalPricelist(); err == nil {
|
||||
pricelist = pl
|
||||
}
|
||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
||||
}
|
||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
||||
|
||||
// Update prices for all items from pricelist
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
if pricelist != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName)
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
||||
if err == nil && price > 0 {
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
@@ -849,8 +804,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
|
||||
}
|
||||
|
||||
localCfg.TotalPrice = &total
|
||||
if pricelist != nil {
|
||||
localCfg.PricelistID = &pricelist.ServerID
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
localCfg.PricelistID = &latestPricelist.ServerID
|
||||
}
|
||||
|
||||
// Set price update timestamp and mark for sync
|
||||
@@ -1250,55 +1205,21 @@ func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, nex
|
||||
current.ServerModel != next.ServerModel ||
|
||||
current.SupportCode != next.SupportCode ||
|
||||
current.Article != next.Article ||
|
||||
current.DisablePriceRefresh != next.DisablePriceRefresh ||
|
||||
current.OnlyInStock != next.OnlyInStock ||
|
||||
current.IsActive != next.IsActive ||
|
||||
current.Line != next.Line {
|
||||
return true
|
||||
}
|
||||
if !equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
|
||||
if !equalUintPtr(current.PricelistID, next.PricelistID) ||
|
||||
!equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) ||
|
||||
!equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) ||
|
||||
!equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) UpdateVendorSpecNoAuth(uuid string, spec localdb.VendorSpec) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
localCfg.VendorSpec = spec
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update vendor spec without auth with version: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) ApplyVendorSpecItemsNoAuth(uuid string, items localdb.LocalConfigItems) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
localCfg.Items = items
|
||||
total := items.Total()
|
||||
if localCfg.ServerCount > 1 {
|
||||
total *= float64(localCfg.ServerCount)
|
||||
}
|
||||
localCfg.TotalPrice = &total
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply vendor spec items without auth with version: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func equalStringPtr(a, b *string) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
|
||||
@@ -137,77 +137,6 @@ func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNoAuthCreatesRevisionWhenPricingSettingsChanged(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "pricing",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "pricing",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
DisablePriceRefresh: true,
|
||||
OnlyInStock: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("update pricing settings: %v", err)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions after pricing settings change, got %d", len(versions))
|
||||
}
|
||||
if versions[1].VersionNo != 2 {
|
||||
t.Fatalf("expected latest version_no=2, got %d", versions[1].VersionNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateVendorSpecNoAuthCreatesRevision(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "bom",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
spec := localdb.VendorSpec{
|
||||
{
|
||||
VendorPartnumber: "PN-001",
|
||||
Quantity: 2,
|
||||
SortOrder: 10,
|
||||
LotMappings: []localdb.VendorSpecLotMapping{
|
||||
{LotName: "CPU_A", QuantityPerPN: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := service.UpdateVendorSpecNoAuth(created.UUID, spec); err != nil {
|
||||
t.Fatalf("update vendor spec: %v", err)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions after vendor spec change, got %d", len(versions))
|
||||
}
|
||||
|
||||
cfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("load config after vendor spec update: %v", err)
|
||||
}
|
||||
if len(cfg.VendorSpec) != 1 || cfg.VendorSpec[0].VendorPartnumber != "PN-001" {
|
||||
t.Fatalf("expected saved vendor spec, got %+v", cfg.VendorSpec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ var (
|
||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||
)
|
||||
|
||||
type ProjectService struct {
|
||||
@@ -109,12 +108,7 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
localProject.Code = code
|
||||
}
|
||||
if req.Variant != nil {
|
||||
newVariant := strings.TrimSpace(*req.Variant)
|
||||
// Block renaming of the main variant (empty Variant) — there must always be a main.
|
||||
if strings.TrimSpace(localProject.Variant) == "" && newVariant != "" {
|
||||
return nil, ErrCannotRenameMainVariant
|
||||
}
|
||||
localProject.Variant = newVariant
|
||||
localProject.Variant = strings.TrimSpace(*req.Variant)
|
||||
if err := validateProjectVariantName(localProject.Variant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ var (
|
||||
|
||||
type QuoteService struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
localDB *localdb.LocalDB
|
||||
pricingService priceResolver
|
||||
@@ -33,12 +34,14 @@ type priceResolver interface {
|
||||
|
||||
func NewQuoteService(
|
||||
componentRepo *repository.ComponentRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
pricelistRepo *repository.PricelistRepository,
|
||||
localDB *localdb.LocalDB,
|
||||
pricingService priceResolver,
|
||||
) *QuoteService {
|
||||
return &QuoteService{
|
||||
componentRepo: componentRepo,
|
||||
statsRepo: statsRepo,
|
||||
pricelistRepo: pricelistRepo,
|
||||
localDB: localDB,
|
||||
pricingService: pricingService,
|
||||
@@ -385,14 +388,13 @@ func (s *QuoteService) lookupPricesByPricelistID(pricelistID uint, lotNames []st
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback path (usually offline): batch local lookup (single query via index).
|
||||
// Fallback path (usually offline): local per-lot lookup.
|
||||
if s.localDB != nil {
|
||||
if localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID); err == nil {
|
||||
if batchPrices, err := s.localDB.GetLocalPricesForLots(localPL.ID, missing); err == nil {
|
||||
for lotName, price := range batchPrices {
|
||||
result[lotName] = price
|
||||
loaded[lotName] = price
|
||||
}
|
||||
for _, lotName := range missing {
|
||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
||||
if found && price > 0 {
|
||||
result[lotName] = price
|
||||
loaded[lotName] = price
|
||||
}
|
||||
}
|
||||
s.updateCache(pricelistID, missing, loaded)
|
||||
@@ -501,3 +503,18 @@ func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// RecordUsage records that components were used in a quote
|
||||
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
|
||||
if s.statsRepo == nil {
|
||||
// Offline mode: usage stats are unavailable and should not block config saves.
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
revenue := item.UnitPrice * float64(item.Quantity)
|
||||
if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
||||
db := newPriceLevelsTestDB(t)
|
||||
repo := repository.NewPricelistRepository(db)
|
||||
service := NewQuoteService(nil, repo, nil, nil)
|
||||
service := NewQuoteService(nil, nil, repo, nil, nil)
|
||||
|
||||
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
|
||||
_ = estimate
|
||||
@@ -57,7 +57,7 @@ func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
||||
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
|
||||
db := newPriceLevelsTestDB(t)
|
||||
repo := repository.NewPricelistRepository(db)
|
||||
service := NewQuoteService(nil, repo, nil, nil)
|
||||
service := NewQuoteService(nil, nil, repo, nil, nil)
|
||||
|
||||
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
|
||||
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
|
||||
|
||||
@@ -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,35 @@ 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",
|
||||
} {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -198,17 +177,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 +193,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
|
||||
@@ -244,10 +215,6 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
|
||||
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
|
||||
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
||||
localPricelistCount := s.localDB.CountLocalPricelists()
|
||||
pricelistItemsCount := s.localDB.CountAllPricelistItems()
|
||||
componentsCount := s.localDB.CountComponents()
|
||||
dbSizeBytes := s.localDB.DBFileSizeBytes()
|
||||
return mariaDB.Exec(`
|
||||
INSERT INTO qt_client_schema_state (
|
||||
username, hostname, app_version,
|
||||
@@ -255,10 +222,9 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
configurations_count, projects_count,
|
||||
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
|
||||
last_sync_error_code, last_sync_error_text,
|
||||
local_pricelist_count, pricelist_items_count, components_count, db_size_bytes,
|
||||
last_checked_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
app_version = VALUES(app_version),
|
||||
last_sync_at = VALUES(last_sync_at),
|
||||
@@ -272,10 +238,6 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
competitor_pricelist_version = VALUES(competitor_pricelist_version),
|
||||
last_sync_error_code = VALUES(last_sync_error_code),
|
||||
last_sync_error_text = VALUES(last_sync_error_text),
|
||||
local_pricelist_count = VALUES(local_pricelist_count),
|
||||
pricelist_items_count = VALUES(pricelist_items_count),
|
||||
components_count = VALUES(components_count),
|
||||
db_size_bytes = VALUES(db_size_bytes),
|
||||
last_checked_at = VALUES(last_checked_at),
|
||||
updated_at = VALUES(updated_at)
|
||||
`, username, hostname, appmeta.Version(),
|
||||
@@ -283,7 +245,6 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
configurationsCount, projectsCount,
|
||||
estimateVersion, warehouseVersion, competitorVersion,
|
||||
lastSyncErrorCode, lastSyncErrorText,
|
||||
localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes,
|
||||
checkedAt, checkedAt).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
@@ -17,18 +16,15 @@ 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")
|
||||
|
||||
// Service handles synchronization between MariaDB and local SQLite
|
||||
type Service struct {
|
||||
connMgr *db.ConnectionManager
|
||||
localDB *localdb.LocalDB
|
||||
directDB *gorm.DB
|
||||
pricelistMu sync.Mutex // prevents concurrent pricelist syncs
|
||||
schemaOnce sync.Once // ensures ensureClientSchemaStateTable runs at most once per process
|
||||
connMgr *db.ConnectionManager
|
||||
localDB *localdb.LocalDB
|
||||
directDB *gorm.DB
|
||||
}
|
||||
|
||||
// NewService creates a new sync service
|
||||
@@ -49,15 +45,10 @@ func NewServiceWithDB(mariaDB *gorm.DB, localDB *localdb.LocalDB) *Service {
|
||||
|
||||
// SyncStatus represents the current sync status
|
||||
type SyncStatus struct {
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
|
||||
LastSyncStatus string `json:"last_sync_status,omitempty"`
|
||||
LastSyncError string `json:"last_sync_error,omitempty"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
LocalPricelists int `json:"local_pricelists"`
|
||||
NeedsSync bool `json:"needs_sync"`
|
||||
IncompleteServerSync bool `json:"incomplete_server_sync"`
|
||||
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
LocalPricelists int `json:"local_pricelists"`
|
||||
NeedsSync bool `json:"needs_sync"`
|
||||
}
|
||||
|
||||
type UserSyncStatus struct {
|
||||
@@ -249,23 +240,30 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
||||
// GetStatus returns the current sync status
|
||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
lastAttempt := s.localDB.GetLastPricelistSyncAttemptAt()
|
||||
lastSyncStatus := s.localDB.GetLastPricelistSyncStatus()
|
||||
lastSyncError := s.localDB.GetLastPricelistSyncError()
|
||||
|
||||
// Count server pricelists (only if already connected, don't reconnect)
|
||||
serverCount := 0
|
||||
connStatus := s.getConnectionStatus()
|
||||
if connStatus.IsConnected {
|
||||
if mariaDB, err := s.getDB(); err == nil && mariaDB != nil {
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
activeCount, err := pricelistRepo.CountActive()
|
||||
if err == nil {
|
||||
serverCount = int(activeCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count local pricelists
|
||||
localCount := s.localDB.CountLocalPricelists()
|
||||
hasFailedSync := strings.EqualFold(lastSyncStatus, "failed")
|
||||
needsSync := lastSync == nil || hasFailedSync
|
||||
|
||||
needsSync, _ := s.NeedSync()
|
||||
|
||||
return &SyncStatus{
|
||||
LastSyncAt: lastSync,
|
||||
LastAttemptAt: lastAttempt,
|
||||
LastSyncStatus: lastSyncStatus,
|
||||
LastSyncError: lastSyncError,
|
||||
ServerPricelists: 0,
|
||||
LocalPricelists: int(localCount),
|
||||
NeedsSync: needsSync,
|
||||
IncompleteServerSync: hasFailedSync,
|
||||
KnownServerChangesMiss: hasFailedSync,
|
||||
LastSyncAt: lastSync,
|
||||
ServerPricelists: serverCount,
|
||||
LocalPricelists: int(localCount),
|
||||
NeedsSync: needsSync,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -331,8 +333,6 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
// Get database connection
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -342,7 +342,6 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
// Get active pricelists from server (up to 100)
|
||||
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
|
||||
if err != nil {
|
||||
s.recordPricelistSyncFailure(err)
|
||||
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
||||
}
|
||||
serverPricelistIDs := make([]uint, 0, len(serverPricelists))
|
||||
@@ -351,30 +350,14 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
}
|
||||
|
||||
synced := 0
|
||||
var syncErr error
|
||||
for _, pl := range serverPricelists {
|
||||
// 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)
|
||||
if err != nil {
|
||||
if syncErr == nil {
|
||||
syncErr = fmt.Errorf("sync items for existing pricelist %s: %w", pl.Version, err)
|
||||
}
|
||||
slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err)
|
||||
} else {
|
||||
slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount)
|
||||
@@ -394,15 +377,19 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
IsUsed: false,
|
||||
}
|
||||
|
||||
itemCount, err := s.syncNewPricelistSnapshot(localPL)
|
||||
if err != nil {
|
||||
if syncErr == nil {
|
||||
syncErr = fmt.Errorf("sync new pricelist %s: %w", pl.Version, err)
|
||||
}
|
||||
slog.Warn("failed to sync pricelist snapshot", "version", pl.Version, "error", err)
|
||||
if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
|
||||
slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||
|
||||
// Sync items for the newly created pricelist
|
||||
itemCount, err := s.SyncPricelistItems(localPL.ID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to sync pricelist items", "version", pl.Version, "error", err)
|
||||
// Continue even if items sync fails - we have the pricelist metadata
|
||||
} else {
|
||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||
}
|
||||
|
||||
synced++
|
||||
}
|
||||
@@ -417,123 +404,14 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
// Backfill lot_category for used pricelists (older local caches may miss the column values).
|
||||
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
||||
|
||||
if syncErr != nil {
|
||||
s.recordPricelistSyncFailure(syncErr)
|
||||
s.localDB.AppendSyncLog("pricelists", "error", syncErr.Error(), synced, plSyncStart, time.Since(plSyncStart).Milliseconds())
|
||||
return synced, syncErr
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
now := time.Now()
|
||||
s.localDB.SetLastSyncTime(now)
|
||||
s.recordPricelistSyncSuccess(now)
|
||||
s.localDB.AppendSyncLog("pricelists", "ok", "", synced, plSyncStart, time.Since(plSyncStart).Milliseconds())
|
||||
s.localDB.SetLastSyncTime(time.Now())
|
||||
s.RecordSyncHeartbeat()
|
||||
|
||||
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
||||
return synced, nil
|
||||
}
|
||||
|
||||
func (s *Service) recordPricelistSyncSuccess(at time.Time) {
|
||||
if s.localDB == nil {
|
||||
return
|
||||
}
|
||||
if err := s.localDB.SetPricelistSyncResult("success", "", at); err != nil {
|
||||
slog.Warn("failed to persist pricelist sync success state", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) recordPricelistSyncFailure(syncErr error) {
|
||||
if s.localDB == nil || syncErr == nil {
|
||||
return
|
||||
}
|
||||
s.markConnectionBroken(syncErr)
|
||||
if err := s.localDB.SetPricelistSyncResult("failed", syncErr.Error(), time.Now()); err != nil {
|
||||
slog.Warn("failed to persist pricelist sync failure state", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) markConnectionBroken(err error) {
|
||||
if err == nil || s.connMgr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
msg := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(msg, "i/o timeout"),
|
||||
strings.Contains(msg, "invalid connection"),
|
||||
strings.Contains(msg, "bad connection"),
|
||||
strings.Contains(msg, "connection reset"),
|
||||
strings.Contains(msg, "broken pipe"),
|
||||
strings.Contains(msg, "unexpected eof"):
|
||||
s.connMgr.MarkOffline(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int, error) {
|
||||
if localPL == nil {
|
||||
return 0, fmt.Errorf("local pricelist is nil")
|
||||
}
|
||||
|
||||
localItems, err := s.fetchServerPricelistItems(localPL.ServerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
slog.Info("synced pricelist items", "pricelist_id", localPL.ID, "items", len(localItems))
|
||||
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 +489,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 +560,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 +570,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 +624,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) {
|
||||
@@ -733,43 +670,36 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
return int(existingCount), nil
|
||||
}
|
||||
|
||||
localItems, err := s.fetchServerPricelistItems(localPL.ServerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for i := range localItems {
|
||||
localItems[i].PricelistID = localPricelistID
|
||||
}
|
||||
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("synced pricelist items", "pricelist_id", localPricelistID, "items", len(localItems))
|
||||
return len(localItems), nil
|
||||
}
|
||||
|
||||
func (s *Service) fetchServerPricelistItems(serverPricelistID uint) ([]localdb.LocalPricelistItem, error) {
|
||||
// Get database connection
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database not available: %w", err)
|
||||
return 0, fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
// Create repository
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
|
||||
// Get items from server
|
||||
serverItems, _, err := pricelistRepo.GetItems(serverPricelistID, 0, 10000, "")
|
||||
serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting server pricelist items: %w", err)
|
||||
return 0, fmt.Errorf("getting server pricelist items: %w", err)
|
||||
}
|
||||
|
||||
// Convert and save locally
|
||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||
for i, item := range serverItems {
|
||||
localItems[i] = *localdb.PricelistItemToLocal(&item, 0)
|
||||
localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
|
||||
}
|
||||
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
|
||||
slog.Warn("pricelist stock enrichment skipped", "pricelist_id", localPricelistID, "error", err)
|
||||
}
|
||||
|
||||
return localItems, nil
|
||||
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("synced pricelist items", "pricelist_id", localPricelistID, "items", len(localItems))
|
||||
return len(localItems), nil
|
||||
}
|
||||
|
||||
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
|
||||
@@ -781,6 +711,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)
|
||||
@@ -812,15 +847,9 @@ func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.Local
|
||||
return localPL, nil
|
||||
}
|
||||
|
||||
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed.
|
||||
// If a sync is already in progress, returns immediately without blocking.
|
||||
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed
|
||||
// This should be called before creating a new configuration when online
|
||||
func (s *Service) SyncPricelistsIfNeeded() error {
|
||||
if !s.pricelistMu.TryLock() {
|
||||
slog.Debug("pricelist sync already in progress, skipping")
|
||||
return nil
|
||||
}
|
||||
defer s.pricelistMu.Unlock()
|
||||
|
||||
needSync, err := s.NeedSync()
|
||||
if err != nil {
|
||||
slog.Warn("failed to check if sync needed", "error", err)
|
||||
@@ -829,16 +858,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
|
||||
}
|
||||
|
||||
@@ -882,7 +901,6 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
for _, change := range sortedChanges {
|
||||
err := s.pushSingleChange(&change)
|
||||
if err != nil {
|
||||
s.markConnectionBroken(err)
|
||||
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
||||
// Increment attempts
|
||||
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
|
||||
@@ -1573,25 +1591,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,15 +1,12 @@
|
||||
package sync_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
||||
@@ -86,58 +83,3 @@ func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
||||
t.Fatalf("expected server pricelist to be synced locally: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPricelistsDoesNotPersistHeaderWithoutItems(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(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: "2026-03-17-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", Price: 10}).Error; err != nil {
|
||||
t.Fatalf("create server pricelist item: %v", err)
|
||||
}
|
||||
|
||||
const callbackName = "test:fail_qt_pricelist_items_query"
|
||||
if err := serverDB.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
|
||||
if db.Statement != nil && db.Statement.Table == "qt_pricelist_items" {
|
||||
_ = db.AddError(errors.New("forced pricelist item fetch failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("register query callback: %v", err)
|
||||
}
|
||||
defer serverDB.Callback().Query().Remove(callbackName)
|
||||
|
||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
synced, err := svc.SyncPricelists()
|
||||
if err == nil {
|
||||
t.Fatalf("expected sync error when item fetch fails")
|
||||
}
|
||||
if synced != 0 {
|
||||
t.Fatalf("expected synced=0 on incomplete sync, got %d", synced)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "forced pricelist item fetch failure") {
|
||||
t.Fatalf("expected item fetch error, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := local.GetLocalPricelistByServerID(serverPL.ID); err == nil {
|
||||
t.Fatalf("expected pricelist header not to be persisted without items")
|
||||
}
|
||||
if got := local.CountLocalPricelists(); got != 0 {
|
||||
t.Fatalf("expected no local pricelists after failed sync, got %d", got)
|
||||
}
|
||||
if ts := local.GetLastSyncTime(); ts != nil {
|
||||
t.Fatalf("expected last_pricelist_sync to stay unset on incomplete sync, got %v", ts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 +2,9 @@ package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -49,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
|
||||
}
|
||||
|
||||
@@ -127,19 +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))
|
||||
case IsTextBOM(data):
|
||||
workspace, err = parseTextBOM(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
|
||||
}
|
||||
@@ -155,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,
|
||||
@@ -272,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,
|
||||
@@ -595,338 +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
|
||||
}
|
||||
|
||||
// textBOMItemLine matches a human-readable BOM line of the form
|
||||
// "<description> - <quantity> шт." where the separator may be a hyphen,
|
||||
// en-dash or em-dash and the quantity may have an optional space before "шт".
|
||||
// The quantity anchor at the end keeps internal hyphens/digits in the
|
||||
// description (e.g. "8-GPU-2304GB") from being mistaken for the separator.
|
||||
var textBOMItemLine = regexp.MustCompile(`(?i)^(.*\S)\s*[-–—]\s*(\d+)\s*шт\.?\s*$`)
|
||||
|
||||
// textBOMHeaderLine matches a configuration header ending with ", в составе:"
|
||||
// regardless of the leading words (e.g. "Сервер <model>" or
|
||||
// "Вычислительный GPU сервер <model>"). The captured group is everything before
|
||||
// the comma; the model is its last whitespace-separated token.
|
||||
var textBOMHeaderLine = regexp.MustCompile(`(?i)^(.*?)\s*,\s*в\s+составе`)
|
||||
|
||||
// ParsePastedBOMText detects and parses a single-column text BOM (Inspur or
|
||||
// Russian text BOM) pasted into the configurator. It shares the same detectors
|
||||
// and parsers as the vendor file-import path, so paste and upload behave
|
||||
// identically. It returns the parsed vendor spec rows and the detected format,
|
||||
// or (nil, "") when the text is not a recognized single-column format and the
|
||||
// caller should fall back to manual column mapping.
|
||||
func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) {
|
||||
data := []byte(text)
|
||||
var ws *importedWorkspace
|
||||
var err error
|
||||
switch {
|
||||
case IsInspurBOM(data):
|
||||
ws, err = parseInspurBOM(data, "")
|
||||
case IsTextBOM(data):
|
||||
ws, err = parseTextBOM(data, "")
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
if err != nil || ws == nil || len(ws.Configurations) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
return ws.Configurations[0].Rows, ws.SourceFormat
|
||||
}
|
||||
|
||||
// IsTextBOM reports whether data looks like a human-readable Russian text BOM,
|
||||
// i.e. it contains at least one "<description> - <quantity> шт." line.
|
||||
func IsTextBOM(data []byte) bool {
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
if textBOMItemLine.MatchString(strings.TrimSpace(raw)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseTextBOM parses a human-readable Russian text BOM into a single configuration.
|
||||
// The optional "Сервер <model>, в составе:" header provides the configuration name and
|
||||
// server model. Each "<description> - <quantity> шт." line becomes one vendor spec row.
|
||||
// The format carries no partnumbers, so rows stay unresolved and editable in the UI
|
||||
// until mapped through the active partnumber book.
|
||||
func parseTextBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
rows := make([]localdb.VendorSpecItem, 0, len(lines))
|
||||
sortOrder := 10
|
||||
serverModel := ""
|
||||
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
|
||||
if fields := strings.Fields(m[1]); len(fields) > 0 {
|
||||
serverModel = fields[len(fields)-1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
m := textBOMItemLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
description := strings.TrimSpace(m[1])
|
||||
qty, err := strconv.Atoi(m[2])
|
||||
if err != nil || qty <= 0 || description == "" {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, localdb.VendorSpecItem{
|
||||
SortOrder: sortOrder,
|
||||
VendorPartnumber: description,
|
||||
Quantity: qty,
|
||||
Description: description,
|
||||
})
|
||||
sortOrder += 10
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("text BOM has no importable rows")
|
||||
}
|
||||
|
||||
name := serverModel
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
|
||||
}
|
||||
if name == "" {
|
||||
name = "Text BOM Import"
|
||||
}
|
||||
|
||||
return &importedWorkspace{
|
||||
SourceFormat: "Text",
|
||||
SourceFileName: sourceFileName,
|
||||
Configurations: []importedConfiguration{
|
||||
{
|
||||
GroupID: "text-0",
|
||||
Name: name,
|
||||
Line: 10,
|
||||
ServerCount: 1,
|
||||
ServerModel: serverModel,
|
||||
Rows: rows,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export.
|
||||
// The file starts (after optional UTF-8 BOM) with the header line:
|
||||
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package services
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -359,462 +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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTextBOM(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"CPU Intel 6760P - 2 шт.", true},
|
||||
{"Fan 18Krpm 8086 - 20 шт.\nRail L-Type 665mm - 1 шт.", true},
|
||||
{"NVIDIA transceiver - 8шт.", true}, // no space before шт
|
||||
{"Сервер KR9288X3, в составе:\nFan - 4 шт.", true},
|
||||
{"|CPU_AMD*1\n|PSU*2", false}, // Inspur
|
||||
{"<CFXML>\n</CFXML>", false},
|
||||
{"just text\nno quantities", false},
|
||||
{"CPU - 2 pcs.", false}, // not Russian шт
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := IsTextBOM([]byte(tc.input))
|
||||
if got != tc.want {
|
||||
t.Errorf("IsTextBOM(%q) = %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextBOM(t *testing.T) {
|
||||
const sample = `Сервер KR9288X3, в составе:
|
||||
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
|
||||
incl. onboard 800G XDR - 8 шт.
|
||||
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
|
||||
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
|
||||
SSD 960G U.2 16GTps 2.5in RAID_1 - 2 шт.
|
||||
SSD 3.84T U.2 16GTps 2.5in R-Standard - 2 шт.
|
||||
NIC 25Gbps 2Port LC Nvidia CX6LX PCIe MM GEN4 - 1 шт.
|
||||
PowerSupply 3200W Titanium 220VACor240VDC - 2 шт.
|
||||
PowerSupply 3300W Titanium 220VACor240VDC - 6 шт.
|
||||
PowerCord 1.9M C20 C19 - 14 шт.
|
||||
Rail L-Type 665mm - 1 шт.
|
||||
Chassis 2.5x12 gpu - 1 шт.
|
||||
Fan 18Krpm 8086 - 20 шт.
|
||||
NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up to 50m, flat top - 8шт.`
|
||||
|
||||
workspace, err := parseTextBOM([]byte(sample), "spec.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if workspace.SourceFormat != "Text" {
|
||||
t.Fatalf("expected SourceFormat Text, got %q", workspace.SourceFormat)
|
||||
}
|
||||
if len(workspace.Configurations) != 1 {
|
||||
t.Fatalf("expected 1 configuration, got %d", len(workspace.Configurations))
|
||||
}
|
||||
cfg := workspace.Configurations[0]
|
||||
if cfg.Name != "KR9288X3" {
|
||||
t.Fatalf("expected name KR9288X3 (from header), got %q", cfg.Name)
|
||||
}
|
||||
if cfg.ServerModel != "KR9288X3" {
|
||||
t.Fatalf("expected ServerModel KR9288X3, got %q", cfg.ServerModel)
|
||||
}
|
||||
if cfg.ServerCount != 1 {
|
||||
t.Fatalf("expected ServerCount 1, got %d", cfg.ServerCount)
|
||||
}
|
||||
const wantRows = 14
|
||||
if len(cfg.Rows) != wantRows {
|
||||
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
|
||||
}
|
||||
|
||||
rowsByDesc := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
|
||||
for _, r := range cfg.Rows {
|
||||
rowsByDesc[r.Description] = r
|
||||
if r.VendorPartnumber != r.Description {
|
||||
t.Fatalf("expected VendorPartnumber to mirror Description, got pn=%q desc=%q", r.VendorPartnumber, r.Description)
|
||||
}
|
||||
}
|
||||
|
||||
// Description with internal hyphens and digits must not be split early.
|
||||
gpu, ok := rowsByDesc["GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E"]
|
||||
if !ok {
|
||||
t.Fatal("expected GPU row not found (check hyphen handling)")
|
||||
}
|
||||
if gpu.Quantity != 1 {
|
||||
t.Fatalf("GPU: expected qty 1, got %d", gpu.Quantity)
|
||||
}
|
||||
|
||||
mem, ok := rowsByDesc["Mem 128G DDR5-6400MHz ECC-RDIMM"]
|
||||
if !ok {
|
||||
t.Fatal("expected Mem row not found")
|
||||
}
|
||||
if mem.Quantity != 16 {
|
||||
t.Fatalf("Mem: expected qty 16, got %d", mem.Quantity)
|
||||
}
|
||||
|
||||
// Quantity with no space before "шт" and commas/hyphens in description.
|
||||
xcvr, ok := rowsByDesc["NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up to 50m, flat top"]
|
||||
if !ok {
|
||||
t.Fatal("expected transceiver row not found (check no-space quantity)")
|
||||
}
|
||||
if xcvr.Quantity != 8 {
|
||||
t.Fatalf("transceiver: expected qty 8, got %d", xcvr.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextBOMVariantHeaderAndLeadingSpace(t *testing.T) {
|
||||
// Header does not start with "Сервер"; some lines have leading/trailing spaces;
|
||||
// descriptions contain commas and internal hyphens.
|
||||
const sample = `Вычислительный GPU сервер G5500V7, в составе:
|
||||
Серверное шасси G5500 V7 (12NVMe + 8SAS/SATA) - 1 шт.
|
||||
Процессор Intel 8558P 48C 2.7G 260MB 350W - 2 шт.
|
||||
Модуль оперативной памяти Mem 128G DDR5-5600MHz ECC-RDIMM - 16 шт.
|
||||
Накопитель SSD 2.5" NVMe 3.84TB - 8 шт.
|
||||
Накопитель SSD 2.5" SATA 3.84TB - 2 шт.
|
||||
Адаптер SAS/SATA RAID0,1,10 12Gb/s-no Cache - 1 шт.
|
||||
Адаптер 25GE(CX6-Lx)-Dual Port SFP28 - 1 шт.
|
||||
Сетевая карта 4 x 1G, Base-T - 1 шт.
|
||||
Адаптер HBA Emulex LPe32002 2 Port 32GFC - 1 шт.
|
||||
Крепежный комплект Ball Bearing Rail Kit - 1 шт.
|
||||
Кабельный органайзер Cable Management Arm - 1 шт.
|
||||
Кабель питания PowerCord 3m C20 C19 - 4 шт.
|
||||
Видеокарта (видеоускоритель) NVIDIA RTX PRO 6000 Server Edition 96GB GDDR7 - 8 шт.
|
||||
Блок питания 3000W Titanium AC Power Supply - 4 шт.`
|
||||
|
||||
workspace, err := parseTextBOM([]byte(sample), "spec.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cfg := workspace.Configurations[0]
|
||||
if cfg.ServerModel != "G5500V7" {
|
||||
t.Fatalf("expected ServerModel G5500V7 (last token before comma), got %q", cfg.ServerModel)
|
||||
}
|
||||
if cfg.Name != "G5500V7" {
|
||||
t.Fatalf("expected name G5500V7, got %q", cfg.Name)
|
||||
}
|
||||
const wantRows = 14
|
||||
if len(cfg.Rows) != wantRows {
|
||||
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
|
||||
}
|
||||
|
||||
for _, r := range cfg.Rows {
|
||||
if r.VendorPartnumber != strings.TrimSpace(r.VendorPartnumber) {
|
||||
t.Fatalf("vendor_partnumber has surrounding whitespace: %q", r.VendorPartnumber)
|
||||
}
|
||||
if r.Description != strings.TrimSpace(r.Description) {
|
||||
t.Fatalf("description has surrounding whitespace: %q", r.Description)
|
||||
}
|
||||
if r.VendorPartnumber == "" {
|
||||
t.Fatal("empty vendor_partnumber")
|
||||
}
|
||||
}
|
||||
|
||||
rowsByDesc := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
|
||||
for _, r := range cfg.Rows {
|
||||
rowsByDesc[r.VendorPartnumber] = r
|
||||
}
|
||||
|
||||
// Leading-space line must yield a trimmed P/N.
|
||||
sata, ok := rowsByDesc[`Накопитель SSD 2.5" SATA 3.84TB`]
|
||||
if !ok {
|
||||
t.Fatal("expected SATA SSD row not found (check leading-space trimming)")
|
||||
}
|
||||
if sata.Quantity != 2 {
|
||||
t.Fatalf("SATA SSD: expected qty 2, got %d", sata.Quantity)
|
||||
}
|
||||
|
||||
// Commas inside the description must not break parsing.
|
||||
raid, ok := rowsByDesc["Адаптер SAS/SATA RAID0,1,10 12Gb/s-no Cache"]
|
||||
if !ok {
|
||||
t.Fatal("expected RAID adapter row not found (check commas in description)")
|
||||
}
|
||||
if raid.Quantity != 1 {
|
||||
t.Fatalf("RAID adapter: expected qty 1, got %d", raid.Quantity)
|
||||
}
|
||||
|
||||
gpu, ok := rowsByDesc["Видеокарта (видеоускоритель) NVIDIA RTX PRO 6000 Server Edition 96GB GDDR7"]
|
||||
if !ok {
|
||||
t.Fatal("expected GPU row not found")
|
||||
}
|
||||
if gpu.Quantity != 8 {
|
||||
t.Fatalf("GPU: expected qty 8, got %d", gpu.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePastedBOMText(t *testing.T) {
|
||||
t.Run("text BOM", func(t *testing.T) {
|
||||
rows, format := ParsePastedBOMText("Сервер X1, в составе:\nCPU Intel 6760P - 2 шт.\nMem 128G - 16 шт.")
|
||||
if format != "Text" {
|
||||
t.Fatalf("expected format Text, got %q", format)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0].VendorPartnumber != "CPU Intel 6760P" || rows[0].Quantity != 2 {
|
||||
t.Fatalf("unexpected first row: %+v", rows[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inspur BOM", func(t *testing.T) {
|
||||
rows, format := ParsePastedBOMText("|CPU_AMD*1\n|PSU*2")
|
||||
if format != "Inspur" {
|
||||
t.Fatalf("expected format Inspur, got %q", format)
|
||||
}
|
||||
if len(rows) != 2 || rows[1].Quantity != 2 {
|
||||
t.Fatalf("unexpected rows: %+v", rows)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrecognized falls through", func(t *testing.T) {
|
||||
rows, format := ParsePastedBOMText("col a\tcol b\nfoo\tbar")
|
||||
if rows != nil || format != "" {
|
||||
t.Fatalf("expected nil/empty for unrecognized text, got rows=%+v format=%q", rows, format)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseTextBOMNameFromFilename(t *testing.T) {
|
||||
const sample = "CPU Intel 6760P - 2 шт.\nMem 128G - 16 шт."
|
||||
workspace, err := parseTextBOM([]byte(sample), "my-config.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cfg := workspace.Configurations[0]
|
||||
if cfg.Name != "my-config" {
|
||||
t.Fatalf("expected name my-config (from filename), got %q", cfg.Name)
|
||||
}
|
||||
if cfg.ServerModel != "" {
|
||||
t.Fatalf("expected empty ServerModel without header, got %q", cfg.ServerModel)
|
||||
}
|
||||
if len(cfg.Rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", len(cfg.Rows))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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.
|
||||
@@ -3,48 +3,16 @@
|
||||
Дата релиза: 2026-03-16
|
||||
Тег: `v1.5.4`
|
||||
|
||||
Предыдущий релиз: `v1.5.0`
|
||||
|
||||
## Ключевые изменения
|
||||
|
||||
- pricing tab переработан: закупка и продажа разделены на отдельные таблицы с ценами за 1 шт.;
|
||||
- экран прайслиста переработан под разные типы источников; удалены misleading-колонки `Поставщик` и `partnumbers`;
|
||||
- runtime и startup ужесточены: локальный клиент принудительно работает только на loopback, конфиг автоматически нормализуется;
|
||||
- runtime автоматически нормализует `server.host` к `127.0.0.1` и переписывает некорректный локальный конфиг;
|
||||
- добавлены действия с вариантом и унифицированы правила именования `_копия` для вариантов и конфигураций;
|
||||
- исправлен CSV-экспорт прайсинговых таблиц в конфигураторе под Excel-совместимый формат Excel-friendly;
|
||||
- таблица проектов переработана: дата последней правки, tooltip с деталями, отдельный автор, компактные действия и ссылка на трекер;
|
||||
- исправлен CSV-экспорт прайсинговых таблиц в конфигураторе под Excel-совместимый формат;
|
||||
- таблица проектов переработана: новая колонка даты, tooltip с деталями, отдельный автор, компактные действия и ссылка на трекер;
|
||||
- sync больше не подменяет `updated_at` проектов временем синхронизации;
|
||||
- добавлена одноразовая утилита `cmd/migrate_project_updated_at` для пересинхронизации `updated_at` проектов из MariaDB в локальную SQLite;
|
||||
- runtime config, release notes и `bible-local/` очищены и приведены к актуальной архитектуре;
|
||||
- добавлена одноразовая утилита `cmd/migrate_project_updated_at` для пересинхронизации `updated_at` проектов из MariaDB в локальную SQLite.
|
||||
- `scripts/release.sh` больше не затирает существующий `RELEASE_NOTES.md`.
|
||||
|
||||
## Summary
|
||||
|
||||
### UI и UX
|
||||
|
||||
- вкладка ценообразования теперь разделена на отдельные таблицы закупки и продажи;
|
||||
- список проектов переработан: новая колонка даты, отдельный автор, tooltip с деталями, компактные действия, ссылка на трекер;
|
||||
- для вариантов добавлены действия переименования, переноса и копирования;
|
||||
- копии вариантов и конфигураций теперь именуются единообразно: `_копия`, `_копия2`, `_копия3`.
|
||||
|
||||
### Прайслисты и экспорт
|
||||
|
||||
- экран прайслиста переработан под разные типы источников;
|
||||
- из прайслистов убраны misleading-колонки `Поставщик` и `partnumbers`;
|
||||
- CSV-экспорт прайсинговых таблиц в конфигураторе приведён к Excel-совместимому формату.
|
||||
|
||||
### Runtime и sync
|
||||
|
||||
- локальный runtime нормализует `server.host` к `127.0.0.1` и переписывает некорректный runtime config;
|
||||
- sync перестал подменять `updated_at` проектов временем локальной синхронизации;
|
||||
- добавлена утилита `cmd/migrate_project_updated_at` для восстановления локальных дат проектов с сервера.
|
||||
|
||||
### Документация и release tooling
|
||||
|
||||
- `bible-local/` сокращён до актуальных архитектурных контрактов;
|
||||
- release notes и release-структура приведены к одному формату;
|
||||
- `scripts/release.sh` теперь сохраняет существующий `RELEASE_NOTES.md` и не затирает его шаблоном.
|
||||
|
||||
## Затронутые области
|
||||
|
||||
- `cmd/qfs/`;
|
||||
@@ -52,8 +20,6 @@
|
||||
- `internal/localdb/`;
|
||||
- `internal/services/project.go`;
|
||||
- `internal/services/sync/service.go`;
|
||||
- `internal/handlers/pricelist.go`;
|
||||
- `web/templates/pricelist_detail.html`;
|
||||
- `web/templates/index.html`;
|
||||
- `web/templates/project_detail.html`;
|
||||
- `web/templates/projects.html`;
|
||||
|
||||
@@ -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 ./...` проходит полностью.
|
||||
1
web/static/vendor/htmx-1.9.10.min.js
vendored
1
web/static/vendor/htmx-1.9.10.min.js
vendored
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user