Compare commits
257 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d190cc7a8 | |||
| 8b2dc6652a | |||
| cea979e327 | |||
| 4d002671ae | |||
| 949479550c | |||
|
|
677b5d898f | ||
|
|
b3cab3477b | ||
|
|
6d4a37df8b | ||
|
|
7cc101d24d | ||
|
|
4900cd073c | ||
|
|
c0588e9710 | ||
|
|
0cd4f99b46 | ||
|
|
4982adbe41 | ||
|
|
5359ae6ded | ||
|
|
76d93c6be8 | ||
|
|
c6385f6cf1 | ||
|
|
1ab5186d0c | ||
|
|
b6fdac1caa | ||
|
|
b837ca7866 | ||
|
|
c8092da370 | ||
|
|
4f105822c6 | ||
|
|
6df262b8ee | ||
|
|
0fc0366bb1 | ||
|
|
d204e337b5 | ||
|
|
d340bf80af | ||
|
|
24c34eb0e1 | ||
|
|
6f2c261350 | ||
|
|
7233a0780f | ||
|
|
360c754952 | ||
| 184f54b663 | |||
| e548305396 | |||
| 09d694234d | |||
| 56782fa718 | |||
| 2bd57591ea | |||
| a81947b852 | |||
| 6146f6aec7 | |||
| 3992dbf919 | |||
| 1de66d6f33 | |||
| 5d5af07fc5 | |||
| 8d965bfee9 | |||
| c5909c6a36 | |||
| 0072f2a15f | |||
| 452811f393 | |||
| 84cab011d3 | |||
| c951ceb44b | |||
| caf1732cd3 | |||
| e58f5774ab | |||
| ddc00523e0 | |||
| ff262822e1 | |||
| 6049334323 | |||
| 5d4e1b44f6 | |||
| 6b56cad248 | |||
| 67a761345f | |||
|
|
55acbe138b | ||
|
|
e1f34ae81b | ||
|
|
860ffa0231 | ||
|
|
6dbaccdf6f | ||
|
|
66ff7e25a6 | ||
| dc37afe178 | |||
| c698a6b70a | |||
|
|
e35b3179d0 | ||
|
|
2e5a5e22d8 | ||
|
|
f18df01618 | ||
|
|
df3cd62cb5 | ||
|
|
89ce001906 | ||
|
|
6a41c957cc | ||
|
|
19b1abf4c8 | ||
|
|
7966ece7a6 | ||
|
|
ae7d8911c6 | ||
|
|
48f03a21fa | ||
|
|
82dcee74c5 | ||
|
|
7aa7b68020 | ||
|
|
ad8cdb0b85 | ||
|
|
1745c8fdd6 | ||
|
|
f844288fb5 | ||
|
|
65641ae49a | ||
|
|
1064e2b985 | ||
|
|
0b0b38c29d | ||
|
|
ba105f8743 | ||
|
|
1ddb60f8c6 | ||
|
|
b0dd206c29 | ||
|
|
c20da96788 | ||
|
|
5848eebf4c | ||
|
|
407ef52d28 | ||
|
|
463836802b | ||
|
|
73e7f0ce11 | ||
|
|
98d8b40282 | ||
|
|
8e7da97394 | ||
|
|
fba9f2972a | ||
|
|
b1fb3db2e0 | ||
|
|
72a21e6335 | ||
|
|
c02286a407 | ||
|
|
63d14dac76 | ||
|
|
a3c26f015b | ||
|
|
84013c9dc4 | ||
|
|
9f8e050349 | ||
|
|
d026c28ea7 | ||
|
|
e39c69e5a4 | ||
|
|
08a8113949 | ||
|
|
3a3a4665b0 | ||
| d82590c34b | |||
| 4db8ca1140 | |||
| e3a1268f74 | |||
| beecaaceb7 | |||
| 564f5bad36 | |||
| 648943b2c3 | |||
| 22eae2b272 | |||
| 2011d3fc77 | |||
|
|
7f4c7328bc | ||
|
|
ed0916d3d1 | ||
|
|
370bd949a5 | ||
|
|
2b63f9ec14 | ||
|
|
69049eba69 | ||
|
|
aca66df25e | ||
|
|
ea5223dee5 | ||
|
|
c76a31f171 | ||
|
|
36699a609f | ||
|
|
e03b8db271 | ||
|
|
180d10914d | ||
|
|
f984d045d2 | ||
| 512b9ca04b | |||
| 130af59e0f | |||
| 521709e0e2 | |||
| 0ceff6cf66 | |||
| fb2a33a71d | |||
| c6c0a53e6e | |||
| 5bad4f86e4 | |||
| e3d322d1f1 | |||
| f3d8e653f8 | |||
|
|
c1993a37cf | ||
|
|
daeb0b0bd7 | ||
|
|
b67a1ae8a5 | ||
|
|
b405ef9c44 | ||
| aad322dd71 | |||
| d0becda71b | |||
| 2e37197a7e | |||
| 530aa0ae48 | |||
| 81203fc7a7 | |||
| eeef5ae25c | |||
| 1606143b9f | |||
| 8be424aa1c | |||
|
|
8db8dce080 | ||
|
|
da4da760d8 | ||
|
|
4f6a62f5dc | ||
|
|
032857017e | ||
|
|
3e7418e524 | ||
|
|
3c8dc246de | ||
|
|
3ab9ca1e73 | ||
|
|
678061430c | ||
|
|
92bca0d0be | ||
|
|
6d39ca7eba | ||
|
|
ca6c5fcdfd | ||
|
|
7bfb909295 | ||
|
|
ac201c65bf | ||
|
|
410957e4f1 | ||
|
|
1bdf405e37 | ||
|
|
5986d2d505 | ||
|
|
2418cec9c3 | ||
|
|
befc70a7e4 | ||
|
|
024a540ad0 | ||
|
|
0a984a5085 | ||
|
|
f3a767d3ed | ||
|
|
1ed1ee3e51 | ||
|
|
af3768a05c | ||
|
|
432d8c57c2 | ||
| 1ec8034689 | |||
| ff87a2636a | |||
| a0bfc49fa6 | |||
| d942623354 | |||
| ceba2f258d | |||
| 20c5d617d5 | |||
| 18988c20f1 | |||
| 207ecfc032 | |||
| ba36c3aae7 | |||
|
|
0e3bbc15b1 | ||
|
|
76e1f95842 | ||
|
|
0d9abdbd50 | ||
|
|
fe07f8dcd4 | ||
|
|
4a89feab12 | ||
|
|
9c5f5dd3f8 | ||
|
|
4b9ce9589c | ||
|
|
e6ed7abbb1 | ||
|
|
fc38286140 | ||
|
|
729463157d | ||
|
|
aa1177cbe1 | ||
|
|
9c75b03c89 | ||
|
|
bb9ee13edc | ||
|
|
ea56660fc3 | ||
|
|
466e0e8506 | ||
|
|
cab8671692 | ||
|
|
95cf376f18 | ||
|
|
e43e3b2e6b | ||
|
|
46951e8492 | ||
|
|
a09bbc689e | ||
|
|
de7115f130 | ||
|
|
751b860afa | ||
|
|
798e0e1023 | ||
|
|
843295be3f | ||
|
|
3dde221a5e | ||
|
|
a8b2fde04c | ||
|
|
c1f936825e | ||
|
|
564c6c1b34 | ||
|
|
9d50c57c25 | ||
|
|
e6bd46368a | ||
|
|
a80d203946 | ||
|
|
111f83095b | ||
|
|
d45158b08d | ||
|
|
6314013356 | ||
|
|
1212574b1c | ||
| 9d5c875fdc | |||
| 832d6f2b58 | |||
|
|
60f839221c | ||
|
|
74e391387f | ||
|
|
d4e238b585 | ||
|
|
3a6d0c0369 | ||
|
|
3c422e6076 | ||
|
|
c3719d39ad | ||
|
|
613ef3a340 | ||
|
|
1aad7220dd | ||
| 26d2207ffa | |||
| cd4bb32625 | |||
| 59120c5597 | |||
| f7099c3adc | |||
| d0ef775b03 | |||
| 7b8f15a931 | |||
| ff15f71b36 | |||
| 233ecebcb9 | |||
| 15f100a517 | |||
|
|
b6e3e38f8e | ||
|
|
50b5e67b8a | ||
|
|
988ccb571e | ||
|
|
733d628c86 | ||
|
|
bb87c6ca19 | ||
|
|
ed1d6e642c | ||
|
|
2f0648f2d4 | ||
|
|
1d5e358f80 | ||
|
|
78fd25472f | ||
|
|
2b5c81001a | ||
|
|
f9096ccad2 | ||
|
|
d26a8c5604 | ||
|
|
087701fe99 | ||
|
|
5a9c5371ba | ||
|
|
6b5f826f4b | ||
|
|
630245b720 | ||
|
|
32413838aa | ||
| 228f64d205 | |||
| 47ce3314b4 | |||
| ba5b14c72f | |||
| 0a93973d02 | |||
| 6b44de48a2 | |||
| cc73a7a11e | |||
| 3821095254 | |||
| d224eea893 | |||
| d294154a6e | |||
| 31c8d4ba28 | |||
| e5bcb1cd86 | |||
|
|
4b486c8b32 |
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,5 +1,10 @@
|
|||||||
# QuoteForge
|
# QuoteForge
|
||||||
config.yaml
|
config.yaml
|
||||||
|
|
||||||
|
# Data exports and imports with real supplier/pricing data
|
||||||
|
*_import.sql
|
||||||
|
*_export.csv
|
||||||
|
test_export.csv
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.pem
|
*.pem
|
||||||
@@ -75,7 +80,12 @@ Network Trash Folder
|
|||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.apdisk
|
||||||
|
|
||||||
# Release artifacts (binaries, archives, checksums), but DO track releases/memory/ for changelog
|
# Release artifacts (binaries, archives, checksums), but keep markdown notes tracked
|
||||||
releases/*
|
releases/*
|
||||||
|
!releases/README.md
|
||||||
!releases/memory/
|
!releases/memory/
|
||||||
!releases/memory/**
|
!releases/memory/**
|
||||||
|
!releases/**/
|
||||||
|
releases/**/*
|
||||||
|
!releases/README.md
|
||||||
|
!releases/*/RELEASE_NOTES.md
|
||||||
|
|||||||
77
README.md
77
README.md
@@ -1,66 +1,53 @@
|
|||||||
# QuoteForge
|
# QuoteForge
|
||||||
|
|
||||||
**Корпоративный конфигуратор серверов и расчёт КП**
|
Local-first desktop web app for server configuration, quotation, and project work.
|
||||||
|
|
||||||
Offline-first архитектура: пользовательские операции через локальную SQLite, MariaDB только для синхронизации.
|
Runtime model:
|
||||||
|
- user work is stored in local SQLite;
|
||||||
|
- MariaDB is used only for setup checks and background sync;
|
||||||
|
- HTTP server binds to loopback only.
|
||||||
|
|
||||||

|
## What the app does
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
---
|
- configuration editor with price refresh from synced pricelists;
|
||||||
|
- projects with variants and ordered configurations;
|
||||||
|
- vendor BOM import and PN -> LOT resolution;
|
||||||
|
- revision history with rollback;
|
||||||
|
- rotating local backups.
|
||||||
|
|
||||||
## Документация
|
## Run
|
||||||
|
|
||||||
Полная архитектурная документация хранится в **[bible/](bible/README.md)**:
|
|
||||||
|
|
||||||
| Файл | Тема |
|
|
||||||
|------|------|
|
|
||||||
| [bible/01-overview.md](bible/01-overview.md) | Продукт, возможности, технологии, структура репо |
|
|
||||||
| [bible/02-architecture.md](bible/02-architecture.md) | Local-first, sync, ценообразование, версионность |
|
|
||||||
| [bible/03-database.md](bible/03-database.md) | SQLite и MariaDB схемы, права, миграции |
|
|
||||||
| [bible/04-api.md](bible/04-api.md) | Все API endpoints и web-маршруты |
|
|
||||||
| [bible/05-config.md](bible/05-config.md) | Конфигурация, env vars, установка |
|
|
||||||
| [bible/06-backup.md](bible/06-backup.md) | Резервное копирование |
|
|
||||||
| [bible/07-dev.md](bible/07-dev.md) | Команды разработки, стиль кода, guardrails |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Быстрый старт
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Применить миграции
|
|
||||||
go run ./cmd/qfs -migrate
|
|
||||||
|
|
||||||
# Запустить
|
|
||||||
go run ./cmd/qfs
|
go run ./cmd/qfs
|
||||||
# или
|
|
||||||
make run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Приложение: http://localhost:8080 → откроется `/setup` для настройки подключения к MariaDB.
|
Useful commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Сборка
|
go run ./cmd/qfs -migrate
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
make build-release
|
make build-release
|
||||||
|
|
||||||
# Проверка
|
|
||||||
go build ./cmd/qfs && go vet ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
On first run the app creates a minimal `config.yaml`, starts on `http://127.0.0.1:8080`, and opens `/setup` if DB credentials were not saved yet.
|
||||||
|
|
||||||
## Releases & Changelog
|
## Documentation
|
||||||
|
|
||||||
Changelog между версиями: `releases/memory/v{major}.{minor}.{patch}.md`
|
- Shared engineering rules: [bible/README.md](bible/README.md)
|
||||||
|
- Project architecture: [bible-local/README.md](bible-local/README.md)
|
||||||
|
- Release notes: `releases/<version>/RELEASE_NOTES.md`
|
||||||
|
|
||||||
---
|
`bible-local/` is the source of truth for QuoteForge-specific architecture. If code changes behavior, update the matching file there in the same commit.
|
||||||
|
|
||||||
## Поддержка
|
## Repository map
|
||||||
|
|
||||||
- Email: mike@mchus.pro
|
```text
|
||||||
- Internal: @mchus
|
cmd/ entry points and migration tools
|
||||||
|
internal/ application code
|
||||||
## Лицензия
|
web/ templates and static assets
|
||||||
|
bible/ shared engineering rules
|
||||||
Собственность компании, только для внутреннего использования. См. [LICENSE](LICENSE).
|
bible-local/ project architecture and contracts
|
||||||
|
releases/ packaged release artifacts and release notes
|
||||||
|
config.example.yaml runtime config reference
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
-- 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: 5a69e0bba8...1977730d93
@@ -1,130 +1,70 @@
|
|||||||
# 01 — Product Overview
|
# 01 - Overview
|
||||||
|
|
||||||
## What is QuoteForge
|
## Product
|
||||||
|
|
||||||
A corporate server configuration and quotation tool.
|
QuoteForge is a local-first tool for server configuration, quotation, and project tracking.
|
||||||
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only by synchronization and dedicated setup/migration tooling.
|
|
||||||
|
|
||||||
---
|
Core user flows:
|
||||||
|
- create and edit configurations locally;
|
||||||
|
- calculate prices from synced pricelists;
|
||||||
|
- group configurations into projects and variants;
|
||||||
|
- import vendor workspaces and map vendor PNs to internal LOTs;
|
||||||
|
- review revision history and roll back safely.
|
||||||
|
|
||||||
## Features
|
## Runtime model
|
||||||
|
|
||||||
### For Users
|
QuoteForge is a single-user thick client.
|
||||||
- Mobile-first interface — works comfortably on phones and tablets
|
|
||||||
- Server configurator — step-by-step component selection
|
|
||||||
- Automatic price calculation — based on pricelists from local cache
|
|
||||||
- CSV export — ready-to-use specifications for clients
|
|
||||||
- Configuration history — versioned snapshots with rollback support
|
|
||||||
- Full offline operation — continue working without network, sync later
|
|
||||||
- Guarded synchronization — sync is blocked by preflight check if local schema is not ready
|
|
||||||
|
|
||||||
### Local Client Security Model
|
Rules:
|
||||||
|
- runtime HTTP binds to loopback only;
|
||||||
|
- browser requests are treated as part of the same local user session;
|
||||||
|
- MariaDB is not a live dependency for normal CRUD;
|
||||||
|
- if non-loopback deployment is ever introduced, auth/RBAC must be added first.
|
||||||
|
|
||||||
QuoteForge is currently a **single-user thick client** bound to `localhost`.
|
## Product scope
|
||||||
|
|
||||||
- The local HTTP/UI layer is not treated as a multi-user security boundary.
|
In scope:
|
||||||
- RBAC is not part of the active product contract for the local client.
|
- configurator and quote calculation;
|
||||||
- The authoritative authentication boundary is the remote sync server and its DB credentials captured during setup.
|
- projects, variants, and configuration ordering;
|
||||||
- If the app is ever exposed beyond `localhost`, auth/RBAC must be reintroduced as an enforced perimeter before release.
|
- local revision history;
|
||||||
|
- read-only pricelist browsing from SQLite cache;
|
||||||
|
- background sync with MariaDB;
|
||||||
|
- rotating local backups.
|
||||||
|
|
||||||
### Price Freshness Indicators
|
Out of scope and intentionally removed:
|
||||||
|
- admin pricing UI/API;
|
||||||
|
- alerts and notification workflows;
|
||||||
|
- stock import tooling;
|
||||||
|
- cron jobs and importer utilities.
|
||||||
|
|
||||||
| Color | Status | Condition |
|
## Tech stack
|
||||||
|-------|--------|-----------|
|
|
||||||
| Green | Fresh | < 30 days, ≥ 3 sources |
|
|
||||||
| Yellow | Normal | 30–60 days |
|
|
||||||
| Orange | Aging | 60–90 days |
|
|
||||||
| Red | Stale | > 90 days or no data |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Stack |
|
| Layer | Stack |
|
||||||
|-------|-------|
|
| --- | --- |
|
||||||
| Backend | Go 1.22+, Gin, GORM |
|
| Backend | Go, Gin, GORM |
|
||||||
| Frontend | HTML, Tailwind CSS, htmx |
|
| Frontend | HTML templates, htmx, Tailwind CSS |
|
||||||
| Local DB | SQLite (`qfs.db`) |
|
| Local storage | SQLite |
|
||||||
| Server DB | MariaDB 11+ (sync transport only for app runtime) |
|
| Sync transport | MariaDB |
|
||||||
| Export | encoding/csv, excelize (XLSX) |
|
| Export | CSV and XLSX generation |
|
||||||
|
|
||||||
---
|
## Repository map
|
||||||
|
|
||||||
## Product Scope
|
|
||||||
|
|
||||||
**In scope:**
|
|
||||||
- Component configurator and quotation calculation
|
|
||||||
- Projects and configurations
|
|
||||||
- Read-only pricelist viewing from local cache
|
|
||||||
- Sync (pull components/pricelists, push local changes)
|
|
||||||
|
|
||||||
**Out of scope (removed intentionally — do not restore):**
|
|
||||||
- Admin pricing UI/API
|
|
||||||
- Stock import
|
|
||||||
- Alerts
|
|
||||||
- Cron/importer utilities
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository Structure
|
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd/
|
||||||
|
qfs/ main HTTP runtime
|
||||||
|
migrate/ server migration tool
|
||||||
|
migrate_ops_projects/ OPS project migration helper
|
||||||
|
internal/
|
||||||
|
appstate/ backup and runtime state
|
||||||
|
config/ runtime config parsing
|
||||||
|
handlers/ HTTP handlers
|
||||||
|
localdb/ SQLite models and migrations
|
||||||
|
repository/ repositories
|
||||||
|
services/ business logic and sync
|
||||||
|
web/
|
||||||
|
templates/ HTML templates
|
||||||
|
static/ static assets
|
||||||
|
bible/ shared engineering rules
|
||||||
|
bible-local/ project-specific architecture
|
||||||
|
releases/ release artifacts and notes
|
||||||
```
|
```
|
||||||
quoteforge/
|
|
||||||
├── cmd/
|
|
||||||
│ ├── qfs/main.go # HTTP server entry point
|
|
||||||
│ ├── migrate/ # Migration tool
|
|
||||||
│ └── migrate_ops_projects/ # OPS project migrator
|
|
||||||
├── internal/
|
|
||||||
│ ├── appmeta/ # App version metadata
|
|
||||||
│ ├── appstate/ # State management, backup
|
|
||||||
│ ├── article/ # Article generation
|
|
||||||
│ ├── config/ # Config parsing
|
|
||||||
│ ├── db/ # DB initialization
|
|
||||||
│ ├── handlers/ # HTTP handlers
|
|
||||||
│ ├── localdb/ # SQLite layer
|
|
||||||
│ ├── middleware/ # Auth, CORS, etc.
|
|
||||||
│ ├── models/ # GORM models
|
|
||||||
│ ├── repository/ # Repository layer
|
|
||||||
│ └── services/ # Business logic
|
|
||||||
├── web/
|
|
||||||
│ ├── templates/ # HTML templates + partials
|
|
||||||
│ └── static/ # CSS, JS, assets
|
|
||||||
├── migrations/ # SQL migration files (30+)
|
|
||||||
├── bible/ # Architectural documentation (this section)
|
|
||||||
├── releases/memory/ # Per-version changelogs
|
|
||||||
├── config.example.yaml # Config template (the only one in repo)
|
|
||||||
└── go.mod
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration with Existing DB
|
|
||||||
|
|
||||||
QuoteForge integrates with the existing `RFQ_LOG` database.
|
|
||||||
|
|
||||||
Hard boundary:
|
|
||||||
|
|
||||||
- normal runtime HTTP handlers, UI flows, pricing, export, BOM resolution, and project/config CRUD must use SQLite only;
|
|
||||||
- MariaDB access is allowed only inside `internal/services/sync/*` and dedicated setup/migration tools under `cmd/`;
|
|
||||||
- any new direct MariaDB query in non-sync runtime code is an architectural violation.
|
|
||||||
|
|
||||||
**Read-only:**
|
|
||||||
- `lot` — component catalog
|
|
||||||
- `qt_lot_metadata` — extended component data
|
|
||||||
- `qt_categories` — categories
|
|
||||||
- `qt_pricelists`, `qt_pricelist_items` — pricelists
|
|
||||||
- `stock_log` — stock quantities consumed during sync enrichment
|
|
||||||
- `qt_partnumber_books`, `qt_partnumber_book_items` — partnumber book snapshots consumed during sync pull
|
|
||||||
|
|
||||||
**Read + Write:**
|
|
||||||
- `qt_configurations` — configurations
|
|
||||||
- `qt_projects` — projects
|
|
||||||
|
|
||||||
**Sync service tables:**
|
|
||||||
- `qt_client_schema_state` — applied migrations state and operational client status per device (`username + hostname`)
|
|
||||||
Fields written by QuoteForge:
|
|
||||||
`app_version`, `last_sync_at`, `last_sync_status`,
|
|
||||||
`pending_changes_count`, `pending_errors_count`, `configurations_count`, `projects_count`,
|
|
||||||
`estimate_pricelist_version`, `warehouse_pricelist_version`, `competitor_pricelist_version`,
|
|
||||||
`last_sync_error_code`, `last_sync_error_text`, `last_checked_at`, `updated_at`
|
|
||||||
- `qt_pricelist_sync_status` — pricelist sync status
|
|
||||||
|
|||||||
@@ -1,251 +1,149 @@
|
|||||||
# 02 — Architecture
|
# 02 - Architecture
|
||||||
|
|
||||||
## Local-First Principle
|
## Local-first rule
|
||||||
|
|
||||||
**SQLite** is the single source of truth for the user.
|
SQLite is the runtime source of truth.
|
||||||
**MariaDB** is a sync server only — it never blocks local operations.
|
MariaDB is sync transport plus setup and migration tooling.
|
||||||
|
|
||||||
```
|
```text
|
||||||
User
|
browser -> Gin handlers -> SQLite
|
||||||
│
|
-> pending_changes
|
||||||
▼
|
background sync <------> MariaDB
|
||||||
SQLite (qfs.db) ← all CRUD operations go here
|
|
||||||
│
|
|
||||||
│ background sync (every 5 min)
|
|
||||||
▼
|
|
||||||
MariaDB (RFQ_LOG) ← pull/push only
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rules:**
|
Rules:
|
||||||
- All CRUD operations go through SQLite only
|
- user CRUD must continue when MariaDB is offline;
|
||||||
- If MariaDB is unavailable → local work continues without restrictions
|
- runtime handlers and pages must read and write SQLite only;
|
||||||
- Changes are queued in `pending_changes` and pushed on next sync
|
- MariaDB access in runtime code is allowed only inside sync and setup flows;
|
||||||
|
- no live MariaDB fallback for reads that already exist in local cache.
|
||||||
|
|
||||||
## MariaDB Boundary
|
## Sync contract
|
||||||
|
|
||||||
MariaDB is not part of the runtime read/write path for user features.
|
Bidirectional:
|
||||||
|
- projects;
|
||||||
|
- configurations;
|
||||||
|
- `vendor_spec`;
|
||||||
|
- pending change metadata.
|
||||||
|
|
||||||
Hard rules:
|
Pull-only:
|
||||||
|
- components;
|
||||||
|
- pricelists and pricelist items;
|
||||||
|
- partnumber books and partnumber book items.
|
||||||
|
|
||||||
- HTTP handlers, web pages, quote calculation, export, vendor BOM resolution, pricelist browsing, project browsing, and configuration CRUD must read/write SQLite only.
|
Readiness guard:
|
||||||
- MariaDB access from the app runtime is allowed only inside the sync subsystem (`internal/services/sync/*`) for explicit pull/push work.
|
- every sync push/pull runs a preflight check;
|
||||||
- Dedicated tooling under `cmd/migrate` and `cmd/migrate_ops_projects` may access MariaDB for operator-run schema/data migration tasks.
|
- blocked sync returns `423 Locked` with a machine-readable reason;
|
||||||
- Setup may test/store connection settings, but after setup the application must treat MariaDB as sync transport only.
|
- local work continues even when sync is blocked.
|
||||||
- Any new repository/service/handler that issues MariaDB queries outside sync is a regression and must be rejected in review.
|
- sync metadata updates must preserve project `updated_at`; sync time belongs in `synced_at`, not in the user-facing last-modified timestamp.
|
||||||
- Local SQLite migrations are code-defined only (`AutoMigrate` + `runLocalMigrations`); there is no server-driven client migration registry.
|
- pricelist pull must persist a new local snapshot atomically: header and items appear together, and `last_pricelist_sync` advances only after item download succeeds.
|
||||||
- Read-only local sync caches are disposable. If a local cache table cannot be migrated safely at startup, the client may quarantine/reset that cache and continue booting.
|
- 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.
|
||||||
|
|
||||||
Forbidden patterns:
|
## Pricing contract
|
||||||
|
|
||||||
- calling `connMgr.GetDB()` from non-sync runtime business code;
|
Prices come only from `local_pricelist_items`.
|
||||||
- constructing MariaDB-backed repositories in handlers for normal user requests;
|
|
||||||
- using MariaDB as online fallback for reads when local SQLite already contains the synced dataset;
|
|
||||||
- adding UI/API features that depend on live MariaDB availability.
|
|
||||||
|
|
||||||
## Local Client Boundary
|
Rules:
|
||||||
|
- `local_components` is metadata-only;
|
||||||
|
- quote calculation must not read prices from components;
|
||||||
|
- latest pricelist selection ignores snapshots without items;
|
||||||
|
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
|
||||||
|
|
||||||
The running app is a localhost-only thick client.
|
## Pricing tab layout
|
||||||
|
|
||||||
- Browser/UI requests on the local machine are treated as part of the same trusted user session.
|
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
|
||||||
- Local routes are not modeled as a hardened multi-user API perimeter.
|
|
||||||
- Authorization to the central server happens through the saved MariaDB connection configured during setup.
|
|
||||||
- Any future deployment that binds beyond `127.0.0.1` must add enforced auth/RBAC before exposure.
|
|
||||||
|
|
||||||
---
|
Column order (both tables):
|
||||||
|
|
||||||
## Synchronization
|
|
||||||
|
|
||||||
### Data Flow Diagram
|
|
||||||
|
|
||||||
```
|
```
|
||||||
[ SERVER / MariaDB ]
|
PN вендора | Описание | LOT | Кол-во | Estimate | Склад | Конкуренты | Ручная цена
|
||||||
┌───────────────────────────┐
|
|
||||||
│ qt_projects │
|
|
||||||
│ qt_configurations │
|
|
||||||
│ qt_pricelists │
|
|
||||||
│ qt_pricelist_items │
|
|
||||||
│ qt_pricelist_sync_status │
|
|
||||||
└─────────────┬─────────────┘
|
|
||||||
│
|
|
||||||
pull (projects/configs/pricelists)
|
|
||||||
│
|
|
||||||
┌────────────────────┴────────────────────┐
|
|
||||||
│ │
|
|
||||||
[ CLIENT A / SQLite ] [ CLIENT B / SQLite ]
|
|
||||||
local_projects local_projects
|
|
||||||
local_configurations local_configurations
|
|
||||||
local_pricelists local_pricelists
|
|
||||||
local_pricelist_items local_pricelist_items
|
|
||||||
pending_changes pending_changes
|
|
||||||
│ │
|
|
||||||
└────── push (projects/configs only) ─────┘
|
|
||||||
│
|
|
||||||
[ SERVER / MariaDB ]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sync Direction by Entity
|
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.
|
||||||
|
|
||||||
| Entity | Direction |
|
Vendor price attachment:
|
||||||
|--------|-----------|
|
- `vendorOrig` and `vendorOrigUnit` (BOM unit/total price) are attached to the first LOT sub-row only;
|
||||||
| Configurations | Client ↔ Server ↔ Other Clients |
|
- subsequent sub-rows carry empty `data-vendor-orig` so `setPricingCustomPriceFromVendor` counts each vendor PN exactly once.
|
||||||
| Projects | Client ↔ Server ↔ Other Clients |
|
|
||||||
| Pricelists | Server → Clients only (no push) |
|
|
||||||
| Components | Server → Clients only |
|
|
||||||
| Partnumber books | Server → Clients only |
|
|
||||||
|
|
||||||
Local pricelists not present on the server and not referenced by active configurations are deleted automatically on sync.
|
Controls terminology:
|
||||||
|
- custom price input is labeled **Ручная цена** (not "Своя цена");
|
||||||
|
- the button that fills custom price from BOM totals is labeled **BOM Цена** (not "Проставить цены BOM").
|
||||||
|
|
||||||
### Soft Deletes (Archive Pattern)
|
CSV export reads PN вендора, Описание, and LOT from `data-vendor-pn`, `data-desc`, `data-lot` row attributes to bypass the rowspan cell offset problem.
|
||||||
|
|
||||||
Configurations and projects are **never hard-deleted**. Deletion is archive via `is_active = false`.
|
## Configuration versioning
|
||||||
|
|
||||||
- `DELETE /api/configs/:uuid` → sets `is_active = false` (archived); can be restored via `reactivate`
|
Configuration revisions are append-only snapshots stored in `local_configuration_versions`.
|
||||||
- `DELETE /api/projects/:uuid` → archives a project **variant** only (`variant` field must be non-empty); main projects cannot be deleted via this endpoint
|
|
||||||
|
|
||||||
## Sync Readiness Guard
|
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;
|
||||||
|
- 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.
|
||||||
|
|
||||||
Before every push/pull, a preflight check runs:
|
## Sync UX
|
||||||
1. Is the server (MariaDB) reachable?
|
|
||||||
2. Is the local client schema initialized and writable?
|
|
||||||
|
|
||||||
**If the check fails:**
|
UI-facing sync status must never block on live MariaDB calls.
|
||||||
- Local CRUD continues without restriction
|
|
||||||
- Sync API returns `423 Locked` with `reason_code` and `reason_text`
|
|
||||||
- UI shows a red indicator with the block reason
|
|
||||||
|
|
||||||
---
|
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.
|
||||||
|
|
||||||
## Pricing
|
## Naming collisions
|
||||||
|
|
||||||
### Principle
|
UI-driven rename and copy flows use one suffix convention for conflicts.
|
||||||
|
|
||||||
**Prices come only from `local_pricelist_items`.**
|
Rules:
|
||||||
Components (`local_components`) are metadata-only — they contain no pricing information.
|
- configuration and variant names must auto-resolve collisions with `_копия`, then `_копия2`, `_копия3`, and so on;
|
||||||
Stock enrichment for pricelist rows is persisted into `local_pricelist_items` during sync; UI/runtime must not resolve it live from MariaDB.
|
- copy checkboxes and copy modals must prefill `_копия`, not ` (копия)`;
|
||||||
|
- the literal variant name `main` is reserved and must not be allowed for non-main variants.
|
||||||
|
|
||||||
### Lookup Pattern
|
## Configuration types
|
||||||
|
|
||||||
```go
|
Configurations have a `config_type` field: `"server"` (default) or `"storage"`.
|
||||||
// Look up a price for a line item
|
|
||||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
|
||||||
if found && price > 0 {
|
|
||||||
// use price
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inside lookupPriceByPricelistID:
|
Rules:
|
||||||
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
|
- `config_type` defaults to `"server"` for all existing and new configurations unless explicitly set;
|
||||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
- 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` = дисковая полка без контроллера.
|
||||||
|
- the available config types and their localized names flow from `qt_settings.config_types` on the server;
|
||||||
|
QF falls back to hardcoded "server/Сервер" and "storage/СХД" when the setting is absent.
|
||||||
|
|
||||||
### Multi-Level Pricelists
|
## Server-driven configurator settings (`qt_settings`)
|
||||||
|
|
||||||
A configuration can reference up to three pricelists simultaneously:
|
QF reads four settings from `qt_settings` (MariaDB) and caches them in `local_qt_settings` (SQLite).
|
||||||
|
They are synced during every component sync. See `bible-local/server-contract-qt-settings.md` for the
|
||||||
|
full contract and JSON schemas.
|
||||||
|
|
||||||
| Field | Purpose |
|
| Setting key | Effect in QF |
|
||||||
|-------|---------|
|
|-------------|-------------|
|
||||||
| `pricelist_id` | Primary (estimate) |
|
| `config_types` | New-config modal buttons; category allowlist per config type |
|
||||||
| `warehouse_pricelist_id` | Warehouse pricing |
|
| `tab_config` | Configurator tab structure, sections, singleSelect |
|
||||||
| `competitor_pricelist_id` | Competitor pricing |
|
| `always_visible_tabs` | Which tabs are shown even when empty |
|
||||||
|
| `required_categories` | Per-config-type badge on tabs with unfilled required categories |
|
||||||
|
|
||||||
Pricelist sources: `estimate` | `warehouse` | `competitor`
|
Rules:
|
||||||
|
- sync runs after `SyncComponents`; failure is non-fatal (Warn log only);
|
||||||
|
- `local_qt_settings` is a read-only cache — never written by user actions;
|
||||||
|
- absent or unparseable settings: QF uses hardcoded fallbacks for that key only;
|
||||||
|
- `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere;
|
||||||
|
- `qt_categories.name` and `qt_categories.name_ru` are not used by QF runtime; do not depend on them.
|
||||||
|
|
||||||
### "Auto" Pricelist Selection
|
## Vendor BOM contract
|
||||||
|
|
||||||
Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`):
|
Vendor BOM is stored in `vendor_spec` on the configuration row.
|
||||||
|
|
||||||
- **Explicit mode:** concrete `pricelist_id` is set by user in settings.
|
Rules:
|
||||||
- **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist.
|
- PN to LOT resolution uses the active local partnumber book;
|
||||||
|
- canonical persisted mapping is `lot_mappings[]`;
|
||||||
`auto` must stay `auto` after price-level refresh and after manual "refresh prices":
|
- QuoteForge does not use legacy BOM tables such as `qt_bom`, `qt_lot_bundles`, or `qt_lot_bundle_items`.
|
||||||
- resolved IDs are runtime-only and must not overwrite user's mode;
|
|
||||||
- switching to explicit selection must clear runtime auto resolution for that source.
|
|
||||||
|
|
||||||
### Latest Pricelist Resolution Rules
|
|
||||||
|
|
||||||
For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with:
|
|
||||||
|
|
||||||
1. only pricelists that have at least one item (`EXISTS ...pricelist_items`);
|
|
||||||
2. deterministic sort: `created_at DESC, id DESC`.
|
|
||||||
|
|
||||||
This prevents selecting empty/incomplete snapshots and removes nondeterministic ties.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration Versioning
|
|
||||||
|
|
||||||
### Principle
|
|
||||||
|
|
||||||
Append-only for **spec+price** changes: immutable snapshots are stored in `local_configuration_versions`.
|
|
||||||
|
|
||||||
```
|
|
||||||
local_configurations
|
|
||||||
└── current_version_id ──► local_configuration_versions (v3) ← active
|
|
||||||
local_configuration_versions (v2)
|
|
||||||
local_configuration_versions (v1)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `version_no = max + 1` when configuration **spec+price** changes
|
|
||||||
- Old versions are never modified or deleted in normal flow
|
|
||||||
- Rollback does **not** rewind history — it creates a **new** version from the snapshot
|
|
||||||
- Operational updates (`line_no` reorder, server count, project move, rename)
|
|
||||||
are synced via `pending_changes` but do **not** create a new revision snapshot
|
|
||||||
|
|
||||||
### Rollback
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /api/configs/:uuid/rollback
|
|
||||||
{
|
|
||||||
"target_version": 3,
|
|
||||||
"note": "optional comment"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Result:
|
|
||||||
- A new version `vN` is created with `data` from the target version
|
|
||||||
- `change_note = "rollback to v{target_version}"` (+ note if provided)
|
|
||||||
- `current_version_id` is switched to the new version
|
|
||||||
- Configuration moves to `sync_status = pending`
|
|
||||||
|
|
||||||
### Sync Status Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
local → pending → synced
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Specification Ordering (`Line`)
|
|
||||||
|
|
||||||
- Each project configuration has persistent `line_no` (`10,20,30...`) in both SQLite and MariaDB.
|
|
||||||
- Project list ordering is deterministic:
|
|
||||||
`line_no ASC`, then `created_at DESC`, then `id DESC`.
|
|
||||||
- Drag-and-drop reorder in project UI updates `line_no` for active project configurations.
|
|
||||||
- Reorder writes are queued as configuration `update` events in `pending_changes`
|
|
||||||
without creating new configuration versions.
|
|
||||||
- Backward compatibility: if remote MariaDB schema does not yet include `line_no`,
|
|
||||||
sync falls back to create/update without `line_no` instead of failing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sync Payload for Versioning
|
|
||||||
|
|
||||||
Events in `pending_changes` for configurations contain:
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| `configuration_uuid` | Identifier |
|
|
||||||
| `operation` | `create` / `update` / `rollback` |
|
|
||||||
| `current_version_id` | Active version ID |
|
|
||||||
| `current_version_no` | Version number |
|
|
||||||
| `snapshot` | Current configuration state |
|
|
||||||
| `idempotency_key` | For idempotent push |
|
|
||||||
| `conflict_policy` | `last_write_wins` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Background Processes
|
|
||||||
|
|
||||||
| Process | Interval | What it does |
|
|
||||||
|---------|----------|--------------|
|
|
||||||
| Sync worker | 5 min | push pending + pull all |
|
|
||||||
| Backup scheduler | configurable (`backup.time`) | creates ZIP archives |
|
|
||||||
|
|||||||
@@ -1,267 +1,420 @@
|
|||||||
# 03 — Database
|
# 03 - Database
|
||||||
|
|
||||||
## SQLite (local, client-side)
|
## SQLite
|
||||||
|
|
||||||
File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
SQLite is the local runtime database.
|
||||||
|
|
||||||
### Tables
|
Main tables:
|
||||||
|
|
||||||
#### Components and Reference Data
|
|
||||||
|
|
||||||
| Table | Purpose | Key Fields |
|
|
||||||
|-------|---------|------------|
|
|
||||||
| `local_components` | Component metadata (NO prices) | `lot_name` (PK), `lot_description`, `category`, `model` |
|
|
||||||
| `connection_settings` | MariaDB connection settings | key-value store |
|
|
||||||
| `app_settings` | Application settings | `key` (PK), `value`, `updated_at` |
|
|
||||||
|
|
||||||
Read-only cache contract:
|
|
||||||
|
|
||||||
- `local_components`, `local_pricelists`, `local_pricelist_items`, `local_partnumber_books`, and `local_partnumber_book_items` are synchronized caches, not user-authored data.
|
|
||||||
- Startup must prefer application availability over preserving a broken cache schema.
|
|
||||||
- If one of these tables cannot be migrated safely, the client may quarantine or drop it and recreate it empty; the next sync repopulates it.
|
|
||||||
|
|
||||||
#### Pricelists
|
|
||||||
|
|
||||||
| Table | Purpose | Key Fields |
|
|
||||||
|-------|---------|------------|
|
|
||||||
| `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` |
|
|
||||||
| `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` |
|
|
||||||
|
|
||||||
#### Partnumber Books (PN → LOT mapping, pull-only from PriceForge)
|
|
||||||
|
|
||||||
| Table | Purpose | Key Fields |
|
|
||||||
|-------|---------|------------|
|
|
||||||
| `local_partnumber_books` | Version snapshots of PN→LOT mappings | `id`, `server_id` (unique), `version`, `created_at`, `is_active` |
|
|
||||||
| `local_partnumber_book_items` | Canonical PN catalog rows | `id`, `partnumber`, `lots_json`, `description` |
|
|
||||||
|
|
||||||
Active book: `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
|
|
||||||
|
|
||||||
#### Configurations and Projects
|
|
||||||
|
|
||||||
| Table | Purpose | Key Fields |
|
|
||||||
|-------|---------|------------|
|
|
||||||
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `vendor_spec` (JSON: PN/qty/description + canonical `lot_mappings[]`), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
|
|
||||||
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
|
|
||||||
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
|
|
||||||
|
|
||||||
#### Sync
|
|
||||||
|
|
||||||
| Table | Purpose |
|
| Table | Purpose |
|
||||||
|-------|---------|
|
| --- | --- |
|
||||||
| `pending_changes` | Queue of changes to push to MariaDB |
|
| `local_components` | synced component metadata |
|
||||||
| `local_schema_migrations` | Applied migrations (idempotency guard) |
|
| `local_pricelists` | local pricelist headers |
|
||||||
|
| `local_pricelist_items` | local pricelist rows, the only runtime price source |
|
||||||
|
| `local_projects` | user projects |
|
||||||
|
| `local_configurations` | user configurations |
|
||||||
|
| `local_configuration_versions` | immutable revision snapshots |
|
||||||
|
| `local_partnumber_books` | partnumber book headers |
|
||||||
|
| `local_partnumber_book_items` | PN -> LOT catalog payload |
|
||||||
|
| `pending_changes` | sync queue |
|
||||||
|
| `connection_settings` | encrypted MariaDB connection settings |
|
||||||
|
| `app_settings` | local app state |
|
||||||
|
| `local_schema_migrations` | applied local migration markers |
|
||||||
|
| `local_qt_settings` | server-pushed configurator settings cache (from `qt_settings`) |
|
||||||
|
|
||||||
---
|
Rules:
|
||||||
|
- cache tables may be rebuilt if local migration recovery requires it;
|
||||||
|
- user-authored tables must not be dropped as a recovery shortcut;
|
||||||
|
- `local_pricelist_items` is the only valid runtime source of prices;
|
||||||
|
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows.
|
||||||
|
|
||||||
### Key SQLite Indexes
|
## MariaDB
|
||||||
|
|
||||||
|
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
|
||||||
|
|
||||||
|
### QuoteForge tables (qt_*)
|
||||||
|
|
||||||
|
Runtime read:
|
||||||
|
- `qt_categories` — pricelist categories (note: `name`/`name_ru` columns being removed; QF does not use them)
|
||||||
|
- `qt_lot_metadata` — component metadata, price settings
|
||||||
|
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
||||||
|
- `qt_pricelist_items` — pricelist rows
|
||||||
|
- `qt_partnumber_books` — partnumber book headers
|
||||||
|
- `qt_partnumber_book_items` — PN→LOT catalog payload
|
||||||
|
- `qt_settings` — server-pushed configurator settings; schema managed by server-side agent (see `bible-local/server-contract-qt-settings.md`)
|
||||||
|
|
||||||
|
Runtime read/write:
|
||||||
|
- `qt_projects` — projects
|
||||||
|
- `qt_configurations` — configurations
|
||||||
|
- `qt_client_schema_state` — per-client sync status and version tracking
|
||||||
|
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
|
||||||
|
|
||||||
|
Insert-only tracking:
|
||||||
|
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
|
||||||
|
|
||||||
|
Server-side only (not queried by client runtime):
|
||||||
|
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
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 | being removed; QF does not use at runtime |
|
||||||
|
| name_ru | varchar(100) | being removed; QF does not use at runtime |
|
||||||
|
| display_order | bigint DEFAULT 0 | |
|
||||||
|
| is_required | tinyint(1) DEFAULT 0 | |
|
||||||
|
|
||||||
|
### qt_settings
|
||||||
|
Managed by the server-side agent. QF has SELECT-only access.
|
||||||
|
See `bible-local/server-contract-qt-settings.md` for full schema and value formats.
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| name | varchar(100) PK | setting key |
|
||||||
|
| value | TEXT NOT NULL | JSON-encoded value |
|
||||||
|
|
||||||
|
### local_qt_settings (SQLite)
|
||||||
|
Read-only cache of `qt_settings`. Synced during component sync.
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| name | text PK | setting key |
|
||||||
|
| value | text | JSON value as-is from server |
|
||||||
|
|
||||||
|
### qt_client_schema_state
|
||||||
|
PK: (username, hostname)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| 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) | |
|
||||||
|
| lot_suggestion | longtext (JSON) | nullable; set when user manually maps PN → LOT in vendor-spec UI; same format as `qt_partnumber_book_items.lots_json`; see [11-lot-suggestions.md](11-lot-suggestions.md) |
|
||||||
|
|
||||||
|
### stock_ignore_rules
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| 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
|
```sql
|
||||||
-- Pricelists
|
-- Read-only: reference and pricing data
|
||||||
INDEX local_pricelist_items(pricelist_id)
|
GRANT SELECT ON RFQ_LOG.qt_categories TO 'qfs_user'@'%';
|
||||||
UNIQUE INDEX local_pricelists(server_id)
|
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'qfs_user'@'%';
|
||||||
INDEX local_pricelists(source, created_at) -- used for "latest by source" queries
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'qfs_user'@'%';
|
||||||
-- latest-by-source runtime query also applies deterministic tie-break by id DESC
|
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'@'%';
|
||||||
|
|
||||||
-- Configurations
|
-- Read/write: runtime sync and user data
|
||||||
INDEX local_configurations(pricelist_id)
|
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_projects TO 'qfs_user'@'%';
|
||||||
INDEX local_configurations(warehouse_pricelist_id)
|
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'qfs_user'@'%';
|
||||||
INDEX local_configurations(competitor_pricelist_id)
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'qfs_user'@'%';
|
||||||
INDEX local_configurations(project_uuid, line_no) -- project ordering (Line column)
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
|
||||||
UNIQUE INDEX local_configurations(uuid)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `items` JSON Structure in Configurations
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"lot_name": "CPU_AMD_9654",
|
|
||||||
"quantity": 2,
|
|
||||||
"unit_price": 123456.78,
|
|
||||||
"section": "Processors"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Prices are stored inside the `items` JSON field and refreshed from the pricelist on configuration refresh.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MariaDB (server-side, sync-only)
|
|
||||||
|
|
||||||
Database: `RFQ_LOG`
|
|
||||||
|
|
||||||
### Tables and Permissions
|
|
||||||
|
|
||||||
| Table | Purpose | Permissions |
|
|
||||||
|-------|---------|-------------|
|
|
||||||
| `lot` | Component catalog | SELECT |
|
|
||||||
| `qt_lot_metadata` | Extended component data | SELECT |
|
|
||||||
| `qt_categories` | Component categories | SELECT |
|
|
||||||
| `qt_pricelists` | Pricelists | SELECT |
|
|
||||||
| `qt_pricelist_items` | Pricelist line items | SELECT |
|
|
||||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment during sync only) | SELECT |
|
|
||||||
| `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE |
|
|
||||||
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
|
||||||
| `qt_client_schema_state` | Applied migrations state + client operational status per `username + hostname` | SELECT, INSERT, UPDATE |
|
|
||||||
| `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE |
|
|
||||||
| `qt_partnumber_books` | Partnumber book headers with snapshot membership in `partnumbers_json` (written by PriceForge) | SELECT |
|
|
||||||
| `qt_partnumber_book_items` | Canonical PN catalog with `lots_json` composition (written by PriceForge) | SELECT |
|
|
||||||
| `qt_vendor_partnumber_seen` | Vendor PN tracking for unresolved/ignored BOM rows (`is_ignored`) | INSERT only for new `partnumber`; existing rows must not be modified |
|
|
||||||
|
|
||||||
Legacy server tables not used by QuoteForge runtime anymore:
|
|
||||||
|
|
||||||
- `qt_bom`
|
|
||||||
- `qt_lot_bundles`
|
|
||||||
- `qt_lot_bundle_items`
|
|
||||||
|
|
||||||
QuoteForge canonical BOM storage is:
|
|
||||||
|
|
||||||
- `qt_configurations.vendor_spec`
|
|
||||||
- row-level PN -> multiple LOT decomposition in `vendor_spec[].lot_mappings[]`
|
|
||||||
|
|
||||||
Partnumber book server read contract:
|
|
||||||
|
|
||||||
1. Read active or target book from `qt_partnumber_books`.
|
|
||||||
2. Parse `partnumbers_json`.
|
|
||||||
3. Load payloads from `qt_partnumber_book_items WHERE partnumber IN (...)`.
|
|
||||||
|
|
||||||
Pricelist stock enrichment contract:
|
|
||||||
|
|
||||||
1. Sync pulls base pricelist rows from `qt_pricelist_items`.
|
|
||||||
2. Sync reads latest stock quantities from `stock_log`.
|
|
||||||
3. Sync resolves `partnumber -> lot` through the local mirror of `qt_partnumber_book_items` (`local_partnumber_book_items.lots_json`).
|
|
||||||
4. Sync stores enriched `available_qty` and `partnumbers` into `local_pricelist_items`.
|
|
||||||
|
|
||||||
Runtime rule:
|
|
||||||
|
|
||||||
- pricelist UI and quote logic read only `local_pricelist_items`;
|
|
||||||
- runtime code must not query `stock_log`, `qt_pricelist_items`, or `qt_partnumber_book_items` directly outside sync.
|
|
||||||
|
|
||||||
`qt_partnumber_book_items` no longer contains `book_id` or `lot_name`.
|
|
||||||
It stores one row per `partnumber` with:
|
|
||||||
|
|
||||||
- `partnumber`
|
|
||||||
- `lots_json` as `[{"lot_name":"CPU_X","qty":2}, ...]`
|
|
||||||
- `description`
|
|
||||||
|
|
||||||
`qt_client_schema_state` current contract:
|
|
||||||
|
|
||||||
- identity key: `username + hostname`
|
|
||||||
- client/runtime state:
|
|
||||||
`app_version`, `last_checked_at`, `updated_at`
|
|
||||||
- operational state:
|
|
||||||
`last_sync_at`, `last_sync_status`
|
|
||||||
- queue health:
|
|
||||||
`pending_changes_count`, `pending_errors_count`
|
|
||||||
- local dataset size:
|
|
||||||
`configurations_count`, `projects_count`
|
|
||||||
- price context:
|
|
||||||
`estimate_pricelist_version`, `warehouse_pricelist_version`, `competitor_pricelist_version`
|
|
||||||
- last known sync problem:
|
|
||||||
`last_sync_error_code`, `last_sync_error_text`
|
|
||||||
|
|
||||||
`last_sync_error_*` source priority:
|
|
||||||
|
|
||||||
1. blocked readiness state from `local_sync_guard_state`
|
|
||||||
2. latest non-empty `pending_changes.last_error`
|
|
||||||
3. `NULL` when no known sync problem exists
|
|
||||||
|
|
||||||
### Grant Permissions to Existing User
|
|
||||||
|
|
||||||
```sql
|
|
||||||
GRANT SELECT ON RFQ_LOG.lot TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.stock_log TO '<DB_USER>'@'%';
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
|
|
||||||
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
|
|
||||||
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO '<DB_USER>'@'%';
|
|
||||||
|
|
||||||
FLUSH PRIVILEGES;
|
FLUSH PRIVILEGES;
|
||||||
```
|
```
|
||||||
|
|
||||||
### Create a New User
|
Rules:
|
||||||
|
- `qt_client_schema_state` requires INSERT + UPDATE for sync status tracking (uses `ON DUPLICATE KEY UPDATE`);
|
||||||
```sql
|
- `qt_vendor_partnumber_seen` requires INSERT + UPDATE (vendor PN discovery during sync);
|
||||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
- 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`;
|
||||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
- the setup page shows `can_write: true` only when `qt_client_schema_state` INSERT succeeds.
|
||||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.stock_log TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'quote_user'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'quote_user'@'%';
|
|
||||||
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'quote_user'@'%';
|
|
||||||
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
SHOW GRANTS FOR 'quote_user'@'%';
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** If pricelists sync but stock enrichment is empty, verify `SELECT` on `qt_pricelist_items`, `qt_partnumber_books`, `qt_partnumber_book_items`, and `stock_log`.
|
|
||||||
|
|
||||||
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
### SQLite Migrations (local) — два уровня, выполняются при каждом старте
|
SQLite:
|
||||||
|
- schema creation and additive changes go through GORM `AutoMigrate`;
|
||||||
|
- data fixes, index repair, and one-off rewrites go through `runLocalMigrations`;
|
||||||
|
- local migration state is tracked in `local_schema_migrations`.
|
||||||
|
|
||||||
**1. GORM AutoMigrate** (`internal/localdb/localdb.go`) — первый и основной уровень.
|
MariaDB:
|
||||||
Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**.
|
- SQL files live in `migrations/`;
|
||||||
|
- they are applied by `go run ./cmd/qfs -migrate`.
|
||||||
Local SQLite partnumber book cache contract:
|
|
||||||
|
|
||||||
- `local_partnumber_books.partnumbers_json` stores PN membership for a pulled book.
|
|
||||||
- `local_partnumber_book_items` is a deduplicated local catalog by `partnumber`.
|
|
||||||
- `local_partnumber_book_items.lots_json` mirrors the server `lots_json` payload.
|
|
||||||
- SQLite migration `2026_03_07_local_partnumber_book_catalog` rebuilds old `book_id + lot_name` rows into the new local cache shape.
|
|
||||||
→ Для добавления новой таблицы или колонки достаточно добавить модель/поле и включить модель в AutoMigrate.
|
|
||||||
|
|
||||||
**2. `runLocalMigrations`** (`internal/localdb/migrations.go`) — второй уровень, для операций которые AutoMigrate не умеет: backfill данных, пересоздание таблиц, создание индексов.
|
|
||||||
Каждая функция выполняется один раз — идемпотентность через запись `id` в `local_schema_migrations`.
|
|
||||||
|
|
||||||
QuoteForge does not use centralized server-driven SQLite migrations.
|
|
||||||
All local SQLite schema/data migrations live in the client codebase.
|
|
||||||
|
|
||||||
### MariaDB Migrations (server-side)
|
|
||||||
|
|
||||||
- Stored in `migrations/` (SQL files)
|
|
||||||
- Applied via `-migrate` flag
|
|
||||||
- `min_app_version` — minimum app version required for the migration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DB Debugging
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Inspect schema
|
|
||||||
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_components"
|
|
||||||
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_configurations"
|
|
||||||
|
|
||||||
# Check pricelist item count
|
|
||||||
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM local_pricelist_items"
|
|
||||||
|
|
||||||
# Check pending sync queue
|
|
||||||
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM pending_changes"
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,170 +1,125 @@
|
|||||||
# 04 — API and Web Routes
|
# 04 - API
|
||||||
|
|
||||||
## API Endpoints
|
## Public web routes
|
||||||
|
|
||||||
### Setup
|
| Route | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | configurator |
|
||||||
|
| `/configs` | configuration list |
|
||||||
|
| `/configs/:uuid/revisions` | revision history page |
|
||||||
|
| `/projects` | project list |
|
||||||
|
| `/projects/:uuid` | project detail |
|
||||||
|
| `/pricelists` | pricelist list |
|
||||||
|
| `/pricelists/:id` | pricelist detail |
|
||||||
|
| `/partnumber-books` | partnumber book page |
|
||||||
|
| `/setup` | DB setup page |
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
## Setup and health
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/setup` | Initial setup page |
|
|
||||||
| POST | `/setup` | Save connection settings |
|
|
||||||
| POST | `/setup/test` | Test MariaDB connection |
|
|
||||||
| GET | `/setup/status` | Setup status |
|
|
||||||
|
|
||||||
### Components
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/health` | process health |
|
||||||
|
| `GET` | `/setup` | setup page |
|
||||||
|
| `POST` | `/setup` | save tested DB settings |
|
||||||
|
| `POST` | `/setup/test` | test DB connection |
|
||||||
|
| `GET` | `/setup/status` | setup status |
|
||||||
|
| `GET` | `/api/db-status` | current DB/sync status |
|
||||||
|
| `GET` | `/api/current-user` | local user identity |
|
||||||
|
| `GET` | `/api/ping` | lightweight API ping |
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
`POST /api/restart` exists only in `debug` mode.
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/components` | List components (metadata only) |
|
|
||||||
| GET | `/api/components/:lot_name` | Component by lot_name |
|
|
||||||
| GET | `/api/categories` | List categories |
|
|
||||||
|
|
||||||
### Quote
|
## Reference data
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
| Method | Path | Purpose |
|
||||||
|--------|----------|---------|
|
| --- | --- | --- |
|
||||||
| POST | `/api/quote/validate` | Validate line items |
|
| `GET` | `/api/components` | list component metadata |
|
||||||
| POST | `/api/quote/calculate` | Calculate quote (prices from pricelist) |
|
| `GET` | `/api/components/:lot_name` | one component |
|
||||||
| POST | `/api/quote/price-levels` | Prices by level (estimate/warehouse/competitor) |
|
| `GET` | `/api/categories` | list categories |
|
||||||
|
| `GET` | `/api/pricelists` | list local pricelists |
|
||||||
|
| `GET` | `/api/pricelists/latest` | latest pricelist by source |
|
||||||
|
| `GET` | `/api/pricelists/:id` | pricelist header |
|
||||||
|
| `GET` | `/api/pricelists/:id/items` | pricelist rows |
|
||||||
|
| `GET` | `/api/pricelists/:id/lots` | lot names in a pricelist |
|
||||||
|
| `GET` | `/api/partnumber-books` | local partnumber books |
|
||||||
|
| `GET` | `/api/partnumber-books/:id` | book items by `server_id` |
|
||||||
|
|
||||||
### Pricelists (read-only)
|
## Quote and export
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
| Method | Path | Purpose |
|
||||||
|--------|----------|---------|
|
| --- | --- | --- |
|
||||||
| GET | `/api/pricelists` | List pricelists (`source`, `active_only`, pagination) |
|
| `POST` | `/api/quote/validate` | validate config items |
|
||||||
| GET | `/api/pricelists/latest` | Latest pricelist by source |
|
| `POST` | `/api/quote/calculate` | calculate quote totals |
|
||||||
| GET | `/api/pricelists/:id` | Pricelist by ID |
|
| `POST` | `/api/quote/price-levels` | resolve estimate/warehouse/competitor prices |
|
||||||
| GET | `/api/pricelists/:id/items` | Pricelist line items |
|
| `POST` | `/api/export/csv` | export a single configuration |
|
||||||
| GET | `/api/pricelists/:id/lots` | Lot names in pricelist |
|
| `GET` | `/api/configs/:uuid/export` | export a stored configuration |
|
||||||
|
| `GET` | `/api/projects/:uuid/export` | legacy project BOM export |
|
||||||
|
| `POST` | `/api/projects/:uuid/export` | pricing-tab project export |
|
||||||
|
|
||||||
`GET /api/pricelists?active_only=true` returns only pricelists that have synced items (`item_count > 0`).
|
## Configurations
|
||||||
|
|
||||||
### Configurations
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/configs` | list configurations |
|
||||||
|
| `POST` | `/api/configs/import` | import configurations from server |
|
||||||
|
| `POST` | `/api/configs` | create configuration |
|
||||||
|
| `POST` | `/api/configs/preview-article` | preview article |
|
||||||
|
| `GET` | `/api/configs/:uuid` | get configuration |
|
||||||
|
| `PUT` | `/api/configs/:uuid` | update configuration |
|
||||||
|
| `DELETE` | `/api/configs/:uuid` | archive configuration |
|
||||||
|
| `POST` | `/api/configs/:uuid/reactivate` | reactivate configuration |
|
||||||
|
| `PATCH` | `/api/configs/:uuid/rename` | rename configuration |
|
||||||
|
| `POST` | `/api/configs/:uuid/clone` | clone configuration |
|
||||||
|
| `POST` | `/api/configs/:uuid/refresh-prices` | refresh prices |
|
||||||
|
| `PATCH` | `/api/configs/:uuid/project` | move configuration to project |
|
||||||
|
| `GET` | `/api/configs/:uuid/versions` | list revisions |
|
||||||
|
| `GET` | `/api/configs/:uuid/versions/:version` | get one revision |
|
||||||
|
| `POST` | `/api/configs/:uuid/rollback` | rollback by creating a new head revision |
|
||||||
|
| `PATCH` | `/api/configs/:uuid/server-count` | update server count |
|
||||||
|
| `GET` | `/api/configs/:uuid/vendor-spec` | read vendor BOM |
|
||||||
|
| `PUT` | `/api/configs/:uuid/vendor-spec` | replace vendor BOM |
|
||||||
|
| `POST` | `/api/configs/:uuid/vendor-spec/resolve` | resolve PN -> LOT |
|
||||||
|
| `POST` | `/api/configs/:uuid/vendor-spec/apply` | apply BOM to cart |
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
## Projects
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/configs` | List configurations |
|
|
||||||
| POST | `/api/configs` | Create configuration |
|
|
||||||
| GET | `/api/configs/:uuid` | Get configuration |
|
|
||||||
| PUT | `/api/configs/:uuid` | Update configuration |
|
|
||||||
| DELETE | `/api/configs/:uuid` | Archive configuration |
|
|
||||||
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
|
|
||||||
| POST | `/api/configs/:uuid/clone` | Clone configuration |
|
|
||||||
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
|
|
||||||
| POST | `/api/configs/:uuid/rename` | Rename configuration |
|
|
||||||
| POST | `/api/configs/preview-article` | Preview generated article for a configuration |
|
|
||||||
| POST | `/api/configs/:uuid/rollback` | Roll back to a version |
|
|
||||||
| GET | `/api/configs/:uuid/versions` | List versions |
|
|
||||||
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
|
|
||||||
|
|
||||||
`line` field in configuration payloads is backed by persistent `line_no` in DB.
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/projects` | paginated project list |
|
||||||
|
| `GET` | `/api/projects/all` | lightweight list for dropdowns |
|
||||||
|
| `POST` | `/api/projects` | create project |
|
||||||
|
| `GET` | `/api/projects/:uuid` | get project |
|
||||||
|
| `PUT` | `/api/projects/:uuid` | update project |
|
||||||
|
| `POST` | `/api/projects/:uuid/archive` | archive project |
|
||||||
|
| `POST` | `/api/projects/:uuid/reactivate` | reactivate project |
|
||||||
|
| `DELETE` | `/api/projects/:uuid` | delete project variant only |
|
||||||
|
| `GET` | `/api/projects/:uuid/configs` | list project configurations |
|
||||||
|
| `PATCH` | `/api/projects/:uuid/configs/reorder` | persist line order |
|
||||||
|
| `POST` | `/api/projects/:uuid/configs` | create configuration inside project |
|
||||||
|
| `POST` | `/api/projects/:uuid/configs/:config_uuid/clone` | clone config into project |
|
||||||
|
| `POST` | `/api/projects/:uuid/vendor-import` | import CFXML workspace into project |
|
||||||
|
|
||||||
### Projects
|
Vendor import contract:
|
||||||
|
- multipart field name is `file`;
|
||||||
|
- file limit is `1 GiB`;
|
||||||
|
- oversized payloads are rejected before XML parsing.
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
## Sync
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/projects` | List projects |
|
|
||||||
| POST | `/api/projects` | Create project |
|
|
||||||
| GET | `/api/projects/:uuid` | Get project |
|
|
||||||
| PUT | `/api/projects/:uuid` | Update project |
|
|
||||||
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
|
|
||||||
| GET | `/api/projects/:uuid/configs` | Project configurations |
|
|
||||||
| PATCH | `/api/projects/:uuid/configs/reorder` | Reorder active project configurations (`ordered_uuids`) and persist `line_no` |
|
|
||||||
| POST | `/api/projects/:uuid/vendor-import` | Import a vendor `CFXML` workspace into the existing project |
|
|
||||||
|
|
||||||
`GET /api/projects/:uuid/configs` ordering:
|
| Method | Path | Purpose |
|
||||||
`line ASC`, then `created_at DESC`, then `id DESC`.
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/sync/status` | sync status |
|
||||||
|
| `GET` | `/api/sync/readiness` | sync readiness |
|
||||||
|
| `GET` | `/api/sync/info` | sync modal data |
|
||||||
|
| `GET` | `/api/sync/users-status` | remote user status |
|
||||||
|
| `GET` | `/api/sync/pending/count` | pending queue count |
|
||||||
|
| `GET` | `/api/sync/pending` | pending queue rows |
|
||||||
|
| `POST` | `/api/sync/components` | pull components |
|
||||||
|
| `POST` | `/api/sync/pricelists` | pull pricelists |
|
||||||
|
| `POST` | `/api/sync/partnumber-books` | pull partnumber books |
|
||||||
|
| `POST` | `/api/sync/partnumber-seen` | report unresolved vendor PN |
|
||||||
|
| `POST` | `/api/sync/all` | push and pull full sync |
|
||||||
|
| `POST` | `/api/sync/push` | push pending changes |
|
||||||
|
| `POST` | `/api/sync/repair` | repair broken pending rows |
|
||||||
|
|
||||||
`POST /api/projects/:uuid/vendor-import` accepts `multipart/form-data` with one required file field:
|
When readiness is blocked, sync write endpoints return `423 Locked`.
|
||||||
|
|
||||||
- `file` — vendor configurator export in `CFXML` format
|
|
||||||
|
|
||||||
### Sync
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose | Flow |
|
|
||||||
|--------|----------|---------|------|
|
|
||||||
| GET | `/api/sync/status` | Overall sync status | read-only |
|
|
||||||
| GET | `/api/sync/readiness` | Preflight status (ready/blocked/unknown) | read-only |
|
|
||||||
| GET | `/api/sync/info` | Data for sync modal | read-only |
|
|
||||||
| GET | `/api/sync/users-status` | Users status | read-only |
|
|
||||||
| GET | `/api/sync/pending` | List pending changes | read-only |
|
|
||||||
| GET | `/api/sync/pending/count` | Count of pending changes | read-only |
|
|
||||||
| POST | `/api/sync/push` | Push pending → MariaDB | SQLite → MariaDB |
|
|
||||||
| POST | `/api/sync/components` | Pull components | MariaDB → SQLite |
|
|
||||||
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
|
|
||||||
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
|
|
||||||
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
|
|
||||||
| POST | `/api/sync/partnumber-seen` | Report unresolved/ignored vendor PNs for server-side tracking | QuoteForge → MariaDB |
|
|
||||||
|
|
||||||
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
|
|
||||||
|
|
||||||
### Vendor Spec (BOM)
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored vendor BOM |
|
|
||||||
| PUT | `/api/configs/:uuid/vendor-spec` | Replace vendor BOM (full update) |
|
|
||||||
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (read-only) |
|
|
||||||
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `GET` / `PUT /api/configs/:uuid/vendor-spec` exchange normalized BOM rows (`vendor_spec`), not raw pasted Excel layout.
|
|
||||||
- BOM row contract stores canonical LOT mapping list as seen in BOM UI:
|
|
||||||
- `lot_mappings[]`
|
|
||||||
- each mapping contains `lot_name` + `quantity_per_pn`
|
|
||||||
- `POST /api/configs/:uuid/vendor-spec/apply` rebuilds cart items from explicit BOM mappings:
|
|
||||||
- all LOTs from `lot_mappings[]`
|
|
||||||
|
|
||||||
### Partnumber Books (read-only)
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| GET | `/api/partnumber-books` | List local book snapshots |
|
|
||||||
| GET | `/api/partnumber-books/:id` | Items for a book by `server_id` (`page`, `per_page`, `search`) |
|
|
||||||
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
|
|
||||||
|
|
||||||
See [09-vendor-spec.md](09-vendor-spec.md) for schema and pull logic.
|
|
||||||
See [09-vendor-spec.md](09-vendor-spec.md) for `vendor_spec` JSON schema and BOM UI mapping contract.
|
|
||||||
|
|
||||||
### Export
|
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| POST | `/api/export/csv` | Export configuration to CSV |
|
|
||||||
| GET | `/api/projects/:uuid/export` | Legacy project CSV export in block BOM format |
|
|
||||||
| POST | `/api/projects/:uuid/export` | Project CSV export in pricing-tab format with selectable columns (`include_lot`, `include_bom`, `include_estimate`, `include_stock`, `include_competitor`) |
|
|
||||||
|
|
||||||
**Export filename format:** `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
|
||||||
(uses `project.Code`, not `project.Name`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Web Routes
|
|
||||||
|
|
||||||
| Route | Page |
|
|
||||||
|-------|------|
|
|
||||||
| `/configs` | Configuration list |
|
|
||||||
| `/configurator` | Configurator |
|
|
||||||
| `/configs/:uuid/revisions` | Configuration revision history |
|
|
||||||
| `/projects` | Project list |
|
|
||||||
| `/projects/:uuid` | Project details |
|
|
||||||
| `/pricelists` | Pricelist list |
|
|
||||||
| `/pricelists/:id` | Pricelist details |
|
|
||||||
| `/partnumber-books` | Partnumber books (active book summary + snapshot history) |
|
|
||||||
| `/setup` | Connection settings |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rollback API (details)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POST /api/configs/:uuid/rollback
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"target_version": 3,
|
|
||||||
"note": "optional comment"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response: updated configuration with the new version.
|
|
||||||
|
|||||||
@@ -1,141 +1,74 @@
|
|||||||
# 05 — Configuration and Environment
|
# 05 - Config
|
||||||
|
|
||||||
## File Paths
|
## Runtime files
|
||||||
|
|
||||||
### SQLite database (`qfs.db`)
|
| Artifact | Default location |
|
||||||
|
| --- | --- |
|
||||||
|
| `qfs.db` | OS-specific user state directory |
|
||||||
|
| `config.yaml` | same state directory as `qfs.db` |
|
||||||
|
| `local_encryption.key` | same state directory as `qfs.db` |
|
||||||
|
| `backups/` | next to `qfs.db` unless overridden |
|
||||||
|
|
||||||
| OS | Default path |
|
The runtime state directory can be overridden with `QFS_STATE_DIR`.
|
||||||
|----|-------------|
|
Direct paths can be overridden with `QFS_DB_PATH` and `QFS_CONFIG_PATH`.
|
||||||
| macOS | `~/Library/Application Support/QuoteForge/qfs.db` |
|
|
||||||
| Linux | `$XDG_STATE_HOME/quoteforge/qfs.db` or `~/.local/state/quoteforge/qfs.db` |
|
|
||||||
| Windows | `%LOCALAPPDATA%\QuoteForge\qfs.db` |
|
|
||||||
|
|
||||||
Override: `-localdb <path>` or `QFS_DB_PATH`.
|
## Runtime config shape
|
||||||
|
|
||||||
### config.yaml
|
Runtime keeps `config.yaml` intentionally small:
|
||||||
|
|
||||||
Searched in the same user-state directory as `qfs.db` by default.
|
|
||||||
If the file does not exist, it is created automatically.
|
|
||||||
If the format is outdated, it is automatically migrated to the runtime format (`server` + `logging` sections only).
|
|
||||||
|
|
||||||
Override: `-config <path>` or `QFS_CONFIG_PATH`.
|
|
||||||
|
|
||||||
**Important:** `config.yaml` is a runtime user file — it is **not stored in the repository**.
|
|
||||||
`config.example.yaml` is the only config template in the repo.
|
|
||||||
|
|
||||||
### Local encryption key
|
|
||||||
|
|
||||||
Saved MariaDB credentials in SQLite are encrypted with:
|
|
||||||
|
|
||||||
1. `QUOTEFORGE_ENCRYPTION_KEY` if explicitly provided, otherwise
|
|
||||||
2. an application-managed random key file stored at `<state dir>/local_encryption.key`.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- The key file is created automatically with mode `0600`.
|
|
||||||
- The key file is not committed and is not included in normal backups.
|
|
||||||
- Restoring `qfs.db` on another machine requires re-entering DB credentials unless the key file is migrated separately.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## config.yaml Structure
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server:
|
server:
|
||||||
host: "0.0.0.0"
|
host: "127.0.0.1"
|
||||||
port: 8080
|
port: 8080
|
||||||
mode: "release" # release | debug
|
mode: "release"
|
||||||
|
read_timeout: 30s
|
||||||
logging:
|
write_timeout: 30s
|
||||||
level: "info" # debug | info | warn | error
|
|
||||||
format: "json" # json | text
|
|
||||||
output: "stdout" # stdout | stderr | /path/to/file
|
|
||||||
|
|
||||||
backup:
|
backup:
|
||||||
time: "00:00" # HH:MM in local time
|
time: "00:00"
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: "info"
|
||||||
|
format: "json"
|
||||||
|
output: "stdout"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Rules:
|
||||||
|
- QuoteForge creates this file automatically if it does not exist;
|
||||||
|
- startup rewrites legacy config files into this minimal runtime shape;
|
||||||
|
- startup normalizes any `server.host` value to `127.0.0.1` before saving the runtime config;
|
||||||
|
- `server.host` must stay on loopback.
|
||||||
|
|
||||||
## Environment Variables
|
Saved MariaDB credentials do not live in `config.yaml`.
|
||||||
|
They are stored in SQLite and encrypted with `local_encryption.key` unless `QUOTEFORGE_ENCRYPTION_KEY` overrides the key material.
|
||||||
|
|
||||||
| Variable | Description | Default |
|
## Environment variables
|
||||||
|----------|-------------|---------|
|
|
||||||
| `QFS_DB_PATH` | Full path to SQLite DB | OS-specific user state dir |
|
|
||||||
| `QFS_STATE_DIR` | State directory (if `QFS_DB_PATH` is not set) | OS-specific user state dir |
|
|
||||||
| `QFS_CONFIG_PATH` | Full path to `config.yaml` | OS-specific user state dir |
|
|
||||||
| `QFS_BACKUP_DIR` | Root directory for rotating backups | `<db dir>/backups` |
|
|
||||||
| `QFS_BACKUP_DISABLE` | Disable automatic backups | — |
|
|
||||||
| `QUOTEFORGE_ENCRYPTION_KEY` | Explicit override for local credential encryption key | app-managed key file |
|
|
||||||
| `QF_DB_HOST` | MariaDB host | localhost |
|
|
||||||
| `QF_DB_PORT` | MariaDB port | 3306 |
|
|
||||||
| `QF_DB_NAME` | Database name | RFQ_LOG |
|
|
||||||
| `QF_DB_USER` | DB user | — |
|
|
||||||
| `QF_DB_PASSWORD` | DB password | — |
|
|
||||||
| `QF_SERVER_PORT` | HTTP server port | 8080 |
|
|
||||||
|
|
||||||
`QFS_BACKUP_DISABLE` accepts: `1`, `true`, `yes`.
|
| Variable | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `QFS_STATE_DIR` | override runtime state directory |
|
||||||
|
| `QFS_DB_PATH` | explicit SQLite path |
|
||||||
|
| `QFS_CONFIG_PATH` | explicit config path |
|
||||||
|
| `QFS_BACKUP_DIR` | explicit backup root |
|
||||||
|
| `QFS_BACKUP_DISABLE` | disable rotating backups |
|
||||||
|
| `QUOTEFORGE_ENCRYPTION_KEY` | override encryption key |
|
||||||
|
| `QF_SERVER_PORT` | override HTTP port |
|
||||||
|
|
||||||
---
|
`QFS_BACKUP_DISABLE` accepts `1`, `true`, or `yes`.
|
||||||
|
|
||||||
## CLI Flags
|
## CLI flags
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Purpose |
|
||||||
|------|-------------|
|
| --- | --- |
|
||||||
| `-config <path>` | Path to config.yaml |
|
| `-config <path>` | config file path |
|
||||||
| `-localdb <path>` | Path to SQLite DB |
|
| `-localdb <path>` | SQLite path |
|
||||||
| `-reset-localdb` | Reset local DB (destructive!) |
|
| `-reset-localdb` | destructive local DB reset |
|
||||||
| `-migrate` | Apply pending migrations and exit |
|
| `-migrate` | apply server migrations and exit |
|
||||||
| `-version` | Print version and exit |
|
| `-version` | print app version and exit |
|
||||||
|
|
||||||
---
|
## First run
|
||||||
|
|
||||||
## Installation and First Run
|
1. runtime ensures `config.yaml` exists;
|
||||||
|
2. runtime opens the local SQLite database;
|
||||||
### Requirements
|
3. if no stored MariaDB credentials exist, `/setup` is served;
|
||||||
- Go 1.22 or higher
|
4. after setup, runtime works locally and sync uses saved DB settings in the background.
|
||||||
- MariaDB 11.x (or MySQL 8.x)
|
|
||||||
- ~50 MB disk space
|
|
||||||
|
|
||||||
### Steps
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Clone the repository
|
|
||||||
git clone <repo-url>
|
|
||||||
cd quoteforge
|
|
||||||
|
|
||||||
# 2. Apply migrations
|
|
||||||
go run ./cmd/qfs -migrate
|
|
||||||
|
|
||||||
# 3. Start
|
|
||||||
go run ./cmd/qfs
|
|
||||||
# or
|
|
||||||
make run
|
|
||||||
```
|
|
||||||
|
|
||||||
Application is available at: http://localhost:8080
|
|
||||||
|
|
||||||
On first run, `/setup` opens for configuring the MariaDB connection.
|
|
||||||
|
|
||||||
### OPS Project Migrator
|
|
||||||
|
|
||||||
Migrates quotes whose names start with `OPS-xxxx` (where `x` is a digit) into a project named `OPS-xxxx`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Preview first (always)
|
|
||||||
go run ./cmd/migrate_ops_projects
|
|
||||||
|
|
||||||
# Apply
|
|
||||||
go run ./cmd/migrate_ops_projects -apply
|
|
||||||
|
|
||||||
# Apply without interactive confirmation
|
|
||||||
go run ./cmd/migrate_ops_projects -apply -yes
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t quoteforge .
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,227 +1,55 @@
|
|||||||
# 06 — Backup
|
# 06 - Backup
|
||||||
|
|
||||||
## Overview
|
## Scope
|
||||||
|
|
||||||
Automatic rotating ZIP backup system for local data.
|
QuoteForge creates rotating local ZIP backups of:
|
||||||
|
- a consistent SQLite snapshot saved as `qfs.db`;
|
||||||
|
- `config.yaml` when present.
|
||||||
|
|
||||||
**What is included in each archive:**
|
The backup intentionally does not include `local_encryption.key`.
|
||||||
- SQLite DB (`qfs.db`)
|
|
||||||
- SQLite sidecars (`qfs.db-wal`, `qfs.db-shm`) if present
|
|
||||||
- `config.yaml` if present
|
|
||||||
|
|
||||||
**Archive name format:** `qfs-backp-YYYY-MM-DD.zip`
|
## Location and naming
|
||||||
|
|
||||||
|
Default root:
|
||||||
|
- `<db dir>/backups`
|
||||||
|
|
||||||
|
Subdirectories:
|
||||||
|
- `daily/`
|
||||||
|
- `weekly/`
|
||||||
|
- `monthly/`
|
||||||
|
- `yearly/`
|
||||||
|
|
||||||
|
Archive name:
|
||||||
|
- `qfs-backp-YYYY-MM-DD.zip`
|
||||||
|
|
||||||
|
## Retention
|
||||||
|
|
||||||
**Retention policy:**
|
|
||||||
| Period | Keep |
|
| Period | Keep |
|
||||||
|--------|------|
|
| --- | --- |
|
||||||
| Daily | 7 archives |
|
| Daily | 7 |
|
||||||
| Weekly | 4 archives |
|
| Weekly | 4 |
|
||||||
| Monthly | 12 archives |
|
| Monthly | 12 |
|
||||||
| Yearly | 10 archives |
|
| Yearly | 10 |
|
||||||
|
|
||||||
**Directories:** `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
backup:
|
|
||||||
time: "00:00" # Trigger time in local time (HH:MM format)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Environment variables:**
|
|
||||||
- `QFS_BACKUP_DIR` — backup root directory (default: `<db dir>/backups`)
|
|
||||||
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`)
|
|
||||||
|
|
||||||
**Safety rules:**
|
|
||||||
- Backup root must resolve outside any git worktree.
|
|
||||||
- If `qfs.db` is placed inside a repository checkout, default backups are rejected until `QFS_BACKUP_DIR` points outside the repo.
|
|
||||||
- Backup archives intentionally do **not** include `local_encryption.key`; restored installations on another machine must re-enter DB credentials.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- **At startup:** if no backup exists for the current period, one is created immediately
|
- on startup, QuoteForge creates a backup if the current period has none yet;
|
||||||
- **Daily:** at the configured time, a new backup is created
|
- a daily scheduler creates the next backup at `backup.time`;
|
||||||
- **Deduplication:** prevented via a `.period.json` marker file in each period directory
|
- duplicate snapshots inside the same period are prevented by a period marker file;
|
||||||
- **Rotation:** excess old archives are deleted automatically
|
- old archives are pruned automatically.
|
||||||
|
|
||||||
---
|
## Safety rules
|
||||||
|
|
||||||
## Implementation
|
- backup root must be outside the git worktree;
|
||||||
|
- backup creation is blocked if the resolved backup root sits inside the repository;
|
||||||
|
- SQLite snapshot must be created from a consistent database copy, not by copying live WAL files directly;
|
||||||
|
- restore to another machine requires re-entering DB credentials unless the encryption key is migrated separately.
|
||||||
|
|
||||||
Module: `internal/appstate/backup.go`
|
## Restore
|
||||||
|
|
||||||
Main function:
|
1. stop QuoteForge;
|
||||||
```go
|
2. unpack the chosen archive outside the repository;
|
||||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error)
|
3. replace `qfs.db`;
|
||||||
```
|
4. replace `config.yaml` if needed;
|
||||||
|
5. restart the app;
|
||||||
Scheduler (in `main.go`):
|
6. re-enter MariaDB credentials if the original encryption key is unavailable.
|
||||||
```go
|
|
||||||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Config struct
|
|
||||||
|
|
||||||
```go
|
|
||||||
type BackupConfig struct {
|
|
||||||
Time string `yaml:"time"`
|
|
||||||
}
|
|
||||||
// Default: "00:00"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
- `backup.time` is in **local time** without timezone offset parsing
|
|
||||||
- `.period.json` is the marker that prevents duplicate backups within the same period
|
|
||||||
- Archive filenames contain only the date; uniqueness is ensured by per-period directories + the period marker
|
|
||||||
- When changing naming or retention: update both the filename logic and the prune logic together
|
|
||||||
- Git worktree detection is path-based (`.git` ancestor check) and blocks backup creation inside the repo tree
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Full Listing: `internal/appstate/backup.go`
|
|
||||||
|
|
||||||
```go
|
|
||||||
package appstate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type backupPeriod struct {
|
|
||||||
name string
|
|
||||||
retention int
|
|
||||||
key func(time.Time) string
|
|
||||||
date func(time.Time) string
|
|
||||||
}
|
|
||||||
|
|
||||||
var backupPeriods = []backupPeriod{
|
|
||||||
{
|
|
||||||
name: "daily",
|
|
||||||
retention: 7,
|
|
||||||
key: func(t time.Time) string { return t.Format("2006-01-02") },
|
|
||||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "weekly",
|
|
||||||
retention: 4,
|
|
||||||
key: func(t time.Time) string {
|
|
||||||
y, w := t.ISOWeek()
|
|
||||||
return fmt.Sprintf("%04d-W%02d", y, w)
|
|
||||||
},
|
|
||||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "monthly",
|
|
||||||
retention: 12,
|
|
||||||
key: func(t time.Time) string { return t.Format("2006-01") },
|
|
||||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "yearly",
|
|
||||||
retention: 10,
|
|
||||||
key: func(t time.Time) string { return t.Format("2006") },
|
|
||||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
|
||||||
if isBackupDisabled() || dbPath == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
root := resolveBackupRoot(dbPath)
|
|
||||||
now := time.Now()
|
|
||||||
created := make([]string, 0)
|
|
||||||
for _, period := range backupPeriods {
|
|
||||||
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
|
|
||||||
if err != nil {
|
|
||||||
return created, err
|
|
||||||
}
|
|
||||||
created = append(created, newFiles...)
|
|
||||||
}
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Full Listing: Scheduler Hook (`main.go`)
|
|
||||||
|
|
||||||
```go
|
|
||||||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
|
|
||||||
if cfg == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hour, minute, err := parseBackupTime(cfg.Backup.Time)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
|
|
||||||
hour, minute = 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Startup check: create backup immediately if none exists for current periods
|
|
||||||
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
|
|
||||||
slog.Error("local backup failed", "error", backupErr)
|
|
||||||
} else {
|
|
||||||
for _, path := range created {
|
|
||||||
slog.Info("local backup completed", "archive", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
next := nextBackupTime(time.Now(), hour, minute)
|
|
||||||
timer := time.NewTimer(time.Until(next))
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
timer.Stop()
|
|
||||||
return
|
|
||||||
case <-timer.C:
|
|
||||||
start := time.Now()
|
|
||||||
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
|
|
||||||
duration := time.Since(start)
|
|
||||||
if backupErr != nil {
|
|
||||||
slog.Error("local backup failed", "error", backupErr, "duration", duration)
|
|
||||||
} else {
|
|
||||||
for _, path := range created {
|
|
||||||
slog.Info("local backup completed", "archive", path, "duration", duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseBackupTime(value string) (int, int, error) {
|
|
||||||
if strings.TrimSpace(value) == "" {
|
|
||||||
return 0, 0, fmt.Errorf("empty backup time")
|
|
||||||
}
|
|
||||||
parsed, err := time.Parse("15:04", value)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
return parsed.Hour(), parsed.Minute(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func nextBackupTime(now time.Time, hour, minute int) time.Time {
|
|
||||||
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
|
||||||
if !now.Before(target) {
|
|
||||||
target = target.Add(24 * time.Hour)
|
|
||||||
}
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,141 +1,35 @@
|
|||||||
# 07 — Development
|
# 07 - Development
|
||||||
|
|
||||||
## Commands
|
## Common commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run (dev)
|
|
||||||
go run ./cmd/qfs
|
go run ./cmd/qfs
|
||||||
make run
|
go run ./cmd/qfs -migrate
|
||||||
|
go run ./cmd/migrate_project_updated_at
|
||||||
# Build
|
|
||||||
make build-release # Optimized build with version info
|
|
||||||
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
|
|
||||||
|
|
||||||
# Cross-platform build
|
|
||||||
make build-all # Linux, macOS, Windows
|
|
||||||
make build-windows # Windows only
|
|
||||||
|
|
||||||
# Verification
|
|
||||||
go build ./cmd/qfs # Must compile without errors
|
|
||||||
go vet ./... # Linter
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
go test ./...
|
go test ./...
|
||||||
make test
|
go vet ./...
|
||||||
|
make build-release
|
||||||
# Utilities
|
make install-hooks
|
||||||
make install-hooks # Git hooks (block committing secrets)
|
|
||||||
make clean # Clean bin/
|
|
||||||
make help # All available commands
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
- **Formatting:** `gofmt` (mandatory)
|
|
||||||
- **Logging:** `slog` only (structured logging to the binary's stdout/stderr). No `console.log` or any other logging in browser-side JS — the browser console is never used for diagnostics.
|
|
||||||
- **Errors:** explicit wrapping with context (`fmt.Errorf("context: %w", err)`)
|
|
||||||
- **Style:** no unnecessary abstractions; minimum code for the task
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
## Guardrails
|
||||||
|
|
||||||
### What Must Never Be Restored
|
- run `gofmt` before commit;
|
||||||
|
- use `slog` for server logging;
|
||||||
|
- keep runtime business logic SQLite-only;
|
||||||
|
- limit MariaDB access to sync, setup, and migration tooling;
|
||||||
|
- keep `config.yaml` out of git and use `config.example.yaml` only as a template;
|
||||||
|
- update `bible-local/` in the same commit as architecture changes.
|
||||||
|
|
||||||
The following components were **intentionally removed** and must not be brought back:
|
## Removed features that must not return
|
||||||
- cron jobs
|
|
||||||
- importer utility
|
|
||||||
- admin pricing UI/API
|
|
||||||
- alerts
|
|
||||||
- stock import
|
|
||||||
|
|
||||||
### Configuration Files
|
- admin pricing UI/API;
|
||||||
|
- alerts and notification workflows;
|
||||||
|
- stock import tooling;
|
||||||
|
- cron jobs;
|
||||||
|
- standalone importer utility.
|
||||||
|
|
||||||
- `config.yaml` — runtime user file, **not stored in the repository**
|
## Release notes
|
||||||
- `config.example.yaml` — the only config template in the repo
|
|
||||||
|
|
||||||
### Sync and Local-First
|
Release history belongs under `releases/<version>/RELEASE_NOTES.md`.
|
||||||
|
Do not keep temporary change summaries in the repository root.
|
||||||
- Any sync changes must preserve local-first behavior
|
|
||||||
- Local CRUD must not be blocked when MariaDB is unavailable
|
|
||||||
- Runtime business code must not query MariaDB directly; all normal reads/writes go through SQLite snapshots
|
|
||||||
- Direct MariaDB access is allowed only in `internal/services/sync/*` and dedicated setup/migration tools under `cmd/`
|
|
||||||
- `connMgr.GetDB()` in handlers/services outside sync is a code review failure unless the code is strictly setup or operator tooling
|
|
||||||
- Local SQLite migrations must be implemented in code; do not add a server-side registry of client SQLite SQL patches
|
|
||||||
- Read-only local cache tables may be reset during startup recovery if migration fails; do not apply that strategy to user-authored tables like configurations, projects, pending changes, or connection settings
|
|
||||||
|
|
||||||
### Formats and UI
|
|
||||||
|
|
||||||
- **CSV export:** filename must use **project code** (`project.Code`), not project name
|
|
||||||
Format: `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
|
||||||
- **Breadcrumbs UI:** names longer than 16 characters must be truncated with an ellipsis
|
|
||||||
|
|
||||||
### Architecture Documentation
|
|
||||||
|
|
||||||
- **Every architectural decision must be recorded in `bible/`**
|
|
||||||
- The corresponding Bible file must be updated **in the same commit** as the code change
|
|
||||||
- On every user-requested commit, review and update the Bible in that same commit
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
### Add a Field to Configuration
|
|
||||||
|
|
||||||
1. Add the field to `LocalConfiguration` struct (`internal/models/`)
|
|
||||||
2. Add GORM tags for the DB column
|
|
||||||
3. Write a SQL migration (`migrations/`)
|
|
||||||
4. Update `ConfigurationToLocal` / `LocalToConfiguration` converters
|
|
||||||
5. Update API handlers and services
|
|
||||||
|
|
||||||
### Add a Field to Component
|
|
||||||
|
|
||||||
1. Add the field to `LocalComponent` struct (`internal/models/`)
|
|
||||||
2. Update the SQL query in `SyncComponents()`
|
|
||||||
3. Update the `componentRow` struct to match
|
|
||||||
4. Update converter functions
|
|
||||||
|
|
||||||
### Add a Pricelist Price Lookup
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Modern pattern
|
|
||||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
|
||||||
if found && price > 0 {
|
|
||||||
// use price
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Gotchas
|
|
||||||
|
|
||||||
1. **`CurrentPrice` removed from components** — any code using it will fail to compile
|
|
||||||
2. **`HasPrice` filter removed** — `component.go ListComponents` no longer supports this filter
|
|
||||||
3. **Quote calculation:** always SQLite-only; do not add a live MariaDB fallback
|
|
||||||
4. **Items JSON:** prices are stored in the `items` field of the configuration, not fetched from components
|
|
||||||
5. **Migrations are additive:** already-applied migrations are skipped (checked by `id` in `local_schema_migrations`)
|
|
||||||
6. **`SyncedAt` removed:** last component sync time is now in `app_settings` (key=`last_component_sync`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Debugging Price Issues
|
|
||||||
|
|
||||||
**Problem: quote returns no prices**
|
|
||||||
1. Check that `pricelist_id` is set on the configuration
|
|
||||||
2. Check that pricelist items exist: `SELECT COUNT(*) FROM local_pricelist_items`
|
|
||||||
3. Check `lookupPriceByPricelistID()` in `quote.go`
|
|
||||||
4. Verify the correct source is used (estimate/warehouse/competitor)
|
|
||||||
|
|
||||||
**Problem: component sync not working**
|
|
||||||
1. Components sync as metadata only — no prices
|
|
||||||
2. Prices come via a separate pricelist sync
|
|
||||||
3. Check `SyncComponents()` and the MariaDB query
|
|
||||||
|
|
||||||
**Problem: configuration refresh does not update prices**
|
|
||||||
1. Refresh uses the latest estimate pricelist by default
|
|
||||||
2. Latest resolution ignores pricelists without items (`EXISTS local_pricelist_items`)
|
|
||||||
3. Old prices in `config.items` are preserved if a line item is not found in the pricelist
|
|
||||||
4. To force a pricelist update: set `configuration.pricelist_id`
|
|
||||||
5. In configurator, `Авто` must remain auto-mode (runtime resolved ID must not be persisted as explicit selection)
|
|
||||||
|
|||||||
@@ -1,568 +1,160 @@
|
|||||||
# 09 — Vendor Spec (BOM Import)
|
# 09 - Vendor BOM
|
||||||
|
|
||||||
## Overview
|
## Storage contract
|
||||||
|
|
||||||
The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a configuration. It maps vendor part numbers (PN) to internal LOT names using an active partnumber book (snapshot pulled from PriceForge), then aggregates quantities to populate the Estimate (cart).
|
Vendor BOM is stored in `local_configurations.vendor_spec` and synced with `qt_configurations.vendor_spec`.
|
||||||
|
|
||||||
---
|
Each row uses this canonical shape:
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Storage
|
|
||||||
|
|
||||||
| Data | Storage | Sync direction |
|
|
||||||
|------|---------|---------------|
|
|
||||||
| `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT, JSON-encoded) | Two-way via `pending_changes` |
|
|
||||||
| Partnumber book snapshots | `local_partnumber_books` + `local_partnumber_book_items` | Pull-only from PriceForge |
|
|
||||||
|
|
||||||
`vendor_spec` is a JSON array of `VendorSpecItem` objects stored inside the configuration row.
|
|
||||||
It syncs to MariaDB `qt_configurations.vendor_spec` via the existing pending_changes mechanism.
|
|
||||||
|
|
||||||
Legacy storage note:
|
|
||||||
|
|
||||||
- QuoteForge does not use `qt_bom`
|
|
||||||
- QuoteForge does not use `qt_lot_bundles`
|
|
||||||
- QuoteForge does not use `qt_lot_bundle_items`
|
|
||||||
|
|
||||||
The only canonical persisted BOM contract for QuoteForge is `qt_configurations.vendor_spec`.
|
|
||||||
|
|
||||||
### `vendor_spec` JSON Schema
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"sort_order": 10,
|
|
||||||
"vendor_partnumber": "ABC-123",
|
|
||||||
"quantity": 2,
|
|
||||||
"description": "...",
|
|
||||||
"unit_price": 4500.00,
|
|
||||||
"total_price": 9000.00,
|
|
||||||
"lot_mappings": [
|
|
||||||
{ "lot_name": "LOT_A", "quantity_per_pn": 1 },
|
|
||||||
{ "lot_name": "LOT_B", "quantity_per_pn": 2 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
`lot_mappings[]` is the canonical persisted LOT mapping list for a BOM row.
|
|
||||||
Each mapping entry stores:
|
|
||||||
|
|
||||||
- `lot_name`
|
|
||||||
- `quantity_per_pn` (how many units of this LOT are included in **one vendor PN**)
|
|
||||||
|
|
||||||
### PN → LOT Mapping Contract (single LOT, multiplier, bundle)
|
|
||||||
|
|
||||||
QuoteForge expects the server to return/store BOM rows (`vendor_spec`) using a single canonical mapping list:
|
|
||||||
|
|
||||||
- `lot_mappings[]` contains **all** LOT mappings for the PN row (single LOT and bundle cases alike)
|
|
||||||
- the list stores exactly what the user sees in BOM (LOT + "LOT в 1 PN")
|
|
||||||
- the DB contract does **not** split mappings into "base LOT" vs "bundle LOTs"
|
|
||||||
|
|
||||||
#### Final quantity contribution to Estimate
|
|
||||||
|
|
||||||
For one BOM row with vendor PN quantity `pn_qty`:
|
|
||||||
|
|
||||||
- each mapping contribution:
|
|
||||||
- `lot_qty = pn_qty * lot_mappings[i].quantity_per_pn`
|
|
||||||
|
|
||||||
#### Example: one PN maps to multiple LOTs
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"vendor_partnumber": "SYS-821GE-TNHR",
|
"sort_order": 10,
|
||||||
"quantity": 3,
|
"vendor_partnumber": "ABC-123",
|
||||||
|
"quantity": 2,
|
||||||
|
"description": "row description",
|
||||||
|
"unit_price": 4500.0,
|
||||||
|
"total_price": 9000.0,
|
||||||
"lot_mappings": [
|
"lot_mappings": [
|
||||||
{ "lot_name": "CHASSIS_X13_8GPU", "quantity_per_pn": 1 },
|
{ "lot_name": "LOT_A", "quantity_per_pn": 1 }
|
||||||
{ "lot_name": "PS_3000W_Titanium", "quantity_per_pn": 2 },
|
|
||||||
{ "lot_name": "RAILKIT_X13", "quantity_per_pn": 1 }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This row contributes to Estimate:
|
Rules:
|
||||||
|
- `lot_mappings[]` is the only persisted PN -> LOT mapping contract;
|
||||||
|
- QuoteForge does not use legacy BOM tables;
|
||||||
|
- apply flow rebuilds cart rows from `lot_mappings[]`.
|
||||||
|
|
||||||
- `CHASSIS_X13_8GPU` → `3 * 1 = 3`
|
## Partnumber books
|
||||||
- `PS_3000W_Titanium` → `3 * 2 = 6`
|
|
||||||
- `RAILKIT_X13` → `3 * 1 = 3`
|
|
||||||
|
|
||||||
---
|
Partnumber books are pull-only snapshots from PriceForge.
|
||||||
|
|
||||||
## Partnumber Books (Snapshots)
|
Local tables:
|
||||||
|
- `local_partnumber_books`
|
||||||
|
- `local_partnumber_book_items`
|
||||||
|
|
||||||
Partnumber books are immutable versioned snapshots of the global PN→LOT mapping table, analogous to pricelists. PriceForge creates new snapshots; QuoteForge only pulls and reads them.
|
Server tables:
|
||||||
|
- `qt_partnumber_books`
|
||||||
|
- `qt_partnumber_book_items`
|
||||||
|
|
||||||
### SQLite (local mirror)
|
Resolution flow:
|
||||||
|
1. load the active local book;
|
||||||
|
2. find `vendor_partnumber`;
|
||||||
|
3. copy `lots_json` into `lot_mappings[]`;
|
||||||
|
4. keep unresolved rows editable in the UI.
|
||||||
|
|
||||||
```sql
|
## CFXML import
|
||||||
CREATE TABLE local_partnumber_books (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
server_id INTEGER UNIQUE NOT NULL, -- id from qt_partnumber_books
|
|
||||||
version TEXT NOT NULL, -- format YYYY-MM-DD-NNN
|
|
||||||
created_at DATETIME NOT NULL,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE local_partnumber_book_items (
|
`POST /api/projects/:uuid/vendor-import` imports one vendor workspace into an existing project.
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
partnumber TEXT NOT NULL,
|
|
||||||
lots_json TEXT NOT NULL,
|
|
||||||
description TEXT
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX idx_local_book_pn ON local_partnumber_book_items(partnumber);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Active book query:** `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
|
|
||||||
|
|
||||||
**Schema creation:** GORM AutoMigrate (not `runLocalMigrations`).
|
|
||||||
|
|
||||||
### MariaDB (managed exclusively by PriceForge)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE qt_partnumber_books (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
version VARCHAR(50) NOT NULL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
partnumbers_json LONGTEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE qt_partnumber_book_items (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
partnumber VARCHAR(255) NOT NULL,
|
|
||||||
lots_json LONGTEXT NOT NULL,
|
|
||||||
description VARCHAR(10000) NULL,
|
|
||||||
UNIQUE KEY uq_qt_partnumber_book_items_partnumber (partnumber)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE qt_configurations ADD COLUMN vendor_spec JSON NULL;
|
|
||||||
```
|
|
||||||
|
|
||||||
QuoteForge has `SELECT` permission only on `qt_partnumber_books` and `qt_partnumber_book_items`. All writes are managed by PriceForge.
|
|
||||||
|
|
||||||
**Grant (add to existing user setup):**
|
|
||||||
```sql
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resolution Algorithm (3-step)
|
|
||||||
|
|
||||||
For each `vendor_partnumber` in the BOM, QuoteForge builds/updates UI-visible LOT mappings:
|
|
||||||
|
|
||||||
1. **Active book lookup** — read active `local_partnumber_books`, verify PN membership in `partnumbers_json`, then query `local_partnumber_book_items WHERE partnumber = ?`.
|
|
||||||
2. **Populate BOM UI** — if a match exists, BOM row gets `lot_mappings[]` from `lots_json` (user can still edit it).
|
|
||||||
3. **Unresolved** — red row + inline LOT input with strict autocomplete.
|
|
||||||
|
|
||||||
Persistence note: the application stores the final user-visible mappings in `lot_mappings[]` (not separate "resolved/manual" persisted fields).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CFXML Workspace Import Contract
|
|
||||||
|
|
||||||
QuoteForge may import a vendor configurator workspace in `CFXML` format as an existing project update path.
|
|
||||||
This import path must convert one external workspace into one QuoteForge project containing multiple configurations.
|
|
||||||
|
|
||||||
### Import Unit Boundaries
|
|
||||||
|
|
||||||
- One `CFXML` workspace file = one QuoteForge project import session.
|
|
||||||
- One top-level configuration group inside the workspace = one QuoteForge configuration.
|
|
||||||
- Software rows are **not** imported as standalone configurations.
|
|
||||||
- All software rows must be attached to the configuration group they belong to.
|
|
||||||
|
|
||||||
### Configuration Grouping
|
|
||||||
|
|
||||||
Top-level `ProductLineItem` rows are grouped by:
|
|
||||||
|
|
||||||
- `ProprietaryGroupIdentifier`
|
|
||||||
|
|
||||||
This field is the canonical boundary of one imported configuration.
|
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
- accepted file field is `file`;
|
||||||
|
- maximum file size is `1 GiB`;
|
||||||
|
- one `ProprietaryGroupIdentifier` becomes one QuoteForge configuration;
|
||||||
|
- software rows stay inside their hardware group and never become standalone configurations;
|
||||||
|
- primary group row is selected structurally, without vendor-specific SKU hardcoding;
|
||||||
|
- imported configuration order follows workspace order.
|
||||||
|
|
||||||
1. Read all top-level `ProductLineItem` rows in document order.
|
Imported configuration fields:
|
||||||
2. Group them by `ProprietaryGroupIdentifier`.
|
- `name` from primary row `ProductName`
|
||||||
3. Preserve document order of groups by the first encountered `ProductLineNumber`.
|
- `server_count` from primary row `Quantity`
|
||||||
4. Import each group as exactly one QuoteForge configuration.
|
- `server_model` from primary row `ProductDescription`
|
||||||
|
- `article` or `support_code` from `ProprietaryProductIdentifier`
|
||||||
|
|
||||||
`ConfigurationGroupLineNumberReference` is not sufficient for grouping imported configurations because
|
Imported BOM rows become `vendor_spec` rows and are resolved through the active local partnumber book when possible.
|
||||||
multiple independent configuration groups may share the same value in one workspace.
|
|
||||||
|
|
||||||
### Primary Row Selection (no SKU hardcode)
|
## Inspur BOM import
|
||||||
|
|
||||||
The importer must not hardcode vendor, model, or server SKU values.
|
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts Inspur text BOM exports.
|
||||||
|
|
||||||
Within each `ProprietaryGroupIdentifier` group, the importer selects one primary top-level row using
|
Format: one component per line, `<partnumber>*<quantity>`. A leading `|` character is optional and stripped. Trailing whitespace around `*` is normalised.
|
||||||
structural rules only:
|
|
||||||
|
|
||||||
1. Prefer rows with `ProductTypeCode = Hardware`.
|
Example:
|
||||||
2. If multiple rows match, prefer the row with the largest number of `ProductSubLineItem` children.
|
```
|
||||||
3. If there is still a tie, prefer the first row by `ProductLineNumber`.
|
|CPU_AMD_9535-EPYC2.4_64C_256M_300W*1
|
||||||
|
|PowerSupply_1300W_Titanium_220VACor240VDC_GaN*2
|
||||||
The primary row provides configuration-level metadata such as:
|
|
||||||
|
|
||||||
- configuration name
|
|
||||||
- server count
|
|
||||||
- server model / description
|
|
||||||
- article / support code candidate
|
|
||||||
|
|
||||||
### Software Inclusion Rule
|
|
||||||
|
|
||||||
All top-level rows belonging to the same `ProprietaryGroupIdentifier` must be imported into the same
|
|
||||||
QuoteForge configuration, including:
|
|
||||||
|
|
||||||
- `Hardware`
|
|
||||||
- `Software`
|
|
||||||
- instruction / service rows represented as software-like items
|
|
||||||
|
|
||||||
Effects:
|
|
||||||
|
|
||||||
- a workspace never creates a separate configuration made only of software;
|
|
||||||
- `software1`, `software2`, license rows, and instruction rows stay inside the related configuration;
|
|
||||||
- the user sees one complete configuration instead of fragmented partial imports.
|
|
||||||
|
|
||||||
### Mapping to QuoteForge Project / Configuration
|
|
||||||
|
|
||||||
For one imported configuration group:
|
|
||||||
|
|
||||||
- QuoteForge configuration `name` <- primary row `ProductName`
|
|
||||||
- QuoteForge configuration `server_count` <- primary row `Quantity`
|
|
||||||
- QuoteForge configuration `server_model` <- primary row `ProductDescription`
|
|
||||||
- QuoteForge configuration `article` or `support_code` <- primary row `ProprietaryProductIdentifier`
|
|
||||||
- QuoteForge configuration `line` <- stable order by group appearance in the workspace
|
|
||||||
|
|
||||||
Project-level fields such as QuoteForge `code`, `name`, and `variant` are not reliably defined by `CFXML`
|
|
||||||
itself and should come from the existing target project context or explicit user input.
|
|
||||||
|
|
||||||
### Mapping to `vendor_spec`
|
|
||||||
|
|
||||||
The importer must build one combined `vendor_spec` array per configuration group.
|
|
||||||
|
|
||||||
Source rows:
|
|
||||||
|
|
||||||
- all `ProductSubLineItem` rows from the primary top-level row;
|
|
||||||
- all `ProductSubLineItem` rows from every non-primary top-level row in the same group;
|
|
||||||
- if a top-level row has no `ProductSubLineItem`, the top-level row itself may be converted into one
|
|
||||||
`vendor_spec` row so that software-only content is not lost.
|
|
||||||
|
|
||||||
Each imported row maps into one `VendorSpecItem`:
|
|
||||||
|
|
||||||
- `sort_order` <- stable sequence within the group
|
|
||||||
- `vendor_partnumber` <- `ProprietaryProductIdentifier`
|
|
||||||
- `quantity` <- `Quantity`
|
|
||||||
- `description` <- `ProductDescription`
|
|
||||||
- `unit_price` <- `UnitListPrice.FinancialAmount.MonetaryAmount` when present
|
|
||||||
- `total_price` <- `quantity * unit_price` when unit price is present
|
|
||||||
- `lot_mappings` <- resolved immediately from the active partnumber book using `lots_json`
|
|
||||||
|
|
||||||
The importer stores vendor-native rows in `vendor_spec`, then immediately runs the same logical flow as BOM
|
|
||||||
Resolve + Apply:
|
|
||||||
|
|
||||||
- resolve vendor PN rows through the active partnumber book
|
|
||||||
- persist canonical `lot_mappings[]`
|
|
||||||
- build normalized configuration `items` from `row.quantity * quantity_per_pn`
|
|
||||||
- fill `items.unit_price` from the latest local `estimate` pricelist
|
|
||||||
- recalculate configuration `total_price`
|
|
||||||
|
|
||||||
### Import Pipeline
|
|
||||||
|
|
||||||
Recommended parser pipeline:
|
|
||||||
|
|
||||||
1. Parse XML into top-level `ProductLineItem` rows.
|
|
||||||
2. Group rows by `ProprietaryGroupIdentifier`.
|
|
||||||
3. Select one primary row per group using structural rules.
|
|
||||||
4. Build one QuoteForge configuration DTO per group.
|
|
||||||
5. Merge all hardware/software rows of the group into one `vendor_spec`.
|
|
||||||
6. Resolve imported PN rows into canonical `lot_mappings[]` using the active partnumber book.
|
|
||||||
7. Build configuration `items` from resolved `lot_mappings[]`.
|
|
||||||
8. Price those `items` from the latest local `estimate` pricelist.
|
|
||||||
9. Save or update the QuoteForge configuration inside the existing project.
|
|
||||||
|
|
||||||
### Recommended Internal DTO
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ImportedProject struct {
|
|
||||||
SourceFormat string
|
|
||||||
SourceFilePath string
|
|
||||||
SourceDocID string
|
|
||||||
|
|
||||||
Code string
|
|
||||||
Name string
|
|
||||||
Variant string
|
|
||||||
|
|
||||||
Configurations []ImportedConfiguration
|
|
||||||
}
|
|
||||||
|
|
||||||
type ImportedConfiguration struct {
|
|
||||||
GroupID string
|
|
||||||
|
|
||||||
Name string
|
|
||||||
Line int
|
|
||||||
ServerCount int
|
|
||||||
|
|
||||||
ServerModel string
|
|
||||||
Article string
|
|
||||||
SupportCode string
|
|
||||||
CurrencyCode string
|
|
||||||
|
|
||||||
TopLevelRows []ImportedTopLevelRow
|
|
||||||
VendorSpec []ImportedVendorRow
|
|
||||||
}
|
|
||||||
|
|
||||||
type ImportedTopLevelRow struct {
|
|
||||||
ProductLineNumber string
|
|
||||||
ItemNo string
|
|
||||||
GroupID string
|
|
||||||
|
|
||||||
ProductType string
|
|
||||||
ProductCode string
|
|
||||||
ProductName string
|
|
||||||
Description string
|
|
||||||
Quantity int
|
|
||||||
UnitPrice *float64
|
|
||||||
IsPrimary bool
|
|
||||||
|
|
||||||
SubRows []ImportedVendorRow
|
|
||||||
}
|
|
||||||
|
|
||||||
type ImportedVendorRow struct {
|
|
||||||
SortOrder int
|
|
||||||
|
|
||||||
SourceLineNumber string
|
|
||||||
SourceParentLine string
|
|
||||||
SourceProductType string
|
|
||||||
|
|
||||||
VendorPartnumber string
|
|
||||||
Description string
|
|
||||||
Quantity int
|
|
||||||
UnitPrice *float64
|
|
||||||
TotalPrice *float64
|
|
||||||
|
|
||||||
ProductCharacter string
|
|
||||||
ProductCharPath string
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Current Product Assumption
|
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.
|
||||||
|
|
||||||
For QuoteForge product behavior, the correct user-facing interpretation is:
|
## Text BOM import
|
||||||
|
|
||||||
- one external project/workspace contains several configurations;
|
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a human-readable Russian text BOM.
|
||||||
- each configuration contains both hardware and software rows that belong to it;
|
|
||||||
- the importer must preserve that grouping exactly.
|
|
||||||
|
|
||||||
---
|
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
|
||||||
## Qty Aggregation Logic
|
`шт` 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.
|
||||||
After resolution, qty per LOT is computed from the BOM row quantity multiplied by the matched `lots_json.qty`:
|
|
||||||
|
|
||||||
|
Example:
|
||||||
```
|
```
|
||||||
qty(lot) = SUM(quantity_of_pn_row * quantity_of_lot_inside_lots_json)
|
Вычислительный 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шт.
|
||||||
```
|
```
|
||||||
|
|
||||||
Examples (book: PN_X → `[{LOT_A, qty:2}, {LOT_B, qty:1}]`):
|
Rules:
|
||||||
- BOM: PN_X ×3 → `LOT_A ×6`, `LOT_B ×3`
|
- the entire file becomes a single configuration (`server_count = 1`);
|
||||||
- BOM: PN_X ×1 and PN_X ×2 → `LOT_A ×6`, `LOT_B ×3`
|
- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the
|
||||||
|
last whitespace-separated token before the comma (so both `Сервер X3` and `Вычислительный GPU сервер X3`
|
||||||
|
resolve to `X3`);
|
||||||
|
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
|
||||||
|
- each line is trimmed, so leading/trailing whitespace never enters `vendor_partnumber`;
|
||||||
|
- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and
|
||||||
|
`description`, so rows resolve through the active partnumber book when matched and otherwise stay
|
||||||
|
unresolved and editable in the UI;
|
||||||
|
- lines that do not match `<description> - <quantity> шт.` are skipped;
|
||||||
|
- no price data is present in the format; `unit_price` and `total_price` are left nil.
|
||||||
|
|
||||||
---
|
## Nx BOM import (quantity-first)
|
||||||
|
|
||||||
## UI: Three Top-Level Tabs
|
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a quantity-first BOM
|
||||||
|
where each item line begins with `<qty>x <description>`.
|
||||||
|
|
||||||
The configurator (`/configurator`) has three tabs:
|
Format: an optional header line ending with `, в составе:` followed by one component per line as
|
||||||
|
`<qty>x <description>`. The `x` separator is case-insensitive; parentheses, commas, and hyphens
|
||||||
1. **Estimate** — existing cart/component configurator (unchanged).
|
inside the description are preserved as-is.
|
||||||
2. **BOM** — paste/import vendor BOM, manual column mapping, LOT matching, bundle decomposition (`1 PN -> multiple LOTs`), "Пересчитать эстимейт", "Очистить".
|
|
||||||
3. **Ценообразование** — pricing summary table + custom price input.
|
|
||||||
|
|
||||||
BOM data is shared between tabs 2 and 3.
|
|
||||||
|
|
||||||
### BOM Import UI (raw table, manual column mapping)
|
|
||||||
|
|
||||||
After paste (`Ctrl+V`) QuoteForge renders an editable raw table (not auto-detected parsing).
|
|
||||||
|
|
||||||
- The pasted rows are shown **as-is** (including header rows, if present).
|
|
||||||
- The user selects a type for each column manually:
|
|
||||||
- `P/N`
|
|
||||||
- `Кол-во`
|
|
||||||
- `Цена`
|
|
||||||
- `Описание`
|
|
||||||
- `Не использовать`
|
|
||||||
- Required mapping:
|
|
||||||
- exactly one `P/N`
|
|
||||||
- exactly one `Кол-во`
|
|
||||||
- Optional mapping:
|
|
||||||
- `Цена` (0..1)
|
|
||||||
- `Описание` (0..1)
|
|
||||||
- Rows can be:
|
|
||||||
- ignored (UI-only, excluded from `vendor_spec`)
|
|
||||||
- deleted
|
|
||||||
- Raw cells are editable inline after paste.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- There is **no auto column detection**.
|
|
||||||
- There is **no auto header-row skip**.
|
|
||||||
- Raw import layout itself is not stored on server; only normalized `vendor_spec` is stored.
|
|
||||||
|
|
||||||
### LOT matching in BOM table
|
|
||||||
|
|
||||||
The BOM table adds service columns on the right:
|
|
||||||
|
|
||||||
- `LOT`
|
|
||||||
- `LOT в 1 PN`
|
|
||||||
- actions (`+`, ignore, delete)
|
|
||||||
|
|
||||||
`LOT` behavior:
|
|
||||||
- The first LOT row shown in the BOM UI is the primary LOT mapping for the PN row.
|
|
||||||
- Additional LOT rows are added via the `+` action.
|
|
||||||
- inline LOT input is strict:
|
|
||||||
- autocomplete source = full local components list (`/api/components?per_page=5000`)
|
|
||||||
- free text that does not match an existing LOT is rejected
|
|
||||||
|
|
||||||
`LOT в 1 PN` behavior:
|
|
||||||
- quantity multiplier for each visible LOT row in BOM (`quantity_per_pn` in persisted `lot_mappings[]`)
|
|
||||||
- default = `1`
|
|
||||||
- editable inline
|
|
||||||
|
|
||||||
### Bundle mode (`1 PN -> multiple LOTs`)
|
|
||||||
|
|
||||||
The `+` action in BOM rows adds an extra LOT mapping row for the same vendor PN row.
|
|
||||||
|
|
||||||
- All visible LOT rows (first + added rows) are persisted uniformly in `lot_mappings[]`
|
|
||||||
- Each mapping row has:
|
|
||||||
- LOT
|
|
||||||
- qty (`LOT in 1 PN` = `quantity_per_pn`)
|
|
||||||
|
|
||||||
### BOM restore on config open
|
|
||||||
|
|
||||||
On config open, QuoteForge loads `vendor_spec` from server and reconstructs the editable BOM table in normalized form:
|
|
||||||
|
|
||||||
- columns restored as: `Qty | P/N | Description | Price`
|
|
||||||
- column mapping restored as:
|
|
||||||
- `qty`, `pn`, `description`, `price`
|
|
||||||
- LOT / `LOT в 1 PN` rows are restored from `vendor_spec.lot_mappings[]`
|
|
||||||
|
|
||||||
This restores the BOM editing state, but not the original raw Excel layout (extra columns, ignored rows, original headers).
|
|
||||||
|
|
||||||
### Pricing Tab: column order
|
|
||||||
|
|
||||||
|
Example:
|
||||||
```
|
```
|
||||||
LOT | PN вендора | Описание | Кол-во | Estimate | Цена вендора | Склад | Конк.
|
Сервер G893-SD1-AAX3, в составе:
|
||||||
|
1x 8U 2CPU 8GPU Server System (32x DDR5 DIMM Slots,8x 2.5" Hot-Swap Drive Bays, 4+4 3000W, 2x 10Gb/s RJ45, 2x IPMI RJ45)
|
||||||
|
2x Intel Xeon 8570 (56 cores, 2.1GHz, 300MB, 350W)
|
||||||
|
32x 64GB DDR5 ECC RDIMM
|
||||||
|
1x GPU Nvidia HGX H200 141GB 8GPU
|
||||||
|
3x 1.92TB NVMe PCIe SFF RI
|
||||||
|
5x 7.68TB NVMe PCIe SFF RI
|
||||||
|
8x 1-port 400G NDR OSFP CX7
|
||||||
|
2x 2-port 100GbE QSFP56 CX6
|
||||||
|
1x 2-port 10GbE RJ45
|
||||||
```
|
```
|
||||||
|
|
||||||
**If BOM is empty** — the pricing tab still renders, using cart items as rows (PN вендора = "—", Цена вендора = "—").
|
Rules:
|
||||||
|
- the entire file becomes a single configuration (`server_count = 1`);
|
||||||
|
- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the
|
||||||
|
last whitespace-separated token before the comma;
|
||||||
|
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
|
||||||
|
- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and
|
||||||
|
`description`, so rows resolve through the active partnumber book when matched and otherwise stay
|
||||||
|
unresolved and editable in the UI;
|
||||||
|
- lines that do not match `<qty>x <description>` are skipped;
|
||||||
|
- no price data is present in the format; `unit_price` and `total_price` are left nil;
|
||||||
|
- detection runs before Text BOM in the format switch (Inspur → Nx → Text).
|
||||||
|
|
||||||
**Description source priority:** BOM row description → LOT description from `local_components`.
|
## Pasted BOM text parsing
|
||||||
|
|
||||||
### Pricing Tab: BOM + Estimate merge behavior
|
`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"|""}`.
|
||||||
|
|
||||||
When BOM exists, the pricing tab renders:
|
This shares the exact detectors and parsers used by the file-import path
|
||||||
|
(`ParsePastedBOMText` → `IsInspurBOM`/`parseInspurBOM`, `IsNxBOM`/`parseNxBOM`, `IsTextBOM`/`parseTextBOM`),
|
||||||
- BOM-based rows (including rows resolved via manual LOT and bundle mappings)
|
so paste and upload behave identically — there is no second parser in the frontend. The configurator's BOM
|
||||||
- plus **Estimate-only LOTs** (rows currently in cart but not covered by BOM mappings)
|
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.
|
||||||
Estimate-only rows are shown as separate rows with:
|
|
||||||
- `PN вендора = "—"`
|
|
||||||
- vendor price = `—`
|
|
||||||
- description from local components
|
|
||||||
|
|
||||||
### Pricing Tab: "Своя цена" input
|
|
||||||
|
|
||||||
- Manual entry → proportionally redistributes custom price into "Цена вендора" cells (proportional to each row's Estimate share). Last row absorbs rounding remainder.
|
|
||||||
- "Проставить цены BOM" button → restores per-row original BOM prices directly (no proportional redistribution). Sets "Своя цена" to their sum.
|
|
||||||
- Both paths show "Скидка от Estimate: X%" info.
|
|
||||||
- "Экспорт CSV" button → downloads `pricing_<uuid>.csv` with UTF-8 BOM, same column order as table, plus Итого row.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
| Method | URL | Description |
|
|
||||||
|--------|-----|-------------|
|
|
||||||
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored BOM |
|
|
||||||
| PUT | `/api/configs/:uuid/vendor-spec` | Replace BOM (full update) |
|
|
||||||
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (no cart mutation) |
|
|
||||||
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
|
|
||||||
| POST | `/api/projects/:uuid/vendor-import` | Import `CFXML` workspace into an existing project and create grouped configurations |
|
|
||||||
| GET | `/api/partnumber-books` | List local book snapshots |
|
|
||||||
| GET | `/api/partnumber-books/:id` | Items for a book by server_id |
|
|
||||||
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
|
|
||||||
| POST | `/api/sync/partnumber-seen` | Push unresolved PNs to `qt_vendor_partnumber_seen` on MariaDB |
|
|
||||||
|
|
||||||
## Unresolved PN Tracking (`qt_vendor_partnumber_seen`)
|
|
||||||
|
|
||||||
After each `resolveBOM()` call, QuoteForge pushes PN rows to `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored):
|
|
||||||
|
|
||||||
- unresolved BOM rows (`ignored = false`)
|
|
||||||
- raw BOM rows explicitly marked as ignored in UI (`ignored = true`) — these rows are **not** saved to `vendor_spec`, but are reported for server-side tracking
|
|
||||||
|
|
||||||
The handler calls `sync.PushPartnumberSeen()` which inserts into `qt_vendor_partnumber_seen`.
|
|
||||||
If a row with the same `partnumber` already exists, QuoteForge must leave it untouched:
|
|
||||||
|
|
||||||
- do not update `last_seen_at`
|
|
||||||
- do not update `is_ignored`
|
|
||||||
- do not update `description`
|
|
||||||
|
|
||||||
Canonical insert behavior:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
|
||||||
VALUES ('manual', '', ?, ?, ?, NOW())
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
partnumber = partnumber
|
|
||||||
```
|
|
||||||
|
|
||||||
Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge uses this table for populating the partnumber book.
|
|
||||||
|
|
||||||
Partnumber book sync contract:
|
|
||||||
|
|
||||||
- PriceForge writes membership snapshots to `qt_partnumber_books.partnumbers_json`.
|
|
||||||
- PriceForge writes canonical PN payloads to `qt_partnumber_book_items`.
|
|
||||||
- QuoteForge syncs book headers first, then pulls PN payloads with:
|
|
||||||
`SELECT partnumber, lots_json, description FROM qt_partnumber_book_items WHERE partnumber IN (...)`
|
|
||||||
|
|
||||||
## BOM Persistence
|
|
||||||
|
|
||||||
- `vendor_spec` is saved to server via `PUT /api/configs/:uuid/vendor-spec`.
|
|
||||||
- `GET` / `PUT` `vendor_spec` must preserve row-level mapping fields used by the UI:
|
|
||||||
- `lot_mappings[]`
|
|
||||||
- each item: `lot_name`, `quantity_per_pn`
|
|
||||||
- `description` is persisted in each BOM row and is used by the Pricing tab when available.
|
|
||||||
- Ignored raw rows are **not** persisted into `vendor_spec`.
|
|
||||||
- The PUT handler explicitly marshals `VendorSpec` to JSON string before passing to GORM `Update` (GORM does not reliably call `driver.Valuer` for custom types in `Update(column, value)`).
|
|
||||||
- BOM is autosaved (debounced) after BOM-changing actions, including:
|
|
||||||
- `resolveBOM()`
|
|
||||||
- LOT row qty (`LOT в 1 PN`) changes
|
|
||||||
- LOT row add/remove (`+` / delete in bundle context)
|
|
||||||
- "Сохранить BOM" button triggers explicit save.
|
|
||||||
|
|
||||||
## Pricing Tab: Estimate Price Source
|
|
||||||
|
|
||||||
`renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with LOTs collected from:
|
|
||||||
|
|
||||||
- `lot_mappings[]` from BOM rows
|
|
||||||
- current Estimate/cart LOTs not covered by BOM mappings (to show estimate-only rows)
|
|
||||||
|
|
||||||
This ensures Estimate prices appear for:
|
|
||||||
|
|
||||||
- manually matched LOTs in the BOM tab
|
|
||||||
- bundle LOTs
|
|
||||||
- LOTs already present in Estimate but not mapped from BOM
|
|
||||||
|
|
||||||
### Apply to Estimate (`Пересчитать эстимейт`)
|
|
||||||
|
|
||||||
When applying BOM to Estimate, QuoteForge builds cart rows from explicit UI mappings stored in `lot_mappings[]`.
|
|
||||||
|
|
||||||
For a BOM row with PN qty = `Q`:
|
|
||||||
|
|
||||||
- each mapped LOT contributes `Q * quantity_per_pn`
|
|
||||||
|
|
||||||
Rows without any valid LOT mapping are skipped.
|
|
||||||
|
|
||||||
## Web Route
|
|
||||||
|
|
||||||
| Route | Page |
|
|
||||||
|-------|------|
|
|
||||||
| `/partnumber-books` | Partnumber books — active book summary (unique LOTs, total PN, primary PN count), searchable items table, collapsible snapshot history |
|
|
||||||
|
|||||||
563
bible-local/10-agent-api-guide.md
Normal file
563
bible-local/10-agent-api-guide.md
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
# 10 - Agent API Guide: Pricing Servers from a TZ
|
||||||
|
|
||||||
|
This guide is written for an AI agent that needs to price a server configuration
|
||||||
|
(техническое задание, ТЗ) using the QuoteForge HTTP API.
|
||||||
|
|
||||||
|
## Runtime assumptions
|
||||||
|
|
||||||
|
- QuoteForge runs locally, binds to `127.0.0.1:8080` by default.
|
||||||
|
- No authentication is required — the app is single-user, loopback-only.
|
||||||
|
- All responses are JSON. All request bodies are JSON unless stated otherwise.
|
||||||
|
- The port can be overridden with the `QF_SERVER_PORT` environment variable.
|
||||||
|
|
||||||
|
Base URL for all examples: `http://127.0.0.1:8080`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration composition rules
|
||||||
|
|
||||||
|
These rules are mandatory and must be respected before saving any configuration.
|
||||||
|
|
||||||
|
### 1. Every configuration must belong to a project
|
||||||
|
|
||||||
|
Configurations cannot be created in isolation. The correct sequence is:
|
||||||
|
|
||||||
|
1. Create a project (`POST /api/projects`) and save the returned `uuid`.
|
||||||
|
2. Create the configuration inside that project by passing `project_uuid` in the
|
||||||
|
config body, or by using `POST /api/projects/:uuid/configs`.
|
||||||
|
|
||||||
|
If the project for a given TZ already exists, retrieve its `uuid` first:
|
||||||
|
```
|
||||||
|
GET /api/projects?page=1&per_page=100
|
||||||
|
```
|
||||||
|
then pass the matching `uuid` in `project_uuid`.
|
||||||
|
|
||||||
|
### 2. Every server configuration must contain all four required component groups
|
||||||
|
|
||||||
|
A configuration is not valid for pricing unless items from all four of the
|
||||||
|
following category groups are present:
|
||||||
|
|
||||||
|
| Category code | Meaning | Notes |
|
||||||
|
|---------------|------------------|---------------------------------------------------|
|
||||||
|
| `MB` | Motherboard | exactly one MB per configuration |
|
||||||
|
| `CPU` | Processor | one or more CPUs |
|
||||||
|
| `MEM` | Memory / RAM | one or more memory modules |
|
||||||
|
| `PS` / `PSU` | Power supply | `PSU` is the current code; `PS` is legacy — both are accepted |
|
||||||
|
|
||||||
|
Before saving, verify the assembled BOM with `POST /api/quote/validate`:
|
||||||
|
the response `errors` array will contain `"Component not found: …"` entries
|
||||||
|
for unknown lot names, and `warnings` will list lots without a price.
|
||||||
|
Reject the configuration and report back to the user if any of the four
|
||||||
|
required categories is missing.
|
||||||
|
|
||||||
|
### 3. Category codes to use when searching
|
||||||
|
|
||||||
|
Use `category=<code>` in `GET /api/components` to narrow results:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/components?category=MB&search=X13&has_price=true
|
||||||
|
GET /api/components?category=CPU&search=Xeon+Gold&has_price=true
|
||||||
|
GET /api/components?category=MEM&search=32GB+DDR5&has_price=true
|
||||||
|
GET /api/components?category=PSU&search=800W&has_price=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Retrieve the full list of active categories at any time:
|
||||||
|
```
|
||||||
|
GET /api/categories
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typical workflow for pricing a server
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Check the app is up GET /api/ping
|
||||||
|
2. Find or create a project GET /api/projects → POST /api/projects
|
||||||
|
3. Find the latest pricelist GET /api/pricelists/latest?source=estimate
|
||||||
|
4. Look up lot names for MB GET /api/components?category=MB&search=…
|
||||||
|
5. Look up lot names for CPU GET /api/components?category=CPU&search=…
|
||||||
|
6. Look up lot names for MEM GET /api/components?category=MEM&search=…
|
||||||
|
7. Look up lot names for PSU GET /api/components?category=PSU&search=…
|
||||||
|
8. (Repeat for other components) GET /api/components?category=…&search=…
|
||||||
|
9. Validate and calculate the quote POST /api/quote/validate
|
||||||
|
10. (Optional) Compare price tiers POST /api/quote/price-levels
|
||||||
|
11. Save configuration in the project POST /api/projects/:uuid/configs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Verify the app is running
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/ping
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200 OK`:
|
||||||
|
```json
|
||||||
|
{"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Find or create a project
|
||||||
|
|
||||||
|
Each TZ maps to one project. Use the TZ identifier as the `code` field.
|
||||||
|
|
||||||
|
### Find an existing project
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/projects?page=1&per_page=100
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200 OK`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||||
|
"code": "TZ-123",
|
||||||
|
"variant": "",
|
||||||
|
"name": "Проект по ТЗ №123",
|
||||||
|
"tracker_url": "",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2026-06-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a new project
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/projects
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "TZ-123",
|
||||||
|
"name": "Проект по ТЗ №123",
|
||||||
|
"tracker_url": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
| field | type | required | description |
|
||||||
|
|---------------|--------|----------|--------------------------------------------------------------------|
|
||||||
|
| `code` | string | yes | short identifier, unique per variant; use the TZ number or ticket |
|
||||||
|
| `variant` | string | no | variant label within the same `code`; default is empty string |
|
||||||
|
| `name` | string | no | human-readable title |
|
||||||
|
| `tracker_url` | string | no | link to a ticket or issue tracker |
|
||||||
|
|
||||||
|
Response `201 Created`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||||
|
"code": "TZ-123",
|
||||||
|
"variant": "",
|
||||||
|
"name": "Проект по ТЗ №123",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2026-06-11T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the `uuid` — it is required to create configurations inside this project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Find the latest pricelist
|
||||||
|
|
||||||
|
QuoteForge maintains three pricing tiers. The `source` values are:
|
||||||
|
|
||||||
|
| source | meaning |
|
||||||
|
|--------------|-----------------------------|
|
||||||
|
| `estimate` | list / catalogue price |
|
||||||
|
| `warehouse` | stock price (purchase cost) |
|
||||||
|
| `competitor` | competitor reference price |
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/pricelists/latest?source=estimate
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200 OK`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"source": "estimate",
|
||||||
|
"version": "2026-05-28",
|
||||||
|
"item_count": 12500,
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2026-05-28T06:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `id` field is a numeric pricelist identifier. Pass it as `pricelist_id`
|
||||||
|
when calculating a quote to pin pricing to a specific pricelist.
|
||||||
|
|
||||||
|
To list all available pricelists:
|
||||||
|
```
|
||||||
|
GET /api/pricelists?source=estimate&active_only=true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Steps 4–8 — Look up component lot names
|
||||||
|
|
||||||
|
Each component is identified by a `lot_name` (internal SKU). The TZ typically
|
||||||
|
contains model names or descriptions; use the search endpoint to resolve them.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/components?search=Xeon+Gold+6342&category=CPU&has_price=true&page=1&per_page=20
|
||||||
|
```
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| parameter | default | description |
|
||||||
|
|------------------|---------|---------------------------------------------------|
|
||||||
|
| `search` | — | free-text search in lot name and description |
|
||||||
|
| `category` | — | filter by category code (`MB`, `CPU`, `MEM`, `PSU`, …) |
|
||||||
|
| `has_price` | false | return only components that have a price |
|
||||||
|
| `include_hidden` | false | include hidden/retired components |
|
||||||
|
| `page` | 1 | page number |
|
||||||
|
| `per_page` | 20 | page size |
|
||||||
|
|
||||||
|
Response `200 OK`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"lot_name": "CPU-XEON-6342",
|
||||||
|
"description": "Intel Xeon Gold 6342, 24C/48T, 2.8 GHz, LGA4189",
|
||||||
|
"category": "CPU",
|
||||||
|
"category_name": "CPU",
|
||||||
|
"model": "Xeon Gold 6342"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To look up a single component by exact lot name:
|
||||||
|
```
|
||||||
|
GET /api/components/CPU-XEON-6342
|
||||||
|
```
|
||||||
|
|
||||||
|
To list all known categories:
|
||||||
|
```
|
||||||
|
GET /api/categories
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 9 — Validate and calculate the quote
|
||||||
|
|
||||||
|
Before saving, validate the assembled BOM. This catches unknown lot names and
|
||||||
|
missing prices, and also confirms that all required categories are covered.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/quote/validate
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{"lot_name": "MB-X13DAI-N", "quantity": 1},
|
||||||
|
{"lot_name": "CPU-XEON-6342", "quantity": 2},
|
||||||
|
{"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8},
|
||||||
|
{"lot_name": "SSD-480GB-SATA", "quantity": 2},
|
||||||
|
{"lot_name": "PSU-800W-TITANIUM", "quantity": 2}
|
||||||
|
],
|
||||||
|
"pricelist_id": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200 OK`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": true,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"lot_name": "MB-X13DAI-N",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit_price": 95000.00,
|
||||||
|
"total_price": 95000.00,
|
||||||
|
"description": "Supermicro X13DAi-N dual-socket server board",
|
||||||
|
"category": "MB",
|
||||||
|
"has_price": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lot_name": "CPU-XEON-6342",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit_price": 87500.00,
|
||||||
|
"total_price": 175000.00,
|
||||||
|
"description": "Intel Xeon Gold 6342, 24C/48T, 2.8 GHz",
|
||||||
|
"category": "CPU",
|
||||||
|
"has_price": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lot_name": "RAM-32GB-DDR4-3200",
|
||||||
|
"quantity": 8,
|
||||||
|
"unit_price": 12000.00,
|
||||||
|
"total_price": 96000.00,
|
||||||
|
"description": "32 GB DDR4-3200 ECC RDIMM",
|
||||||
|
"category": "MEM",
|
||||||
|
"has_price": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lot_name": "PSU-800W-TITANIUM",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit_price": 18500.00,
|
||||||
|
"total_price": 37000.00,
|
||||||
|
"description": "800W 80+ Titanium redundant PSU",
|
||||||
|
"category": "PSU",
|
||||||
|
"has_price": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": [],
|
||||||
|
"warnings": [],
|
||||||
|
"total": 403000.00
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agent check after validation:**
|
||||||
|
|
||||||
|
1. `valid` must be `true` — all lot names resolved.
|
||||||
|
2. `errors` must be empty — no unknown components.
|
||||||
|
3. The returned `items` array must contain at least one entry from each required
|
||||||
|
category: `MB`, `CPU`, `MEM`, and `PS` or `PSU`.
|
||||||
|
4. Items with `has_price: false` are allowed but should be flagged to the user.
|
||||||
|
|
||||||
|
If any check fails, do not save the configuration. Report the issue and ask the
|
||||||
|
user to clarify or replace the problematic component.
|
||||||
|
|
||||||
|
For simple price totals without validation metadata use `POST /api/quote/calculate`
|
||||||
|
— identical request body, response contains only `items` and `total`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 10 (optional) — Compare price tiers
|
||||||
|
|
||||||
|
To see estimate, warehouse, and competitor prices side-by-side for a BOM:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/quote/price-levels
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{"lot_name": "CPU-XEON-6342", "quantity": 2},
|
||||||
|
{"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8}
|
||||||
|
],
|
||||||
|
"pricelist_ids": {
|
||||||
|
"estimate": 42,
|
||||||
|
"warehouse": 31,
|
||||||
|
"competitor": 15
|
||||||
|
},
|
||||||
|
"no_cache": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`pricelist_ids` is optional. When omitted the latest pricelist for each source
|
||||||
|
is used automatically.
|
||||||
|
|
||||||
|
Response `200 OK`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"lot_name": "CPU-XEON-6342",
|
||||||
|
"quantity": 2,
|
||||||
|
"estimate_price": 87500.00,
|
||||||
|
"warehouse_price": 71000.00,
|
||||||
|
"competitor_price": 85000.00,
|
||||||
|
"delta_wh_estimate_abs": -16500.00,
|
||||||
|
"delta_wh_estimate_pct": -18.86,
|
||||||
|
"delta_comp_estimate_abs": -2500.00,
|
||||||
|
"delta_comp_estimate_pct": -2.86,
|
||||||
|
"delta_comp_wh_abs": 14000.00,
|
||||||
|
"delta_comp_wh_pct": 19.72,
|
||||||
|
"price_missing": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resolved_pricelist_ids": {
|
||||||
|
"estimate": 42,
|
||||||
|
"warehouse": 31,
|
||||||
|
"competitor": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`price_missing` lists the source names for which no price was found for that lot.
|
||||||
|
Delta fields are `null` when either operand price is missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 11 — Save a configuration inside the project
|
||||||
|
|
||||||
|
Use the project-scoped endpoint so the configuration is immediately linked to
|
||||||
|
the correct project without a separate move operation.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/projects/:project_uuid/configs
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
The request body is identical to `POST /api/configs` — the `project_uuid` field
|
||||||
|
in the body is ignored when using the project-scoped route; the URL parameter
|
||||||
|
takes precedence.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Сервер по ТЗ №123 — вариант А",
|
||||||
|
"items": [
|
||||||
|
{"lot_name": "MB-X13DAI-N", "quantity": 1, "unit_price": 95000.00},
|
||||||
|
{"lot_name": "CPU-XEON-6342", "quantity": 2, "unit_price": 87500.00},
|
||||||
|
{"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8, "unit_price": 12000.00},
|
||||||
|
{"lot_name": "SSD-480GB-SATA", "quantity": 2, "unit_price": 8500.00},
|
||||||
|
{"lot_name": "PSU-800W-TITANIUM", "quantity": 2, "unit_price": 18500.00}
|
||||||
|
],
|
||||||
|
"server_model": "2U",
|
||||||
|
"support_code": "NBD",
|
||||||
|
"server_count": 1,
|
||||||
|
"pricelist_id": 42,
|
||||||
|
"warehouse_pricelist_id": 31,
|
||||||
|
"competitor_pricelist_id": 15,
|
||||||
|
"config_type": "server",
|
||||||
|
"notes": "Автоматически создано агентом на основании ТЗ №123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key fields:
|
||||||
|
|
||||||
|
| field | type | required | description |
|
||||||
|
|--------------------------|--------|----------|-----------------------------------------------------|
|
||||||
|
| `name` | string | yes | human-readable name |
|
||||||
|
| `items` | array | yes | `{lot_name, quantity, unit_price}` from validate |
|
||||||
|
| `server_model` | string | no | chassis/form-factor code; used for article generation |
|
||||||
|
| `support_code` | string | no | support tier code; used for article generation |
|
||||||
|
| `server_count` | int | no | number of identical servers; total is multiplied |
|
||||||
|
| `pricelist_id` | uint | no | estimate pricelist to attach |
|
||||||
|
| `warehouse_pricelist_id` | uint | no | warehouse pricelist to attach |
|
||||||
|
| `competitor_pricelist_id`| uint | no | competitor pricelist to attach |
|
||||||
|
| `config_type` | string | no | `"server"` (default) or `"storage"` |
|
||||||
|
| `notes` | string | no | free text |
|
||||||
|
| `custom_price` | float | no | override total price |
|
||||||
|
| `disable_price_refresh` | bool | no | prevent automatic price refresh on open |
|
||||||
|
| `only_in_stock` | bool | no | filter to in-stock components only |
|
||||||
|
|
||||||
|
Response `201 Created`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "Сервер по ТЗ №123 — вариант А",
|
||||||
|
"items": [...],
|
||||||
|
"total_price": 403000.00,
|
||||||
|
"server_count": 1,
|
||||||
|
"config_type": "server",
|
||||||
|
"article": "2U-6342x2-32GBx8-NBD",
|
||||||
|
"project_uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||||
|
"created_at": "2026-06-11T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `uuid` can be used for all subsequent operations on this configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working with saved configurations
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/configs/:uuid — retrieve a saved configuration
|
||||||
|
PUT /api/configs/:uuid — full update (same body as create)
|
||||||
|
POST /api/configs/:uuid/refresh-prices — re-price from latest pricelist
|
||||||
|
POST /api/configs/:uuid/clone — duplicate: body {"name": "clone name"}
|
||||||
|
GET /api/configs/:uuid/versions — revision history
|
||||||
|
GET /api/configs?page=1&per_page=20 — list all configurations
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error responses
|
||||||
|
|
||||||
|
All error responses follow the same shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error": "human-readable message"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common status codes:
|
||||||
|
|
||||||
|
| code | meaning |
|
||||||
|
|------|-------------------------------------------------------|
|
||||||
|
| 400 | invalid request body or validation failure |
|
||||||
|
| 404 | entity (component, pricelist, config) not found |
|
||||||
|
| 423 | sync readiness is blocked; retry after sync completes |
|
||||||
|
| 500 | internal server error |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minimal end-to-end example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE=http://127.0.0.1:8080
|
||||||
|
|
||||||
|
# 1. Verify the app is up
|
||||||
|
curl -s $BASE/api/ping
|
||||||
|
|
||||||
|
# 2. Create a project for this TZ
|
||||||
|
PROJECT_UUID=$(curl -s -X POST $BASE/api/projects \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"code": "TZ-123", "name": "Проект по ТЗ №123"}' | jq -r .uuid)
|
||||||
|
|
||||||
|
# 3. Get latest estimate pricelist
|
||||||
|
PRICELIST_ID=$(curl -s "$BASE/api/pricelists/latest?source=estimate" | jq .id)
|
||||||
|
|
||||||
|
# 4. Find lot names for required categories
|
||||||
|
curl -s "$BASE/api/components?category=MB&search=X13&has_price=true" | jq '.components[].lot_name'
|
||||||
|
curl -s "$BASE/api/components?category=CPU&search=Xeon&has_price=true" | jq '.components[].lot_name'
|
||||||
|
curl -s "$BASE/api/components?category=MEM&search=32GB&has_price=true" | jq '.components[].lot_name'
|
||||||
|
curl -s "$BASE/api/components?category=PSU&search=800W&has_price=true" | jq '.components[].lot_name'
|
||||||
|
|
||||||
|
# 5. Validate the BOM (must contain MB, CPU, MEM, PSU/PS)
|
||||||
|
curl -s -X POST $BASE/api/quote/validate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"pricelist_id\": $PRICELIST_ID,
|
||||||
|
\"items\": [
|
||||||
|
{\"lot_name\": \"MB-X13DAI-N\", \"quantity\": 1},
|
||||||
|
{\"lot_name\": \"CPU-XEON-6342\", \"quantity\": 2},
|
||||||
|
{\"lot_name\": \"RAM-32GB-DDR4-3200\", \"quantity\": 8},
|
||||||
|
{\"lot_name\": \"PSU-800W-TITANIUM\", \"quantity\": 2}
|
||||||
|
]
|
||||||
|
}" | jq '{valid, errors, warnings, total}'
|
||||||
|
|
||||||
|
# 6. Save the configuration inside the project
|
||||||
|
curl -s -X POST "$BASE/api/projects/$PROJECT_UUID/configs" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"name\": \"Сервер по ТЗ №123\",
|
||||||
|
\"pricelist_id\": $PRICELIST_ID,
|
||||||
|
\"server_model\": \"2U\",
|
||||||
|
\"server_count\": 1,
|
||||||
|
\"config_type\": \"server\",
|
||||||
|
\"items\": [
|
||||||
|
{\"lot_name\": \"MB-X13DAI-N\", \"quantity\": 1, \"unit_price\": 95000},
|
||||||
|
{\"lot_name\": \"CPU-XEON-6342\", \"quantity\": 2, \"unit_price\": 87500},
|
||||||
|
{\"lot_name\": \"RAM-32GB-DDR4-3200\", \"quantity\": 8, \"unit_price\": 12000},
|
||||||
|
{\"lot_name\": \"PSU-800W-TITANIUM\", \"quantity\": 2, \"unit_price\": 18500}
|
||||||
|
]
|
||||||
|
}" | jq '{uuid, total_price, article}'
|
||||||
|
```
|
||||||
161
bible-local/11-lot-suggestions.md
Normal file
161
bible-local/11-lot-suggestions.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# 11 - Lot Suggestions (qt_vendor_partnumber_seen)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`qt_vendor_partnumber_seen` records vendor partnumbers encountered during import
|
||||||
|
that have no mapping in the active partnumber book. When a user manually maps
|
||||||
|
such a partnumber to one or more LOT names in the QuoteForge UI, those mappings
|
||||||
|
are written back to the server as **lot suggestions** — hints for the team that
|
||||||
|
maintains `qt_partnumber_book_items`.
|
||||||
|
|
||||||
|
## Schema Extension
|
||||||
|
|
||||||
|
Add one nullable column to `qt_vendor_partnumber_seen`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE `qt_vendor_partnumber_seen`
|
||||||
|
ADD COLUMN `lot_suggestion` longtext DEFAULT NULL
|
||||||
|
COMMENT 'JSON array [{lot_name, qty}] — user-entered LOT mappings from the UI';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updated table contract (relevant columns only)
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `partnumber` | varchar(255) UNIQUE NOT NULL | natural key |
|
||||||
|
| `lot_suggestion` | longtext (JSON) | nullable; set when user maps the PN manually |
|
||||||
|
|
||||||
|
`lot_suggestion` contains the same JSON shape as `qt_partnumber_book_items.lots_json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "lot_name": "LOT_A", "qty": 1 },
|
||||||
|
{ "lot_name": "LOT_B", "qty": 2 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- `null` or absent means no suggestion has been entered yet;
|
||||||
|
- an empty array `[]` is not a valid value — use `null` instead;
|
||||||
|
- a single PN may map to multiple lots (`lot_name` entries), each with its own `qty`;
|
||||||
|
- the array is ordered — the order reflects the order of `lot_mappings[]` in the
|
||||||
|
vendor spec row at the time of last user save;
|
||||||
|
- `qty` must be a positive integer (≥ 1).
|
||||||
|
|
||||||
|
## Write Contract (QuoteForge → MariaDB)
|
||||||
|
|
||||||
|
QuoteForge writes `lot_suggestion` when all of the following are true:
|
||||||
|
|
||||||
|
1. The user saves a vendor BOM via `PUT /api/configs/:uuid/vendor-spec`.
|
||||||
|
2. At least one `vendor_spec` row has a non-empty `lot_mappings[]` array (manually
|
||||||
|
entered or confirmed by the user — not auto-resolved from a partnumber book).
|
||||||
|
3. The MariaDB connection is available at the time of save.
|
||||||
|
|
||||||
|
For each such row:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO qt_vendor_partnumber_seen
|
||||||
|
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
|
||||||
|
VALUES
|
||||||
|
('manual', '', ?, ?, 0, NOW(3), ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
lot_suggestion = VALUES(lot_suggestion),
|
||||||
|
last_seen_at = IF(lot_suggestion IS NULL, last_seen_at, NOW(3))
|
||||||
|
```
|
||||||
|
|
||||||
|
- `lot_suggestion` value = JSON-marshalled `lot_mappings[]` from the vendor spec item,
|
||||||
|
reusing the same `{lot_name, qty}` shape.
|
||||||
|
- If the PN row already exists and `lot_suggestion` is already set, it is **overwritten**
|
||||||
|
with the latest user input (the user is assumed to have corrected it).
|
||||||
|
- If the user **clears** all lot_mappings for a PN (sets to empty), no update is sent —
|
||||||
|
the existing `lot_suggestion` on the server is left untouched.
|
||||||
|
- Rows where `lot_mappings[]` is empty or nil are skipped entirely (no insert, no update).
|
||||||
|
- Writes are best-effort: a MariaDB error for one row is logged and skipped; remaining
|
||||||
|
rows continue. A write failure does not fail the vendor-spec save.
|
||||||
|
|
||||||
|
## Read Contract (Partnumber-Book Creation Tool → MariaDB)
|
||||||
|
|
||||||
|
The tool that maintains `qt_partnumber_book_items` reads `qt_vendor_partnumber_seen`
|
||||||
|
to discover new partnumbers and their suggested mappings.
|
||||||
|
|
||||||
|
### Discovery query
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.partnumber,
|
||||||
|
s.description,
|
||||||
|
s.vendor,
|
||||||
|
s.lot_suggestion,
|
||||||
|
s.last_seen_at,
|
||||||
|
b.lots_json AS book_lots_json
|
||||||
|
FROM qt_vendor_partnumber_seen s
|
||||||
|
LEFT JOIN qt_partnumber_book_items b ON b.partnumber = s.partnumber
|
||||||
|
WHERE s.is_ignored = 0
|
||||||
|
AND s.lot_suggestion IS NOT NULL
|
||||||
|
ORDER BY s.last_seen_at DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interpretation rules
|
||||||
|
|
||||||
|
| Condition | Meaning | Suggested action |
|
||||||
|
|-----------|---------|-----------------|
|
||||||
|
| `book_lots_json IS NULL` AND `lot_suggestion IS NOT NULL` | No book entry yet; user suggested mapping | Create new `qt_partnumber_book_items` row with `lots_json = lot_suggestion` |
|
||||||
|
| `book_lots_json IS NOT NULL` AND `lot_suggestion IS NOT NULL` AND they differ | User corrected or extended the existing mapping | Review diff and decide whether to update `qt_partnumber_book_items` |
|
||||||
|
| `book_lots_json IS NOT NULL` AND `lot_suggestion IS NOT NULL` AND they match | Suggestion already applied | No action needed |
|
||||||
|
|
||||||
|
### Suggestion format
|
||||||
|
|
||||||
|
`lot_suggestion` is valid JSON (or `null`). Parse it as an array of objects:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "lot_name": "LOT_A", "qty": 1 },
|
||||||
|
{ "lot_name": "LOT_B", "qty": 2 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Map directly to `qt_partnumber_book_items.lots_json` — the formats are identical.
|
||||||
|
|
||||||
|
### Multiple lots per PN
|
||||||
|
|
||||||
|
One PN may have multiple suggestion entries (e.g., a bundle). The array carries
|
||||||
|
all of them. The book-creation tool must preserve the full array when writing
|
||||||
|
`lots_json`, not just the first element.
|
||||||
|
|
||||||
|
### Qty semantics
|
||||||
|
|
||||||
|
`qty` in a lot suggestion means "how many of this LOT per one occurrence of the
|
||||||
|
vendor PN". This matches `qt_partnumber_book_items.lots_json` exactly. Example:
|
||||||
|
a server platform that comes with 4 PSUs would produce
|
||||||
|
`[{"lot_name": "PS_1300W_Titanium", "qty": 4}]`.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
The existing `qfs_user` grant covers this column — no new permission is required:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
|
||||||
|
```
|
||||||
|
|
||||||
|
The book-creation tool connects with its own credentials and needs at minimum:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_vendor_partnumber_seen TO '<book_tool_user>'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_partnumber_book_items TO '<book_tool_user>'@'%';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Migration is applied outside this repo (server-side DDL):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE `qt_vendor_partnumber_seen`
|
||||||
|
ADD COLUMN IF NOT EXISTS `lot_suggestion` longtext DEFAULT NULL
|
||||||
|
COMMENT 'JSON [{lot_name, qty}] — user LOT suggestions from QuoteForge UI';
|
||||||
|
```
|
||||||
|
|
||||||
|
QuoteForge handles a missing column gracefully: if the migration has not run yet,
|
||||||
|
the write with `lot_suggestion` fails with "Unknown column" (MariaDB 1054), a warning
|
||||||
|
is logged, and the row is re-inserted without the column. The app never crashes on
|
||||||
|
migration lag.
|
||||||
@@ -1,55 +1,32 @@
|
|||||||
# QuoteForge Bible — Architectural Documentation
|
# QuoteForge Bible
|
||||||
|
|
||||||
The single source of truth for architecture, schemas, and patterns.
|
Project-specific architecture and operational contracts.
|
||||||
|
|
||||||
---
|
## Files
|
||||||
|
|
||||||
## Table of Contents
|
| File | Scope |
|
||||||
|
| --- | --- |
|
||||||
|
| [01-overview.md](01-overview.md) | Product scope, runtime model, repository map |
|
||||||
|
| [02-architecture.md](02-architecture.md) | Local-first rules, sync, pricing, versioning |
|
||||||
|
| [03-database.md](03-database.md) | SQLite and MariaDB data model, permissions, migrations |
|
||||||
|
| [04-api.md](04-api.md) | HTTP routes and API contract |
|
||||||
|
| [05-config.md](05-config.md) | Runtime config, paths, env vars, startup behavior |
|
||||||
|
| [06-backup.md](06-backup.md) | Backup contract and restore workflow |
|
||||||
|
| [07-dev.md](07-dev.md) | Development commands and guardrails |
|
||||||
|
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
|
||||||
|
| [10-agent-api-guide.md](10-agent-api-guide.md) | End-to-end API guide for agents pricing servers from a TZ |
|
||||||
|
| [11-lot-suggestions.md](11-lot-suggestions.md) | lot_suggestion column in qt_vendor_partnumber_seen — write/read contract for manual UI mappings |
|
||||||
|
|
||||||
| File | Topic |
|
## Rules
|
||||||
|------|-------|
|
|
||||||
| [01-overview.md](01-overview.md) | Product: purpose, features, tech stack, repository structure |
|
|
||||||
| [02-architecture.md](02-architecture.md) | Architecture: local-first, sync, pricing, versioning |
|
|
||||||
| [03-database.md](03-database.md) | DB schemas: SQLite + MariaDB, permissions, indexes |
|
|
||||||
| [04-api.md](04-api.md) | API endpoints and web routes |
|
|
||||||
| [05-config.md](05-config.md) | Configuration, environment variables, paths, installation |
|
|
||||||
| [06-backup.md](06-backup.md) | Backup: implementation, rotation policy |
|
|
||||||
| [07-dev.md](07-dev.md) | Development: commands, code style, guardrails |
|
|
||||||
|
|
||||||
---
|
- `bible-local/` is the source of truth for QuoteForge-specific behavior.
|
||||||
|
- Keep these files in English.
|
||||||
|
- Update the matching file in the same commit as any architectural change.
|
||||||
|
- Remove stale documentation instead of preserving history in place.
|
||||||
|
|
||||||
## Bible Rules
|
## Quick reference
|
||||||
|
|
||||||
> **Every architectural decision must be recorded in the Bible.**
|
- Local DB path: see [05-config.md](05-config.md)
|
||||||
>
|
- Runtime bind: loopback only
|
||||||
> Any change to DB schema, data access patterns, sync behavior, API contracts,
|
- Local backups: see [06-backup.md](06-backup.md)
|
||||||
> configuration format, or any other system-level aspect — the corresponding `bible/` file
|
- Release notes: `releases/<version>/RELEASE_NOTES.md`
|
||||||
> **must be updated in the same commit** as the code.
|
|
||||||
>
|
|
||||||
> On every user-requested commit, the Bible must be reviewed and updated in that commit.
|
|
||||||
>
|
|
||||||
> The Bible is the single source of truth for architecture. Outdated documentation is worse than none.
|
|
||||||
|
|
||||||
> **Documentation language: English.**
|
|
||||||
>
|
|
||||||
> All files in `bible/` are written and updated **in English only**.
|
|
||||||
> Mixing languages is not allowed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
**Where is user data stored?**
|
|
||||||
SQLite → `~/Library/Application Support/QuoteForge/qfs.db` (macOS). MariaDB is sync-only.
|
|
||||||
|
|
||||||
**How to look up a price for a line item?**
|
|
||||||
`local_pricelist_items` → by `pricelist_id` from config + `lot_name`. Prices are **never** taken from `local_components`.
|
|
||||||
|
|
||||||
**Pre-commit check?**
|
|
||||||
`go build ./cmd/qfs && go vet ./...`
|
|
||||||
|
|
||||||
**What must never be restored?**
|
|
||||||
cron jobs, admin pricing, alerts, stock import, importer utility — all removed intentionally.
|
|
||||||
|
|
||||||
**Where is the release changelog?**
|
|
||||||
`releases/memory/v{major}.{minor}.{patch}.md`
|
|
||||||
|
|||||||
31
bible-local/decisions/README.md
Normal file
31
bible-local/decisions/README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Architectural Decision Log
|
||||||
|
|
||||||
|
One file per decision, named `YYYY-MM-DD-short-topic.md`.
|
||||||
|
|
||||||
|
Write a new entry when:
|
||||||
|
- Choosing between non-obvious implementation approaches.
|
||||||
|
- Intentionally rejecting a feature or pattern.
|
||||||
|
- A bug causes a rule change.
|
||||||
|
- Freezing or deprecating something.
|
||||||
|
|
||||||
|
Format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Decision: <short title>
|
||||||
|
|
||||||
|
**Date:** YYYY-MM-DD
|
||||||
|
**Status:** active | superseded by YYYY-MM-DD-topic.md
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Situation making this decision necessary.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
What was decided, stated clearly.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
What this means going forward; what is forbidden or required.
|
||||||
|
```
|
||||||
|
|
||||||
|
When a decision is superseded: add "superseded by" to the old file and create the new one.
|
||||||
|
Do NOT delete old entries.
|
||||||
|
Record the decision in the SAME COMMIT as the implementation code.
|
||||||
86
bible-local/runtime-flows.md
Normal file
86
bible-local/runtime-flows.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Runtime Flows
|
||||||
|
|
||||||
|
Critical mutation paths, deduplication logic, and cross-entity side effects.
|
||||||
|
Update this file in the same commit as any change to the flows below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Configuration save (create/update)
|
||||||
|
|
||||||
|
1. Handler receives JSON body; validates via `ShouldBindJSON`.
|
||||||
|
2. `LocalConfigurationService.Create` or `Update` is called.
|
||||||
|
3. Service computes `total_price` from `req.Items.Total()` (sum of `unit_price * quantity` per item).
|
||||||
|
4. A new revision snapshot is created via `createWithVersion`; revision number increments.
|
||||||
|
5. `quoteService.RecordUsage` is called best-effort (warn on failure, do not abort save).
|
||||||
|
6. Configuration row written to SQLite (`local_configurations`); version row appended to `local_configuration_versions`.
|
||||||
|
7. Pending change queued in `pending_changes` for later sync push.
|
||||||
|
|
||||||
|
**DO NOT** read prices from `local_components` during save - prices must already be on items.
|
||||||
|
**DO NOT** skip version creation on rename/reorder/project-move - those operations call different paths that must NOT call `createWithVersion`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Refresh prices (POST /api/configs/:uuid/refresh-prices)
|
||||||
|
|
||||||
|
1. Handler calls `LocalConfigurationService.RefreshPricesNoAuth(uuid, pricelistServerID)`.
|
||||||
|
2. If online, `SyncPricelistsIfNeeded` runs best-effort (warn on failure, do not block).
|
||||||
|
3. Resolves target pricelist in order:
|
||||||
|
a. Explicitly requested pricelist (`pricelistServerID` param).
|
||||||
|
b. Pricelist stored in configuration row (`localCfg.PricelistID`).
|
||||||
|
c. Latest local pricelist as fallback.
|
||||||
|
4. For each item in the config, looks up price from `local_pricelist_items` via `GetLocalPricesForLots` (batch, single query).
|
||||||
|
5. Items with matching prices are updated; items with no price keep their existing `unit_price`.
|
||||||
|
6. Updated configuration saved as a new version (same flow as §1 from step 4 onward).
|
||||||
|
|
||||||
|
**DO NOT** read prices from `qt_pricelist_items` (MariaDB) directly - prices come from SQLite cache only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Pricelist sync (POST /api/sync/pricelists)
|
||||||
|
|
||||||
|
1. Readiness guard checked; returns 423 if guard blocks sync.
|
||||||
|
2. `SyncService.SyncPricelists` pulls from `qt_pricelists` and `qt_pricelist_items` (MariaDB).
|
||||||
|
3. For each pricelist: header upserted first, then items replaced atomically via `ReplaceLocalPricelistItems`.
|
||||||
|
4. After all pricelists: `RecalculateAllLocalPricelistUsage` marks which pricelists are referenced by active configurations.
|
||||||
|
5. Sync result (status, error, timestamp) written to `app_settings` via `SetPricelistSyncResult`.
|
||||||
|
|
||||||
|
**DO NOT** write pricelist header without items in the same transaction - must be atomic.
|
||||||
|
**DO NOT** query MariaDB from runtime handlers outside sync/setup flows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Vendor spec apply (POST /api/configs/:uuid/vendor-spec/apply)
|
||||||
|
|
||||||
|
1. Incoming `items[]` (lot_name, quantity, unit_price) replace the configuration's `items` entirely.
|
||||||
|
2. New item list saved through `LocalConfigurationService.UpdateItemsNoAuth`.
|
||||||
|
3. A new revision is created reflecting the BOM-derived item state.
|
||||||
|
|
||||||
|
**DO NOT** apply vendor spec without going through the service layer - handler must not write items directly to DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Configuration versioning invariants
|
||||||
|
|
||||||
|
- `local_configuration_versions` is append-only; rows are never updated or deleted.
|
||||||
|
- Version deduplication: if new snapshot hash equals current head, no new version is created.
|
||||||
|
- Rollback = create new HEAD revision from old snapshot data (does not restore version pointer to old row).
|
||||||
|
- UI must always show "main" (implicit head) as the active state; never point to a numbered revision after save.
|
||||||
|
- Operations that do NOT create a new version: rename, reorder within project, project move, pricelist selector change only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Pending changes queue
|
||||||
|
|
||||||
|
- Every local write (create/update/delete) appends a row to `pending_changes`.
|
||||||
|
- `POST /api/sync/push` drains the queue by writing to MariaDB.
|
||||||
|
- If a push fails, `increment_attempts` and `last_error` are updated; row stays in queue.
|
||||||
|
- `RepairPendingChanges` reconciles orphaned changes (configuration/project deleted locally).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Error handling boundary rules
|
||||||
|
|
||||||
|
- Handlers: log 500 responses with `slog.Error`; surface error message via `RespondError`.
|
||||||
|
- Services: wrap errors with `fmt.Errorf("context: %w", err)`; do NOT log inside service.
|
||||||
|
- Repositories: return raw errors; no logging.
|
||||||
|
- Best-effort operations (usage stats, background sync): log `slog.Warn` and continue.
|
||||||
165
bible-local/server-contract-qt-settings.md
Normal file
165
bible-local/server-contract-qt-settings.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Server contract: qt_settings
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`qt_settings` is a general-purpose key→JSON-value table that the price management
|
||||||
|
application uses to push configuration into QuoteForge clients. QF reads it during
|
||||||
|
component sync and caches the result in `local_qt_settings` (SQLite).
|
||||||
|
|
||||||
|
## Required MariaDB changes (implemented by server-side agent)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS qt_settings (
|
||||||
|
name VARCHAR(100) NOT NULL PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL -- JSON-encoded value
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_settings TO 'qfs_user'@'%';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings consumed by QuoteForge
|
||||||
|
|
||||||
|
All values are JSON. Missing or unparseable entries are silently skipped; QF
|
||||||
|
falls back to hardcoded defaults for each missing key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `config_types`
|
||||||
|
|
||||||
|
Defines the available device configuration types, their localized names, and the
|
||||||
|
category codes that are allowed for each type. QF uses this for:
|
||||||
|
- the new-config modal (button list + labels);
|
||||||
|
- the configurator's category filter per `config_type`.
|
||||||
|
|
||||||
|
**Value format:** JSON array of objects.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "server",
|
||||||
|
"name_ru": "Сервер",
|
||||||
|
"display_order": 10,
|
||||||
|
"categories": [
|
||||||
|
"MB","CPU","MEM","RAID",
|
||||||
|
"SSD","HDD","M2","EDSFF","HHHL",
|
||||||
|
"GPU","NIC","HCA","DPU","HBA",
|
||||||
|
"PSU","PS","ACC","RISERS","CARD","BB"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "storage",
|
||||||
|
"name_ru": "СХД",
|
||||||
|
"display_order": 20,
|
||||||
|
"categories": [
|
||||||
|
"DKC","CPU","MEM","PS",
|
||||||
|
"SSD","HDD","M2","EDSFF","HHHL",
|
||||||
|
"NIC","HBA","HCA","ACC","CARD"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `code` | string | Identifier stored on `qt_configurations.config_type`. Must be stable. |
|
||||||
|
| `name_ru` | string | Display name in Russian for the QF UI. |
|
||||||
|
| `display_order` | int | Sort order for the modal button list. |
|
||||||
|
| `categories` | string[] | Allowlist of LOT category codes visible in this config type. A category absent from ALL entries is visible in all types. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `tab_config`
|
||||||
|
|
||||||
|
Defines the configurator tab layout: which tabs exist, which categories each tab
|
||||||
|
contains, optional sub-sections within a tab, and whether the tab uses
|
||||||
|
single-select mode.
|
||||||
|
|
||||||
|
**Value format:** JSON array of tab objects (ordered — defines tab bar order).
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "base",
|
||||||
|
"label": "Base",
|
||||||
|
"single_select": true,
|
||||||
|
"categories": ["MB","CPU","MEM","ENC","DKC","CTL"],
|
||||||
|
"sections": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "storage",
|
||||||
|
"label": "Storage",
|
||||||
|
"single_select": false,
|
||||||
|
"categories": ["RAID","M2","SSD","HDD","EDSFF","HHHL"],
|
||||||
|
"sections": [
|
||||||
|
{ "title": "RAID Контроллеры", "categories": ["RAID"] },
|
||||||
|
{ "title": "Диски", "categories": ["M2","SSD","HDD","EDSFF","HHHL"] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "pci",
|
||||||
|
"label": "PCI",
|
||||||
|
"single_select": false,
|
||||||
|
"categories": ["GPU","DPU","NIC","HCA","HBA","HIC"],
|
||||||
|
"sections": [
|
||||||
|
{ "title": "GPU / DPU", "categories": ["GPU","DPU"] },
|
||||||
|
{ "title": "NIC / HCA", "categories": ["NIC","HCA"] },
|
||||||
|
{ "title": "HBA", "categories": ["HBA"] },
|
||||||
|
{ "title": "HIC", "categories": ["HIC"] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "key": "power", "label": "Power", "single_select": false, "categories": ["PS","PSU"] },
|
||||||
|
{ "key": "accessories", "label": "Accessories", "single_select": false, "categories": ["ACC","CARD"] },
|
||||||
|
{ "key": "sw", "label": "SW", "single_select": false, "categories": ["SW"] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
The QF frontend always appends an "other" tab for any categories not listed here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `always_visible_tabs`
|
||||||
|
|
||||||
|
Tab keys that are always shown in the configurator regardless of whether they
|
||||||
|
contain any items. Other tabs are hidden when empty.
|
||||||
|
|
||||||
|
**Value format:** JSON string array.
|
||||||
|
|
||||||
|
```json
|
||||||
|
["base", "storage", "pci"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `required_categories`
|
||||||
|
|
||||||
|
Category codes that must have at least one LOT selected for a configuration to
|
||||||
|
be considered complete. Keyed by `config_type` code. QF uses this to show a
|
||||||
|
badge on the tab label when required categories are missing.
|
||||||
|
|
||||||
|
**Value format:** JSON object mapping config_type code → string array.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": ["CPU", "MEM", "BB"],
|
||||||
|
"storage": ["DKC", "CPU", "MEM"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backward compatibility
|
||||||
|
|
||||||
|
- If `qt_settings` does not exist (old server): QF logs `Warn` during sync and
|
||||||
|
leaves `local_qt_settings` empty. The frontend falls back to hardcoded defaults
|
||||||
|
for all four settings. No crash, no data loss.
|
||||||
|
- If a specific key is absent from `qt_settings`: QF falls back to the hardcoded
|
||||||
|
default for that key only.
|
||||||
|
- Old QF clients that do not know about `local_qt_settings` continue to use their
|
||||||
|
hardcoded JS constants unchanged.
|
||||||
|
|
||||||
|
## Note on `qt_categories`
|
||||||
|
|
||||||
|
`qt_categories.name` and `qt_categories.name_ru` are being removed.
|
||||||
|
QF runtime does not depend on them — `GetCategories` derives `Name` from the
|
||||||
|
category code string stored in `local_components`.
|
||||||
@@ -2,8 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||||
@@ -153,7 +153,7 @@ func main() {
|
|||||||
log.Printf(" Skipped: %d", skipped)
|
log.Printf(" Skipped: %d", skipped)
|
||||||
log.Printf(" Errors: %d", errors)
|
log.Printf(" Errors: %d", errors)
|
||||||
|
|
||||||
fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs")
|
slog.Info("Done! You can now run the server with: go run ./cmd/qfs")
|
||||||
}
|
}
|
||||||
|
|
||||||
func derefUint(v *uint) uint {
|
func derefUint(v *uint) uint {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -79,12 +80,12 @@ func main() {
|
|||||||
|
|
||||||
printPlan(actions)
|
printPlan(actions)
|
||||||
if len(actions) == 0 {
|
if len(actions) == 0 {
|
||||||
fmt.Println("Nothing to migrate.")
|
slog.Info("Nothing to migrate.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !*apply {
|
if !*apply {
|
||||||
fmt.Println("\nPreview complete. Re-run with -apply to execute.")
|
slog.Info("Preview complete. Re-run with -apply to execute.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ func main() {
|
|||||||
log.Fatalf("confirmation failed: %v", confirmErr)
|
log.Fatalf("confirmation failed: %v", confirmErr)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
fmt.Println("Aborted.")
|
slog.Info("Aborted.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +104,7 @@ func main() {
|
|||||||
log.Fatalf("migration failed: %v", err)
|
log.Fatalf("migration failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Migration completed successfully.")
|
slog.Info("Migration completed successfully.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureProjectsTable(db *gorm.DB) error {
|
func ensureProjectsTable(db *gorm.DB) error {
|
||||||
@@ -212,10 +213,8 @@ func printPlan(actions []migrationAction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Planned actions: %d\n", len(actions))
|
slog.Info("Plan summary", "actions", len(actions), "create", createCount, "reactivate", reactivateCount)
|
||||||
fmt.Printf("Projects to create: %d\n", createCount)
|
slog.Info("Details:")
|
||||||
fmt.Printf("Projects to reactivate: %d\n", reactivateCount)
|
|
||||||
fmt.Println("\nDetails:")
|
|
||||||
|
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
extra := ""
|
extra := ""
|
||||||
|
|||||||
173
cmd/migrate_project_updated_at/main.go
Normal file
173
cmd/migrate_project_updated_at/main.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type projectTimestampRow struct {
|
||||||
|
UUID string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type updatePlanRow struct {
|
||||||
|
UUID string
|
||||||
|
Code string
|
||||||
|
Variant string
|
||||||
|
LocalUpdatedAt time.Time
|
||||||
|
ServerUpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
defaultLocalDBPath, err := appstate.ResolveDBPath("")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to resolve default local SQLite path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||||
|
apply := flag.Bool("apply", false, "apply updates to local SQLite (default is preview only)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
local, err := localdb.New(*localDBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize local database: %v", err)
|
||||||
|
}
|
||||||
|
defer local.Close()
|
||||||
|
|
||||||
|
if !local.HasSettings() {
|
||||||
|
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn, err := local.GetDSN()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to build DSN from SQLite settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to connect to MariaDB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverRows, err := loadServerProjects(db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load server projects: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localProjects, err := local.GetAllProjects(true)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load local projects: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := buildUpdatePlan(localProjects, serverRows)
|
||||||
|
printPlan(plan, *apply)
|
||||||
|
|
||||||
|
if !*apply || len(plan) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := 0
|
||||||
|
for i := range plan {
|
||||||
|
project, err := local.GetProjectByUUID(plan[i].UUID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("skip %s: load local project: %v", plan[i].UUID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
project.UpdatedAt = plan[i].ServerUpdatedAt
|
||||||
|
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
|
||||||
|
log.Printf("skip %s: save local project: %v", plan[i].UUID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("updated %d local project timestamps", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadServerProjects(db *gorm.DB) (map[string]time.Time, error) {
|
||||||
|
var rows []projectTimestampRow
|
||||||
|
if err := db.Model(&models.Project{}).
|
||||||
|
Select("uuid, updated_at").
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(map[string]time.Time, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.UUID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[row.UUID] = row.UpdatedAt
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUpdatePlan(localProjects []localdb.LocalProject, serverRows map[string]time.Time) []updatePlanRow {
|
||||||
|
plan := make([]updatePlanRow, 0)
|
||||||
|
for i := range localProjects {
|
||||||
|
project := localProjects[i]
|
||||||
|
serverUpdatedAt, ok := serverRows[project.UUID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if project.UpdatedAt.Equal(serverUpdatedAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plan = append(plan, updatePlanRow{
|
||||||
|
UUID: project.UUID,
|
||||||
|
Code: project.Code,
|
||||||
|
Variant: project.Variant,
|
||||||
|
LocalUpdatedAt: project.UpdatedAt,
|
||||||
|
ServerUpdatedAt: serverUpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(plan, func(i, j int) bool {
|
||||||
|
if plan[i].Code != plan[j].Code {
|
||||||
|
return plan[i].Code < plan[j].Code
|
||||||
|
}
|
||||||
|
return plan[i].Variant < plan[j].Variant
|
||||||
|
})
|
||||||
|
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPlan(plan []updatePlanRow, apply bool) {
|
||||||
|
mode := "preview"
|
||||||
|
if apply {
|
||||||
|
mode = "apply"
|
||||||
|
}
|
||||||
|
log.Printf("project updated_at resync mode=%s changes=%d", mode, len(plan))
|
||||||
|
if len(plan) == 0 {
|
||||||
|
log.Printf("no local project timestamps need resync")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, row := range plan {
|
||||||
|
variant := row.Variant
|
||||||
|
if variant == "" {
|
||||||
|
variant = "main"
|
||||||
|
}
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatStamp(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return "zero"
|
||||||
|
}
|
||||||
|
return value.Format(time.RFC3339)
|
||||||
|
}
|
||||||
@@ -39,6 +39,10 @@ logging:
|
|||||||
t.Fatalf("load legacy config: %v", err)
|
t.Fatalf("load legacy config: %v", err)
|
||||||
}
|
}
|
||||||
setConfigDefaults(cfg)
|
setConfigDefaults(cfg)
|
||||||
|
cfg.Server.Host, _, err = normalizeLoopbackServerHost(cfg.Server.Host)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize server host: %v", err)
|
||||||
|
}
|
||||||
if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil {
|
if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil {
|
||||||
t.Fatalf("migrate config: %v", err)
|
t.Fatalf("migrate config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -60,7 +64,43 @@ logging:
|
|||||||
if !strings.Contains(text, "port: 9191") {
|
if !strings.Contains(text, "port: 9191") {
|
||||||
t.Fatalf("migrated config did not preserve server port:\n%s", text)
|
t.Fatalf("migrated config did not preserve server port:\n%s", text)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(text, "host: 127.0.0.1") {
|
||||||
|
t.Fatalf("migrated config did not normalize server host:\n%s", text)
|
||||||
|
}
|
||||||
if !strings.Contains(text, "level: debug") {
|
if !strings.Contains(text, "level: debug") {
|
||||||
t.Fatalf("migrated config did not preserve logging level:\n%s", text)
|
t.Fatalf("migrated config did not preserve logging level:\n%s", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeLoopbackServerHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
host string
|
||||||
|
want string
|
||||||
|
wantChanged bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{host: "127.0.0.1", want: "127.0.0.1", wantChanged: false, wantErr: false},
|
||||||
|
{host: "localhost", want: "127.0.0.1", wantChanged: true, wantErr: false},
|
||||||
|
{host: "::1", want: "127.0.0.1", wantChanged: true, wantErr: false},
|
||||||
|
{host: "0.0.0.0", want: "127.0.0.1", wantChanged: true, wantErr: false},
|
||||||
|
{host: "192.168.1.10", want: "127.0.0.1", wantChanged: true, wantErr: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, changed, err := normalizeLoopbackServerHost(tc.host)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatalf("expected error for host %q", tc.host)
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("unexpected error for host %q: %v", tc.host, err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("unexpected normalized host for %q: got %q want %q", tc.host, got, tc.want)
|
||||||
|
}
|
||||||
|
if changed != tc.wantChanged {
|
||||||
|
t.Fatalf("unexpected changed flag for %q: got %t want %t", tc.host, changed, tc.wantChanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
347
cmd/qfs/main.go
347
cmd/qfs/main.go
@@ -10,6 +10,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -43,11 +44,16 @@ import (
|
|||||||
|
|
||||||
// Version is set via ldflags during build
|
// Version is set via ldflags during build
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
var errVendorImportTooLarge = errors.New("vendor workspace file exceeds 1 GiB limit")
|
||||||
|
|
||||||
const backgroundSyncInterval = 5 * time.Minute
|
const backgroundSyncInterval = 5 * time.Minute
|
||||||
const onDemandPullCooldown = 30 * time.Second
|
const onDemandPullCooldown = 30 * time.Second
|
||||||
const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать"
|
const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать"
|
||||||
|
|
||||||
|
var vendorImportMaxBytes int64 = 1 << 30
|
||||||
|
|
||||||
|
const vendorImportMultipartOverheadBytes int64 = 8 << 20
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
showStartupConsoleWarning()
|
showStartupConsoleWarning()
|
||||||
|
|
||||||
@@ -142,6 +148,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setConfigDefaults(cfg)
|
setConfigDefaults(cfg)
|
||||||
|
normalizedHost, changed, err := normalizeLoopbackServerHost(cfg.Server.Host)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("invalid server host", "host", cfg.Server.Host, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
slog.Warn("corrected server host to loopback", "from", cfg.Server.Host, "to", normalizedHost)
|
||||||
|
}
|
||||||
|
cfg.Server.Host = normalizedHost
|
||||||
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
|
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
|
||||||
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
|
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -274,8 +289,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func showStartupConsoleWarning() {
|
func showStartupConsoleWarning() {
|
||||||
// Visible in console output.
|
slog.Warn(startupConsoleWarning)
|
||||||
fmt.Println(startupConsoleWarning)
|
|
||||||
// Keep the warning always visible in the console window title when supported.
|
// Keep the warning always visible in the console window title when supported.
|
||||||
fmt.Printf("\033]0;%s\007", startupConsoleWarning)
|
fmt.Printf("\033]0;%s\007", startupConsoleWarning)
|
||||||
}
|
}
|
||||||
@@ -317,31 +331,51 @@ func setConfigDefaults(cfg *config.Config) {
|
|||||||
cfg.Server.ReadTimeout = 30 * time.Second
|
cfg.Server.ReadTimeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
if cfg.Server.WriteTimeout == 0 {
|
if cfg.Server.WriteTimeout == 0 {
|
||||||
cfg.Server.WriteTimeout = 30 * time.Second
|
// 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.
|
||||||
if cfg.Pricing.DefaultMethod == "" {
|
cfg.Server.WriteTimeout = 10 * time.Minute
|
||||||
cfg.Pricing.DefaultMethod = "weighted_median"
|
|
||||||
}
|
|
||||||
if cfg.Pricing.DefaultPeriodDays == 0 {
|
|
||||||
cfg.Pricing.DefaultPeriodDays = 90
|
|
||||||
}
|
|
||||||
if cfg.Pricing.FreshnessGreenDays == 0 {
|
|
||||||
cfg.Pricing.FreshnessGreenDays = 30
|
|
||||||
}
|
|
||||||
if cfg.Pricing.FreshnessYellowDays == 0 {
|
|
||||||
cfg.Pricing.FreshnessYellowDays = 60
|
|
||||||
}
|
|
||||||
if cfg.Pricing.FreshnessRedDays == 0 {
|
|
||||||
cfg.Pricing.FreshnessRedDays = 90
|
|
||||||
}
|
|
||||||
if cfg.Pricing.MinQuotesForMedian == 0 {
|
|
||||||
cfg.Pricing.MinQuotesForMedian = 3
|
|
||||||
}
|
}
|
||||||
if cfg.Backup.Time == "" {
|
if cfg.Backup.Time == "" {
|
||||||
cfg.Backup.Time = "00:00"
|
cfg.Backup.Time = "00:00"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeLoopbackServerHost(host string) (string, bool, error) {
|
||||||
|
trimmed := strings.TrimSpace(host)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", false, fmt.Errorf("server.host must not be empty")
|
||||||
|
}
|
||||||
|
const loopbackHost = "127.0.0.1"
|
||||||
|
if trimmed == loopbackHost {
|
||||||
|
return loopbackHost, false, nil
|
||||||
|
}
|
||||||
|
if strings.EqualFold(trimmed, "localhost") {
|
||||||
|
return loopbackHost, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := net.ParseIP(strings.Trim(trimmed, "[]"))
|
||||||
|
if ip != nil {
|
||||||
|
if ip.IsLoopback() || ip.IsUnspecified() {
|
||||||
|
return loopbackHost, trimmed != loopbackHost, nil
|
||||||
|
}
|
||||||
|
return loopbackHost, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return loopbackHost, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vendorImportBodyLimit() int64 {
|
||||||
|
return vendorImportMaxBytes + vendorImportMultipartOverheadBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVendorImportTooLarge(fileSize int64, err error) bool {
|
||||||
|
if fileSize > vendorImportMaxBytes {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var maxBytesErr *http.MaxBytesError
|
||||||
|
return errors.As(err, &maxBytesErr)
|
||||||
|
}
|
||||||
|
|
||||||
func ensureDefaultConfigFile(configPath string) error {
|
func ensureDefaultConfigFile(configPath string) error {
|
||||||
if strings.TrimSpace(configPath) == "" {
|
if strings.TrimSpace(configPath) == "" {
|
||||||
return fmt.Errorf("config path is empty")
|
return fmt.Errorf("config path is empty")
|
||||||
@@ -361,7 +395,7 @@ func ensureDefaultConfigFile(configPath string) error {
|
|||||||
port: 8080
|
port: 8080
|
||||||
mode: "release"
|
mode: "release"
|
||||||
read_timeout: 30s
|
read_timeout: 30s
|
||||||
write_timeout: 30s
|
write_timeout: 10m
|
||||||
|
|
||||||
backup:
|
backup:
|
||||||
time: "00:00"
|
time: "00:00"
|
||||||
@@ -643,9 +677,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
var projectService *services.ProjectService
|
var projectService *services.ProjectService
|
||||||
|
|
||||||
syncService = sync.NewService(connMgr, local)
|
syncService = sync.NewService(connMgr, local)
|
||||||
componentService := services.NewComponentService(nil, nil, nil)
|
componentService := services.NewComponentService(nil, nil)
|
||||||
quoteService := services.NewQuoteService(nil, nil, nil, local, nil)
|
quoteService := services.NewQuoteService(nil, nil, local, nil)
|
||||||
exportService := services.NewExportService(cfg.Export, nil, local)
|
exportService := services.NewExportService(cfg.Export, local)
|
||||||
|
|
||||||
// isOnline function for local-first architecture
|
// isOnline function for local-first architecture
|
||||||
isOnline := func() bool {
|
isOnline := func() bool {
|
||||||
@@ -745,13 +779,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||||
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
|
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
|
||||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||||
vendorSpecHandler := handlers.NewVendorSpecHandler(local)
|
vendorSpecHandler := handlers.NewVendorSpecHandler(local, syncService)
|
||||||
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
|
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
|
||||||
|
respondError := handlers.RespondError
|
||||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
supportBundleHandler := handlers.NewSupportBundleHandler(local, connMgr, syncService, cfg.Logging.FilePath)
|
||||||
|
|
||||||
// Setup handler (for reconfiguration)
|
// Setup handler (for reconfiguration)
|
||||||
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig)
|
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -766,6 +803,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
// Router
|
// Router
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
|
router.MaxMultipartMemory = vendorImportBodyLimit()
|
||||||
router.Use(gin.Recovery())
|
router.Use(gin.Recovery())
|
||||||
router.Use(requestLogger())
|
router.Use(requestLogger())
|
||||||
router.Use(middleware.CORS())
|
router.Use(middleware.CORS())
|
||||||
@@ -786,17 +824,17 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Restart endpoint (for development purposes)
|
// Restart endpoint is intentionally debug-only.
|
||||||
router.POST("/api/restart", func(c *gin.Context) {
|
if cfg.Server.Mode == "debug" {
|
||||||
// This will cause the server to restart by exiting
|
router.POST("/api/restart", func(c *gin.Context) {
|
||||||
// The restartProcess function will be called to restart the process
|
slog.Info("Restart requested via API")
|
||||||
slog.Info("Restart requested via API")
|
go func() {
|
||||||
go func() {
|
time.Sleep(100 * time.Millisecond)
|
||||||
time.Sleep(100 * time.Millisecond)
|
restartProcess()
|
||||||
restartProcess()
|
}()
|
||||||
}()
|
c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
// DB status endpoint
|
// DB status endpoint
|
||||||
router.GET("/api/db-status", func(c *gin.Context) {
|
router.GET("/api/db-status", func(c *gin.Context) {
|
||||||
@@ -856,6 +894,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||||
router.GET("/partnumber-books", webHandler.PartnumberBooks)
|
router.GET("/partnumber-books", webHandler.PartnumberBooks)
|
||||||
|
|
||||||
|
// Short project URLs: /:code → main variant, /:code/:variant → named variant
|
||||||
|
router.GET("/:code", func(c *gin.Context) {
|
||||||
|
code := c.Param("code")
|
||||||
|
project, err := projectService.GetByCode(code)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/projects")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
|
||||||
|
})
|
||||||
|
router.GET("/:code/:variant", func(c *gin.Context) {
|
||||||
|
code := c.Param("code")
|
||||||
|
variant := c.Param("variant")
|
||||||
|
project, err := projectService.GetByCodeAndVariant(code, variant)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/projects")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
|
||||||
|
})
|
||||||
|
|
||||||
// htmx partials
|
// htmx partials
|
||||||
partials := router.Group("/partials")
|
partials := router.Group("/partials")
|
||||||
{
|
{
|
||||||
@@ -870,6 +929,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
api.GET("/support-bundle", supportBundleHandler.DownloadBundle)
|
||||||
|
|
||||||
// Components (public read)
|
// Components (public read)
|
||||||
components := api.Group("/components")
|
components := api.Group("/components")
|
||||||
{
|
{
|
||||||
@@ -879,6 +940,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
// Categories (public)
|
// Categories (public)
|
||||||
api.GET("/categories", componentHandler.GetCategories)
|
api.GET("/categories", componentHandler.GetCategories)
|
||||||
|
api.GET("/configurator-settings", componentHandler.GetConfiguratorSettings)
|
||||||
|
|
||||||
// Quote (public)
|
// Quote (public)
|
||||||
quote := api.Group("/quote")
|
quote := api.Group("/quote")
|
||||||
@@ -911,6 +973,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
|
pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stateless BOM text parsing shared by paste and file-import paths.
|
||||||
|
api.POST("/vendor-spec/parse-text", vendorSpecHandler.ParseText)
|
||||||
|
|
||||||
// Configurations (public - RBAC disabled)
|
// Configurations (public - RBAC disabled)
|
||||||
configs := api.Group("/configs")
|
configs := api.Group("/configs")
|
||||||
{
|
{
|
||||||
@@ -928,7 +993,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search)
|
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,7 +1014,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"})
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
@@ -958,13 +1023,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
configs.POST("", func(c *gin.Context) {
|
configs.POST("", func(c *gin.Context) {
|
||||||
var req services.CreateConfigRequest
|
var req services.CreateConfigRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := configService.Create(dbUsername, &req)
|
config, err := configService.Create(dbUsername, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,12 +1039,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
configs.POST("/preview-article", func(c *gin.Context) {
|
configs.POST("/preview-article", func(c *gin.Context) {
|
||||||
var req services.ArticlePreviewRequest
|
var req services.ArticlePreviewRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := configService.BuildArticlePreview(&req)
|
result, err := configService.BuildArticlePreview(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -1002,7 +1067,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
var req services.CreateConfigRequest
|
var req services.CreateConfigRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1010,13 +1075,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrConfigNotFound):
|
case errors.Is(err, services.ErrConfigNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
case errors.Is(err, services.ErrProjectNotFound):
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
case errors.Is(err, services.ErrProjectForbidden):
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
respondError(c, http.StatusForbidden, "access denied", err)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1027,7 +1092,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
configs.DELETE("/:uuid", func(c *gin.Context) {
|
configs.DELETE("/:uuid", func(c *gin.Context) {
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
if err := configService.DeleteNoAuth(uuid); err != nil {
|
if err := configService.DeleteNoAuth(uuid); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "archived"})
|
c.JSON(http.StatusOK, gin.H{"message": "archived"})
|
||||||
@@ -1037,7 +1102,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
config, err := configService.ReactivateNoAuth(uuid)
|
config, err := configService.ReactivateNoAuth(uuid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -1052,13 +1117,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := configService.RenameNoAuth(uuid, req.Name)
|
config, err := configService.RenameNoAuth(uuid, req.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1072,7 +1137,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
FromVersion int `json:"from_version"`
|
FromVersion int `json:"from_version"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,7 +1147,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1091,34 +1156,48 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
|
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
config, err := configService.RefreshPricesNoAuth(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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, config)
|
c.JSON(http.StatusOK, config)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
configs.POST("/:uuid/snapshot", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
if err := configService.SnapshotCurrentState(uuid); err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
|
||||||
configs.PATCH("/:uuid/project", func(c *gin.Context) {
|
configs.PATCH("/:uuid/project", func(c *gin.Context) {
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
var req struct {
|
var req struct {
|
||||||
ProjectUUID string `json:"project_uuid"`
|
ProjectUUID string `json:"project_uuid"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID)
|
updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrConfigNotFound):
|
case errors.Is(err, services.ErrConfigNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
case errors.Is(err, services.ErrProjectNotFound):
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
case errors.Is(err, services.ErrProjectForbidden):
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
respondError(c, http.StatusForbidden, "access denied", err)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1147,7 +1226,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
case errors.Is(err, services.ErrInvalidVersionNumber):
|
case errors.Is(err, services.ErrInvalidVersionNumber):
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"})
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1175,7 +1254,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
case errors.Is(err, services.ErrConfigVersionNotFound):
|
case errors.Is(err, services.ErrConfigVersionNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1190,7 +1269,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.TargetVersion <= 0 {
|
if req.TargetVersion <= 0 {
|
||||||
@@ -1208,7 +1287,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
case errors.Is(err, services.ErrVersionConflict):
|
case errors.Is(err, services.ErrVersionConflict):
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "version conflict"})
|
c.JSON(http.StatusConflict, gin.H{"error": "version conflict"})
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1230,6 +1309,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
})
|
})
|
||||||
|
|
||||||
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
||||||
|
configs.POST("/:uuid/export/pricing", exportHandler.ExportConfigPricingCSV)
|
||||||
|
|
||||||
// Vendor spec (BOM) endpoints
|
// Vendor spec (BOM) endpoints
|
||||||
configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec)
|
configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec)
|
||||||
@@ -1243,12 +1323,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
ServerCount int `json:"server_count" binding:"required,min=1"`
|
ServerCount int `json:"server_count" binding:"required,min=1"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
config, err := configService.UpdateServerCount(uuid, req.ServerCount)
|
config, err := configService.UpdateServerCount(uuid, req.ServerCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, config)
|
c.JSON(http.StatusOK, config)
|
||||||
@@ -1293,7 +1373,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
allProjects, err := projectService.ListByUser(dbUsername, true)
|
allProjects, err := projectService.ListByUser(dbUsername, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1427,7 +1507,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
projects.GET("/all", func(c *gin.Context) {
|
projects.GET("/all", func(c *gin.Context) {
|
||||||
allProjects, err := projectService.ListByUser(dbUsername, true)
|
allProjects, err := projectService.ListByUser(dbUsername, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1457,7 +1537,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
projects.POST("", func(c *gin.Context) {
|
projects.POST("", func(c *gin.Context) {
|
||||||
var req services.CreateProjectRequest
|
var req services.CreateProjectRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Code) == "" {
|
if strings.TrimSpace(req.Code) == "" {
|
||||||
@@ -1467,10 +1547,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
project, err := projectService.Create(dbUsername, &req)
|
project, err := projectService.Create(dbUsername, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
|
case errors.Is(err, services.ErrReservedMainVariant),
|
||||||
|
errors.Is(err, services.ErrProjectCodeInvalidChars),
|
||||||
|
errors.Is(err, services.ErrProjectVariantInvalidChars):
|
||||||
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
case errors.Is(err, services.ErrProjectCodeExists):
|
case errors.Is(err, services.ErrProjectCodeExists):
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1482,11 +1566,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrProjectNotFound):
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
case errors.Is(err, services.ErrProjectForbidden):
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
respondError(c, http.StatusForbidden, "access denied", err)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1496,20 +1580,25 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
projects.PUT("/:uuid", func(c *gin.Context) {
|
projects.PUT("/:uuid", func(c *gin.Context) {
|
||||||
var req services.UpdateProjectRequest
|
var req services.UpdateProjectRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
|
case errors.Is(err, services.ErrReservedMainVariant),
|
||||||
|
errors.Is(err, services.ErrCannotRenameMainVariant),
|
||||||
|
errors.Is(err, services.ErrProjectCodeInvalidChars),
|
||||||
|
errors.Is(err, services.ErrProjectVariantInvalidChars):
|
||||||
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
case errors.Is(err, services.ErrProjectCodeExists):
|
case errors.Is(err, services.ErrProjectCodeExists):
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||||
case errors.Is(err, services.ErrProjectNotFound):
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
case errors.Is(err, services.ErrProjectForbidden):
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
respondError(c, http.StatusForbidden, "access denied", err)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1520,11 +1609,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil {
|
if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrProjectNotFound):
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
case errors.Is(err, services.ErrProjectForbidden):
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
respondError(c, http.StatusForbidden, "access denied", err)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1535,11 +1624,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil {
|
if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrProjectNotFound):
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
case errors.Is(err, services.ErrProjectForbidden):
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
respondError(c, http.StatusForbidden, "access denied", err)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1550,13 +1639,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
|
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrCannotDeleteMainVariant):
|
case errors.Is(err, services.ErrCannotDeleteMainVariant):
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
case errors.Is(err, services.ErrProjectNotFound):
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
case errors.Is(err, services.ErrProjectForbidden):
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
respondError(c, http.StatusForbidden, "access denied", err)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1576,11 +1665,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrProjectNotFound):
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
case errors.Is(err, services.ErrProjectForbidden):
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
respondError(c, http.StatusForbidden, "access denied", err)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1593,7 +1682,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
OrderedUUIDs []string `json:"ordered_uuids"`
|
OrderedUUIDs []string `json:"ordered_uuids"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.OrderedUUIDs) == 0 {
|
if len(req.OrderedUUIDs) == 0 {
|
||||||
@@ -1605,9 +1694,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrProjectNotFound):
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1628,7 +1717,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
projects.POST("/:uuid/configs", func(c *gin.Context) {
|
projects.POST("/:uuid/configs", func(c *gin.Context) {
|
||||||
var req services.CreateConfigRequest
|
var req services.CreateConfigRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
projectUUID := c.Param("uuid")
|
projectUUID := c.Param("uuid")
|
||||||
@@ -1636,32 +1725,45 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
config, err := configService.Create(dbUsername, &req)
|
config, err := configService.Create(dbUsername, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, config)
|
c.JSON(http.StatusCreated, config)
|
||||||
})
|
})
|
||||||
|
|
||||||
projects.POST("/:uuid/vendor-import", func(c *gin.Context) {
|
projects.POST("/:uuid/vendor-import", func(c *gin.Context) {
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, vendorImportBodyLimit())
|
||||||
fileHeader, err := c.FormFile("file")
|
fileHeader, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
if isVendorImportTooLarge(0, err) {
|
||||||
|
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, http.StatusBadRequest, "file is required", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isVendorImportTooLarge(fileHeader.Size, nil) {
|
||||||
|
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := fileHeader.Open()
|
file, err := fileHeader.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"})
|
respondError(c, http.StatusBadRequest, "failed to open uploaded file", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
data, err := io.ReadAll(file)
|
data, err := io.ReadAll(io.LimitReader(file, vendorImportMaxBytes+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read uploaded file"})
|
respondError(c, http.StatusBadRequest, "failed to read uploaded file", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !services.IsCFXMLWorkspace(data) {
|
if int64(len(data)) > vendorImportMaxBytes {
|
||||||
|
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) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1670,9 +1772,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrProjectNotFound):
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
respondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1688,14 +1790,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
projectUUID := c.Param("uuid")
|
projectUUID := c.Param("uuid")
|
||||||
config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID)
|
config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, config)
|
c.JSON(http.StatusCreated, config)
|
||||||
@@ -1709,7 +1811,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
syncAPI.GET("/readiness", syncHandler.GetReadiness)
|
syncAPI.GET("/readiness", syncHandler.GetReadiness)
|
||||||
syncAPI.GET("/info", syncHandler.GetInfo)
|
syncAPI.GET("/info", syncHandler.GetInfo)
|
||||||
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
|
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
|
||||||
syncAPI.POST("/components", syncHandler.SyncComponents)
|
|
||||||
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
||||||
syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks)
|
syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks)
|
||||||
syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen)
|
syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen)
|
||||||
@@ -1769,22 +1870,12 @@ func requestLogger() gin.HandlerFunc {
|
|||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
query := c.Request.URL.RawQuery
|
query := c.Request.URL.RawQuery
|
||||||
|
|
||||||
blw := &captureResponseWriter{
|
|
||||||
ResponseWriter: c.Writer,
|
|
||||||
body: bytes.NewBuffer(nil),
|
|
||||||
}
|
|
||||||
c.Writer = blw
|
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
status := c.Writer.Status()
|
status := c.Writer.Status()
|
||||||
|
|
||||||
if status >= http.StatusBadRequest {
|
if status >= http.StatusBadRequest {
|
||||||
responseBody := strings.TrimSpace(blw.body.String())
|
|
||||||
if len(responseBody) > 2048 {
|
|
||||||
responseBody = responseBody[:2048] + "...(truncated)"
|
|
||||||
}
|
|
||||||
errText := strings.TrimSpace(c.Errors.String())
|
errText := strings.TrimSpace(c.Errors.String())
|
||||||
|
|
||||||
slog.Error("request failed",
|
slog.Error("request failed",
|
||||||
@@ -1795,7 +1886,6 @@ func requestLogger() gin.HandlerFunc {
|
|||||||
"latency", latency,
|
"latency", latency,
|
||||||
"ip", c.ClientIP(),
|
"ip", c.ClientIP(),
|
||||||
"errors", errText,
|
"errors", errText,
|
||||||
"response", responseBody,
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1810,22 +1900,3 @@ func requestLogger() gin.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type captureResponseWriter struct {
|
|
||||||
gin.ResponseWriter
|
|
||||||
body *bytes.Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *captureResponseWriter) Write(b []byte) (int, error) {
|
|
||||||
if len(b) > 0 {
|
|
||||||
_, _ = w.body.Write(b)
|
|
||||||
}
|
|
||||||
return w.ResponseWriter.Write(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *captureResponseWriter) WriteString(s string) (int, error) {
|
|
||||||
if s != "" {
|
|
||||||
_, _ = w.body.WriteString(s)
|
|
||||||
}
|
|
||||||
return w.ResponseWriter.WriteString(s)
|
|
||||||
}
|
|
||||||
|
|||||||
48
cmd/qfs/request_logger_test.go
Normal file
48
cmd/qfs/request_logger_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestLoggerDoesNotLogResponseBody(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
var logBuffer bytes.Buffer
|
||||||
|
previousLogger := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{})))
|
||||||
|
defer slog.SetDefault(previousLogger)
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(requestLogger())
|
||||||
|
router.GET("/fail", func(c *gin.Context) {
|
||||||
|
_ = c.Error(errors.New("root cause"))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "do not log this body"})
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/fail?debug=1", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
logOutput := logBuffer.String()
|
||||||
|
if !strings.Contains(logOutput, "request failed") {
|
||||||
|
t.Fatalf("expected request failure log, got %q", logOutput)
|
||||||
|
}
|
||||||
|
if strings.Contains(logOutput, "do not log this body") {
|
||||||
|
t.Fatalf("response body leaked into logs: %q", logOutput)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logOutput, "root cause") {
|
||||||
|
t.Fatalf("expected error details in logs, got %q", logOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
@@ -290,6 +292,88 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVendorImportRejectsOversizedUpload(t *testing.T) {
|
||||||
|
moveToRepoRoot(t)
|
||||||
|
|
||||||
|
prevLimit := vendorImportMaxBytes
|
||||||
|
vendorImportMaxBytes = 128
|
||||||
|
defer func() { vendorImportMaxBytes = prevLimit }()
|
||||||
|
|
||||||
|
local, connMgr, _ := newAPITestStack(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
setConfigDefaults(cfg)
|
||||||
|
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup router: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Import Project","code":"IMP"}`)))
|
||||||
|
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||||
|
createProjectRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||||
|
if createProjectRec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var project models.Project
|
||||||
|
if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil {
|
||||||
|
t.Fatalf("unmarshal project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
part, err := writer.CreateFormFile("file", "huge.xml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create form file: %v", err)
|
||||||
|
}
|
||||||
|
payload := "<CFXML>" + strings.Repeat("A", int(vendorImportMaxBytes)+1) + "</CFXML>"
|
||||||
|
if _, err := part.Write([]byte(payload)); err != nil {
|
||||||
|
t.Fatalf("write multipart payload: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/vendor-import", &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for oversized upload, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "1 GiB") {
|
||||||
|
t.Fatalf("expected size limit message, got %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateConfigMalformedJSONReturnsGenericError(t *testing.T) {
|
||||||
|
moveToRepoRoot(t)
|
||||||
|
|
||||||
|
local, connMgr, _ := newAPITestStack(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
setConfigDefaults(cfg)
|
||||||
|
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup router: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/configs", bytes.NewReader([]byte(`{"name":`)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for malformed json, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(rec.Body.String()), "unexpected eof") {
|
||||||
|
t.Fatalf("expected sanitized error body, got %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "invalid request") {
|
||||||
|
t.Fatalf("expected generic invalid request message, got %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) {
|
func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +1,18 @@
|
|||||||
# QuoteForge Configuration
|
# QuoteForge runtime config
|
||||||
# Copy this file to config.yaml and update values
|
# Runtime creates a minimal config automatically on first start.
|
||||||
|
# This file is only a reference template.
|
||||||
|
|
||||||
server:
|
server:
|
||||||
host: "127.0.0.1" # Use 0.0.0.0 to listen on all interfaces
|
host: "127.0.0.1" # Loopback only; remote HTTP binding is unsupported
|
||||||
port: 8080
|
port: 8080
|
||||||
mode: "release" # debug | release
|
mode: "release" # debug | release
|
||||||
read_timeout: "30s"
|
read_timeout: "30s"
|
||||||
write_timeout: "30s"
|
write_timeout: "30s"
|
||||||
|
|
||||||
database:
|
|
||||||
host: "localhost"
|
|
||||||
port: 3306
|
|
||||||
name: "RFQ_LOG"
|
|
||||||
user: "quoteforge"
|
|
||||||
password: "CHANGE_ME"
|
|
||||||
max_open_conns: 25
|
|
||||||
max_idle_conns: 5
|
|
||||||
conn_max_lifetime: "5m"
|
|
||||||
|
|
||||||
pricing:
|
|
||||||
default_method: "weighted_median" # median | average | weighted_median
|
|
||||||
default_period_days: 90
|
|
||||||
freshness_green_days: 30
|
|
||||||
freshness_yellow_days: 60
|
|
||||||
freshness_red_days: 90
|
|
||||||
min_quotes_for_median: 3
|
|
||||||
popularity_decay_days: 180
|
|
||||||
|
|
||||||
export:
|
|
||||||
temp_dir: "/tmp/quoteforge-exports"
|
|
||||||
max_file_age: "1h"
|
|
||||||
company_name: "Your Company Name"
|
|
||||||
|
|
||||||
backup:
|
backup:
|
||||||
time: "00:00"
|
time: "00:00"
|
||||||
|
|
||||||
alerts:
|
|
||||||
enabled: true
|
|
||||||
check_interval: "1h"
|
|
||||||
high_demand_threshold: 5 # КП за 30 дней
|
|
||||||
trending_threshold_percent: 50 # % роста для алерта
|
|
||||||
|
|
||||||
notifications:
|
|
||||||
email_enabled: false
|
|
||||||
smtp_host: "smtp.example.com"
|
|
||||||
smtp_port: 587
|
|
||||||
smtp_user: ""
|
|
||||||
smtp_password: ""
|
|
||||||
from_address: "quoteforge@example.com"
|
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: "info" # debug | info | warn | error
|
level: "info" # debug | info | warn | error
|
||||||
format: "json" # json | text
|
format: "json" # json | text
|
||||||
output: "stdout" # stdout | file
|
output: "stdout" # stdout | stderr | /path/to/file
|
||||||
file_path: "/var/log/quoteforge/app.log"
|
|
||||||
|
|||||||
213
docs/storage-components-guide.md
Normal file
213
docs/storage-components-guide.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# Руководство по составлению каталога лотов СХД
|
||||||
|
|
||||||
|
## Что такое 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
|
||||||
|
```
|
||||||
@@ -10,6 +10,10 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type backupPeriod struct {
|
type backupPeriod struct {
|
||||||
@@ -250,6 +254,12 @@ func pruneOldBackups(periodDir string, keep int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createBackupArchive(destPath, dbPath, configPath string) error {
|
func createBackupArchive(destPath, dbPath, configPath string) error {
|
||||||
|
snapshotPath, cleanup, err := createSQLiteSnapshot(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
file, err := os.Create(destPath)
|
file, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -257,12 +267,10 @@ func createBackupArchive(destPath, dbPath, configPath string) error {
|
|||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
zipWriter := zip.NewWriter(file)
|
zipWriter := zip.NewWriter(file)
|
||||||
if err := addZipFile(zipWriter, dbPath); err != nil {
|
if err := addZipFileAs(zipWriter, snapshotPath, filepath.Base(dbPath)); err != nil {
|
||||||
_ = zipWriter.Close()
|
_ = zipWriter.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
|
|
||||||
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
|
|
||||||
|
|
||||||
if strings.TrimSpace(configPath) != "" {
|
if strings.TrimSpace(configPath) != "" {
|
||||||
_ = addZipOptionalFile(zipWriter, configPath)
|
_ = addZipOptionalFile(zipWriter, configPath)
|
||||||
@@ -274,6 +282,77 @@ func createBackupArchive(destPath, dbPath, configPath string) error {
|
|||||||
return file.Sync()
|
return file.Sync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createSQLiteSnapshot(dbPath string) (string, func(), error) {
|
||||||
|
tempFile, err := os.CreateTemp("", "qfs-backup-*.db")
|
||||||
|
if err != nil {
|
||||||
|
return "", func() {}, err
|
||||||
|
}
|
||||||
|
tempPath := tempFile.Name()
|
||||||
|
if err := tempFile.Close(); err != nil {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return "", func() {}, err
|
||||||
|
}
|
||||||
|
if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return "", func() {}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", func() {}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", func() {}, err
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
if err := db.Exec("PRAGMA busy_timeout = 5000").Error; err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", func() {}, fmt.Errorf("configure sqlite busy_timeout: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
literalPath := strings.ReplaceAll(tempPath, "'", "''")
|
||||||
|
if err := vacuumIntoWithRetry(db, literalPath); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", func() {}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempPath, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vacuumIntoWithRetry(db *gorm.DB, literalPath string) error {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
if err := db.Exec("VACUUM INTO '" + literalPath + "'").Error; err != nil {
|
||||||
|
lastErr = err
|
||||||
|
if !isSQLiteBusyError(err) {
|
||||||
|
return fmt.Errorf("create sqlite snapshot: %w", err)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(attempt+1) * 250 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("create sqlite snapshot after retries: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSQLiteBusyError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(lower, "database is locked") || strings.Contains(lower, "database is busy")
|
||||||
|
}
|
||||||
|
|
||||||
func addZipOptionalFile(writer *zip.Writer, path string) error {
|
func addZipOptionalFile(writer *zip.Writer, path string) error {
|
||||||
if _, err := os.Stat(path); err != nil {
|
if _, err := os.Stat(path); err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -282,6 +361,10 @@ func addZipOptionalFile(writer *zip.Writer, path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func addZipFile(writer *zip.Writer, path string) error {
|
func addZipFile(writer *zip.Writer, path string) error {
|
||||||
|
return addZipFileAs(writer, path, filepath.Base(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addZipFileAs(writer *zip.Writer, path string, archiveName string) error {
|
||||||
in, err := os.Open(path)
|
in, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -297,7 +380,7 @@ func addZipFile(writer *zip.Writer, path string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
header.Name = filepath.Base(path)
|
header.Name = archiveName
|
||||||
header.Method = zip.Deflate
|
header.Method = zip.Deflate
|
||||||
|
|
||||||
out, err := writer.CreateHeader(header)
|
out, err := writer.CreateHeader(header)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package appstate
|
package appstate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
|
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
|
||||||
@@ -13,8 +17,8 @@ func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
|
|||||||
dbPath := filepath.Join(temp, "qfs.db")
|
dbPath := filepath.Join(temp, "qfs.db")
|
||||||
cfgPath := filepath.Join(temp, "config.yaml")
|
cfgPath := filepath.Join(temp, "config.yaml")
|
||||||
|
|
||||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
if err := writeTestSQLiteDB(dbPath); err != nil {
|
||||||
t.Fatalf("write db: %v", err)
|
t.Fatalf("write sqlite db: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||||
t.Fatalf("write config: %v", err)
|
t.Fatalf("write config: %v", err)
|
||||||
@@ -36,6 +40,7 @@ func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
|
|||||||
if _, err := os.Stat(dailyArchive); err != nil {
|
if _, err := os.Stat(dailyArchive); err != nil {
|
||||||
t.Fatalf("daily archive missing: %v", err)
|
t.Fatalf("daily archive missing: %v", err)
|
||||||
}
|
}
|
||||||
|
assertZipContains(t, dailyArchive, "qfs.db", "config.yaml")
|
||||||
|
|
||||||
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
|
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
|
||||||
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
|
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
|
||||||
@@ -57,8 +62,8 @@ func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
|
|||||||
dbPath := filepath.Join(temp, "qfs.db")
|
dbPath := filepath.Join(temp, "qfs.db")
|
||||||
cfgPath := filepath.Join(temp, "config.yaml")
|
cfgPath := filepath.Join(temp, "config.yaml")
|
||||||
|
|
||||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
if err := writeTestSQLiteDB(dbPath); err != nil {
|
||||||
t.Fatalf("write db: %v", err)
|
t.Fatalf("write sqlite db: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||||
t.Fatalf("write config: %v", err)
|
t.Fatalf("write config: %v", err)
|
||||||
@@ -95,8 +100,8 @@ func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) {
|
|||||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||||
t.Fatalf("mkdir data dir: %v", err)
|
t.Fatalf("mkdir data dir: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
if err := writeTestSQLiteDB(dbPath); err != nil {
|
||||||
t.Fatalf("write db: %v", err)
|
t.Fatalf("write sqlite db: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||||
t.Fatalf("write cfg: %v", err)
|
t.Fatalf("write cfg: %v", err)
|
||||||
@@ -110,3 +115,43 @@ func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeTestSQLiteDB(path string) error {
|
||||||
|
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
return db.Exec(`
|
||||||
|
CREATE TABLE sample_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO sample_items(name) VALUES ('backup');
|
||||||
|
`).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertZipContains(t *testing.T, archivePath string, expected ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
reader, err := zip.OpenReader(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open archive: %v", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
found := make(map[string]bool, len(reader.File))
|
||||||
|
for _, file := range reader.File {
|
||||||
|
found[file.Name] = true
|
||||||
|
}
|
||||||
|
for _, name := range expected {
|
||||||
|
if !found[name] {
|
||||||
|
t.Fatalf("archive %s missing %s", archivePath, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,38 +45,55 @@ func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) {
|
func TestResolveLotCategoriesStrict_FallbackToLatestPricelist(t *testing.T) {
|
||||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init local db: %v", err)
|
t.Fatalf("init local db: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = local.Close() })
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
// Older pricelist used by the configuration — CPU_B has no category here
|
||||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
ServerID: 2,
|
ServerID: 2,
|
||||||
Source: "estimate",
|
Source: "estimate",
|
||||||
Version: "S-2026-02-11-002",
|
Version: "S-2026-02-11-002",
|
||||||
Name: "test",
|
Name: "old",
|
||||||
|
IsActive: false,
|
||||||
|
CreatedAt: time.Now().Add(-time.Hour),
|
||||||
|
SyncedAt: time.Now().Add(-time.Hour),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save old pricelist: %v", err)
|
||||||
|
}
|
||||||
|
oldPL, err := local.GetLocalPricelistByServerID(2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get old pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
|
{PricelistID: oldPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save old pricelist items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newer active pricelist — CPU_B has category set
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: 3,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "S-2026-02-11-003",
|
||||||
|
Name: "latest",
|
||||||
|
IsActive: true,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
SyncedAt: time.Now(),
|
SyncedAt: time.Now(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("save local pricelist: %v", err)
|
t.Fatalf("save latest pricelist: %v", err)
|
||||||
}
|
}
|
||||||
localPL, err := local.GetLocalPricelistByServerID(2)
|
latestPL, err := local.GetLocalPricelistByServerID(3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get local pricelist: %v", err)
|
t.Fatalf("get latest pricelist: %v", err)
|
||||||
}
|
}
|
||||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
{PricelistID: localPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
|
{PricelistID: latestPL.ID, LotName: "CPU_B", LotCategory: "CPU", Price: 10},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("save local items: %v", err)
|
t.Fatalf("save latest pricelist items: %v", err)
|
||||||
}
|
|
||||||
if err := local.DB().Create(&localdb.LocalComponent{
|
|
||||||
LotName: "CPU_B",
|
|
||||||
Category: "CPU",
|
|
||||||
LotDescription: "cpu",
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("save local components: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"})
|
cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"})
|
||||||
|
|||||||
@@ -22,24 +22,29 @@ type BuildResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
|
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
|
||||||
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
|
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
|
||||||
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
|
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
|
||||||
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
|
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
|
||||||
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
|
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
|
||||||
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
|
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
|
||||||
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
|
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) {
|
func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) {
|
||||||
segments := make([]string, 0, 8)
|
segs := make([]namedSeg, 0, 8)
|
||||||
warnings := make([]string, 0)
|
warnings := make([]string, 0)
|
||||||
|
|
||||||
model := NormalizeServerModel(opts.ServerModel)
|
model := NormalizeServerModel(opts.ServerModel)
|
||||||
if model == "" {
|
if model == "" {
|
||||||
return BuildResult{}, fmt.Errorf("server_model required")
|
return BuildResult{}, fmt.Errorf("server_model required")
|
||||||
}
|
}
|
||||||
segments = append(segments, model)
|
segs = append(segs, namedSeg{"MODEL", model})
|
||||||
|
|
||||||
lotNames := make([]string, 0, len(items))
|
lotNames := make([]string, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
@@ -55,41 +60,39 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
|
|||||||
return BuildResult{}, err
|
return BuildResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cpuSeg := buildCPUSegment(items, cats)
|
if cpuSeg := buildCPUSegment(items, cats); cpuSeg != "" {
|
||||||
if cpuSeg != "" {
|
segs = append(segs, namedSeg{"CPU", cpuSeg})
|
||||||
segments = append(segments, cpuSeg)
|
|
||||||
}
|
}
|
||||||
memSeg, memWarn := buildMemSegment(items, cats)
|
memSeg, memWarn := buildMemSegment(items, cats)
|
||||||
if memWarn != "" {
|
if memWarn != "" {
|
||||||
warnings = append(warnings, memWarn)
|
warnings = append(warnings, memWarn)
|
||||||
}
|
}
|
||||||
if memSeg != "" {
|
if memSeg != "" {
|
||||||
segments = append(segments, memSeg)
|
segs = append(segs, namedSeg{"MEM", memSeg})
|
||||||
}
|
}
|
||||||
gpuSeg := buildGPUSegment(items, cats)
|
if gpuSeg := buildGPUSegment(items, cats); gpuSeg != "" {
|
||||||
if gpuSeg != "" {
|
segs = append(segs, namedSeg{"GPU", gpuSeg})
|
||||||
segments = append(segments, gpuSeg)
|
|
||||||
}
|
}
|
||||||
diskSeg, diskWarn := buildDiskSegment(items, cats)
|
diskSeg, diskWarn := buildDiskSegment(items, cats)
|
||||||
if diskWarn != "" {
|
if diskWarn != "" {
|
||||||
warnings = append(warnings, diskWarn)
|
warnings = append(warnings, diskWarn)
|
||||||
}
|
}
|
||||||
if diskSeg != "" {
|
if diskSeg != "" {
|
||||||
segments = append(segments, diskSeg)
|
segs = append(segs, namedSeg{"DISK", diskSeg})
|
||||||
}
|
}
|
||||||
netSeg, netWarn := buildNetSegment(items, cats)
|
netSeg, netWarn := buildNetSegment(items, cats)
|
||||||
if netWarn != "" {
|
if netWarn != "" {
|
||||||
warnings = append(warnings, netWarn)
|
warnings = append(warnings, netWarn)
|
||||||
}
|
}
|
||||||
if netSeg != "" {
|
if netSeg != "" {
|
||||||
segments = append(segments, netSeg)
|
segs = append(segs, namedSeg{"NET", netSeg})
|
||||||
}
|
}
|
||||||
psuSeg, psuWarn := buildPSUSegment(items, cats)
|
psuSeg, psuWarn := buildPSUSegment(items, cats)
|
||||||
if psuWarn != "" {
|
if psuWarn != "" {
|
||||||
warnings = append(warnings, psuWarn)
|
warnings = append(warnings, psuWarn)
|
||||||
}
|
}
|
||||||
if psuSeg != "" {
|
if psuSeg != "" {
|
||||||
segments = append(segments, psuSeg)
|
segs = append(segs, namedSeg{"PSU", psuSeg})
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(opts.SupportCode) != "" {
|
if strings.TrimSpace(opts.SupportCode) != "" {
|
||||||
@@ -97,12 +100,12 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
|
|||||||
if !isSupportCodeValid(code) {
|
if !isSupportCodeValid(code) {
|
||||||
return BuildResult{}, fmt.Errorf("invalid_support_code")
|
return BuildResult{}, fmt.Errorf("invalid_support_code")
|
||||||
}
|
}
|
||||||
segments = append(segments, code)
|
segs = append(segs, namedSeg{"SUPPORT", code})
|
||||||
}
|
}
|
||||||
|
|
||||||
article := strings.Join(segments, "-")
|
article := strings.Join(namedSegsValues(segs), "-")
|
||||||
if len([]rune(article)) > 80 {
|
if len([]rune(article)) > 80 {
|
||||||
article = compressArticle(segments)
|
article = compressArticle(segs)
|
||||||
warnings = append(warnings, "compressed")
|
warnings = append(warnings, "compressed")
|
||||||
}
|
}
|
||||||
if len([]rune(article)) > 80 {
|
if len([]rune(article)) > 80 {
|
||||||
@@ -112,6 +115,23 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
|
|||||||
return BuildResult{Article: article, Warnings: warnings}, nil
|
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 {
|
func isSupportCodeValid(code string) bool {
|
||||||
if len(code) < 3 {
|
if len(code) < 3 {
|
||||||
return false
|
return false
|
||||||
@@ -329,33 +349,60 @@ func parseGPUModel(lotName string) string {
|
|||||||
}
|
}
|
||||||
parts := strings.Split(upper, "_")
|
parts := strings.Split(upper, "_")
|
||||||
model := ""
|
model := ""
|
||||||
|
numSuffix := ""
|
||||||
mem := ""
|
mem := ""
|
||||||
for i, p := range parts {
|
for i, p := range parts {
|
||||||
if p == "" {
|
if p == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch p {
|
switch p {
|
||||||
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
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]
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
default:
|
default:
|
||||||
if strings.Contains(p, "GB") {
|
if strings.Contains(p, "GB") {
|
||||||
mem = p
|
mem = p
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if model == "" && (i > 0) {
|
if model == "" && i > 0 {
|
||||||
model = p
|
model = p
|
||||||
|
} else if model != "" && numSuffix == "" && isNumeric(p) {
|
||||||
|
numSuffix = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if model != "" && mem != "" {
|
full := model
|
||||||
return model + "_" + mem
|
if numSuffix != "" {
|
||||||
|
full = model + numSuffix
|
||||||
}
|
}
|
||||||
if model != "" {
|
if full != "" && mem != "" {
|
||||||
return model
|
return full + "_" + mem
|
||||||
|
}
|
||||||
|
if full != "" {
|
||||||
|
return full
|
||||||
}
|
}
|
||||||
return normalizeModelToken(lotName)
|
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 {
|
func parseMemGiB(lotName string) int {
|
||||||
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
|
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
|
||||||
return atoi(m[1]) * 1024
|
return atoi(m[1]) * 1024
|
||||||
@@ -457,60 +504,50 @@ func atoi(v string) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
func compressArticle(segments []string) string {
|
func compressArticle(segs []namedSeg) string {
|
||||||
if len(segments) == 0 {
|
if len(segs) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
normalized := make([]string, 0, len(segments))
|
for i, s := range segs {
|
||||||
for _, s := range segments {
|
segs[i].value = strings.ReplaceAll(s.value, "GbE", "G")
|
||||||
normalized = append(normalized, strings.ReplaceAll(s, "GbE", "G"))
|
|
||||||
}
|
}
|
||||||
segments = normalized
|
article := strings.Join(namedSegsValues(segs), "-")
|
||||||
article := strings.Join(segments, "-")
|
|
||||||
if len([]rune(article)) <= 80 {
|
if len([]rune(article)) <= 80 {
|
||||||
return article
|
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
|
// 1) remove PSU
|
||||||
if i, ok := index(6); ok {
|
if i := findSegGroup(segs, "PSU"); i >= 0 {
|
||||||
segments = append(segments[:i], segments[i+1:]...)
|
segs = append(segs[:i], segs[i+1:]...)
|
||||||
article = strings.Join(segments, "-")
|
article = strings.Join(namedSegsValues(segs), "-")
|
||||||
if len([]rune(article)) <= 80 {
|
if len([]rune(article)) <= 80 {
|
||||||
return article
|
return article
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) compress NET/HBA/HCA
|
// 2) compress NET/HBA/HCA
|
||||||
if i, ok := index(5); ok {
|
if i := findSegGroup(segs, "NET"); i >= 0 {
|
||||||
segments[i] = compressNetSegment(segments[i])
|
segs[i].value = compressNetSegment(segs[i].value)
|
||||||
article = strings.Join(segments, "-")
|
article = strings.Join(namedSegsValues(segs), "-")
|
||||||
if len([]rune(article)) <= 80 {
|
if len([]rune(article)) <= 80 {
|
||||||
return article
|
return article
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) compress DISK
|
// 3) compress DISK
|
||||||
if i, ok := index(4); ok {
|
if i := findSegGroup(segs, "DISK"); i >= 0 {
|
||||||
segments[i] = compressDiskSegment(segments[i])
|
segs[i].value = compressDiskSegment(segs[i].value)
|
||||||
article = strings.Join(segments, "-")
|
article = strings.Join(namedSegsValues(segs), "-")
|
||||||
if len([]rune(article)) <= 80 {
|
if len([]rune(article)) <= 80 {
|
||||||
return article
|
return article
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) compress GPU to vendor only (GPU_NV)
|
// 4) compress GPU to vendor only (GPU_NV)
|
||||||
if i, ok := index(3); ok {
|
if i := findSegGroup(segs, "GPU"); i >= 0 {
|
||||||
segments[i] = compressGPUSegment(segments[i])
|
segs[i].value = compressGPUSegment(segs[i].value)
|
||||||
}
|
}
|
||||||
return strings.Join(segments, "-")
|
return strings.Join(namedSegsValues(segs), "-")
|
||||||
}
|
}
|
||||||
|
|
||||||
func compressNetSegment(seg string) string {
|
func compressNetSegment(seg string) string {
|
||||||
|
|||||||
@@ -61,6 +61,79 @@ 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 {
|
func contains(s, sub string) bool {
|
||||||
return strings.Contains(s, sub)
|
return strings.Contains(s, sub)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,14 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
Database DatabaseConfig `yaml:"database"`
|
Export ExportConfig `yaml:"export"`
|
||||||
Pricing PricingConfig `yaml:"pricing"`
|
Logging LoggingConfig `yaml:"logging"`
|
||||||
Export ExportConfig `yaml:"export"`
|
Backup BackupConfig `yaml:"backup"`
|
||||||
Alerts AlertsConfig `yaml:"alerts"`
|
|
||||||
Notifications NotificationsConfig `yaml:"notifications"`
|
|
||||||
Logging LoggingConfig `yaml:"logging"`
|
|
||||||
Backup BackupConfig `yaml:"backup"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@@ -30,64 +25,6 @@ type ServerConfig struct {
|
|||||||
WriteTimeout time.Duration `yaml:"write_timeout"`
|
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
|
||||||
Host string `yaml:"host"`
|
|
||||||
Port int `yaml:"port"`
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
User string `yaml:"user"`
|
|
||||||
Password string `yaml:"password"`
|
|
||||||
MaxOpenConns int `yaml:"max_open_conns"`
|
|
||||||
MaxIdleConns int `yaml:"max_idle_conns"`
|
|
||||||
ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DatabaseConfig) DSN() string {
|
|
||||||
cfg := mysqlDriver.NewConfig()
|
|
||||||
cfg.User = d.User
|
|
||||||
cfg.Passwd = d.Password
|
|
||||||
cfg.Net = "tcp"
|
|
||||||
cfg.Addr = net.JoinHostPort(d.Host, strconv.Itoa(d.Port))
|
|
||||||
cfg.DBName = d.Name
|
|
||||||
cfg.ParseTime = true
|
|
||||||
cfg.Loc = time.Local
|
|
||||||
cfg.Params = map[string]string{
|
|
||||||
"charset": "utf8mb4",
|
|
||||||
}
|
|
||||||
return cfg.FormatDSN()
|
|
||||||
}
|
|
||||||
|
|
||||||
type PricingConfig struct {
|
|
||||||
DefaultMethod string `yaml:"default_method"`
|
|
||||||
DefaultPeriodDays int `yaml:"default_period_days"`
|
|
||||||
FreshnessGreenDays int `yaml:"freshness_green_days"`
|
|
||||||
FreshnessYellowDays int `yaml:"freshness_yellow_days"`
|
|
||||||
FreshnessRedDays int `yaml:"freshness_red_days"`
|
|
||||||
MinQuotesForMedian int `yaml:"min_quotes_for_median"`
|
|
||||||
PopularityDecayDays int `yaml:"popularity_decay_days"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExportConfig struct {
|
|
||||||
TempDir string `yaml:"temp_dir"`
|
|
||||||
MaxFileAge time.Duration `yaml:"max_file_age"`
|
|
||||||
CompanyName string `yaml:"company_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertsConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
CheckInterval time.Duration `yaml:"check_interval"`
|
|
||||||
HighDemandThreshold int `yaml:"high_demand_threshold"`
|
|
||||||
TrendingThresholdPercent int `yaml:"trending_threshold_percent"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NotificationsConfig struct {
|
|
||||||
EmailEnabled bool `yaml:"email_enabled"`
|
|
||||||
SMTPHost string `yaml:"smtp_host"`
|
|
||||||
SMTPPort int `yaml:"smtp_port"`
|
|
||||||
SMTPUser string `yaml:"smtp_user"`
|
|
||||||
SMTPPassword string `yaml:"smtp_password"`
|
|
||||||
FromAddress string `yaml:"from_address"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoggingConfig struct {
|
type LoggingConfig struct {
|
||||||
Level string `yaml:"level"`
|
Level string `yaml:"level"`
|
||||||
Format string `yaml:"format"`
|
Format string `yaml:"format"`
|
||||||
@@ -95,6 +32,10 @@ type LoggingConfig struct {
|
|||||||
FilePath string `yaml:"file_path"`
|
FilePath string `yaml:"file_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportConfig is kept for constructor compatibility in export services.
|
||||||
|
// Runtime no longer persists an export section in config.yaml.
|
||||||
|
type ExportConfig struct{}
|
||||||
|
|
||||||
type BackupConfig struct {
|
type BackupConfig struct {
|
||||||
Time string `yaml:"time"`
|
Time string `yaml:"time"`
|
||||||
}
|
}
|
||||||
@@ -132,38 +73,6 @@ func (c *Config) setDefaults() {
|
|||||||
c.Server.WriteTimeout = 30 * time.Second
|
c.Server.WriteTimeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Database.Port == 0 {
|
|
||||||
c.Database.Port = 3306
|
|
||||||
}
|
|
||||||
if c.Database.MaxOpenConns == 0 {
|
|
||||||
c.Database.MaxOpenConns = 25
|
|
||||||
}
|
|
||||||
if c.Database.MaxIdleConns == 0 {
|
|
||||||
c.Database.MaxIdleConns = 5
|
|
||||||
}
|
|
||||||
if c.Database.ConnMaxLifetime == 0 {
|
|
||||||
c.Database.ConnMaxLifetime = 5 * time.Minute
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Pricing.DefaultMethod == "" {
|
|
||||||
c.Pricing.DefaultMethod = "weighted_median"
|
|
||||||
}
|
|
||||||
if c.Pricing.DefaultPeriodDays == 0 {
|
|
||||||
c.Pricing.DefaultPeriodDays = 90
|
|
||||||
}
|
|
||||||
if c.Pricing.FreshnessGreenDays == 0 {
|
|
||||||
c.Pricing.FreshnessGreenDays = 30
|
|
||||||
}
|
|
||||||
if c.Pricing.FreshnessYellowDays == 0 {
|
|
||||||
c.Pricing.FreshnessYellowDays = 60
|
|
||||||
}
|
|
||||||
if c.Pricing.FreshnessRedDays == 0 {
|
|
||||||
c.Pricing.FreshnessRedDays = 90
|
|
||||||
}
|
|
||||||
if c.Pricing.MinQuotesForMedian == 0 {
|
|
||||||
c.Pricing.MinQuotesForMedian = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Logging.Level == "" {
|
if c.Logging.Level == "" {
|
||||||
c.Logging.Level = "info"
|
c.Logging.Level = "info"
|
||||||
}
|
}
|
||||||
@@ -180,5 +89,5 @@ func (c *Config) setDefaults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) Address() string {
|
func (c *Config) Address() string {
|
||||||
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
|
return net.JoinHostPort(c.Server.Host, strconv.Itoa(c.Server.Port))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,22 @@ func (cm *ConnectionManager) Disconnect() {
|
|||||||
cm.lastError = nil
|
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)
|
// GetLastError returns the last connection error (thread-safe)
|
||||||
func (cm *ConnectionManager) GetLastError() error {
|
func (cm *ConnectionManager) GetLastError() error {
|
||||||
cm.mu.RLock()
|
cm.mu.RLock()
|
||||||
|
|||||||
60
internal/db/validate.go
Normal file
60
internal/db/validate.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gormmysql "gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errPermissionProbeRollback = errors.New("permission probe rollback")
|
||||||
|
|
||||||
|
// ValidateMariaDBConnection opens a one-off connection using dsn, pings, checks
|
||||||
|
// the required lot table exists, and probes write access to qt_client_schema_state.
|
||||||
|
// Returns (lot row count, canWrite, error).
|
||||||
|
func ValidateMariaDBConnection(dsn string) (int64, bool, error) {
|
||||||
|
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, fmt.Errorf("open MariaDB connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, fmt.Errorf("get database handle: %w", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
return 0, false, fmt.Errorf("ping MariaDB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lotCount int64
|
||||||
|
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
|
||||||
|
return 0, false, fmt.Errorf("check required table lot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lotCount, testSyncWritePermission(db), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSyncWritePermission(db *gorm.DB) bool {
|
||||||
|
sentinel := fmt.Sprintf("quoteforge-permission-check-%d", time.Now().UnixNano())
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO qt_client_schema_state (username, hostname, last_checked_at, updated_at)
|
||||||
|
VALUES (?, ?, NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_checked_at = VALUES(last_checked_at),
|
||||||
|
updated_at = VALUES(updated_at)
|
||||||
|
`, sentinel, "setup-check").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errPermissionProbeRollback
|
||||||
|
})
|
||||||
|
|
||||||
|
return errors.Is(err, errPermissionProbeRollback)
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
offset := (page - 1) * perPage
|
offset := (page - 1) * perPage
|
||||||
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,11 +64,16 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, &services.ComponentListResult{
|
c.JSON(http.StatusOK, &services.ComponentListResult{
|
||||||
Components: components,
|
Items: components,
|
||||||
Total: total,
|
TotalCount: total,
|
||||||
Page: page,
|
Page: page,
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
|
TotalPages: totalPages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +95,12 @@ func (h *ComponentHandler) Get(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *ComponentHandler) GetCategories(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()
|
codes, err := h.localDB.GetLocalComponentCategories()
|
||||||
if err == nil && len(codes) > 0 {
|
if err == nil && len(codes) > 0 {
|
||||||
categories := make([]models.Category, 0, len(codes))
|
categories := make([]models.Category, 0, len(codes))
|
||||||
@@ -98,7 +109,15 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
|||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
categories = append(categories, models.Category{Code: trimmed, Name: trimmed})
|
order := orderMap[strings.ToUpper(trimmed)]
|
||||||
|
if order == 0 {
|
||||||
|
order = models.MaxKnownDisplayOrder + 1
|
||||||
|
}
|
||||||
|
categories = append(categories, models.Category{
|
||||||
|
Code: trimmed,
|
||||||
|
Name: trimmed,
|
||||||
|
DisplayOrder: order,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, categories)
|
c.JSON(http.StatusOK, categories)
|
||||||
return
|
return
|
||||||
@@ -106,3 +125,102 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, models.DefaultCategories)
|
c.JSON(http.StatusOK, models.DefaultCategories)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ComponentHandler) GetConfiguratorSettings(c *gin.Context) {
|
||||||
|
s, _ := h.localDB.GetConfiguratorSettings()
|
||||||
|
if s == nil {
|
||||||
|
s = &localdb.ConfiguratorSettings{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.ConfigTypes) == 0 {
|
||||||
|
s.ConfigTypes = defaultConfigTypes()
|
||||||
|
}
|
||||||
|
if len(s.TabConfig) == 0 {
|
||||||
|
s.TabConfig = defaultTabConfig()
|
||||||
|
}
|
||||||
|
if len(s.AlwaysVisibleTabs) == 0 {
|
||||||
|
s.AlwaysVisibleTabs = []string{"base", "storage", "pci"}
|
||||||
|
}
|
||||||
|
if len(s.RequiredCategories) == 0 {
|
||||||
|
s.RequiredCategories = map[string][]string{"server": {"CPU", "MEM", "BB"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfigTypes() []localdb.ConfigTypeDef {
|
||||||
|
return []localdb.ConfigTypeDef{
|
||||||
|
{
|
||||||
|
Code: "server",
|
||||||
|
NameRu: "Сервер",
|
||||||
|
DisplayOrder: 10,
|
||||||
|
Categories: []string{
|
||||||
|
"MB", "CPU", "MEM", "RAID",
|
||||||
|
"SSD", "HDD", "M2", "EDSFF", "HHHL",
|
||||||
|
"GPU", "NIC", "HCA", "DPU", "HBA",
|
||||||
|
"PSU", "PS", "ACC", "RISERS", "CARD", "BB",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Code: "storage",
|
||||||
|
NameRu: "СХД",
|
||||||
|
DisplayOrder: 20,
|
||||||
|
Categories: []string{
|
||||||
|
"DKC", "CPU", "MEM", "PS",
|
||||||
|
"SSD", "HDD", "M2", "EDSFF", "HHHL",
|
||||||
|
"NIC", "HBA", "HCA", "ACC", "CARD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultTabConfig() []localdb.TabDef {
|
||||||
|
return []localdb.TabDef{
|
||||||
|
{
|
||||||
|
Key: "base",
|
||||||
|
Label: "Base",
|
||||||
|
SingleSelect: true,
|
||||||
|
Categories: []string{"MB", "CPU", "MEM", "ENC", "DKC", "CTL"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "storage",
|
||||||
|
Label: "Storage",
|
||||||
|
SingleSelect: false,
|
||||||
|
Categories: []string{"RAID", "M2", "SSD", "HDD", "EDSFF", "HHHL"},
|
||||||
|
Sections: []localdb.TabSection{
|
||||||
|
{Title: "RAID Контроллеры", Categories: []string{"RAID"}},
|
||||||
|
{Title: "Диски", Categories: []string{"M2", "SSD", "HDD", "EDSFF", "HHHL"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "pci",
|
||||||
|
Label: "PCI",
|
||||||
|
SingleSelect: false,
|
||||||
|
Categories: []string{"GPU", "DPU", "NIC", "HCA", "HBA", "HIC"},
|
||||||
|
Sections: []localdb.TabSection{
|
||||||
|
{Title: "GPU / DPU", Categories: []string{"GPU", "DPU"}},
|
||||||
|
{Title: "NIC / HCA", Categories: []string{"NIC", "HCA"}},
|
||||||
|
{Title: "HBA", Categories: []string{"HBA"}},
|
||||||
|
{Title: "HIC", Categories: []string{"HIC"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "power",
|
||||||
|
Label: "Power",
|
||||||
|
SingleSelect: false,
|
||||||
|
Categories: []string{"PS", "PSU"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "accessories",
|
||||||
|
Label: "Accessories",
|
||||||
|
SingleSelect: false,
|
||||||
|
Categories: []string{"ACC", "CARD"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "sw",
|
||||||
|
Label: "SW",
|
||||||
|
SingleSelect: false,
|
||||||
|
Categories: []string{"SW"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,17 +48,19 @@ type ExportRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectExportOptionsRequest struct {
|
type ProjectExportOptionsRequest struct {
|
||||||
IncludeLOT bool `json:"include_lot"`
|
IncludeLOT bool `json:"include_lot"`
|
||||||
IncludeBOM bool `json:"include_bom"`
|
IncludeBOM bool `json:"include_bom"`
|
||||||
IncludeEstimate bool `json:"include_estimate"`
|
IncludeEstimate bool `json:"include_estimate"`
|
||||||
IncludeStock bool `json:"include_stock"`
|
IncludeStock bool `json:"include_stock"`
|
||||||
IncludeCompetitor bool `json:"include_competitor"`
|
IncludeCompetitor bool `json:"include_competitor"`
|
||||||
|
Basis string `json:"basis"` // "fob" or "ddp"
|
||||||
|
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||||
var req ExportRequest
|
var req ExportRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
|
|
||||||
// Validate before streaming (can return JSON error)
|
// Validate before streaming (can return JSON error)
|
||||||
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no items to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +150,9 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
// Get config before streaming (can return JSON error)
|
// Get config before streaming (can return JSON error)
|
||||||
config, err := h.configService.GetByUUID(uuid, h.dbUsername)
|
config, err := h.configService.GetByUUIDNoAuth(uuid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +160,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
|
|
||||||
// Validate before streaming (can return JSON error)
|
// Validate before streaming (can return JSON error)
|
||||||
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no items to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,18 +195,18 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
|||||||
|
|
||||||
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
|
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
|
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Configs) == 0 {
|
if len(result.Configs) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,28 +223,18 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
func (h *ExportHandler) ExportConfigPricingCSV(c *gin.Context) {
|
||||||
projectUUID := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
var req ProjectExportOptionsRequest
|
var req ProjectExportOptionsRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
|
config, err := h.configService.GetByUUIDNoAuth(uuid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(result.Configs) == 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,15 +244,92 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
|||||||
IncludeEstimate: req.IncludeEstimate,
|
IncludeEstimate: req.IncludeEstimate,
|
||||||
IncludeStock: req.IncludeStock,
|
IncludeStock: req.IncludeStock,
|
||||||
IncludeCompetitor: req.IncludeCompetitor,
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(c, http.StatusNotFound, "resource not found", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
|
||||||
|
if err != nil {
|
||||||
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(result.Configs) == 0 {
|
||||||
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"})
|
||||||
|
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.ProjectToPricingExportData(result.Configs, opts)
|
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%s (%s) pricing.csv", time.Now().Format("2006-01-02"), project.Code)
|
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)
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,15 @@ func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*model
|
|||||||
return m.config, m.err
|
return m.config, m.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockConfigService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||||
|
return m.config, m.err
|
||||||
|
}
|
||||||
|
|
||||||
func TestExportCSV_Success(t *testing.T) {
|
func TestExportCSV_Success(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
// Create handler with mocks
|
// Create handler with mocks
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{},
|
&mockConfigService{},
|
||||||
@@ -105,7 +109,7 @@ func TestExportCSV_Success(t *testing.T) {
|
|||||||
func TestExportCSV_InvalidRequest(t *testing.T) {
|
func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{},
|
&mockConfigService{},
|
||||||
@@ -124,8 +128,8 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
|
|||||||
handler.ExportCSV(c)
|
handler.ExportCSV(c)
|
||||||
|
|
||||||
// Should return 400 Bad Request
|
// Should return 400 Bad Request
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
t.Errorf("Expected status 400, got %d", w.Code)
|
t.Errorf("Expected status 422, got %d", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should return JSON error
|
// Should return JSON error
|
||||||
@@ -139,7 +143,7 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
|
|||||||
func TestExportCSV_EmptyItems(t *testing.T) {
|
func TestExportCSV_EmptyItems(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{},
|
&mockConfigService{},
|
||||||
@@ -158,8 +162,8 @@ func TestExportCSV_EmptyItems(t *testing.T) {
|
|||||||
handler.ExportCSV(c)
|
handler.ExportCSV(c)
|
||||||
|
|
||||||
// Should return 400 Bad Request (validation error from gin binding)
|
// Should return 400 Bad Request (validation error from gin binding)
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
|
t.Logf("Status code: %d (expected 422 for empty items)", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +185,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{config: mockConfig},
|
&mockConfigService{config: mockConfig},
|
||||||
@@ -228,7 +232,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
|||||||
func TestExportConfigCSV_NotFound(t *testing.T) {
|
func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{err: errors.New("config not found")},
|
&mockConfigService{err: errors.New("config not found")},
|
||||||
@@ -271,7 +275,7 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{config: mockConfig},
|
&mockConfigService{config: mockConfig},
|
||||||
@@ -290,8 +294,8 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
|||||||
handler.ExportConfigCSV(c)
|
handler.ExportConfigCSV(c)
|
||||||
|
|
||||||
// Should return 400 Bad Request
|
// Should return 400 Bad Request
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
t.Errorf("Expected status 400, got %d", w.Code)
|
t.Errorf("Expected status 422, got %d", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should return JSON error
|
// Should return JSON error
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (h *PartnumberBooksHandler) List(c *gin.Context) {
|
|||||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||||
books, err := bookRepo.ListBooks()
|
books, err := bookRepo.ListBooks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +51,11 @@ func (h *PartnumberBooksHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"books": summaries,
|
"items": summaries,
|
||||||
"total": len(summaries),
|
"total_count": len(summaries),
|
||||||
|
"page": 1,
|
||||||
|
"per_page": len(summaries),
|
||||||
|
"total_pages": 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +65,7 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
|||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid book ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,28 +80,32 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
|||||||
perPage = 100
|
perPage = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find local book by server_id
|
book, err := h.localDB.GetLocalPartnumberBookByServerID(uint(id))
|
||||||
var book localdb.LocalPartnumberBook
|
if err != nil {
|
||||||
if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
items, total, err := bookRepo.GetBookItemsPage(book.ID, search, page, perPage)
|
items, total, err := bookRepo.GetBookItemsPage(book.ID, search, page, perPage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"book_id": book.ServerID,
|
"book_id": book.ServerID,
|
||||||
"version": book.Version,
|
"version": book.Version,
|
||||||
"is_active": book.IsActive,
|
"is_active": book.IsActive,
|
||||||
"partnumbers": book.PartnumbersJSON,
|
"partnumbers": book.PartnumbersJSON,
|
||||||
"items": items,
|
"items": items,
|
||||||
"total": total,
|
"total_count": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
|
"total_pages": totalPages,
|
||||||
"search": search,
|
"search": search,
|
||||||
"book_total": bookRepo.CountBookItems(book.ID),
|
"book_total": bookRepo.CountBookItems(book.ID),
|
||||||
"lot_count": bookRepo.CountDistinctLots(book.ID),
|
"lot_count": bookRepo.CountDistinctLots(book.ID),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
localPLs, err := h.localDB.GetLocalPricelists()
|
localPLs, err := h.localDB.GetLocalPricelists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if source != "" {
|
if source != "" {
|
||||||
@@ -106,11 +106,16 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"pricelists": summaries,
|
"items": summaries,
|
||||||
"total": total,
|
"total_count": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
|
"total_pages": totalPages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +124,7 @@ func (h *PricelistHandler) Get(c *gin.Context) {
|
|||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +151,7 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
|||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,40 +170,37 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
|||||||
if perPage < 1 {
|
if perPage < 1 {
|
||||||
perPage = 50
|
perPage = 50
|
||||||
}
|
}
|
||||||
var items []localdb.LocalPricelistItem
|
|
||||||
dbq := h.localDB.DB().Model(&localdb.LocalPricelistItem{}).Where("pricelist_id = ?", localPL.ID)
|
|
||||||
if strings.TrimSpace(search) != "" {
|
|
||||||
dbq = dbq.Where("lot_name LIKE ?", "%"+strings.TrimSpace(search)+"%")
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
if err := dbq.Count(&total).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
offset := (page - 1) * perPage
|
|
||||||
|
|
||||||
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
|
items, total, err := h.localDB.GetLocalPricelistItemsPage(localPL.ID, strings.TrimSpace(search), page, perPage)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resultItems := make([]gin.H, 0, len(items))
|
resultItems := make([]gin.H, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
resultItems = append(resultItems, gin.H{
|
resultItems = append(resultItems, gin.H{
|
||||||
"id": item.ID,
|
"id": item.ID,
|
||||||
"lot_name": item.LotName,
|
"lot_name": item.LotName,
|
||||||
"price": item.Price,
|
"lot_description": "",
|
||||||
"category": item.LotCategory,
|
"price": item.Price,
|
||||||
"available_qty": item.AvailableQty,
|
"category": item.LotCategory,
|
||||||
"partnumbers": []string(item.Partnumbers),
|
"available_qty": item.AvailableQty,
|
||||||
|
"partnumbers": []string(item.Partnumbers),
|
||||||
|
"partnumber_qtys": map[string]interface{}{},
|
||||||
|
"competitor_names": []string{},
|
||||||
|
"price_spread_pct": nil,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"source": localPL.Source,
|
"source": localPL.Source,
|
||||||
"items": resultItems,
|
"items": resultItems,
|
||||||
"total": total,
|
"total_count": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
|
"total_pages": totalPages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +208,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
|
|||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +219,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
items, err := h.localDB.GetLocalPricelistItems(localPL.ID)
|
items, err := h.localDB.GetLocalPricelistItems(localPL.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lotNames := make([]string, 0, len(items))
|
lotNames := make([]string, 0, len(items))
|
||||||
|
|||||||
@@ -141,21 +141,21 @@ func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Pricelists []struct {
|
Items []struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
} `json:"pricelists"`
|
} `json:"items"`
|
||||||
Total int `json:"total"`
|
TotalCount int `json:"total_count"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("unmarshal response: %v", err)
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
}
|
}
|
||||||
if resp.Total != 1 {
|
if resp.TotalCount != 1 {
|
||||||
t.Fatalf("expected total=1, got %d", resp.Total)
|
t.Fatalf("expected total=1, got %d", resp.TotalCount)
|
||||||
}
|
}
|
||||||
if len(resp.Pricelists) != 1 {
|
if len(resp.Items) != 1 {
|
||||||
t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists))
|
t.Fatalf("expected 1 pricelist, got %d", len(resp.Items))
|
||||||
}
|
}
|
||||||
if resp.Pricelists[0].ID != 10 {
|
if resp.Items[0].ID != 10 {
|
||||||
t.Fatalf("expected pricelist id=10, got %d", resp.Pricelists[0].ID)
|
t.Fatalf("expected pricelist id=10, got %d", resp.Items[0].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ func NewQuoteHandler(quoteService *services.QuoteService) *QuoteHandler {
|
|||||||
func (h *QuoteHandler) Validate(c *gin.Context) {
|
func (h *QuoteHandler) Validate(c *gin.Context) {
|
||||||
var req services.QuoteRequest
|
var req services.QuoteRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.quoteService.ValidateAndCalculate(&req)
|
result, err := h.quoteService.ValidateAndCalculate(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,13 +34,13 @@ func (h *QuoteHandler) Validate(c *gin.Context) {
|
|||||||
func (h *QuoteHandler) Calculate(c *gin.Context) {
|
func (h *QuoteHandler) Calculate(c *gin.Context) {
|
||||||
var req services.QuoteRequest
|
var req services.QuoteRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.quoteService.ValidateAndCalculate(&req)
|
result, err := h.quoteService.ValidateAndCalculate(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +53,13 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
|
|||||||
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
|
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
|
||||||
var req services.PriceLevelsRequest
|
var req services.PriceLevelsRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.quoteService.CalculatePriceLevels(&req)
|
result, err := h.quoteService.CalculatePriceLevels(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
internal/handlers/respond.go
Normal file
73
internal/handlers/respond.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RespondError(c *gin.Context, status int, fallback string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": clientFacingErrorMessage(status, fallback, err)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientFacingErrorMessage(status int, fallback string, err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if status >= 500 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if isRequestDecodeError(err) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
message := strings.TrimSpace(err.Error())
|
||||||
|
if message == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if looksTechnicalError(message) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRequestDecodeError(err error) bool {
|
||||||
|
var syntaxErr *json.SyntaxError
|
||||||
|
if errors.As(err, &syntaxErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var unmarshalTypeErr *json.UnmarshalTypeError
|
||||||
|
if errors.As(err, &unmarshalTypeErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF)
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksTechnicalError(message string) bool {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(message))
|
||||||
|
needles := []string{
|
||||||
|
"sql",
|
||||||
|
"gorm",
|
||||||
|
"driver",
|
||||||
|
"constraint",
|
||||||
|
"syntax error",
|
||||||
|
"unexpected eof",
|
||||||
|
"record not found",
|
||||||
|
"no such table",
|
||||||
|
"stack trace",
|
||||||
|
}
|
||||||
|
for _, needle := range needles {
|
||||||
|
if strings.Contains(lower, needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
41
internal/handlers/respond_test.go
Normal file
41
internal/handlers/respond_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientFacingErrorMessageKeepsDomain4xx(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := clientFacingErrorMessage(400, "invalid request", &json.SyntaxError{Offset: 1})
|
||||||
|
if got != "invalid request" {
|
||||||
|
t.Fatalf("expected fallback for decode error, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientFacingErrorMessagePreservesBusinessMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := errString("main project variant cannot be deleted")
|
||||||
|
got := clientFacingErrorMessage(400, "invalid request", err)
|
||||||
|
if got != err.Error() {
|
||||||
|
t.Fatalf("expected business message, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientFacingErrorMessageHidesTechnical4xx(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := errString("sql: no rows in result set")
|
||||||
|
got := clientFacingErrorMessage(404, "resource not found", err)
|
||||||
|
if got != "resource not found" {
|
||||||
|
t.Fatalf("expected fallback for technical error, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errString string
|
||||||
|
|
||||||
|
func (e errString) Error() string {
|
||||||
|
return string(e)
|
||||||
|
}
|
||||||
@@ -12,11 +12,8 @@ import (
|
|||||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
gormmysql "gorm.io/driver/mysql"
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SetupHandler struct {
|
type SetupHandler struct {
|
||||||
@@ -64,7 +61,8 @@ func (h *SetupHandler) ShowSetup(c *gin.Context) {
|
|||||||
|
|
||||||
tmpl := h.templates["setup.html"]
|
tmpl := h.templates["setup.html"]
|
||||||
if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil {
|
if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil {
|
||||||
c.String(http.StatusInternalServerError, "Template error: %v", err)
|
_ = c.Error(err)
|
||||||
|
c.String(http.StatusInternalServerError, "Template error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,49 +87,16 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
||||||
|
lotCount, canWrite, err := db.ValidateMariaDBConnection(dsn)
|
||||||
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("Connection failed: %v", err),
|
"error": "Connection check failed",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("Failed to get database handle: %v", err),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer sqlDB.Close()
|
|
||||||
|
|
||||||
if err := sqlDB.Ping(); err != nil {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("Ping failed: %v", err),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for required tables
|
|
||||||
var lotCount int64
|
|
||||||
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("Table 'lot' not found or inaccessible: %v", err),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check write permission
|
|
||||||
canWrite := testWritePermission(db)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"lot_count": lotCount,
|
"lot_count": lotCount,
|
||||||
@@ -164,26 +129,21 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
|||||||
|
|
||||||
// Test connection first
|
// Test connection first
|
||||||
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
||||||
|
if _, _, err := db.ValidateMariaDBConnection(dsn); err != nil {
|
||||||
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
_ = c.Error(err)
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("Connection failed: %v", err),
|
"error": "Connection check failed",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlDB, _ := db.DB()
|
|
||||||
sqlDB.Close()
|
|
||||||
|
|
||||||
// Save settings
|
// Save settings
|
||||||
if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil {
|
if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("Failed to save settings: %v", err),
|
"error": "Failed to save settings",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -232,22 +192,6 @@ func (h *SetupHandler) GetStatus(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func testWritePermission(db *gorm.DB) bool {
|
|
||||||
// Simple check: try to create a temporary table and drop it
|
|
||||||
testTable := fmt.Sprintf("qt_write_test_%d", time.Now().UnixNano())
|
|
||||||
|
|
||||||
// Try to create a test table
|
|
||||||
err := db.Exec(fmt.Sprintf("CREATE TABLE %s (id INT)", testTable)).Error
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop it immediately
|
|
||||||
db.Exec(fmt.Sprintf("DROP TABLE %s", testTable))
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string {
|
func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string {
|
||||||
cfg := mysqlDriver.NewConfig()
|
cfg := mysqlDriver.NewConfig()
|
||||||
cfg.User = user
|
cfg.User = user
|
||||||
@@ -263,3 +207,4 @@ func buildMySQLDSN(host string, port int, database, user, password string, timeo
|
|||||||
}
|
}
|
||||||
return cfg.FormatDSN()
|
return cfg.FormatDSN()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
317
internal/handlers/support_bundle.go
Normal file
317
internal/handlers/support_bundle.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
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.CountComponents(),
|
||||||
|
"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"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
IsActive: pl.IsActive,
|
||||||
|
}
|
||||||
|
bySource[pl.Source] = append(bySource[pl.Source], e)
|
||||||
|
}
|
||||||
|
writeJSON("pricelists.json", bySource)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pricelist_coverage.json — for each local estimate pricelist: item count by lot_category
|
||||||
|
if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil {
|
||||||
|
type catRow struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
type plCoverage struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
ServerID uint `json:"server_id"`
|
||||||
|
TotalItems int64 `json:"total_items"`
|
||||||
|
Categories []catRow `json:"categories"`
|
||||||
|
}
|
||||||
|
rows, total, catErr := h.localDB.GetLocalPricelistCoverageByCategory(pl.ID)
|
||||||
|
if catErr == nil {
|
||||||
|
cats := make([]catRow, 0, len(rows))
|
||||||
|
for cat, cnt := range rows {
|
||||||
|
cats = append(cats, catRow{Category: cat, Count: cnt})
|
||||||
|
}
|
||||||
|
writeJSON("pricelist_coverage.json", plCoverage{
|
||||||
|
Version: pl.Version,
|
||||||
|
ServerID: pl.ServerID,
|
||||||
|
TotalItems: total,
|
||||||
|
Categories: cats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// configurator_settings.json — what /api/configurator-settings actually returns
|
||||||
|
if cfgSettings, err := h.localDB.GetConfiguratorSettings(); err == nil {
|
||||||
|
writeJSON("configurator_settings.json", cfgSettings)
|
||||||
|
} else {
|
||||||
|
writeJSON("configurator_settings.json", map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// component_categories.json — distinct categories in active estimate pricelist
|
||||||
|
if cats, err := h.localDB.GetLocalComponentCategories(); err == nil {
|
||||||
|
writeJSON("component_categories.json", cats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autocomplete_lots.json — per-category breakdown of lots with their prices
|
||||||
|
// Mirrors what filterAutocomplete() works with: lot_name + estimate_price per category.
|
||||||
|
if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil {
|
||||||
|
if items, err := h.localDB.GetLocalPricelistItems(pl.ID); err == nil {
|
||||||
|
type lotEntry struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
HasPrice bool `json:"has_price"`
|
||||||
|
}
|
||||||
|
byCategory := map[string][]lotEntry{}
|
||||||
|
for _, it := range items {
|
||||||
|
entry := lotEntry{
|
||||||
|
LotName: it.LotName,
|
||||||
|
Price: it.Price,
|
||||||
|
HasPrice: it.Price > 0,
|
||||||
|
}
|
||||||
|
byCategory[it.LotCategory] = append(byCategory[it.LotCategory], entry)
|
||||||
|
}
|
||||||
|
writeJSON("autocomplete_lots.json", map[string]any{
|
||||||
|
"pricelist_version": pl.Version,
|
||||||
|
"pricelist_id": pl.ServerID,
|
||||||
|
"by_category": byCategory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// schema_migrations.json
|
||||||
|
migrations, err := h.localDB.GetSchemaMigrations()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("support bundle: could not load schema migrations", "err", err)
|
||||||
|
}
|
||||||
|
writeJSON("schema_migrations.json", migrations)
|
||||||
|
|
||||||
|
// latest_pricelist_items.json — all items from the most recent active estimate pricelist
|
||||||
|
if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil {
|
||||||
|
if items, err := h.localDB.GetLocalPricelistItems(pl.ID); err == nil {
|
||||||
|
type plItem struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
LotCategory string `json:"lot_category"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
}
|
||||||
|
out := make([]plItem, len(items))
|
||||||
|
for i, it := range items {
|
||||||
|
out[i] = plItem{
|
||||||
|
LotName: it.LotName,
|
||||||
|
LotCategory: it.LotCategory,
|
||||||
|
Price: it.Price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON("latest_pricelist_items.json", map[string]any{
|
||||||
|
"pricelist_version": pl.Version,
|
||||||
|
"pricelist_id": pl.ServerID,
|
||||||
|
"source": pl.Source,
|
||||||
|
"item_count": len(out),
|
||||||
|
"items": out,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// local.db — full SQLite database file (for deep diagnostics)
|
||||||
|
if dbPath := h.localDB.DBFilePath(); dbPath != "" {
|
||||||
|
if f, err := os.Open(dbPath); err == nil {
|
||||||
|
defer f.Close()
|
||||||
|
if w, err := zw.Create("local.db"); err == nil {
|
||||||
|
if _, err := io.Copy(w, f); err != nil {
|
||||||
|
slog.Warn("support bundle: error copying local.db", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// app.log (tail 5 MiB)
|
||||||
|
if h.logFilePath != "" {
|
||||||
|
if f, err := os.Open(h.logFilePath); err == nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
20
internal/handlers/support_bundle_disk_unix.go
Normal file
20
internal/handlers/support_bundle_disk_unix.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//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,
|
||||||
|
}
|
||||||
|
}
|
||||||
7
internal/handlers/support_bundle_disk_windows.go
Normal file
7
internal/handlers/support_bundle_disk_windows.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
func diskUsage(_ string) map[string]any {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
stdsync "sync"
|
stdsync "sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -49,15 +50,18 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
|||||||
|
|
||||||
// SyncStatusResponse represents the sync status
|
// SyncStatusResponse represents the sync status
|
||||||
type SyncStatusResponse struct {
|
type SyncStatusResponse struct {
|
||||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||||||
IsOnline bool `json:"is_online"`
|
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||||||
ComponentsCount int64 `json:"components_count"`
|
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
|
||||||
PricelistsCount int64 `json:"pricelists_count"`
|
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
|
||||||
ServerPricelists int `json:"server_pricelists"`
|
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
|
||||||
NeedComponentSync bool `json:"need_component_sync"`
|
IsOnline bool `json:"is_online"`
|
||||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
ComponentsCount int64 `json:"components_count"`
|
||||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
PricelistsCount int64 `json:"pricelists_count"`
|
||||||
|
ServerPricelists int `json:"server_pricelists"`
|
||||||
|
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||||
|
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncReadinessResponse struct {
|
type SyncReadinessResponse struct {
|
||||||
@@ -72,42 +76,30 @@ type SyncReadinessResponse struct {
|
|||||||
// GetStatus returns current sync status
|
// GetStatus returns current sync status
|
||||||
// GET /api/sync/status
|
// GET /api/sync/status
|
||||||
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||||
// Check online status by pinging MariaDB
|
connStatus := h.connMgr.GetStatus()
|
||||||
isOnline := h.checkOnline()
|
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||||||
|
|
||||||
// Get sync times
|
|
||||||
lastComponentSync := h.localDB.GetComponentSyncTime()
|
|
||||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||||
|
componentsCount := h.localDB.CountComponents()
|
||||||
// Get counts
|
|
||||||
componentsCount := h.localDB.CountLocalComponents()
|
|
||||||
pricelistsCount := h.localDB.CountLocalPricelists()
|
pricelistsCount := h.localDB.CountLocalPricelists()
|
||||||
|
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||||
// Get server pricelist count if online
|
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||||
serverPricelists := 0
|
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||||
needPricelistSync := false
|
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||||
if isOnline {
|
readiness := h.getReadinessLocal()
|
||||||
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.getReadinessCached(10 * time.Second)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||||
LastComponentSync: lastComponentSync,
|
LastPricelistSync: lastPricelistSync,
|
||||||
LastPricelistSync: lastPricelistSync,
|
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||||
IsOnline: isOnline,
|
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||||
ComponentsCount: componentsCount,
|
LastPricelistSyncError: lastPricelistSyncError,
|
||||||
PricelistsCount: pricelistsCount,
|
HasIncompleteServerSync: hasFailedSync,
|
||||||
ServerPricelists: serverPricelists,
|
KnownServerChangesMiss: hasFailedSync,
|
||||||
NeedComponentSync: needComponentSync,
|
IsOnline: isOnline,
|
||||||
NeedPricelistSync: needPricelistSync,
|
ComponentsCount: componentsCount,
|
||||||
Readiness: readiness,
|
PricelistsCount: pricelistsCount,
|
||||||
|
ServerPricelists: 0,
|
||||||
|
NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
|
||||||
|
Readiness: readiness,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,9 +108,7 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
|
|||||||
func (h *SyncHandler) GetReadiness(c *gin.Context) {
|
func (h *SyncHandler) GetReadiness(c *gin.Context) {
|
||||||
readiness, err := h.syncService.GetReadiness()
|
readiness, err := h.syncService.GetReadiness()
|
||||||
if err != nil && readiness == nil {
|
if err != nil && readiness == nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if readiness == nil {
|
if readiness == nil {
|
||||||
@@ -158,8 +148,9 @@ func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool {
|
|||||||
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": err.Error(),
|
"error": "internal server error",
|
||||||
})
|
})
|
||||||
|
_ = c.Error(err)
|
||||||
_ = readiness
|
_ = readiness
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -172,41 +163,6 @@ type SyncResultResponse struct {
|
|||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncComponents syncs components from MariaDB to local SQLite
|
|
||||||
// POST /api/sync/components
|
|
||||||
func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
|
||||||
if !h.ensureSyncReadiness(c) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get database connection from ConnectionManager
|
|
||||||
mariaDB, err := h.connMgr.GetDB()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": "Database connection failed: " + err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.localDB.SyncComponents(mariaDB)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("component sync failed", "error", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncResultResponse{
|
|
||||||
Success: true,
|
|
||||||
Message: "Components synced successfully",
|
|
||||||
Synced: result.TotalSynced,
|
|
||||||
Duration: result.Duration.String(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncPricelists syncs pricelists from MariaDB to local SQLite
|
// SyncPricelists syncs pricelists from MariaDB to local SQLite
|
||||||
// POST /api/sync/pricelists
|
// POST /api/sync/pricelists
|
||||||
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||||
@@ -217,13 +173,20 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
|||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
synced, err := h.syncService.SyncPricelists()
|
synced, err := h.syncService.SyncPricelists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, startTime, time.Since(startTime).Milliseconds())
|
||||||
slog.Error("pricelist sync failed", "error", err)
|
slog.Error("pricelist sync failed", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": err.Error(),
|
"error": "pricelist sync failed",
|
||||||
})
|
})
|
||||||
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.localDB.AppendSyncLog("pricelists", "ok", "", synced, startTime, time.Since(startTime).Milliseconds())
|
||||||
|
|
||||||
|
if _, err := h.syncService.PullPartnumberBooks(); err != nil {
|
||||||
|
slog.Warn("partnumber books pull failed after pricelist sync", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncResultResponse{
|
c.JSON(http.StatusOK, SyncResultResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -231,7 +194,6 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
|||||||
Synced: synced,
|
Synced: synced,
|
||||||
Duration: time.Since(startTime).String(),
|
Duration: time.Since(startTime).String(),
|
||||||
})
|
})
|
||||||
h.syncService.RecordSyncHeartbeat()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite.
|
// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite.
|
||||||
@@ -247,8 +209,9 @@ func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
|
|||||||
slog.Error("partnumber books pull failed", "error", err)
|
slog.Error("partnumber books pull failed", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": err.Error(),
|
"error": "partnumber books sync failed",
|
||||||
})
|
})
|
||||||
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +221,6 @@ func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
|
|||||||
Synced: pulled,
|
Synced: pulled,
|
||||||
Duration: time.Since(startTime).String(),
|
Duration: time.Since(startTime).String(),
|
||||||
})
|
})
|
||||||
h.syncService.RecordSyncHeartbeat()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncAllResponse represents result of full sync
|
// SyncAllResponse represents result of full sync
|
||||||
@@ -266,7 +228,6 @@ type SyncAllResponse struct {
|
|||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
PendingPushed int `json:"pending_pushed"`
|
PendingPushed int `json:"pending_pushed"`
|
||||||
ComponentsSynced int `json:"components_synced"`
|
|
||||||
PricelistsSynced int `json:"pricelists_synced"`
|
PricelistsSynced int `json:"pricelists_synced"`
|
||||||
ProjectsImported int `json:"projects_imported"`
|
ProjectsImported int `json:"projects_imported"`
|
||||||
ProjectsUpdated int `json:"projects_updated"`
|
ProjectsUpdated int `json:"projects_updated"`
|
||||||
@@ -287,7 +248,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
var pendingPushed, componentsSynced, pricelistsSynced int
|
var pricelistsSynced int
|
||||||
|
|
||||||
// Push local pending changes first (projects/configurations)
|
// Push local pending changes first (projects/configurations)
|
||||||
pendingPushed, err := h.syncService.PushPendingChanges()
|
pendingPushed, err := h.syncService.PushPendingChanges()
|
||||||
@@ -295,55 +256,42 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
slog.Error("pending push failed during full sync", "error", err)
|
slog.Error("pending push failed during full sync", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "Pending changes push failed: " + err.Error(),
|
"error": "pending changes push failed",
|
||||||
})
|
})
|
||||||
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync components
|
|
||||||
mariaDB, err := h.connMgr.GetDB()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": "Database connection failed: " + err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
compResult, err := h.localDB.SyncComponents(mariaDB)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("component sync failed during full sync", "error", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": "Component sync failed: " + err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
componentsSynced = compResult.TotalSynced
|
|
||||||
|
|
||||||
// Sync pricelists
|
// Sync pricelists
|
||||||
|
plNow := time.Now()
|
||||||
pricelistsSynced, err = h.syncService.SyncPricelists()
|
pricelistsSynced, err = h.syncService.SyncPricelists()
|
||||||
if err != nil {
|
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)
|
slog.Error("pricelist sync failed during full sync", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "Pricelist sync failed: " + err.Error(),
|
"error": "pricelist sync failed",
|
||||||
"pending_pushed": pendingPushed,
|
"pending_pushed": pendingPushed,
|
||||||
"components_synced": componentsSynced,
|
|
||||||
})
|
})
|
||||||
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.localDB.AppendSyncLog("pricelists", "ok", "", pricelistsSynced, plNow, time.Since(plNow).Milliseconds())
|
||||||
|
|
||||||
|
if _, err := h.syncService.PullPartnumberBooks(); err != nil {
|
||||||
|
slog.Warn("partnumber books pull failed during full sync", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
projectsResult, err := h.syncService.ImportProjectsToLocal()
|
projectsResult, err := h.syncService.ImportProjectsToLocal()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("project import failed during full sync", "error", err)
|
slog.Error("project import failed during full sync", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "Project import failed: " + err.Error(),
|
"error": "project import failed",
|
||||||
"pending_pushed": pendingPushed,
|
"pending_pushed": pendingPushed,
|
||||||
"components_synced": componentsSynced,
|
|
||||||
"pricelists_synced": pricelistsSynced,
|
"pricelists_synced": pricelistsSynced,
|
||||||
})
|
})
|
||||||
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,14 +300,14 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
slog.Error("configuration import failed during full sync", "error", err)
|
slog.Error("configuration import failed during full sync", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "Configuration import failed: " + err.Error(),
|
"error": "configuration import failed",
|
||||||
"pending_pushed": pendingPushed,
|
"pending_pushed": pendingPushed,
|
||||||
"components_synced": componentsSynced,
|
|
||||||
"pricelists_synced": pricelistsSynced,
|
"pricelists_synced": pricelistsSynced,
|
||||||
"projects_imported": projectsResult.Imported,
|
"projects_imported": projectsResult.Imported,
|
||||||
"projects_updated": projectsResult.Updated,
|
"projects_updated": projectsResult.Updated,
|
||||||
"projects_skipped": projectsResult.Skipped,
|
"projects_skipped": projectsResult.Skipped,
|
||||||
})
|
})
|
||||||
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +315,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
Success: true,
|
Success: true,
|
||||||
Message: "Full sync completed successfully",
|
Message: "Full sync completed successfully",
|
||||||
PendingPushed: pendingPushed,
|
PendingPushed: pendingPushed,
|
||||||
ComponentsSynced: componentsSynced,
|
|
||||||
PricelistsSynced: pricelistsSynced,
|
PricelistsSynced: pricelistsSynced,
|
||||||
ProjectsImported: projectsResult.Imported,
|
ProjectsImported: projectsResult.Imported,
|
||||||
ProjectsUpdated: projectsResult.Updated,
|
ProjectsUpdated: projectsResult.Updated,
|
||||||
@@ -377,7 +324,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
ConfigurationsSkipped: configsResult.Skipped,
|
ConfigurationsSkipped: configsResult.Skipped,
|
||||||
Duration: time.Since(startTime).String(),
|
Duration: time.Since(startTime).String(),
|
||||||
})
|
})
|
||||||
h.syncService.RecordSyncHeartbeat()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkOnline checks if MariaDB is accessible
|
// checkOnline checks if MariaDB is accessible
|
||||||
@@ -398,8 +344,9 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
|
|||||||
slog.Error("push pending changes failed", "error", err)
|
slog.Error("push pending changes failed", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": err.Error(),
|
"error": "pending changes push failed",
|
||||||
})
|
})
|
||||||
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +356,6 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
|
|||||||
Synced: pushed,
|
Synced: pushed,
|
||||||
Duration: time.Since(startTime).String(),
|
Duration: time.Since(startTime).String(),
|
||||||
})
|
})
|
||||||
h.syncService.RecordSyncHeartbeat()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPendingCount returns the number of pending changes
|
// GetPendingCount returns the number of pending changes
|
||||||
@@ -426,9 +372,7 @@ func (h *SyncHandler) GetPendingCount(c *gin.Context) {
|
|||||||
func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
||||||
changes, err := h.localDB.GetPendingChanges()
|
changes, err := h.localDB.GetPendingChanges()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,8 +389,9 @@ func (h *SyncHandler) RepairPendingChanges(c *gin.Context) {
|
|||||||
slog.Error("repair pending changes failed", "error", err)
|
slog.Error("repair pending changes failed", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": err.Error(),
|
"error": "pending changes repair failed",
|
||||||
})
|
})
|
||||||
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,8 +410,13 @@ type SyncInfoResponse struct {
|
|||||||
DBName string `json:"db_name"`
|
DBName string `json:"db_name"`
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
IsOnline bool `json:"is_online"`
|
IsOnline bool `json:"is_online"`
|
||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
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"`
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
LotCount int64 `json:"lot_count"`
|
LotCount int64 `json:"lot_count"`
|
||||||
@@ -502,8 +452,8 @@ type SyncError struct {
|
|||||||
// GetInfo returns sync information for modal
|
// GetInfo returns sync information for modal
|
||||||
// GET /api/sync/info
|
// GET /api/sync/info
|
||||||
func (h *SyncHandler) GetInfo(c *gin.Context) {
|
func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||||
// Check online status by pinging MariaDB
|
connStatus := h.connMgr.GetStatus()
|
||||||
isOnline := h.checkOnline()
|
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||||||
|
|
||||||
// Get DB connection info
|
// Get DB connection info
|
||||||
var dbHost, dbUser, dbName string
|
var dbHost, dbUser, dbName string
|
||||||
@@ -515,11 +465,17 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
|||||||
|
|
||||||
// Get sync times
|
// Get sync times
|
||||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
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
|
// Get local counts
|
||||||
configCount := h.localDB.CountConfigurations()
|
configCount := h.localDB.CountConfigurations()
|
||||||
projectCount := h.localDB.CountProjects()
|
projectCount := h.localDB.CountProjects()
|
||||||
componentCount := h.localDB.CountLocalComponents()
|
componentCount := h.localDB.CountComponents()
|
||||||
pricelistCount := h.localDB.CountLocalPricelists()
|
pricelistCount := h.localDB.CountLocalPricelists()
|
||||||
|
|
||||||
// Get error count (only changes with LastError != "")
|
// Get error count (only changes with LastError != "")
|
||||||
@@ -547,22 +503,27 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
|||||||
syncErrors = syncErrors[:10]
|
syncErrors = syncErrors[:10]
|
||||||
}
|
}
|
||||||
|
|
||||||
readiness := h.getReadinessCached(10 * time.Second)
|
readiness := h.getReadinessLocal()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||||
DBHost: dbHost,
|
DBHost: dbHost,
|
||||||
DBUser: dbUser,
|
DBUser: dbUser,
|
||||||
DBName: dbName,
|
DBName: dbName,
|
||||||
IsOnline: isOnline,
|
IsOnline: isOnline,
|
||||||
LastSyncAt: lastPricelistSync,
|
LastSyncAt: lastPricelistSync,
|
||||||
LotCount: componentCount,
|
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||||
LotLogCount: pricelistCount,
|
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||||
ConfigCount: configCount,
|
LastPricelistSyncError: lastPricelistSyncError,
|
||||||
ProjectCount: projectCount,
|
NeedPricelistSync: needPricelistSync,
|
||||||
PendingChanges: changes,
|
HasIncompleteServerSync: hasIncompleteServerSync,
|
||||||
ErrorCount: errorCount,
|
LotCount: componentCount,
|
||||||
Errors: syncErrors,
|
LotLogCount: pricelistCount,
|
||||||
Readiness: readiness,
|
ConfigCount: configCount,
|
||||||
|
ProjectCount: projectCount,
|
||||||
|
PendingChanges: changes,
|
||||||
|
ErrorCount: errorCount,
|
||||||
|
Errors: syncErrors,
|
||||||
|
Readiness: readiness,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,14 +544,9 @@ func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep current client heartbeat fresh so app version is available in the table.
|
|
||||||
h.syncService.RecordSyncHeartbeat()
|
|
||||||
|
|
||||||
users, err := h.syncService.ListUserSyncStatuses(threshold)
|
users, err := h.syncService.ListUserSyncStatuses(threshold)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,15 +575,33 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
|||||||
|
|
||||||
// Get pending count
|
// Get pending count
|
||||||
pendingCount := h.localDB.GetPendingCount()
|
pendingCount := h.localDB.GetPendingCount()
|
||||||
readiness := h.getReadinessCached(10 * time.Second)
|
readiness := h.getReadinessLocal()
|
||||||
isBlocked := readiness != nil && readiness.Blocked
|
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)
|
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
|
||||||
|
|
||||||
data := gin.H{
|
data := gin.H{
|
||||||
"IsOffline": isOffline,
|
"IsOffline": isOffline,
|
||||||
"PendingCount": pendingCount,
|
"PendingCount": pendingCount,
|
||||||
"IsBlocked": isBlocked,
|
"IsBlocked": isBlocked,
|
||||||
|
"HasFailedSync": hasFailedSync,
|
||||||
|
"HasIncompleteServerSync": hasIncompleteServerSync,
|
||||||
|
"SyncIssueTitle": func() string {
|
||||||
|
if hasIncompleteServerSync {
|
||||||
|
return "Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые не загружены локально."
|
||||||
|
}
|
||||||
|
if hasFailedSync {
|
||||||
|
if lastPricelistSyncError != "" {
|
||||||
|
return lastPricelistSyncError
|
||||||
|
}
|
||||||
|
return "Последняя синхронизация прайслистов завершилась ошибкой."
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(),
|
||||||
"BlockedReason": func() string {
|
"BlockedReason": func() string {
|
||||||
if readiness == nil {
|
if readiness == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -639,24 +613,41 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
|||||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
|
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
|
||||||
slog.Error("failed to render sync_status template", "error", err)
|
slog.Error("failed to render sync_status template", "error", err)
|
||||||
c.String(http.StatusInternalServerError, "Template error: "+err.Error())
|
_ = c.Error(err)
|
||||||
|
c.String(http.StatusInternalServerError, "Template error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness {
|
func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness {
|
||||||
h.readinessMu.Lock()
|
h.readinessMu.Lock()
|
||||||
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
|
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < 10*time.Second {
|
||||||
cached := *h.readinessCached
|
cached := *h.readinessCached
|
||||||
h.readinessMu.Unlock()
|
h.readinessMu.Unlock()
|
||||||
return &cached
|
return &cached
|
||||||
}
|
}
|
||||||
h.readinessMu.Unlock()
|
h.readinessMu.Unlock()
|
||||||
|
|
||||||
readiness, err := h.syncService.GetReadiness()
|
state, err := h.localDB.GetSyncGuardState()
|
||||||
if err != nil && readiness == nil {
|
if err != nil || state == nil {
|
||||||
return 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.readinessMu.Lock()
|
||||||
h.readinessCached = readiness
|
h.readinessCached = readiness
|
||||||
h.readinessCachedAt = time.Now()
|
h.readinessCachedAt = time.Now()
|
||||||
@@ -675,7 +666,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
|
|||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,7 +682,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.syncService.PushPartnumberSeen(items); err != nil {
|
if err := h.syncService.PushPartnumberSeen(items); err != nil {
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusServiceUnavailable, "service unavailable", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
|
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
|
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
|
||||||
type VendorSpecHandler struct {
|
type VendorSpecHandler struct {
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
|
configService *services.LocalConfigurationService
|
||||||
|
syncService *syncsvc.Service // optional; nil = no server push
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
func NewVendorSpecHandler(localDB *localdb.LocalDB, syncService *syncsvc.Service) *VendorSpecHandler {
|
||||||
return &VendorSpecHandler{localDB: localDB}
|
return &VendorSpecHandler{
|
||||||
|
localDB: localDB,
|
||||||
|
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
|
||||||
|
syncService: syncService,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookupConfig finds an active configuration by UUID using the standard localDB method.
|
// lookupConfig finds an active configuration by UUID using the standard localDB method.
|
||||||
@@ -33,6 +40,28 @@ func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfigurati
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseText parses a pasted single-column text BOM (Inspur or Russian text BOM)
|
||||||
|
// using the same parsers as the vendor file-import path. It is stateless: no
|
||||||
|
// configuration is required. Returns the parsed rows and the detected format, or
|
||||||
|
// an empty result when the text is not a recognized single-column format (the
|
||||||
|
// client then falls back to manual column mapping).
|
||||||
|
// POST /api/vendor-spec/parse-text
|
||||||
|
func (h *VendorSpecHandler) ParseText(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, format := services.ParsePastedBOMText(body.Text)
|
||||||
|
if rows == nil {
|
||||||
|
rows = []localdb.VendorSpecItem{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"rows": rows, "format": format})
|
||||||
|
}
|
||||||
|
|
||||||
// GetVendorSpec returns the vendor spec (BOM) for a configuration.
|
// GetVendorSpec returns the vendor spec (BOM) for a configuration.
|
||||||
// GET /api/configs/:uuid/vendor-spec
|
// GET /api/configs/:uuid/vendor-spec
|
||||||
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
|
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
|
||||||
@@ -62,7 +91,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
|||||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,19 +109,62 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
spec := localdb.VendorSpec(body.VendorSpec)
|
spec := localdb.VendorSpec(body.VendorSpec)
|
||||||
specJSON, err := json.Marshal(spec)
|
if _, err := h.configService.UpdateVendorSpecNoAuth(cfg.UUID, spec); err != nil {
|
||||||
if err != nil {
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push manual lot mappings as suggestions to the server (best-effort, non-blocking).
|
||||||
|
h.pushLotSuggestions(body.VendorSpec)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pushLotSuggestions sends manual PN→LOT mappings to qt_vendor_partnumber_seen.lot_suggestion.
|
||||||
|
// Errors are logged and silently dropped — they must not affect the HTTP response.
|
||||||
|
func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
|
||||||
|
if h.syncService == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []syncsvc.SeenPartnumber
|
||||||
|
for _, row := range spec {
|
||||||
|
if row.VendorPartnumber == "" || len(row.LotMappings) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
suggestion := make([]syncsvc.LotSuggestionEntry, 0, len(row.LotMappings))
|
||||||
|
for _, m := range row.LotMappings {
|
||||||
|
if m.LotName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty := m.QuantityPerPN
|
||||||
|
if qty < 1 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
suggestion = append(suggestion, syncsvc.LotSuggestionEntry{
|
||||||
|
LotName: m.LotName,
|
||||||
|
Qty: qty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(suggestion) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, syncsvc.SeenPartnumber{
|
||||||
|
Partnumber: row.VendorPartnumber,
|
||||||
|
Description: row.Description,
|
||||||
|
LotSuggestion: suggestion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.syncService.PushPartnumberSeen(items); err != nil {
|
||||||
|
slog.Warn("vendor_spec: failed to push lot suggestions to server", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||||
if len(in) == 0 {
|
if len(in) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -100,7 +172,7 @@ func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpe
|
|||||||
merged := make(map[string]int, len(in))
|
merged := make(map[string]int, len(in))
|
||||||
order := make([]string, 0, len(in))
|
order := make([]string, 0, len(in))
|
||||||
for _, m := range in {
|
for _, m := range in {
|
||||||
lot := strings.TrimSpace(m.LotName)
|
lot := models.NormalizeLotName(m.LotName)
|
||||||
if lot == "" {
|
if lot == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -138,7 +210,7 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
|||||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,14 +219,18 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
|||||||
|
|
||||||
resolved, err := resolver.Resolve(body.VendorSpec)
|
resolved, err := resolver.Resolve(body.VendorSpec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
book, _ := bookRepo.GetActiveBook()
|
book, err := bookRepo.GetActiveBook()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("vendor spec resolve: no active partnumber book", "err", err)
|
||||||
|
book = nil
|
||||||
|
}
|
||||||
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
|
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +257,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
|
|||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,14 +270,8 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsJSON, err := json.Marshal(newItems)
|
if _, err := h.configService.ApplyVendorSpecItemsNoAuth(cfg.UUID, newItems); err != nil {
|
||||||
if err != nil {
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
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/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -111,15 +113,18 @@ func NewWebHandler(_ string, localDB *localdb.LocalDB) (*WebHandler, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
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")
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
tmpl, ok := h.templates[name]
|
tmpl, ok := h.templates[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
c.String(500, "Template not found: %s", name)
|
_ = c.Error(fmt.Errorf("template %q not found", name))
|
||||||
|
c.String(500, "Template error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Execute the page template which will use base
|
// Execute the page template which will use base
|
||||||
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
|
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
|
||||||
c.String(500, "Template error: %v", err)
|
_ = c.Error(err)
|
||||||
|
c.String(500, "Template error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
internal/handlers/web_test.go
Normal file
47
internal/handlers/web_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWebHandlerRenderHidesTemplateExecutionError(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
tmpl := template.Must(template.New("broken.html").Funcs(template.FuncMap{
|
||||||
|
"boom": func() (string, error) {
|
||||||
|
return "", errors.New("secret template failure")
|
||||||
|
},
|
||||||
|
}).Parse(`{{define "broken.html"}}{{boom}}{{end}}`))
|
||||||
|
|
||||||
|
handler := &WebHandler{
|
||||||
|
templates: map[string]*template.Template{
|
||||||
|
"broken.html": tmpl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/broken", nil)
|
||||||
|
|
||||||
|
handler.render(ctx, "broken.html", gin.H{})
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if body := strings.TrimSpace(rec.Body.String()); body != "Template error" {
|
||||||
|
t.Fatalf("expected generic template error, got %q", body)
|
||||||
|
}
|
||||||
|
if len(ctx.Errors) != 1 {
|
||||||
|
t.Fatalf("expected logged template error, got %d", len(ctx.Errors))
|
||||||
|
}
|
||||||
|
if !strings.Contains(ctx.Errors.String(), "secret template failure") {
|
||||||
|
t.Fatalf("expected original error in gin context, got %q", ctx.Errors.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,8 @@ package localdb
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ComponentFilter for searching with filters
|
// ComponentFilter for searching with filters
|
||||||
@@ -24,306 +21,213 @@ type ComponentSyncResult struct {
|
|||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncComponents loads components from MariaDB (lot + qt_lot_metadata) into local_components
|
// latestActivePricelistID returns the local DB id of the most recently created
|
||||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
// active pricelist for the given source ("estimate", "warehouse", etc.).
|
||||||
startTime := time.Now()
|
func (l *LocalDB) latestActivePricelistID(source string) (uint, error) {
|
||||||
|
var id uint
|
||||||
// Query to join lot with qt_lot_metadata (metadata only, no pricing)
|
err := l.db.Table("local_pricelists").
|
||||||
// Use LEFT JOIN to include lots without metadata
|
Select("id").
|
||||||
type componentRow struct {
|
Where("is_active = ? AND source = ?", true, source).
|
||||||
LotName string
|
Order("created_at DESC, id DESC").
|
||||||
LotDescription string
|
Limit(1).
|
||||||
Category *string
|
Scan(&id).Error
|
||||||
Model *string
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []componentRow
|
|
||||||
err := mariaDB.Raw(`
|
|
||||||
SELECT
|
|
||||||
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
|
|
||||||
WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL
|
|
||||||
ORDER BY l.lot_name
|
|
||||||
`).Scan(&rows).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if id == 0 {
|
||||||
if len(rows) == 0 {
|
return 0, fmt.Errorf("no active %s pricelist", source)
|
||||||
slog.Warn("no components found in MariaDB")
|
|
||||||
return &ComponentSyncResult{
|
|
||||||
Duration: time.Since(startTime),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
return id, nil
|
||||||
// Get existing local components for comparison
|
|
||||||
existingMap := make(map[string]bool)
|
|
||||||
var existing []LocalComponent
|
|
||||||
if err := l.db.Find(&existing).Error; err != nil {
|
|
||||||
return nil, fmt.Errorf("reading existing local components: %w", err)
|
|
||||||
}
|
|
||||||
for _, c := range existing {
|
|
||||||
existingMap[c.LotName] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare components for batch insert/update
|
|
||||||
syncTime := time.Now()
|
|
||||||
components := make([]LocalComponent, 0, len(rows))
|
|
||||||
newCount := 0
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
category := ""
|
|
||||||
if row.Category != nil {
|
|
||||||
category = *row.Category
|
|
||||||
} else {
|
|
||||||
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
|
||||||
parts := strings.SplitN(row.LotName, "_", 2)
|
|
||||||
if len(parts) >= 1 {
|
|
||||||
category = parts[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
model := ""
|
|
||||||
if row.Model != nil {
|
|
||||||
model = *row.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
comp := LocalComponent{
|
|
||||||
LotName: row.LotName,
|
|
||||||
LotDescription: row.LotDescription,
|
|
||||||
Category: category,
|
|
||||||
Model: model,
|
|
||||||
}
|
|
||||||
components = append(components, comp)
|
|
||||||
|
|
||||||
if !existingMap[row.LotName] {
|
|
||||||
newCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use transaction for bulk upsert
|
|
||||||
err = l.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Delete all existing and insert new (simpler than upsert for SQLite)
|
|
||||||
if err := tx.Where("1=1").Delete(&LocalComponent{}).Error; err != nil {
|
|
||||||
return fmt.Errorf("clearing local components: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch insert
|
|
||||||
batchSize := 500
|
|
||||||
for i := 0; i < len(components); i += batchSize {
|
|
||||||
end := i + batchSize
|
|
||||||
if end > len(components) {
|
|
||||||
end = len(components)
|
|
||||||
}
|
|
||||||
if err := tx.CreateInBatches(components[i:end], batchSize).Error; err != nil {
|
|
||||||
return fmt.Errorf("inserting components batch: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last sync time
|
|
||||||
if err := l.SetComponentSyncTime(syncTime); err != nil {
|
|
||||||
slog.Warn("failed to update component sync time", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &ComponentSyncResult{
|
|
||||||
TotalSynced: len(components),
|
|
||||||
NewCount: newCount,
|
|
||||||
UpdateCount: len(components) - newCount,
|
|
||||||
Duration: time.Since(startTime),
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("components synced",
|
|
||||||
"total", result.TotalSynced,
|
|
||||||
"new", result.NewCount,
|
|
||||||
"updated", result.UpdateCount,
|
|
||||||
"duration", result.Duration)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchLocalComponents searches components in local cache by query string
|
// pricelistItemRow is used for scanning rows from local_pricelist_items.
|
||||||
// Searches in lot_name, lot_description, category, and model fields
|
type pricelistItemRow struct {
|
||||||
|
LotName string `gorm:"column:lot_name"`
|
||||||
|
Category string `gorm:"column:lot_category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r pricelistItemRow) toLocalComponent() LocalComponent {
|
||||||
|
return LocalComponent{
|
||||||
|
LotName: r.LotName,
|
||||||
|
Category: r.Category,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// SearchLocalComponents searches components in the latest active estimate
|
||||||
|
// pricelist by lot_name.
|
||||||
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
|
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
|
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||||
var components []LocalComponent
|
|
||||||
|
|
||||||
if query == "" {
|
|
||||||
// Return all components with limit
|
|
||||||
err := l.db.Order("lot_name").Limit(limit).Find(&components).Error
|
|
||||||
return components, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search with LIKE on multiple fields
|
|
||||||
searchPattern := "%" + strings.ToLower(query) + "%"
|
|
||||||
|
|
||||||
err := l.db.Where(
|
|
||||||
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
|
|
||||||
searchPattern, searchPattern, searchPattern, searchPattern,
|
|
||||||
).Order("lot_name").Limit(limit).Find(&components).Error
|
|
||||||
|
|
||||||
return components, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchLocalComponentsByCategory searches components by category and optional query
|
|
||||||
func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string, limit int) ([]LocalComponent, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
var components []LocalComponent
|
|
||||||
db := l.db.Where("LOWER(category) = ?", strings.ToLower(category))
|
|
||||||
|
|
||||||
if query != "" {
|
|
||||||
searchPattern := "%" + strings.ToLower(query) + "%"
|
|
||||||
db = db.Where(
|
|
||||||
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(model) LIKE ?",
|
|
||||||
searchPattern, searchPattern, searchPattern,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := db.Order("lot_name").Limit(limit).Find(&components).Error
|
|
||||||
return components, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListComponents returns components with filtering and pagination
|
|
||||||
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
|
|
||||||
db := l.db
|
|
||||||
|
|
||||||
// Apply category filter
|
|
||||||
if filter.Category != "" {
|
|
||||||
db = db.Where("LOWER(category) = ?", strings.ToLower(filter.Category))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply search filter
|
|
||||||
if filter.Search != "" {
|
|
||||||
searchPattern := "%" + strings.ToLower(filter.Search) + "%"
|
|
||||||
db = db.Where(
|
|
||||||
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
|
|
||||||
searchPattern, searchPattern, searchPattern, searchPattern,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
var total int64
|
|
||||||
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply pagination and get results
|
|
||||||
var components []LocalComponent
|
|
||||||
if err := db.Order("lot_name").Offset(offset).Limit(limit).Find(&components).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return components, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLocalComponent returns a single component by lot_name
|
|
||||||
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
|
||||||
var component LocalComponent
|
|
||||||
err := l.db.Where("lot_name = ?", lotName).First(&component).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &component, nil
|
|
||||||
|
db := l.db.Table("local_pricelist_items").
|
||||||
|
Where("pricelist_id = ?", pricelistID)
|
||||||
|
if query != "" {
|
||||||
|
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []pricelistItemRow
|
||||||
|
if err := db.Select("lot_name, lot_category").Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
components := make([]LocalComponent, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
components[i] = r.toLocalComponent()
|
||||||
|
}
|
||||||
|
return components, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLocalComponentCategoriesByLotNames returns category for each lot_name in the local component cache.
|
// SearchLocalComponentsByCategory searches components in the latest active
|
||||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
// estimate pricelist filtered by category.
|
||||||
|
func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit int) ([]LocalComponent, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
db := l.db.Table("local_pricelist_items").
|
||||||
|
Where("pricelist_id = ? AND UPPER(lot_category) = ?", pricelistID, strings.ToUpper(category))
|
||||||
|
if query != "" {
|
||||||
|
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []pricelistItemRow
|
||||||
|
if err := db.Select("lot_name, lot_category").Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
components := make([]LocalComponent, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
components[i] = r.toLocalComponent()
|
||||||
|
}
|
||||||
|
return components, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListComponents returns components from the latest active estimate pricelist
|
||||||
|
// with optional category/search filtering and pagination.
|
||||||
|
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
|
||||||
|
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
db := l.db.Table("local_pricelist_items").
|
||||||
|
Where("pricelist_id = ?", pricelistID)
|
||||||
|
|
||||||
|
if filter.Category != "" {
|
||||||
|
db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(filter.Category))
|
||||||
|
}
|
||||||
|
if filter.Search != "" {
|
||||||
|
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(filter.Search)+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []pricelistItemRow
|
||||||
|
if err := db.Select("lot_name, lot_category").Order("lot_name").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
components := make([]LocalComponent, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
components[i] = r.toLocalComponent()
|
||||||
|
}
|
||||||
|
return components, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalComponent returns a single component by lot_name from the latest
|
||||||
|
// active estimate pricelist.
|
||||||
|
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
||||||
|
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var row pricelistItemRow
|
||||||
|
if err := l.db.Table("local_pricelist_items").
|
||||||
|
Select("lot_name, lot_category").
|
||||||
|
Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)).
|
||||||
|
First(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c := row.toLocalComponent()
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalComponentCategoriesByLotNames returns category for each lot_name
|
||||||
|
// from the latest active estimate pricelist.
|
||||||
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
|
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
|
||||||
result := make(map[string]string, len(lotNames))
|
result := make(map[string]string, len(lotNames))
|
||||||
if len(lotNames) == 0 {
|
if len(lotNames) == 0 {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||||
type row struct {
|
if err != nil {
|
||||||
LotName string `gorm:"column:lot_name"`
|
return result, nil
|
||||||
Category string `gorm:"column:category"`
|
|
||||||
}
|
}
|
||||||
var rows []row
|
|
||||||
if err := l.db.Model(&LocalComponent{}).
|
// Build uppercase → original mapping so result keys match what the caller passed.
|
||||||
Select("lot_name, category").
|
upperToOrig := make(map[string]string, len(lotNames))
|
||||||
Where("lot_name IN ?", lotNames).
|
upper := make([]string, len(lotNames))
|
||||||
Find(&rows).Error; err != nil {
|
for i, n := range lotNames {
|
||||||
|
u := strings.ToUpper(n)
|
||||||
|
upper[i] = u
|
||||||
|
upperToOrig[u] = n
|
||||||
|
}
|
||||||
|
var rows []pricelistItemRow
|
||||||
|
if err := l.db.Table("local_pricelist_items").
|
||||||
|
Select("lot_name, lot_category").
|
||||||
|
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, upper).
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
result[r.LotName] = r.Category
|
orig := upperToOrig[strings.ToUpper(r.LotName)]
|
||||||
|
if orig == "" {
|
||||||
|
orig = r.LotName
|
||||||
|
}
|
||||||
|
result[orig] = r.Category
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLocalComponentCategories returns distinct categories from local components
|
// GetLocalComponentCategories returns distinct categories from the latest
|
||||||
|
// active estimate pricelist.
|
||||||
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
||||||
var categories []string
|
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||||
err := l.db.Model(&LocalComponent{}).
|
|
||||||
Distinct("category").
|
|
||||||
Where("category != ''").
|
|
||||||
Order("category").
|
|
||||||
Pluck("category", &categories).Error
|
|
||||||
return categories, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountLocalComponents returns the total number of local components
|
|
||||||
func (l *LocalDB) CountLocalComponents() int64 {
|
|
||||||
var count int64
|
|
||||||
l.db.Model(&LocalComponent{}).Count(&count)
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountLocalComponentsByCategory returns component count by category
|
|
||||||
func (l *LocalDB) CountLocalComponentsByCategory(category string) int64 {
|
|
||||||
var count int64
|
|
||||||
l.db.Model(&LocalComponent{}).Where("LOWER(category) = ?", strings.ToLower(category)).Count(&count)
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetComponentSyncTime returns the last component sync timestamp
|
|
||||||
func (l *LocalDB) GetComponentSyncTime() *time.Time {
|
|
||||||
var setting struct {
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
if err := l.db.Table("app_settings").
|
|
||||||
Where("key = ?", "last_component_sync").
|
|
||||||
First(&setting).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := time.Parse(time.RFC3339, setting.Value)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
return &t
|
|
||||||
|
var categories []string
|
||||||
|
if err := l.db.Table("local_pricelist_items").
|
||||||
|
Where("pricelist_id = ? AND lot_category != ''", pricelistID).
|
||||||
|
Distinct("lot_category").
|
||||||
|
Order("lot_category").
|
||||||
|
Pluck("lot_category", &categories).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return categories, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetComponentSyncTime sets the last component sync timestamp
|
// CountComponents returns the number of distinct lot names in the latest
|
||||||
func (l *LocalDB) SetComponentSyncTime(t time.Time) error {
|
// active estimate pricelist (used to check if data is available).
|
||||||
return l.db.Exec(`
|
func (l *LocalDB) CountComponents() int64 {
|
||||||
INSERT INTO app_settings (key, value, updated_at)
|
pricelistID, err := l.latestActivePricelistID("estimate")
|
||||||
VALUES (?, ?, ?)
|
if err != nil {
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
return 0
|
||||||
`, "last_component_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
}
|
||||||
|
var count int64
|
||||||
|
l.db.Table("local_pricelist_items").Where("pricelist_id = ?", pricelistID).Count(&count)
|
||||||
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// NeedComponentSync checks if component sync is needed (older than specified hours)
|
|
||||||
func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
|
||||||
syncTime := l.GetComponentSyncTime()
|
|
||||||
if syncTime == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -95,3 +95,60 @@ func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
|
|||||||
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
items := make(LocalConfigItems, len(cfg.Items))
|
items := make(LocalConfigItems, len(cfg.Items))
|
||||||
for i, item := range cfg.Items {
|
for i, item := range cfg.Items {
|
||||||
items[i] = LocalConfigItem{
|
items[i] = LocalConfigItem{
|
||||||
LotName: item.LotName,
|
LotName: models.NormalizeLotName(item.LotName),
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
UnitPrice: item.UnitPrice,
|
UnitPrice: item.UnitPrice,
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
PricelistID: cfg.PricelistID,
|
PricelistID: cfg.PricelistID,
|
||||||
WarehousePricelistID: cfg.WarehousePricelistID,
|
WarehousePricelistID: cfg.WarehousePricelistID,
|
||||||
CompetitorPricelistID: cfg.CompetitorPricelistID,
|
CompetitorPricelistID: cfg.CompetitorPricelistID,
|
||||||
|
ConfigType: cfg.ConfigType,
|
||||||
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
|
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
|
||||||
DisablePriceRefresh: cfg.DisablePriceRefresh,
|
DisablePriceRefresh: cfg.DisablePriceRefresh,
|
||||||
OnlyInStock: cfg.OnlyInStock,
|
OnlyInStock: cfg.OnlyInStock,
|
||||||
@@ -82,6 +83,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
PricelistID: local.PricelistID,
|
PricelistID: local.PricelistID,
|
||||||
WarehousePricelistID: local.WarehousePricelistID,
|
WarehousePricelistID: local.WarehousePricelistID,
|
||||||
CompetitorPricelistID: local.CompetitorPricelistID,
|
CompetitorPricelistID: local.CompetitorPricelistID,
|
||||||
|
ConfigType: local.ConfigType,
|
||||||
VendorSpec: localVendorSpecToModel(local.VendorSpec),
|
VendorSpec: localVendorSpecToModel(local.VendorSpec),
|
||||||
DisablePriceRefresh: local.DisablePriceRefresh,
|
DisablePriceRefresh: local.DisablePriceRefresh,
|
||||||
OnlyInStock: local.OnlyInStock,
|
OnlyInStock: local.OnlyInStock,
|
||||||
@@ -269,7 +271,7 @@ func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *Lo
|
|||||||
partnumbers = append(partnumbers, item.Partnumbers...)
|
partnumbers = append(partnumbers, item.Partnumbers...)
|
||||||
return &LocalPricelistItem{
|
return &LocalPricelistItem{
|
||||||
PricelistID: localPricelistID,
|
PricelistID: localPricelistID,
|
||||||
LotName: item.LotName,
|
LotName: models.NormalizeLotName(item.LotName),
|
||||||
LotCategory: item.LotCategory,
|
LotCategory: item.LotCategory,
|
||||||
Price: item.Price,
|
Price: item.Price,
|
||||||
AvailableQty: item.AvailableQty,
|
AvailableQty: item.AvailableQty,
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ type LocalDB struct {
|
|||||||
var localReadOnlyCacheTables = []string{
|
var localReadOnlyCacheTables = []string{
|
||||||
"local_pricelist_items",
|
"local_pricelist_items",
|
||||||
"local_pricelists",
|
"local_pricelists",
|
||||||
"local_components",
|
|
||||||
"local_partnumber_book_items",
|
"local_partnumber_book_items",
|
||||||
"local_partnumber_books",
|
"local_partnumber_books",
|
||||||
}
|
}
|
||||||
@@ -78,7 +77,6 @@ func ResetData(dbPath string) error {
|
|||||||
"local_configuration_versions",
|
"local_configuration_versions",
|
||||||
"local_pricelists",
|
"local_pricelists",
|
||||||
"local_pricelist_items",
|
"local_pricelist_items",
|
||||||
"local_components",
|
|
||||||
"local_sync_guard_state",
|
"local_sync_guard_state",
|
||||||
"pending_changes",
|
"pending_changes",
|
||||||
"app_settings",
|
"app_settings",
|
||||||
@@ -116,6 +114,14 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
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 {
|
if err := ensureLocalProjectsTable(db); err != nil {
|
||||||
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
||||||
}
|
}
|
||||||
@@ -216,11 +222,12 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
|
|||||||
&LocalConfigurationVersion{},
|
&LocalConfigurationVersion{},
|
||||||
&LocalPricelist{},
|
&LocalPricelist{},
|
||||||
&LocalPricelistItem{},
|
&LocalPricelistItem{},
|
||||||
&LocalComponent{},
|
|
||||||
&AppSetting{},
|
&AppSetting{},
|
||||||
&LocalSyncGuardState{},
|
&LocalSyncGuardState{},
|
||||||
&PendingChange{},
|
&PendingChange{},
|
||||||
&LocalPartnumberBook{},
|
&LocalPartnumberBook{},
|
||||||
|
&SyncLogEntry{},
|
||||||
|
&LocalQtSetting{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,7 +495,10 @@ func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string
|
|||||||
// HasSettings returns true if connection settings exist
|
// HasSettings returns true if connection settings exist
|
||||||
func (l *LocalDB) HasSettings() bool {
|
func (l *LocalDB) HasSettings() bool {
|
||||||
var count int64
|
var count int64
|
||||||
l.db.Model(&ConnectionSettings{}).Count(&count)
|
if err := l.db.Model(&ConnectionSettings{}).Count(&count).Error; err != nil {
|
||||||
|
slog.Error("localdb: HasSettings count failed", "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +621,46 @@ func (l *LocalDB) SaveProject(project *LocalProject) error {
|
|||||||
return l.db.Save(project).Error
|
return l.db.Save(project).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveProjectPreservingUpdatedAt stores a project without replacing UpdatedAt
|
||||||
|
// with the current local sync time.
|
||||||
|
func (l *LocalDB) SaveProjectPreservingUpdatedAt(project *LocalProject) error {
|
||||||
|
if project == nil {
|
||||||
|
return fmt.Errorf("project is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if project.ID == 0 && strings.TrimSpace(project.UUID) != "" {
|
||||||
|
var existing LocalProject
|
||||||
|
err := l.db.Where("uuid = ?", project.UUID).First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
project.ID = existing.ID
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if project.ID == 0 {
|
||||||
|
return l.db.Create(project).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.db.Model(&LocalProject{}).
|
||||||
|
Where("id = ?", project.ID).
|
||||||
|
UpdateColumns(map[string]interface{}{
|
||||||
|
"uuid": project.UUID,
|
||||||
|
"server_id": project.ServerID,
|
||||||
|
"owner_username": project.OwnerUsername,
|
||||||
|
"code": project.Code,
|
||||||
|
"variant": project.Variant,
|
||||||
|
"name": project.Name,
|
||||||
|
"tracker_url": project.TrackerURL,
|
||||||
|
"is_active": project.IsActive,
|
||||||
|
"is_system": project.IsSystem,
|
||||||
|
"created_at": project.CreatedAt,
|
||||||
|
"updated_at": project.UpdatedAt,
|
||||||
|
"synced_at": project.SyncedAt,
|
||||||
|
"sync_status": project.SyncStatus,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) {
|
func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) {
|
||||||
var projects []LocalProject
|
var projects []LocalProject
|
||||||
query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername)
|
query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername)
|
||||||
@@ -639,6 +689,22 @@ func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) {
|
|||||||
return &project, nil
|
return &project, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *LocalDB) GetProjectByCode(code string) (*LocalProject, error) {
|
||||||
|
var project LocalProject
|
||||||
|
if err := l.db.Where("LOWER(code) = LOWER(?) AND variant = ''", code).First(&project).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &project, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LocalDB) GetProjectByCodeAndVariant(code, variant string) (*LocalProject, error) {
|
||||||
|
var project LocalProject
|
||||||
|
if err := l.db.Where("LOWER(code) = LOWER(?) AND LOWER(variant) = LOWER(?)", code, variant).First(&project).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &project, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) {
|
func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) {
|
||||||
var project LocalProject
|
var project LocalProject
|
||||||
if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil {
|
if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil {
|
||||||
@@ -995,14 +1061,18 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
|
|||||||
// CountConfigurations returns the number of local configurations
|
// CountConfigurations returns the number of local configurations
|
||||||
func (l *LocalDB) CountConfigurations() int64 {
|
func (l *LocalDB) CountConfigurations() int64 {
|
||||||
var count int64
|
var count int64
|
||||||
l.db.Model(&LocalConfiguration{}).Count(&count)
|
if err := l.db.Model(&LocalConfiguration{}).Count(&count).Error; err != nil {
|
||||||
|
slog.Error("localdb: CountConfigurations failed", "err", err)
|
||||||
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountProjects returns the number of local projects
|
// CountProjects returns the number of local projects
|
||||||
func (l *LocalDB) CountProjects() int64 {
|
func (l *LocalDB) CountProjects() int64 {
|
||||||
var count int64
|
var count int64
|
||||||
l.db.Model(&LocalProject{}).Count(&count)
|
if err := l.db.Model(&LocalProject{}).Count(&count).Error; err != nil {
|
||||||
|
slog.Error("localdb: CountProjects failed", "err", err)
|
||||||
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1026,6 +1096,26 @@ func (l *LocalDB) GetLastSyncTime() *time.Time {
|
|||||||
return &t
|
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
|
// SetLastSyncTime sets the last sync timestamp
|
||||||
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
||||||
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
|
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
|
||||||
@@ -1036,6 +1126,115 @@ func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
|||||||
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// CountLocalPricelists returns the number of local pricelists
|
// CountLocalPricelists returns the number of local pricelists
|
||||||
func (l *LocalDB) CountLocalPricelists() int64 {
|
func (l *LocalDB) CountLocalPricelists() int64 {
|
||||||
var count int64
|
var count int64
|
||||||
@@ -1043,11 +1242,33 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// DBFilePath returns the path to the SQLite database file.
|
||||||
|
func (l *LocalDB) DBFilePath() string {
|
||||||
|
return l.path
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBFileSizeBytes returns the size of the SQLite database file in bytes.
|
||||||
|
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 active estimate pricelist.
|
||||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||||
var pricelist LocalPricelist
|
var pricelist LocalPricelist
|
||||||
if err := l.db.
|
if err := l.db.
|
||||||
Where("source = ?", "estimate").
|
Where("source = ? AND is_active = ?", "estimate", true).
|
||||||
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
||||||
Order("created_at DESC, id DESC").
|
Order("created_at DESC, id DESC").
|
||||||
First(&pricelist).Error; err != nil {
|
First(&pricelist).Error; err != nil {
|
||||||
@@ -1056,11 +1277,11 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
|||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
|
// GetLatestLocalPricelistBySource returns the most recently synced active pricelist for a source.
|
||||||
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
||||||
var pricelist LocalPricelist
|
var pricelist LocalPricelist
|
||||||
if err := l.db.
|
if err := l.db.
|
||||||
Where("source = ?", source).
|
Where("source = ? AND is_active = ?", source, true).
|
||||||
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
||||||
Order("created_at DESC, id DESC").
|
Order("created_at DESC, id DESC").
|
||||||
First(&pricelist).Error; err != nil {
|
First(&pricelist).Error; err != nil {
|
||||||
@@ -1069,6 +1290,17 @@ func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelis
|
|||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeactivateLocalPricelistsNotIn marks all local pricelists with is_active=true whose
|
||||||
|
// server_id is not in activeServerIDs as inactive. Used after each pricelist sync to
|
||||||
|
// mirror server-side deactivations locally.
|
||||||
|
func (l *LocalDB) DeactivateLocalPricelistsNotIn(activeServerIDs []uint) error {
|
||||||
|
q := l.db.Model(&LocalPricelist{}).Where("is_active = ?", true)
|
||||||
|
if len(activeServerIDs) > 0 {
|
||||||
|
q = q.Where("server_id NOT IN ?", activeServerIDs)
|
||||||
|
}
|
||||||
|
return q.Update("is_active", false).Error
|
||||||
|
}
|
||||||
|
|
||||||
// GetLocalPricelistByServerID returns a local pricelist by its server ID
|
// GetLocalPricelistByServerID returns a local pricelist by its server ID
|
||||||
func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) {
|
func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) {
|
||||||
var pricelist LocalPricelist
|
var pricelist LocalPricelist
|
||||||
@@ -1136,6 +1368,30 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocalPricelistCoverageByCategory returns item count per lot_category and the total
|
||||||
|
// for the given local pricelist ID. Only items with price > 0 are counted.
|
||||||
|
func (l *LocalDB) GetLocalPricelistCoverageByCategory(pricelistID uint) (map[string]int64, int64, error) {
|
||||||
|
type row struct {
|
||||||
|
Category string `gorm:"column:lot_category"`
|
||||||
|
Count int64 `gorm:"column:cnt"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.db.Model(&LocalPricelistItem{}).
|
||||||
|
Select("COALESCE(NULLIF(TRIM(lot_category),''), '?') AS lot_category, COUNT(*) AS cnt").
|
||||||
|
Where("pricelist_id = ? AND price > 0", pricelistID).
|
||||||
|
Group("lot_category").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
result := make(map[string]int64, len(rows))
|
||||||
|
var total int64
|
||||||
|
for _, r := range rows {
|
||||||
|
result[r.Category] = r.Count
|
||||||
|
total += r.Count
|
||||||
|
}
|
||||||
|
return result, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
|
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
|
||||||
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
|
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
@@ -1147,20 +1403,20 @@ func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (i
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveLocalPricelistItems saves pricelist items to local SQLite
|
// SaveLocalPricelistItems saves pricelist items to local SQLite.
|
||||||
|
// Duplicate (pricelist_id, lot_name) rows are silently ignored.
|
||||||
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch insert
|
|
||||||
batchSize := 500
|
batchSize := 500
|
||||||
for i := 0; i < len(items); i += batchSize {
|
for i := 0; i < len(items); i += batchSize {
|
||||||
end := i + batchSize
|
end := i + batchSize
|
||||||
if end > len(items) {
|
if end > len(items) {
|
||||||
end = len(items)
|
end = len(items)
|
||||||
}
|
}
|
||||||
if err := l.db.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
if err := l.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1200,16 +1456,49 @@ func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
// GetLocalPriceForLot returns the price for a lot from a local pricelist.
|
||||||
|
// Matching is case-insensitive via UPPER(lot_name) to handle legacy mixed-case rows.
|
||||||
func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||||
var item LocalPricelistItem
|
var item LocalPricelistItem
|
||||||
if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).
|
if err := l.db.Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)).
|
||||||
First(&item).Error; err != nil {
|
First(&item).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return item.Price, nil
|
return item.Price, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query.
|
||||||
|
// Missing lots are omitted from the result.
|
||||||
|
// lotNames must already be normalized (uppercased); matching is done via UPPER(lot_name) to handle
|
||||||
|
// legacy rows that were stored in mixed case before normalization was enforced at sync time.
|
||||||
|
// Keys in the returned map are uppercased (matching the input lotNames).
|
||||||
|
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
|
||||||
|
result := make(map[string]float64, len(lotNames))
|
||||||
|
if len(lotNames) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
LotName string `gorm:"column:lot_name"`
|
||||||
|
Price float64 `gorm:"column:price"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
// Use UPPER(lot_name) so rows synced before normalization (mixed-case) are still matched.
|
||||||
|
if err := l.db.Model(&LocalPricelistItem{}).
|
||||||
|
Select("lot_name, price").
|
||||||
|
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, lotNames).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Price > 0 {
|
||||||
|
// Key must be uppercase to match callers that normalise lot names before lookup.
|
||||||
|
result[strings.ToUpper(r.LotName)] = r.Price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
// 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.
|
// 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) {
|
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||||
@@ -1227,15 +1516,27 @@ func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uin
|
|||||||
LotName string `gorm:"column:lot_name"`
|
LotName string `gorm:"column:lot_name"`
|
||||||
LotCategory string `gorm:"column:lot_category"`
|
LotCategory string `gorm:"column:lot_category"`
|
||||||
}
|
}
|
||||||
|
// Build uppercase → original mapping so result keys match what the caller passed.
|
||||||
|
upperToOrig := make(map[string]string, len(lotNames))
|
||||||
|
upper := make([]string, len(lotNames))
|
||||||
|
for i, n := range lotNames {
|
||||||
|
u := strings.ToUpper(n)
|
||||||
|
upper[i] = u
|
||||||
|
upperToOrig[u] = n
|
||||||
|
}
|
||||||
var rows []row
|
var rows []row
|
||||||
if err := l.db.Model(&LocalPricelistItem{}).
|
if err := l.db.Model(&LocalPricelistItem{}).
|
||||||
Select("lot_name, lot_category").
|
Select("lot_name, lot_category").
|
||||||
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
|
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", localPL.ID, upper).
|
||||||
Find(&rows).Error; err != nil {
|
Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
result[r.LotName] = r.LotCategory
|
orig := upperToOrig[strings.ToUpper(r.LotName)]
|
||||||
|
if orig == "" {
|
||||||
|
orig = r.LotName
|
||||||
|
}
|
||||||
|
result[orig] = r.LotCategory
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -1573,3 +1874,40 @@ func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requi
|
|||||||
}),
|
}),
|
||||||
}).Create(state).Error
|
}).Create(state).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocalPartnumberBookByServerID returns a local partnumber book by its server-side ID.
|
||||||
|
func (l *LocalDB) GetLocalPartnumberBookByServerID(serverID uint) (*LocalPartnumberBook, error) {
|
||||||
|
var book LocalPartnumberBook
|
||||||
|
if err := l.db.Where("server_id = ?", serverID).First(&book).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &book, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalPricelistItemsPage returns a paginated, searchable list of items for a pricelist.
|
||||||
|
func (l *LocalDB) GetLocalPricelistItemsPage(pricelistID uint, search string, page, perPage int) ([]LocalPricelistItem, int64, error) {
|
||||||
|
dbq := l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID)
|
||||||
|
if search != "" {
|
||||||
|
dbq = dbq.Where("lot_name LIKE ?", "%"+search+"%")
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := dbq.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
var items []LocalPricelistItem
|
||||||
|
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("fetch pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ var localMigrations = []localMigration{
|
|||||||
name: "Convert local partnumber book cache to book membership + deduplicated PN catalog",
|
name: "Convert local partnumber book cache to book membership + deduplicated PN catalog",
|
||||||
run: migrateLocalPartnumberBookCatalog,
|
run: migrateLocalPartnumberBookCatalog,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "2026_03_13_pricelist_items_dedup_unique",
|
||||||
|
name: "Deduplicate local_pricelist_items and add unique index on (pricelist_id, lot_name)",
|
||||||
|
run: deduplicatePricelistItemsAndAddUniqueIndex,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
type localPartnumberCatalogRow struct {
|
type localPartnumberCatalogRow struct {
|
||||||
@@ -1092,3 +1097,27 @@ func rebuildLocalPartnumberBookCatalog(tx *gorm.DB, catalog map[string]*localPar
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error {
|
||||||
|
// Remove duplicate (pricelist_id, lot_name) rows keeping only the row with the lowest id.
|
||||||
|
if err := tx.Exec(`
|
||||||
|
DELETE FROM local_pricelist_items
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT MIN(id) FROM local_pricelist_items
|
||||||
|
GROUP BY pricelist_id, lot_name
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("deduplicate local_pricelist_items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add unique index to prevent future duplicates.
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot_unique
|
||||||
|
ON local_pricelist_items(pricelist_id, lot_name)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create unique index on local_pricelist_items: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("deduplicated local_pricelist_items and added unique index")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppSetting stores application settings in local SQLite
|
// AppSetting stores application settings in local SQLite
|
||||||
@@ -46,7 +48,13 @@ func (c *LocalConfigItems) Scan(value interface{}) error {
|
|||||||
default:
|
default:
|
||||||
return errors.New("type assertion failed for LocalConfigItems")
|
return errors.New("type assertion failed for LocalConfigItems")
|
||||||
}
|
}
|
||||||
return json.Unmarshal(bytes, c)
|
if err := json.Unmarshal(bytes, c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range *c {
|
||||||
|
(*c)[i].LotName = models.NormalizeLotName((*c)[i].LotName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c LocalConfigItems) Total() float64 {
|
func (c LocalConfigItems) Total() float64 {
|
||||||
@@ -110,6 +118,7 @@ type LocalConfiguration struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
SyncedAt *time.Time `json:"synced_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'
|
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||||
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
||||||
@@ -168,7 +177,8 @@ type LocalPricelist struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
|
CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
|
||||||
SyncedAt time.Time `json:"synced_at"`
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||||
|
IsActive bool `gorm:"not null;default:true;index" json:"is_active"` // Mirrors qt_pricelists.is_active
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LocalPricelist) TableName() string {
|
func (LocalPricelist) TableName() string {
|
||||||
@@ -316,6 +326,19 @@ type VendorSpecLotMapping struct {
|
|||||||
QuantityPerPN int `json:"quantity_per_pn"`
|
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
|
// VendorSpec is a JSON-encodable slice of VendorSpecItem
|
||||||
type VendorSpec []VendorSpecItem
|
type VendorSpec []VendorSpecItem
|
||||||
|
|
||||||
@@ -342,3 +365,12 @@ func (v *VendorSpec) Scan(value interface{}) error {
|
|||||||
}
|
}
|
||||||
return json.Unmarshal(bytes, v)
|
return json.Unmarshal(bytes, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LocalQtSetting caches server-pushed settings from qt_settings (MariaDB) into local SQLite.
|
||||||
|
// Synced during component sync. Each row is a JSON-valued setting identified by name.
|
||||||
|
type LocalQtSetting struct {
|
||||||
|
Name string `gorm:"primaryKey;size:100"`
|
||||||
|
Value string `gorm:"type:text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LocalQtSetting) TableName() string { return "local_qt_settings" }
|
||||||
|
|||||||
53
internal/localdb/project_sync_timestamp_test.go
Normal file
53
internal/localdb/project_sync_timestamp_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveProjectPreservingUpdatedAtKeepsProvidedTimestamp(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "project_sync_timestamp.db")
|
||||||
|
|
||||||
|
local, err := New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
createdAt := time.Date(2026, 2, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
updatedAt := time.Date(2026, 2, 3, 12, 30, 0, 0, time.UTC)
|
||||||
|
project := &LocalProject{
|
||||||
|
UUID: "project-1",
|
||||||
|
OwnerUsername: "tester",
|
||||||
|
Code: "OPS-1",
|
||||||
|
Variant: "Lenovo",
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
UpdatedAt: updatedAt,
|
||||||
|
SyncStatus: "synced",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
|
||||||
|
t.Fatalf("save project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncedAt := time.Date(2026, 3, 16, 8, 45, 0, 0, time.UTC)
|
||||||
|
project.SyncedAt = &syncedAt
|
||||||
|
project.SyncStatus = "synced"
|
||||||
|
|
||||||
|
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
|
||||||
|
t.Fatalf("save project second time: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := local.GetProjectByUUID(project.UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get project: %v", err)
|
||||||
|
}
|
||||||
|
if !stored.UpdatedAt.Equal(updatedAt) {
|
||||||
|
t.Fatalf("updated_at changed during sync save: got %s want %s", stored.UpdatedAt, updatedAt)
|
||||||
|
}
|
||||||
|
if stored.SyncedAt == nil || !stored.SyncedAt.Equal(syncedAt) {
|
||||||
|
t.Fatalf("synced_at not updated correctly: got %+v want %s", stored.SyncedAt, syncedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
126
internal/localdb/qt_settings.go
Normal file
126
internal/localdb/qt_settings.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigTypeDef describes one device configuration type as synced from qt_settings.
|
||||||
|
type ConfigTypeDef struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
NameRu string `json:"name_ru"`
|
||||||
|
DisplayOrder int `json:"display_order"`
|
||||||
|
Categories []string `json:"categories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabSection is a named sub-group of categories within a configurator tab.
|
||||||
|
type TabSection struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Categories []string `json:"categories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabDef describes one tab in the configurator as synced from qt_settings.
|
||||||
|
type TabDef struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
SingleSelect bool `json:"single_select"`
|
||||||
|
Categories []string `json:"categories"`
|
||||||
|
Sections []TabSection `json:"sections,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfiguratorSettings holds all four server-driven settings consumed by the configurator.
|
||||||
|
// Fields are nil/empty when the corresponding qt_settings key is absent or unparseable;
|
||||||
|
// callers are expected to apply hardcoded fallbacks in that case.
|
||||||
|
type ConfiguratorSettings struct {
|
||||||
|
ConfigTypes []ConfigTypeDef `json:"config_types"`
|
||||||
|
TabConfig []TabDef `json:"tab_config"`
|
||||||
|
AlwaysVisibleTabs []string `json:"always_visible_tabs"`
|
||||||
|
RequiredCategories map[string][]string `json:"required_categories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncQtSettings reads all rows from qt_settings on MariaDB and replaces the
|
||||||
|
// local_qt_settings cache in a single SQLite transaction.
|
||||||
|
// If the read fails (no connection, table missing on old server) or the server
|
||||||
|
// returns an empty table, the existing local_qt_settings are preserved so the
|
||||||
|
// configurator keeps working offline or against old server versions.
|
||||||
|
func (l *LocalDB) SyncQtSettings(mariaDB *gorm.DB) error {
|
||||||
|
var rows []LocalQtSetting
|
||||||
|
if err := mariaDB.
|
||||||
|
Table("qt_settings").
|
||||||
|
Select("name, value").
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
slog.Warn("qt_settings: read from MariaDB failed, keeping existing local cache", "error", err)
|
||||||
|
return fmt.Errorf("reading qt_settings from MariaDB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
slog.Warn("qt_settings: server returned empty table, keeping existing local cache")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec("DELETE FROM local_qt_settings").Error; err != nil {
|
||||||
|
return fmt.Errorf("clearing local_qt_settings: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Create(&rows).Error; err != nil {
|
||||||
|
return fmt.Errorf("inserting local_qt_settings: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("qt_settings synced", "count", len(rows))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQtSetting returns the raw JSON value for a named setting.
|
||||||
|
// found is false when the key does not exist.
|
||||||
|
func (l *LocalDB) GetQtSetting(name string) (value string, found bool, err error) {
|
||||||
|
var row LocalQtSetting
|
||||||
|
res := l.db.Where("name = ?", name).First(&row)
|
||||||
|
if res.Error != nil {
|
||||||
|
if res.Error == gorm.ErrRecordNotFound {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return "", false, res.Error
|
||||||
|
}
|
||||||
|
return row.Value, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfiguratorSettings reads all four known settings from local_qt_settings and
|
||||||
|
// parses them. Any missing or unparseable key is left as nil/zero in the result;
|
||||||
|
// the caller must apply fallbacks.
|
||||||
|
func (l *LocalDB) GetConfiguratorSettings() (*ConfiguratorSettings, error) {
|
||||||
|
out := &ConfiguratorSettings{}
|
||||||
|
|
||||||
|
keys := []string{"config_types", "tab_config", "always_visible_tabs", "required_categories"}
|
||||||
|
for _, key := range keys {
|
||||||
|
raw, found, err := l.GetQtSetting(key)
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("reading setting %q: %w", key, err)
|
||||||
|
}
|
||||||
|
if !found || raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "config_types":
|
||||||
|
if err := json.Unmarshal([]byte(raw), &out.ConfigTypes); err != nil {
|
||||||
|
slog.Warn("failed to parse config_types setting", "error", err)
|
||||||
|
}
|
||||||
|
case "tab_config":
|
||||||
|
if err := json.Unmarshal([]byte(raw), &out.TabConfig); err != nil {
|
||||||
|
slog.Warn("failed to parse tab_config setting", "error", err)
|
||||||
|
}
|
||||||
|
case "always_visible_tabs":
|
||||||
|
if err := json.Unmarshal([]byte(raw), &out.AlwaysVisibleTabs); err != nil {
|
||||||
|
slog.Warn("failed to parse always_visible_tabs setting", "error", err)
|
||||||
|
}
|
||||||
|
case "required_categories":
|
||||||
|
if err := json.Unmarshal([]byte(raw), &out.RequiredCategories); err != nil {
|
||||||
|
slog.Warn("failed to parse required_categories setting", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -112,10 +112,16 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type configurationSpecPriceFingerprint struct {
|
type configurationSpecPriceFingerprint struct {
|
||||||
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
||||||
ServerCount int `json:"server_count"`
|
ServerCount int `json:"server_count"`
|
||||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||||
CustomPrice *float64 `json:"custom_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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type configurationSpecPriceFingerprintItem struct {
|
type configurationSpecPriceFingerprintItem struct {
|
||||||
@@ -146,10 +152,16 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin
|
|||||||
})
|
})
|
||||||
|
|
||||||
payload := configurationSpecPriceFingerprint{
|
payload := configurationSpecPriceFingerprint{
|
||||||
Items: items,
|
Items: items,
|
||||||
ServerCount: localCfg.ServerCount,
|
ServerCount: localCfg.ServerCount,
|
||||||
TotalPrice: localCfg.TotalPrice,
|
TotalPrice: localCfg.TotalPrice,
|
||||||
CustomPrice: localCfg.CustomPrice,
|
CustomPrice: localCfg.CustomPrice,
|
||||||
|
PricelistID: localCfg.PricelistID,
|
||||||
|
WarehousePricelistID: localCfg.WarehousePricelistID,
|
||||||
|
CompetitorPricelistID: localCfg.CompetitorPricelistID,
|
||||||
|
DisablePriceRefresh: localCfg.DisablePriceRefresh,
|
||||||
|
OnlyInStock: localCfg.OnlyInStock,
|
||||||
|
VendorSpec: localCfg.VendorSpec,
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := json.Marshal(payload)
|
raw, err := json.Marshal(payload)
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql/driver"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AlertHighDemandStalePrice AlertType = "high_demand_stale_price"
|
|
||||||
AlertPriceSpike AlertType = "price_spike"
|
|
||||||
AlertPriceDrop AlertType = "price_drop"
|
|
||||||
AlertNoRecentQuotes AlertType = "no_recent_quotes"
|
|
||||||
AlertTrendingNoPrice AlertType = "trending_no_price"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertSeverity string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SeverityLow AlertSeverity = "low"
|
|
||||||
SeverityMedium AlertSeverity = "medium"
|
|
||||||
SeverityHigh AlertSeverity = "high"
|
|
||||||
SeverityCritical AlertSeverity = "critical"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AlertStatusNew AlertStatus = "new"
|
|
||||||
AlertStatusAcknowledged AlertStatus = "acknowledged"
|
|
||||||
AlertStatusResolved AlertStatus = "resolved"
|
|
||||||
AlertStatusIgnored AlertStatus = "ignored"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertDetails map[string]interface{}
|
|
||||||
|
|
||||||
func (d AlertDetails) Value() (driver.Value, error) {
|
|
||||||
return json.Marshal(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *AlertDetails) Scan(value interface{}) error {
|
|
||||||
if value == nil {
|
|
||||||
*d = make(AlertDetails)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
bytes, ok := value.([]byte)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("type assertion to []byte failed")
|
|
||||||
}
|
|
||||||
return json.Unmarshal(bytes, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
type PricingAlert struct {
|
|
||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
|
||||||
LotName string `gorm:"size:255;not null" json:"lot_name"`
|
|
||||||
AlertType AlertType `gorm:"type:enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price');not null" json:"alert_type"`
|
|
||||||
Severity AlertSeverity `gorm:"type:enum('low','medium','high','critical');default:'medium'" json:"severity"`
|
|
||||||
Message string `gorm:"type:text;not null" json:"message"`
|
|
||||||
Details AlertDetails `gorm:"type:json" json:"details"`
|
|
||||||
Status AlertStatus `gorm:"type:enum('new','acknowledged','resolved','ignored');default:'new'" json:"status"`
|
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (PricingAlert) TableName() string {
|
|
||||||
return "qt_pricing_alerts"
|
|
||||||
}
|
|
||||||
|
|
||||||
type TrendDirection string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TrendUp TrendDirection = "up"
|
|
||||||
TrendStable TrendDirection = "stable"
|
|
||||||
TrendDown TrendDirection = "down"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ComponentUsageStats struct {
|
|
||||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
|
||||||
QuotesTotal int `gorm:"default:0" json:"quotes_total"`
|
|
||||||
QuotesLast30d int `gorm:"default:0" json:"quotes_last_30d"`
|
|
||||||
QuotesLast7d int `gorm:"default:0" json:"quotes_last_7d"`
|
|
||||||
TotalQuantity int `gorm:"default:0" json:"total_quantity"`
|
|
||||||
TotalRevenue float64 `gorm:"type:decimal(14,2);default:0" json:"total_revenue"`
|
|
||||||
TrendDirection TrendDirection `gorm:"type:enum('up','stable','down');default:'stable'" json:"trend_direction"`
|
|
||||||
TrendPercent float64 `gorm:"type:decimal(5,2);default:0" json:"trend_percent"`
|
|
||||||
LastUsedAt *time.Time `json:"last_used_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ComponentUsageStats) TableName() string {
|
|
||||||
return "qt_component_usage_stats"
|
|
||||||
}
|
|
||||||
@@ -13,32 +13,32 @@ func (Category) TableName() string {
|
|||||||
return "qt_categories"
|
return "qt_categories"
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultCategories defines the standard categories with display order
|
// DefaultCategories defines the standard categories with display order.
|
||||||
// Order: BB, CPU, MEM, GPU, SSD, RAID, HBA, NIC, PSU, RISERS, ACC, and others
|
// 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.
|
||||||
var DefaultCategories = []Category{
|
var DefaultCategories = []Category{
|
||||||
{Code: "BB", Name: "Barebone", NameRu: "Шасси", DisplayOrder: 1, IsRequired: true},
|
{Code: "MB", Name: "Motherboard", NameRu: "Материнская плата", DisplayOrder: 10},
|
||||||
{Code: "CPU", Name: "Processor", NameRu: "Процессор", DisplayOrder: 2, IsRequired: true},
|
{Code: "CPU", Name: "Processor", NameRu: "Процессор", DisplayOrder: 20, IsRequired: true},
|
||||||
{Code: "MEM", Name: "Memory", NameRu: "Оперативная память", DisplayOrder: 3, IsRequired: true},
|
{Code: "MEM", Name: "Memory", NameRu: "Оперативная память", DisplayOrder: 30, IsRequired: true},
|
||||||
{Code: "GPU", Name: "Graphics Card", NameRu: "Видеокарта", DisplayOrder: 4},
|
{Code: "RAID", Name: "RAID Controller", NameRu: "RAID контроллер", DisplayOrder: 40},
|
||||||
{Code: "SSD", Name: "SSD Storage", NameRu: "SSD накопитель", DisplayOrder: 5},
|
{Code: "SSD", Name: "SSD Storage", NameRu: "SSD накопитель", DisplayOrder: 50},
|
||||||
{Code: "RAID", Name: "RAID Controller", NameRu: "RAID контроллер", DisplayOrder: 6},
|
{Code: "HDD", Name: "HDD Storage", NameRu: "HDD накопитель", DisplayOrder: 51},
|
||||||
{Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 7},
|
{Code: "M2", Name: "M.2 Storage", NameRu: "M.2 накопитель", DisplayOrder: 52},
|
||||||
{Code: "NIC", Name: "Network Card", NameRu: "Сетевая карта", DisplayOrder: 8},
|
{Code: "EDSFF", Name: "EDSFF Storage", NameRu: "EDSFF накопитель", DisplayOrder: 53},
|
||||||
{Code: "PSU", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 9},
|
{Code: "HHHL", Name: "HHHL Storage", NameRu: "HHHL накопитель", DisplayOrder: 54},
|
||||||
{Code: "RISERS", Name: "Risers", NameRu: "Райзеры", DisplayOrder: 10},
|
{Code: "GPU", Name: "Graphics Card", NameRu: "Видеокарта", DisplayOrder: 60},
|
||||||
{Code: "ACC", Name: "Accessories", NameRu: "Аксессуары", DisplayOrder: 11},
|
{Code: "NIC", Name: "Network Card", NameRu: "Сетевая карта", DisplayOrder: 70},
|
||||||
// Additional categories
|
{Code: "HCA", Name: "HCA Adapter", NameRu: "HCA адаптер", DisplayOrder: 71},
|
||||||
{Code: "MB", Name: "Motherboard", NameRu: "Материнская плата", DisplayOrder: 12},
|
{Code: "DPU", Name: "DPU", NameRu: "DPU", DisplayOrder: 72},
|
||||||
{Code: "HDD", Name: "HDD Storage", NameRu: "HDD накопитель", DisplayOrder: 13},
|
{Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 80},
|
||||||
{Code: "HCA", Name: "HCA Adapter", NameRu: "HCA адаптер", DisplayOrder: 14},
|
{Code: "PSU", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 90},
|
||||||
{Code: "DPU", Name: "DPU", NameRu: "DPU", DisplayOrder: 15},
|
{Code: "PS", Name: "Power Supply (Legacy)", NameRu: "Блок питания", DisplayOrder: 91},
|
||||||
{Code: "M2", Name: "M.2 Storage", NameRu: "M.2 накопитель", DisplayOrder: 16},
|
{Code: "ACC", Name: "Accessories", NameRu: "Аксессуары", DisplayOrder: 100},
|
||||||
{Code: "EDSFF", Name: "EDSFF Storage", NameRu: "EDSFF накопитель", DisplayOrder: 17},
|
{Code: "RISERS", Name: "Risers", NameRu: "Райзеры", DisplayOrder: 101},
|
||||||
{Code: "HHHL", Name: "HHHL Storage", NameRu: "HHHL накопитель", DisplayOrder: 18},
|
{Code: "CARD", Name: "Cards", NameRu: "Карты", DisplayOrder: 110},
|
||||||
{Code: "PS", Name: "Power Supply (Legacy)", NameRu: "Блок питания", DisplayOrder: 19},
|
{Code: "BB", Name: "Barebone", NameRu: "Шасси", DisplayOrder: 120, IsRequired: true},
|
||||||
{Code: "CARD", Name: "Cards", NameRu: "Карты", DisplayOrder: 20},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaxKnownDisplayOrder is the highest display order for known categories
|
// MaxKnownDisplayOrder is the highest display order for known categories.
|
||||||
// New categories will get display order starting from this + 1
|
// New categories will get display order starting from this + 1.
|
||||||
const MaxKnownDisplayOrder = 100
|
const MaxKnownDisplayOrder = 200
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ type Configuration struct {
|
|||||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,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"`
|
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||||
Line int `gorm:"column:line_no;index" json:"line"`
|
Line int `gorm:"column:line_no;index" json:"line"`
|
||||||
@@ -123,16 +124,3 @@ func (Configuration) TableName() string {
|
|||||||
return "qt_configurations"
|
return "qt_configurations"
|
||||||
}
|
}
|
||||||
|
|
||||||
type PriceOverride struct {
|
|
||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
|
||||||
LotName string `gorm:"size:255;not null" json:"lot_name"`
|
|
||||||
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
|
||||||
ValidFrom time.Time `gorm:"type:date;not null" json:"valid_from"`
|
|
||||||
ValidUntil *time.Time `gorm:"type:date" json:"valid_until"`
|
|
||||||
Reason string `gorm:"type:text" json:"reason"`
|
|
||||||
CreatedBy uint `gorm:"not null" json:"created_by"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (PriceOverride) TableName() string {
|
|
||||||
return "qt_price_overrides"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "time"
|
import "strings"
|
||||||
|
|
||||||
|
// NormalizeLotName returns the canonical form of a lot name: trimmed and uppercased.
|
||||||
|
// Apply at every point where a lot name enters the system (sync, API input, config load).
|
||||||
|
func NormalizeLotName(s string) string {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
|
||||||
// Lot represents existing lot table
|
// Lot represents existing lot table
|
||||||
type Lot struct {
|
type Lot struct {
|
||||||
@@ -12,58 +18,3 @@ type Lot struct {
|
|||||||
func (Lot) TableName() string {
|
func (Lot) TableName() string {
|
||||||
return "lot"
|
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,9 +14,6 @@ func AllModels() []interface{} {
|
|||||||
&LotMetadata{},
|
&LotMetadata{},
|
||||||
&Project{},
|
&Project{},
|
||||||
&Configuration{},
|
&Configuration{},
|
||||||
&PriceOverride{},
|
|
||||||
&PricingAlert{},
|
|
||||||
&ComponentUsageStats{},
|
|
||||||
&Pricelist{},
|
&Pricelist{},
|
||||||
&PricelistItem{},
|
&PricelistItem{},
|
||||||
}
|
}
|
||||||
@@ -31,7 +28,9 @@ func Migrate(db *gorm.DB) error {
|
|||||||
errStr := err.Error()
|
errStr := err.Error()
|
||||||
if strings.Contains(errStr, "Can't DROP") ||
|
if strings.Contains(errStr, "Can't DROP") ||
|
||||||
strings.Contains(errStr, "Duplicate key name") ||
|
strings.Contains(errStr, "Duplicate key name") ||
|
||||||
strings.Contains(errStr, "check that it exists") {
|
strings.Contains(errStr, "check that it exists") ||
|
||||||
|
strings.Contains(errStr, "Cannot change column") ||
|
||||||
|
strings.Contains(errStr, "used in a foreign key constraint") {
|
||||||
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
|
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -41,12 +40,18 @@ func Migrate(db *gorm.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedCategories inserts default categories if not exist
|
// SeedCategories upserts default categories, updating display_order on existing rows.
|
||||||
func SeedCategories(db *gorm.DB) error {
|
func SeedCategories(db *gorm.DB) error {
|
||||||
for _, cat := range DefaultCategories {
|
for _, cat := range DefaultCategories {
|
||||||
result := db.Where("code = ?", cat.Code).FirstOrCreate(&cat)
|
var existing Category
|
||||||
if result.Error != nil {
|
if err := db.Where("code = ?", cat.Code).First(&existing).Error; err != nil {
|
||||||
return result.Error
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
10
internal/models/qt_setting.go
Normal file
10
internal/models/qt_setting.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// QtSetting is the MariaDB-side model for qt_settings.
|
||||||
|
// The table is managed by the server-side agent; QF only reads from it.
|
||||||
|
type QtSetting struct {
|
||||||
|
Name string `gorm:"primaryKey;size:100" json:"name"`
|
||||||
|
Value string `gorm:"type:text" json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (QtSetting) TableName() string { return "qt_settings" }
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAlertRepository(db *gorm.DB) *AlertRepository {
|
|
||||||
return &AlertRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) Create(alert *models.PricingAlert) error {
|
|
||||||
return r.db.Create(alert).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) GetByID(id uint) (*models.PricingAlert, error) {
|
|
||||||
var alert models.PricingAlert
|
|
||||||
err := r.db.First(&alert, id).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &alert, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) Update(alert *models.PricingAlert) error {
|
|
||||||
return r.db.Save(alert).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlertFilter struct {
|
|
||||||
Status models.AlertStatus
|
|
||||||
Severity models.AlertSeverity
|
|
||||||
Type models.AlertType
|
|
||||||
LotName string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) List(filter AlertFilter, offset, limit int) ([]models.PricingAlert, int64, error) {
|
|
||||||
var alerts []models.PricingAlert
|
|
||||||
var total int64
|
|
||||||
|
|
||||||
query := r.db.Model(&models.PricingAlert{})
|
|
||||||
|
|
||||||
if filter.Status != "" {
|
|
||||||
query = query.Where("status = ?", filter.Status)
|
|
||||||
}
|
|
||||||
if filter.Severity != "" {
|
|
||||||
query = query.Where("severity = ?", filter.Severity)
|
|
||||||
}
|
|
||||||
if filter.Type != "" {
|
|
||||||
query = query.Where("alert_type = ?", filter.Type)
|
|
||||||
}
|
|
||||||
if filter.LotName != "" {
|
|
||||||
query = query.Where("lot_name = ?", filter.LotName)
|
|
||||||
}
|
|
||||||
|
|
||||||
query.Count(&total)
|
|
||||||
|
|
||||||
err := query.
|
|
||||||
Order("FIELD(severity, 'critical', 'high', 'medium', 'low')").
|
|
||||||
Order("created_at DESC").
|
|
||||||
Offset(offset).
|
|
||||||
Limit(limit).
|
|
||||||
Find(&alerts).Error
|
|
||||||
|
|
||||||
return alerts, total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) CountByStatus(status models.AlertStatus) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
err := r.db.Model(&models.PricingAlert{}).
|
|
||||||
Where("status = ?", status).
|
|
||||||
Count(&count).Error
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) UpdateStatus(id uint, status models.AlertStatus) error {
|
|
||||||
return r.db.Model(&models.PricingAlert{}).
|
|
||||||
Where("id = ?", id).
|
|
||||||
Update("status", status).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AlertRepository) ExistsByLotAndType(lotName string, alertType models.AlertType) (bool, error) {
|
|
||||||
var count int64
|
|
||||||
err := r.db.Model(&models.PricingAlert{}).
|
|
||||||
Where("lot_name = ? AND alert_type = ? AND status IN ('new', 'acknowledged')", lotName, alertType).
|
|
||||||
Count(&count).Error
|
|
||||||
return count > 0, err
|
|
||||||
}
|
|
||||||
@@ -63,11 +63,6 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
|
|||||||
Order("current_price " + sortDir)
|
Order("current_price " + sortDir)
|
||||||
case "lot_name":
|
case "lot_name":
|
||||||
query = query.Order("lot_name " + sortDir)
|
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:
|
||||||
// Default: sort by popularity, no price goes last
|
// Default: sort by popularity, no price goes last
|
||||||
query = query.
|
query = query.
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ func (r *PartnumberBookRepository) listCatalogItems(partnumbers localdb.LocalStr
|
|||||||
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers))
|
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers))
|
||||||
if search != "" {
|
if search != "" {
|
||||||
trimmedSearch := "%" + search + "%"
|
trimmedSearch := "%" + search + "%"
|
||||||
query = query.Where("partnumber LIKE ? OR lots_json LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
|
query = query.Where("partnumber LIKE ? OR CAST(lots_json AS TEXT) LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
|
||||||
}
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -269,12 +269,21 @@ func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPricesForLots returns price map for given lots within a pricelist.
|
// GetPricesForLots returns price map for given lots within a pricelist.
|
||||||
|
// Keys in the returned map match the requested lot names (case-preserving) so that
|
||||||
|
// callers using Go map lookups are not confused by case differences between the
|
||||||
|
// requested name and the stored value (e.g. pricelist renamed lots to UPPERCASE).
|
||||||
func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
|
func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
|
||||||
result := make(map[string]float64, len(lotNames))
|
result := make(map[string]float64, len(lotNames))
|
||||||
if pricelistID == 0 || len(lotNames) == 0 {
|
if pricelistID == 0 || len(lotNames) == 0 {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build case-insensitive index: lowercase → original requested name.
|
||||||
|
lotIndex := make(map[string]string, len(lotNames))
|
||||||
|
for _, n := range lotNames {
|
||||||
|
lotIndex[strings.ToLower(n)] = n
|
||||||
|
}
|
||||||
|
|
||||||
var rows []models.PricelistItem
|
var rows []models.PricelistItem
|
||||||
if err := r.db.Select("lot_name, price").
|
if err := r.db.Select("lot_name, price").
|
||||||
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
|
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
|
||||||
@@ -284,7 +293,11 @@ func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []stri
|
|||||||
|
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if row.Price > 0 {
|
if row.Price > 0 {
|
||||||
result[row.LotName] = row.Price
|
key := row.LotName
|
||||||
|
if requested, ok := lotIndex[strings.ToLower(row.LotName)]; ok {
|
||||||
|
key = requested
|
||||||
|
}
|
||||||
|
result[key] = row.Price
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.StockLog{}); err != nil {
|
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}); err != nil {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
return NewPricelistRepository(db)
|
return NewPricelistRepository(db)
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StatsRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStatsRepository(db *gorm.DB) *StatsRepository {
|
|
||||||
return &StatsRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *StatsRepository) GetByLotName(lotName string) (*models.ComponentUsageStats, error) {
|
|
||||||
var stats models.ComponentUsageStats
|
|
||||||
err := r.db.Where("lot_name = ?", lotName).First(&stats).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *StatsRepository) Upsert(stats *models.ComponentUsageStats) error {
|
|
||||||
return r.db.Save(stats).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *StatsRepository) IncrementUsage(lotName string, quantity int, revenue float64) error {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
result := r.db.Model(&models.ComponentUsageStats{}).
|
|
||||||
Where("lot_name = ?", lotName).
|
|
||||||
Updates(map[string]interface{}{
|
|
||||||
"quotes_total": gorm.Expr("quotes_total + 1"),
|
|
||||||
"quotes_last_30d": gorm.Expr("quotes_last_30d + 1"),
|
|
||||||
"quotes_last_7d": gorm.Expr("quotes_last_7d + 1"),
|
|
||||||
"total_quantity": gorm.Expr("total_quantity + ?", quantity),
|
|
||||||
"total_revenue": gorm.Expr("total_revenue + ?", revenue),
|
|
||||||
"last_used_at": now,
|
|
||||||
})
|
|
||||||
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
stats := &models.ComponentUsageStats{
|
|
||||||
LotName: lotName,
|
|
||||||
QuotesTotal: 1,
|
|
||||||
QuotesLast30d: 1,
|
|
||||||
QuotesLast7d: 1,
|
|
||||||
TotalQuantity: quantity,
|
|
||||||
TotalRevenue: revenue,
|
|
||||||
LastUsedAt: &now,
|
|
||||||
}
|
|
||||||
return r.db.Create(stats).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *StatsRepository) GetTopComponents(limit int) ([]models.ComponentUsageStats, error) {
|
|
||||||
var stats []models.ComponentUsageStats
|
|
||||||
err := r.db.
|
|
||||||
Order("quotes_last_30d DESC").
|
|
||||||
Limit(limit).
|
|
||||||
Find(&stats).Error
|
|
||||||
return stats, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *StatsRepository) GetTrendingComponents(limit int) ([]models.ComponentUsageStats, error) {
|
|
||||||
var stats []models.ComponentUsageStats
|
|
||||||
err := r.db.
|
|
||||||
Where("trend_direction = ? AND trend_percent > ?", models.TrendUp, 20).
|
|
||||||
Order("trend_percent DESC").
|
|
||||||
Limit(limit).
|
|
||||||
Find(&stats).Error
|
|
||||||
return stats, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetWeeklyCounters resets quotes_last_7d (run weekly via cron)
|
|
||||||
func (r *StatsRepository) ResetWeeklyCounters() error {
|
|
||||||
return r.db.Model(&models.ComponentUsageStats{}).
|
|
||||||
Where("1 = 1").
|
|
||||||
Update("quotes_last_7d", 0).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetMonthlyCounters resets quotes_last_30d (run monthly via cron)
|
|
||||||
func (r *StatsRepository) ResetMonthlyCounters() error {
|
|
||||||
return r.db.Model(&models.ComponentUsageStats{}).
|
|
||||||
Where("1 = 1").
|
|
||||||
Update("quotes_last_30d", 0).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
@@ -11,18 +12,15 @@ import (
|
|||||||
type ComponentService struct {
|
type ComponentService struct {
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
categoryRepo *repository.CategoryRepository
|
categoryRepo *repository.CategoryRepository
|
||||||
statsRepo *repository.StatsRepository
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewComponentService(
|
func NewComponentService(
|
||||||
componentRepo *repository.ComponentRepository,
|
componentRepo *repository.ComponentRepository,
|
||||||
categoryRepo *repository.CategoryRepository,
|
categoryRepo *repository.CategoryRepository,
|
||||||
statsRepo *repository.StatsRepository,
|
|
||||||
) *ComponentService {
|
) *ComponentService {
|
||||||
return &ComponentService{
|
return &ComponentService{
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
categoryRepo: categoryRepo,
|
categoryRepo: categoryRepo,
|
||||||
statsRepo: statsRepo,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +39,11 @@ func ParsePartNumber(lotName string) (category, model string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ComponentListResult struct {
|
type ComponentListResult struct {
|
||||||
Components []ComponentView `json:"components"`
|
Items []ComponentView `json:"items"`
|
||||||
Total int64 `json:"total"`
|
TotalCount int64 `json:"total_count"`
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
PerPage int `json:"per_page"`
|
PerPage int `json:"per_page"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ComponentView struct {
|
type ComponentView struct {
|
||||||
@@ -63,10 +62,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
|||||||
// Components should be loaded via /api/sync/components first
|
// Components should be loaded via /api/sync/components first
|
||||||
if s.componentRepo == nil {
|
if s.componentRepo == nil {
|
||||||
return &ComponentListResult{
|
return &ComponentListResult{
|
||||||
Components: []ComponentView{},
|
Items: []ComponentView{},
|
||||||
Total: 0,
|
TotalCount: 0,
|
||||||
Page: page,
|
Page: page,
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
|
TotalPages: 1,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,11 +107,16 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
|||||||
views[i] = view
|
views[i] = view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
return &ComponentListResult{
|
return &ComponentListResult{
|
||||||
Components: views,
|
Items: views,
|
||||||
Total: total,
|
TotalCount: total,
|
||||||
Page: page,
|
Page: page,
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
|
TotalPages: totalPages,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +131,10 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track usage
|
// Track usage (best-effort)
|
||||||
_ = s.componentRepo.IncrementRequestCount(lotName)
|
if err := s.componentRepo.IncrementRequestCount(lotName); err != nil {
|
||||||
|
slog.Warn("component: could not increment request count", "lot", lotName, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
view := &ComponentView{
|
view := &ComponentView{
|
||||||
LotName: c.LotName,
|
LotName: c.LotName,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ var (
|
|||||||
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
|
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
|
||||||
type ConfigurationGetter interface {
|
type ConfigurationGetter interface {
|
||||||
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
|
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
|
||||||
|
GetByUUIDNoAuth(uuid string) (*models.Configuration, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigurationService struct {
|
type ConfigurationService struct {
|
||||||
@@ -58,6 +59,7 @@ type CreateConfigRequest struct {
|
|||||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||||
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
|
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
|
||||||
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
|
||||||
|
ConfigType string `json:"config_type,omitempty"` // "server" | "storage"
|
||||||
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||||
OnlyInStock bool `json:"only_in_stock"`
|
OnlyInStock bool `json:"only_in_stock"`
|
||||||
}
|
}
|
||||||
@@ -103,17 +105,18 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
|||||||
PricelistID: pricelistID,
|
PricelistID: pricelistID,
|
||||||
WarehousePricelistID: req.WarehousePricelistID,
|
WarehousePricelistID: req.WarehousePricelistID,
|
||||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||||
|
ConfigType: req.ConfigType,
|
||||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||||
OnlyInStock: req.OnlyInStock,
|
OnlyInStock: req.OnlyInStock,
|
||||||
}
|
}
|
||||||
|
if config.ConfigType == "" {
|
||||||
|
config.ConfigType = "server"
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(config); err != nil {
|
if err := s.configRepo.Create(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record usage stats
|
|
||||||
_ = s.quoteService.RecordUsage(req.Items)
|
|
||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,20 +13,17 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExportService struct {
|
type ExportService struct {
|
||||||
config config.ExportConfig
|
config config.ExportConfig
|
||||||
categoryRepo *repository.CategoryRepository
|
localDB *localdb.LocalDB
|
||||||
localDB *localdb.LocalDB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository, local *localdb.LocalDB) *ExportService {
|
func NewExportService(cfg config.ExportConfig, local *localdb.LocalDB) *ExportService {
|
||||||
return &ExportService{
|
return &ExportService{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
categoryRepo: categoryRepo,
|
localDB: local,
|
||||||
localDB: local,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +53,25 @@ type ProjectExportData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectPricingExportOptions struct {
|
type ProjectPricingExportOptions struct {
|
||||||
IncludeLOT bool `json:"include_lot"`
|
IncludeLOT bool `json:"include_lot"`
|
||||||
IncludeBOM bool `json:"include_bom"`
|
IncludeBOM bool `json:"include_bom"`
|
||||||
IncludeEstimate bool `json:"include_estimate"`
|
IncludeEstimate bool `json:"include_estimate"`
|
||||||
IncludeStock bool `json:"include_stock"`
|
IncludeStock bool `json:"include_stock"`
|
||||||
IncludeCompetitor bool `json:"include_competitor"`
|
IncludeCompetitor bool `json:"include_competitor"`
|
||||||
|
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
|
||||||
|
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||||
|
ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
|
||||||
|
if o.SaleMarkup > 0 {
|
||||||
|
return o.SaleMarkup
|
||||||
|
}
|
||||||
|
return 1.3
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o ProjectPricingExportOptions) isDDP() bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(o.Basis), "ddp")
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectPricingExportData struct {
|
type ProjectPricingExportData struct {
|
||||||
@@ -77,14 +88,15 @@ type ProjectPricingExportConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectPricingExportRow struct {
|
type ProjectPricingExportRow struct {
|
||||||
LotDisplay string
|
LotDisplay string
|
||||||
VendorPN string
|
VendorPN string
|
||||||
Description string
|
Description string
|
||||||
Quantity int
|
Quantity int
|
||||||
BOMTotal *float64
|
BOMTotal *float64
|
||||||
Estimate *float64
|
Estimate *float64
|
||||||
Stock *float64
|
Stock *float64
|
||||||
Competitor *float64
|
Competitor *float64
|
||||||
|
ManualPrice *float64 // proportional share of the user-defined total price
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCSV writes project export data in the new structured CSV format.
|
// ToCSV writes project export data in the new structured CSV format.
|
||||||
@@ -114,16 +126,7 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
|
|||||||
return fmt.Errorf("failed to write header: %w", err)
|
return fmt.Errorf("failed to write header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get category hierarchy for sorting
|
categoryOrder := defaultCategoryOrder()
|
||||||
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 {
|
for i, block := range data.Configs {
|
||||||
lineNo := block.Line
|
lineNo := block.Line
|
||||||
@@ -200,27 +203,30 @@ func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
|
|||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
|
func sortConfigsByLine(configs []models.Configuration) []models.Configuration {
|
||||||
sortedConfigs := make([]models.Configuration, len(configs))
|
sorted := make([]models.Configuration, len(configs))
|
||||||
copy(sortedConfigs, configs)
|
copy(sorted, configs)
|
||||||
sort.Slice(sortedConfigs, func(i, j int) bool {
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
leftLine := sortedConfigs[i].Line
|
li, lj := sorted[i].Line, sorted[j].Line
|
||||||
rightLine := sortedConfigs[j].Line
|
if li <= 0 {
|
||||||
|
li = int(^uint(0) >> 1)
|
||||||
if leftLine <= 0 {
|
|
||||||
leftLine = int(^uint(0) >> 1)
|
|
||||||
}
|
}
|
||||||
if rightLine <= 0 {
|
if lj <= 0 {
|
||||||
rightLine = int(^uint(0) >> 1)
|
lj = int(^uint(0) >> 1)
|
||||||
}
|
}
|
||||||
if leftLine != rightLine {
|
if li != lj {
|
||||||
return leftLine < rightLine
|
return li < lj
|
||||||
}
|
}
|
||||||
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
|
if !sorted[i].CreatedAt.Equal(sorted[j].CreatedAt) {
|
||||||
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
|
return sorted[i].CreatedAt.After(sorted[j].CreatedAt)
|
||||||
}
|
}
|
||||||
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
|
return sorted[i].UUID > sorted[j].UUID
|
||||||
})
|
})
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
|
||||||
|
sortedConfigs := sortConfigsByLine(configs)
|
||||||
|
|
||||||
blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs))
|
blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs))
|
||||||
for i := range sortedConfigs {
|
for i := range sortedConfigs {
|
||||||
@@ -251,18 +257,16 @@ func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData
|
|||||||
return fmt.Errorf("failed to write pricing header: %w", err)
|
return fmt.Errorf("failed to write pricing header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for idx, cfg := range data.Configs {
|
writeRows := opts.IncludeLOT || opts.IncludeBOM
|
||||||
|
for _, cfg := range data.Configs {
|
||||||
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
|
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
|
||||||
return fmt.Errorf("failed to write config summary row: %w", err)
|
return fmt.Errorf("failed to write config summary row: %w", err)
|
||||||
}
|
}
|
||||||
for _, row := range cfg.Rows {
|
if writeRows {
|
||||||
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
for _, row := range cfg.Rows {
|
||||||
return fmt.Errorf("failed to write pricing row: %w", err)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,26 +289,7 @@ func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectEx
|
|||||||
|
|
||||||
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
||||||
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
||||||
sortedConfigs := make([]models.Configuration, len(configs))
|
sortedConfigs := sortConfigsByLine(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))
|
blocks := make([]ConfigExportBlock, 0, len(configs))
|
||||||
for i := range sortedConfigs {
|
for i := range sortedConfigs {
|
||||||
@@ -316,6 +301,18 @@ 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 {
|
func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock {
|
||||||
// Batch-fetch categories from local data (pricelist items → local_components fallback)
|
// Batch-fetch categories from local data (pricelist items → local_components fallback)
|
||||||
lotNames := make([]string, len(cfg.Items))
|
lotNames := make([]string, len(cfg.Items))
|
||||||
@@ -393,17 +390,37 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
description = componentDescriptions[rowMappings[0].LotName]
|
description = componentDescriptions[rowMappings[0].LotName]
|
||||||
}
|
}
|
||||||
|
|
||||||
pricingRow := ProjectPricingExportRow{
|
if len(rowMappings) == 0 {
|
||||||
LotDisplay: formatLotDisplay(rowMappings),
|
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||||
VendorPN: row.VendorPartnumber,
|
LotDisplay: "н/д",
|
||||||
Description: description,
|
VendorPN: row.VendorPartnumber,
|
||||||
Quantity: exportPositiveInt(row.Quantity, 1),
|
Description: description,
|
||||||
BOMTotal: vendorRowTotal(row),
|
Quantity: exportPositiveInt(row.Quantity, 1),
|
||||||
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
|
BOMTotal: vendorRowTotal(row),
|
||||||
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
|
})
|
||||||
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// One export row per LOT mapping so that bundles (1 PN → N LOTs) appear
|
||||||
|
// as separate lines, matching the frontend pricing table layout.
|
||||||
|
pnQty := exportPositiveInt(row.Quantity, 1)
|
||||||
|
for i, mapping := range rowMappings {
|
||||||
|
lotQty := pnQty * mapping.QuantityPerPN
|
||||||
|
var bomTotal *float64
|
||||||
|
if i == 0 {
|
||||||
|
bomTotal = vendorRowTotal(row)
|
||||||
|
}
|
||||||
|
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||||
|
LotDisplay: mapping.LotName,
|
||||||
|
VendorPN: row.VendorPartnumber,
|
||||||
|
Description: description,
|
||||||
|
Quantity: lotQty,
|
||||||
|
BOMTotal: bomTotal,
|
||||||
|
Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }),
|
||||||
|
Stock: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Stock }),
|
||||||
|
Competitor: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Competitor }),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
block.Rows = append(block.Rows, pricingRow)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range cfg.Items {
|
for _, item := range cfg.Items {
|
||||||
@@ -424,10 +441,25 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if opts.isDDP() {
|
||||||
|
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||||
|
}
|
||||||
|
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||||
|
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||||
|
}
|
||||||
return block, nil
|
return block, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
catOrder := defaultCategoryOrder()
|
||||||
|
lotNames := make([]string, 0, len(cfg.Items))
|
||||||
for _, item := range cfg.Items {
|
for _, item := range cfg.Items {
|
||||||
|
if item.LotName != "" {
|
||||||
|
lotNames = append(lotNames, item.LotName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemCategories := s.resolveCategories(cfg.PricelistID, lotNames)
|
||||||
|
sortedItems := sortConfigItemsByCategoryMap(cfg.Items, catOrder, itemCategories)
|
||||||
|
for _, item := range sortedItems {
|
||||||
if item.LotName == "" {
|
if item.LotName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -443,9 +475,48 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.isDDP() {
|
||||||
|
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||||
|
}
|
||||||
|
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||||
|
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||||
|
}
|
||||||
|
|
||||||
return block, nil
|
return block, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortConfigItemsByCategoryMap returns a copy of items sorted by category display order.
|
||||||
|
// categories maps lot_name → category code; catOrder maps category code → display order.
|
||||||
|
func sortConfigItemsByCategoryMap(items models.ConfigItems, catOrder map[string]int, categories map[string]string) models.ConfigItems {
|
||||||
|
sorted := make(models.ConfigItems, len(items))
|
||||||
|
copy(sorted, items)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
orderI, hasI := categoryDisplayOrder(catOrder, categories[sorted[i].LotName])
|
||||||
|
orderJ, hasJ := categoryDisplayOrder(catOrder, categories[sorted[j].LotName])
|
||||||
|
if hasI && hasJ {
|
||||||
|
return orderI < orderJ
|
||||||
|
}
|
||||||
|
return hasI && !hasJ
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
|
||||||
|
for i := range rows {
|
||||||
|
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
|
||||||
|
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.
|
// resolveCategories returns lot_name → category map.
|
||||||
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
||||||
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
||||||
@@ -486,20 +557,30 @@ func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string)
|
|||||||
return categories
|
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).
|
// sortItemsByCategory sorts items by category display order (items without category go to the end).
|
||||||
func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
|
func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
|
||||||
for i := 0; i < len(items)-1; i++ {
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
for j := i + 1; j < len(items); j++ {
|
orderI, hasI := categoryDisplayOrder(categoryOrder, items[i].Category)
|
||||||
orderI, hasI := categoryOrder[items[i].Category]
|
orderJ, hasJ := categoryDisplayOrder(categoryOrder, items[j].Category)
|
||||||
orderJ, hasJ := categoryOrder[items[j].Category]
|
if hasI && hasJ {
|
||||||
|
return orderI < orderJ
|
||||||
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 {
|
type pricingLevels struct {
|
||||||
@@ -539,45 +620,44 @@ func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
estimatePrices := s.batchLookupPrices(estimateID, lots)
|
||||||
|
stockPrices := s.batchLookupPrices(warehouseID, lots)
|
||||||
|
competitorPrices := s.batchLookupPrices(competitorID, lots)
|
||||||
|
|
||||||
for _, lot := range lots {
|
for _, lot := range lots {
|
||||||
level := pricingLevels{}
|
level := pricingLevels{}
|
||||||
level.Estimate = s.lookupPricePointer(estimateID, lot)
|
if p, ok := estimatePrices[lot]; ok {
|
||||||
level.Stock = s.lookupPricePointer(warehouseID, lot)
|
level.Estimate = floatPtr(p)
|
||||||
level.Competitor = s.lookupPricePointer(competitorID, lot)
|
}
|
||||||
|
if p, ok := stockPrices[lot]; ok {
|
||||||
|
level.Stock = floatPtr(p)
|
||||||
|
}
|
||||||
|
if p, ok := competitorPrices[lot]; ok {
|
||||||
|
level.Competitor = floatPtr(p)
|
||||||
|
}
|
||||||
result[lot] = level
|
result[lot] = level
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 {
|
// batchLookupPrices fetches prices for all lots from a pricelist in a single query.
|
||||||
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" {
|
func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string) map[string]float64 {
|
||||||
|
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || len(lots) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
|
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
prices, err := s.localDB.GetLocalPricesForLots(localPL.ID, lots)
|
||||||
if err != nil || price <= 0 {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return floatPtr(price)
|
return prices
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
|
func (s *ExportService) resolveLotDescriptions(_ *models.Configuration, _ *localdb.LocalConfiguration) map[string]string {
|
||||||
lots := collectPricingLots(cfg, localCfg, true)
|
return map[string]string{}
|
||||||
result := make(map[string]string, len(lots))
|
|
||||||
if s.localDB == nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
for _, lot := range lots {
|
|
||||||
component, err := s.localDB.GetLocalComponent(lot)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result[lot] = component.LotDescription
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
||||||
@@ -661,6 +741,52 @@ func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.V
|
|||||||
return floatPtr(total)
|
return floatPtr(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// distributeManualPrice sets ManualPrice on each row proportionally based on the
|
||||||
|
// row's Estimate share. The last row with a price absorbs rounding remainder so
|
||||||
|
// the sum of ManualPrice values always equals manualPrice exactly.
|
||||||
|
func distributeManualPrice(rows []ProjectPricingExportRow, manualPrice float64) {
|
||||||
|
if manualPrice <= 0 || len(rows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totalEstimate := 0.0
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Estimate != nil && *row.Estimate > 0 {
|
||||||
|
totalEstimate += *row.Estimate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if totalEstimate <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastIdx := -1
|
||||||
|
for i, row := range rows {
|
||||||
|
if row.Estimate != nil && *row.Estimate > 0 {
|
||||||
|
lastIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assigned := 0.0
|
||||||
|
for i, row := range rows {
|
||||||
|
if row.Estimate == nil || *row.Estimate <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var share float64
|
||||||
|
if i == lastIdx {
|
||||||
|
share = math.Round((manualPrice-assigned)*100) / 100
|
||||||
|
} else {
|
||||||
|
share = math.Round((*row.Estimate/totalEstimate)*manualPrice*100) / 100
|
||||||
|
assigned += share
|
||||||
|
}
|
||||||
|
rows[i].ManualPrice = floatPtr(share)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeSingleLotTotal(priceMap map[string]pricingLevels, lotName string, qty int, selector func(pricingLevels) *float64) *float64 {
|
||||||
|
price := selector(priceMap[lotName])
|
||||||
|
if price == nil || *price <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return floatPtr(*price * float64(qty))
|
||||||
|
}
|
||||||
|
|
||||||
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
|
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
|
||||||
if unitPrice == nil || *unitPrice <= 0 {
|
if unitPrice == nil || *unitPrice <= 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -681,7 +807,7 @@ func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quanti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||||
headers := make([]string, 0, 8)
|
headers := make([]string, 0, 9)
|
||||||
headers = append(headers, "Line Item")
|
headers = append(headers, "Line Item")
|
||||||
if opts.IncludeLOT {
|
if opts.IncludeLOT {
|
||||||
headers = append(headers, "LOT")
|
headers = append(headers, "LOT")
|
||||||
@@ -699,11 +825,14 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
|||||||
if opts.IncludeCompetitor {
|
if opts.IncludeCompetitor {
|
||||||
headers = append(headers, "Конкуренты")
|
headers = append(headers, "Конкуренты")
|
||||||
}
|
}
|
||||||
|
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||||
|
headers = append(headers, "Ручная цена")
|
||||||
|
}
|
||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
|
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
|
||||||
record := make([]string, 0, 8)
|
record := make([]string, 0, 9)
|
||||||
record = append(record, "")
|
record = append(record, "")
|
||||||
if opts.IncludeLOT {
|
if opts.IncludeLOT {
|
||||||
record = append(record, emptyDash(row.LotDisplay))
|
record = append(record, emptyDash(row.LotDisplay))
|
||||||
@@ -725,17 +854,20 @@ func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions
|
|||||||
if opts.IncludeCompetitor {
|
if opts.IncludeCompetitor {
|
||||||
record = append(record, formatMoneyValue(row.Competitor))
|
record = append(record, formatMoneyValue(row.Competitor))
|
||||||
}
|
}
|
||||||
|
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||||
|
record = append(record, formatMoneyValue(row.ManualPrice))
|
||||||
|
}
|
||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
|
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
|
||||||
record := make([]string, 0, 8)
|
record := make([]string, 0, 9)
|
||||||
record = append(record, fmt.Sprintf("%d", cfg.Line))
|
record = append(record, fmt.Sprintf("%d", cfg.Line))
|
||||||
if opts.IncludeLOT {
|
if opts.IncludeLOT {
|
||||||
record = append(record, "")
|
record = append(record, "")
|
||||||
}
|
}
|
||||||
record = append(record,
|
record = append(record,
|
||||||
"",
|
emptyDash(cfg.Article),
|
||||||
emptyDash(cfg.Name),
|
emptyDash(cfg.Name),
|
||||||
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
|
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
|
||||||
)
|
)
|
||||||
@@ -751,19 +883,12 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
|
|||||||
if opts.IncludeCompetitor {
|
if opts.IncludeCompetitor {
|
||||||
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
|
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
|
||||||
}
|
}
|
||||||
|
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||||
|
record = append(record, formatMoneyValue(opts.ManualPrice))
|
||||||
|
}
|
||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
|
|
||||||
switch len(mappings) {
|
|
||||||
case 0:
|
|
||||||
return "н/д"
|
|
||||||
case 1:
|
|
||||||
return mappings[0].LotName
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatMoneyValue(value *float64) string {
|
func formatMoneyValue(value *float64) string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func newTestProjectData(items []ExportItem, article string, serverCount int) *Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSV_UTF8BOM(t *testing.T) {
|
func TestToCSV_UTF8BOM(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
data := newTestProjectData([]ExportItem{
|
data := newTestProjectData([]ExportItem{
|
||||||
{
|
{
|
||||||
@@ -63,7 +63,7 @@ func TestToCSV_UTF8BOM(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
data := newTestProjectData([]ExportItem{
|
data := newTestProjectData([]ExportItem{
|
||||||
{
|
{
|
||||||
@@ -130,7 +130,7 @@ func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSV_ServerRow(t *testing.T) {
|
func TestToCSV_ServerRow(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
data := newTestProjectData([]ExportItem{
|
data := newTestProjectData([]ExportItem{
|
||||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
{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) {
|
func TestToCSV_CategorySorting(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
data := newTestProjectData([]ExportItem{
|
data := newTestProjectData([]ExportItem{
|
||||||
{LotName: "LOT-001", Category: "CAT-A", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
{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) {
|
func TestToCSV_EmptyData(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
data := &ProjectExportData{
|
data := &ProjectExportData{
|
||||||
Configs: []ConfigExportBlock{},
|
Configs: []ConfigExportBlock{},
|
||||||
@@ -247,7 +247,7 @@ func TestToCSV_EmptyData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
data := newTestProjectData([]ExportItem{
|
data := newTestProjectData([]ExportItem{
|
||||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
{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) {
|
func TestToCSV_WriterError(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
data := newTestProjectData([]ExportItem{
|
data := newTestProjectData([]ExportItem{
|
||||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
{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) {
|
func TestToCSV_MultipleBlocks(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
data := &ProjectExportData{
|
data := &ProjectExportData{
|
||||||
Configs: []ConfigExportBlock{
|
Configs: []ConfigExportBlock{
|
||||||
@@ -359,7 +359,7 @@ func TestToCSV_MultipleBlocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProjectToExportData_SortsByLine(t *testing.T) {
|
func TestProjectToExportData_SortsByLine(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
|
|
||||||
configs := []models.Configuration{
|
configs := []models.Configuration{
|
||||||
{
|
{
|
||||||
@@ -445,7 +445,7 @@ func TestFormatPriceComma(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
|
func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
data := &ProjectPricingExportData{
|
data := &ProjectPricingExportData{
|
||||||
Configs: []ProjectPricingExportConfig{
|
Configs: []ProjectPricingExportConfig{
|
||||||
{
|
{
|
||||||
@@ -499,7 +499,7 @@ func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read summary row: %v", err)
|
t.Fatalf("read summary row: %v", err)
|
||||||
}
|
}
|
||||||
expectedSummary := []string{"10", "", "", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
|
expectedSummary := []string{"10", "", "ART-1", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
|
||||||
for i, want := range expectedSummary {
|
for i, want := range expectedSummary {
|
||||||
if summary[i] != want {
|
if summary[i] != want {
|
||||||
t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i])
|
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) {
|
func TestProjectToPricingExportData_UsesCartRowsWithoutBOM(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
svc := NewExportService(config.ExportConfig{}, nil)
|
||||||
configs := []models.Configuration{
|
configs := []models.Configuration{
|
||||||
{
|
{
|
||||||
UUID: "cfg-1",
|
UUID: "cfg-1",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -49,11 +50,13 @@ func NewLocalConfigurationService(
|
|||||||
|
|
||||||
// Create creates a new configuration in local SQLite and queues it for sync
|
// Create creates a new configuration in local SQLite and queues it for sync
|
||||||
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
// If online, check for new pricelists first
|
// If online, trigger pricelist sync in the background — do not block config creation
|
||||||
if s.isOnline() {
|
if s.isOnline() {
|
||||||
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
go func() {
|
||||||
// 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)
|
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||||
@@ -99,10 +102,14 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
PricelistID: pricelistID,
|
PricelistID: pricelistID,
|
||||||
WarehousePricelistID: req.WarehousePricelistID,
|
WarehousePricelistID: req.WarehousePricelistID,
|
||||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||||
|
ConfigType: req.ConfigType,
|
||||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||||
OnlyInStock: req.OnlyInStock,
|
OnlyInStock: req.OnlyInStock,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
if cfg.ConfigType == "" {
|
||||||
|
cfg.ConfigType = "server"
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to local model
|
// Convert to local model
|
||||||
localCfg := localdb.ConfigurationToLocal(cfg)
|
localCfg := localdb.ConfigurationToLocal(cfg)
|
||||||
@@ -112,9 +119,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
}
|
}
|
||||||
cfg.Line = localCfg.Line
|
cfg.Line = localCfg.Line
|
||||||
|
|
||||||
// Record usage stats
|
|
||||||
_ = s.quoteService.RecordUsage(req.Items)
|
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,17 +403,38 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
return nil, ErrConfigForbidden
|
return nil, ErrConfigForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh local pricelists when online and use latest active/local pricelist for recalculation.
|
// Refresh local pricelists when online.
|
||||||
if s.isOnline() {
|
if s.isOnline() {
|
||||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||||
|
slog.Warn("local configuration: background pricelist sync failed", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture fingerprint of the current state before any mutations.
|
||||||
|
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
preRefreshCfg := *localCfg
|
||||||
|
|
||||||
// Update prices for all items from pricelist
|
// Update prices for all items from pricelist
|
||||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
for i, item := range localCfg.Items {
|
for i, item := range localCfg.Items {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if pricelist != nil {
|
||||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName)
|
||||||
if err == nil && price > 0 {
|
if err == nil && price > 0 {
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
updatedItems[i] = localdb.LocalConfigItem{
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
@@ -434,8 +459,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCfg.TotalPrice = &total
|
localCfg.TotalPrice = &total
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if pricelist != nil {
|
||||||
localCfg.PricelistID = &latestPricelist.ServerID
|
localCfg.PricelistID = &pricelist.ServerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set price update timestamp and mark for sync
|
// Set price update timestamp and mark for sync
|
||||||
@@ -444,6 +469,18 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
localCfg.UpdatedAt = now
|
localCfg.UpdatedAt = now
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
// Before saving the new prices, snapshot the pre-refresh state so the revision
|
||||||
|
// history shows a clear before/after for every price update.
|
||||||
|
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
if preRefreshFP != postRefreshFP {
|
||||||
|
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ownerUsername); err != nil {
|
||||||
|
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("refresh prices with version: %w", err)
|
return nil, fmt.Errorf("refresh prices with version: %w", err)
|
||||||
@@ -762,8 +799,10 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
|
|||||||
return templates[start:end], total, nil
|
return templates[start:end], total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check
|
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check.
|
||||||
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
// 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) {
|
||||||
// Get configuration from local SQLite
|
// Get configuration from local SQLite
|
||||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -771,15 +810,47 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.isOnline() {
|
if s.isOnline() {
|
||||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||||
|
slog.Warn("local configuration: background pricelist sync failed", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture fingerprint of the current state before any mutations.
|
||||||
|
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
preRefreshCfg := *localCfg
|
||||||
|
|
||||||
// Update prices for all items from pricelist
|
// Update prices for all items from pricelist
|
||||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
for i, item := range localCfg.Items {
|
for i, item := range localCfg.Items {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if pricelist != nil {
|
||||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName)
|
||||||
if err == nil && price > 0 {
|
if err == nil && price > 0 {
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
updatedItems[i] = localdb.LocalConfigItem{
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
@@ -804,8 +875,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCfg.TotalPrice = &total
|
localCfg.TotalPrice = &total
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if pricelist != nil {
|
||||||
localCfg.PricelistID = &latestPricelist.ServerID
|
localCfg.PricelistID = &pricelist.ServerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set price update timestamp and mark for sync
|
// Set price update timestamp and mark for sync
|
||||||
@@ -814,6 +885,18 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
localCfg.UpdatedAt = now
|
localCfg.UpdatedAt = now
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
// Before saving the new prices, snapshot the pre-refresh state so the revision
|
||||||
|
// history shows a clear before/after for every price update.
|
||||||
|
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
if preRefreshFP != postRefreshFP {
|
||||||
|
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ""); err != nil {
|
||||||
|
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("refresh prices without auth with version: %w", err)
|
return nil, fmt.Errorf("refresh prices without auth with version: %w", err)
|
||||||
@@ -821,6 +904,16 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SnapshotCurrentState creates a revision of the current configuration state without modifying it.
|
||||||
|
// Called before a client-side price refresh so the revision history has a clear before/after.
|
||||||
|
func (s *LocalConfigurationService) SnapshotCurrentState(uuid string) error {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return ErrConfigNotFound
|
||||||
|
}
|
||||||
|
return s.snapshotPreRefreshTx(localCfg, "")
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateServerCount updates server count and recalculates total price without creating a new version.
|
// UpdateServerCount updates server count and recalculates total price without creating a new version.
|
||||||
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
|
||||||
if serverCount < 1 {
|
if serverCount < 1 {
|
||||||
@@ -1205,21 +1298,55 @@ func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, nex
|
|||||||
current.ServerModel != next.ServerModel ||
|
current.ServerModel != next.ServerModel ||
|
||||||
current.SupportCode != next.SupportCode ||
|
current.SupportCode != next.SupportCode ||
|
||||||
current.Article != next.Article ||
|
current.Article != next.Article ||
|
||||||
current.DisablePriceRefresh != next.DisablePriceRefresh ||
|
|
||||||
current.OnlyInStock != next.OnlyInStock ||
|
|
||||||
current.IsActive != next.IsActive ||
|
current.IsActive != next.IsActive ||
|
||||||
current.Line != next.Line {
|
current.Line != next.Line {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if !equalUintPtr(current.PricelistID, next.PricelistID) ||
|
if !equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
|
||||||
!equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) ||
|
|
||||||
!equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) ||
|
|
||||||
!equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
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 {
|
func equalStringPtr(a, b *string) bool {
|
||||||
if a == nil && b == nil {
|
if a == nil && b == nil {
|
||||||
return true
|
return true
|
||||||
@@ -1353,12 +1480,25 @@ func (s *LocalConfigurationService) appendVersionTx(
|
|||||||
localCfg *localdb.LocalConfiguration,
|
localCfg *localdb.LocalConfiguration,
|
||||||
operation string,
|
operation string,
|
||||||
createdBy string,
|
createdBy string,
|
||||||
|
) (*localdb.LocalConfigurationVersion, error) {
|
||||||
|
return s.appendVersionTxNote(tx, localCfg, operation, createdBy, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) appendVersionTxNote(
|
||||||
|
tx *gorm.DB,
|
||||||
|
localCfg *localdb.LocalConfiguration,
|
||||||
|
operation string,
|
||||||
|
createdBy string,
|
||||||
|
noteOverride string,
|
||||||
) (*localdb.LocalConfigurationVersion, error) {
|
) (*localdb.LocalConfigurationVersion, error) {
|
||||||
snapshot, err := s.buildConfigurationSnapshot(localCfg)
|
snapshot, err := s.buildConfigurationSnapshot(localCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build snapshot: %w", err)
|
return nil, fmt.Errorf("build snapshot: %w", err)
|
||||||
}
|
}
|
||||||
changeNote := fmt.Sprintf("%s via local-first flow", operation)
|
changeNote := fmt.Sprintf("%s via local-first flow", operation)
|
||||||
|
if noteOverride != "" {
|
||||||
|
changeNote = noteOverride
|
||||||
|
}
|
||||||
|
|
||||||
var createdByPtr *string
|
var createdByPtr *string
|
||||||
if createdBy != "" {
|
if createdBy != "" {
|
||||||
@@ -1399,6 +1539,35 @@ func (s *LocalConfigurationService) appendVersionTx(
|
|||||||
return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID)
|
return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// snapshotPreRefreshTx creates a revision of the current configuration state before a price
|
||||||
|
// refresh so the history clearly shows what existed before prices were updated.
|
||||||
|
// Called only when prices are about to change (fingerprints differ).
|
||||||
|
func (s *LocalConfigurationService) snapshotPreRefreshTx(localCfg *localdb.LocalConfiguration, createdBy string) error {
|
||||||
|
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
|
var locked localdb.LocalConfiguration
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("uuid = ?", localCfg.UUID).
|
||||||
|
First(&locked).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrConfigNotFound
|
||||||
|
}
|
||||||
|
return fmt.Errorf("lock row for pre-refresh snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := s.appendVersionTxNote(tx, localCfg, "update", createdBy, "до обновления цен")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("append pre-refresh version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", localCfg.UUID).
|
||||||
|
Update("current_version_id", version.ID).Error; err != nil {
|
||||||
|
return fmt.Errorf("set current_version_id for pre-refresh snapshot: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) {
|
func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) {
|
||||||
return localdb.BuildConfigurationSnapshot(localCfg)
|
return localdb.BuildConfigurationSnapshot(localCfg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,77 @@ 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) {
|
func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) {
|
||||||
service, local := newLocalConfigServiceForTest(t)
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,12 +17,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrProjectNotFound = errors.New("project not found")
|
ErrProjectNotFound = errors.New("project not found")
|
||||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||||
|
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||||
|
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||||
|
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||||
|
ErrProjectVariantInvalidChars = errors.New("имя варианта содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs.
|
||||||
|
var projectCodeRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||||
|
|
||||||
type ProjectService struct {
|
type ProjectService struct {
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
}
|
}
|
||||||
@@ -62,7 +70,13 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
|||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, fmt.Errorf("project code is required")
|
return nil, fmt.Errorf("project code is required")
|
||||||
}
|
}
|
||||||
|
if !projectCodeRe.MatchString(code) {
|
||||||
|
return nil, ErrProjectCodeInvalidChars
|
||||||
|
}
|
||||||
variant := strings.TrimSpace(req.Variant)
|
variant := strings.TrimSpace(req.Variant)
|
||||||
|
if err := validateProjectVariantName(variant); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
|
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -101,10 +115,21 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
|||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, fmt.Errorf("project code is required")
|
return nil, fmt.Errorf("project code is required")
|
||||||
}
|
}
|
||||||
|
if !projectCodeRe.MatchString(code) {
|
||||||
|
return nil, ErrProjectCodeInvalidChars
|
||||||
|
}
|
||||||
localProject.Code = code
|
localProject.Code = code
|
||||||
}
|
}
|
||||||
if req.Variant != nil {
|
if req.Variant != nil {
|
||||||
localProject.Variant = strings.TrimSpace(*req.Variant)
|
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
|
||||||
|
if err := validateProjectVariantName(localProject.Variant); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
|
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -166,6 +191,16 @@ func normalizeProjectVariant(variant string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(variant))
|
return strings.ToLower(strings.TrimSpace(variant))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateProjectVariantName(variant string) error {
|
||||||
|
if normalizeProjectVariant(variant) == "main" {
|
||||||
|
return ErrReservedMainVariant
|
||||||
|
}
|
||||||
|
if variant != "" && !projectCodeRe.MatchString(variant) {
|
||||||
|
return ErrProjectVariantInvalidChars
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
||||||
return s.setProjectActive(projectUUID, ownerUsername, false)
|
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||||
}
|
}
|
||||||
@@ -262,6 +297,24 @@ func (s *ProjectService) GetByUUID(projectUUID, ownerUsername string) (*models.P
|
|||||||
return localdb.LocalToProject(localProject), nil
|
return localdb.LocalToProject(localProject), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByCode finds the main variant of a project by its code (case-insensitive).
|
||||||
|
func (s *ProjectService) GetByCode(code string) (*models.Project, error) {
|
||||||
|
localProject, err := s.localDB.GetProjectByCode(code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
return localdb.LocalToProject(localProject), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByCodeAndVariant finds a project by code + variant (both case-insensitive).
|
||||||
|
func (s *ProjectService) GetByCodeAndVariant(code, variant string) (*models.Project, error) {
|
||||||
|
localProject, err := s.localDB.GetProjectByCodeAndVariant(code, variant)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
return localdb.LocalToProject(localProject), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) {
|
func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) {
|
||||||
project, err := s.GetByUUID(projectUUID, ownerUsername)
|
project, err := s.GetByUUID(projectUUID, ownerUsername)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
60
internal/services/project_test.go
Normal file
60
internal/services/project_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectServiceCreateRejectsReservedMainVariant(t *testing.T) {
|
||||||
|
local, err := newProjectTestLocalDB(t)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
service := NewProjectService(local)
|
||||||
|
|
||||||
|
_, err = service.Create("tester", &CreateProjectRequest{
|
||||||
|
Code: "OPS-1",
|
||||||
|
Variant: "main",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrReservedMainVariant) {
|
||||||
|
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectServiceUpdateRejectsReservedMainVariant(t *testing.T) {
|
||||||
|
local, err := newProjectTestLocalDB(t)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
service := NewProjectService(local)
|
||||||
|
|
||||||
|
created, err := service.Create("tester", &CreateProjectRequest{
|
||||||
|
Code: "OPS-1",
|
||||||
|
Variant: "Lenovo",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainName := "main"
|
||||||
|
_, err = service.Update(created.UUID, "tester", &UpdateProjectRequest{
|
||||||
|
Variant: &mainName,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrReservedMainVariant) {
|
||||||
|
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProjectTestLocalDB(t *testing.T) (*localdb.LocalDB, error) {
|
||||||
|
t.Helper()
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "project_test.db")
|
||||||
|
local, err := localdb.New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
return local, nil
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ var (
|
|||||||
|
|
||||||
type QuoteService struct {
|
type QuoteService struct {
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
statsRepo *repository.StatsRepository
|
|
||||||
pricelistRepo *repository.PricelistRepository
|
pricelistRepo *repository.PricelistRepository
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
pricingService priceResolver
|
pricingService priceResolver
|
||||||
@@ -34,14 +33,12 @@ type priceResolver interface {
|
|||||||
|
|
||||||
func NewQuoteService(
|
func NewQuoteService(
|
||||||
componentRepo *repository.ComponentRepository,
|
componentRepo *repository.ComponentRepository,
|
||||||
statsRepo *repository.StatsRepository,
|
|
||||||
pricelistRepo *repository.PricelistRepository,
|
pricelistRepo *repository.PricelistRepository,
|
||||||
localDB *localdb.LocalDB,
|
localDB *localdb.LocalDB,
|
||||||
pricingService priceResolver,
|
pricingService priceResolver,
|
||||||
) *QuoteService {
|
) *QuoteService {
|
||||||
return &QuoteService{
|
return &QuoteService{
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
statsRepo: statsRepo,
|
|
||||||
pricelistRepo: pricelistRepo,
|
pricelistRepo: pricelistRepo,
|
||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
pricingService: pricingService,
|
pricingService: pricingService,
|
||||||
@@ -114,6 +111,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
|||||||
if len(req.Items) == 0 {
|
if len(req.Items) == 0 {
|
||||||
return nil, ErrEmptyQuote
|
return nil, ErrEmptyQuote
|
||||||
}
|
}
|
||||||
|
for i := range req.Items {
|
||||||
|
req.Items[i].LotName = models.NormalizeLotName(req.Items[i].LotName)
|
||||||
|
}
|
||||||
|
|
||||||
// Strict local-first path: calculations use local SQLite snapshot regardless of online status.
|
// Strict local-first path: calculations use local SQLite snapshot regardless of online status.
|
||||||
if s.localDB != nil {
|
if s.localDB != nil {
|
||||||
@@ -248,6 +248,16 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
|
|||||||
if len(req.Items) == 0 {
|
if len(req.Items) == 0 {
|
||||||
return nil, ErrEmptyQuote
|
return nil, ErrEmptyQuote
|
||||||
}
|
}
|
||||||
|
// Keep original lot names so the response mirrors what the caller sent.
|
||||||
|
// Normalization is applied only for internal DB lookups.
|
||||||
|
originalLotNames := make(map[string]string, len(req.Items))
|
||||||
|
for i := range req.Items {
|
||||||
|
upper := models.NormalizeLotName(req.Items[i].LotName)
|
||||||
|
if _, exists := originalLotNames[upper]; !exists {
|
||||||
|
originalLotNames[upper] = req.Items[i].LotName
|
||||||
|
}
|
||||||
|
req.Items[i].LotName = upper
|
||||||
|
}
|
||||||
|
|
||||||
lotNames := make([]string, 0, len(req.Items))
|
lotNames := make([]string, 0, len(req.Items))
|
||||||
seenLots := make(map[string]struct{}, len(req.Items))
|
seenLots := make(map[string]struct{}, len(req.Items))
|
||||||
@@ -306,8 +316,12 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, reqItem := range req.Items {
|
for _, reqItem := range req.Items {
|
||||||
|
responseLotName := originalLotNames[reqItem.LotName]
|
||||||
|
if responseLotName == "" {
|
||||||
|
responseLotName = reqItem.LotName
|
||||||
|
}
|
||||||
item := PriceLevelsItem{
|
item := PriceLevelsItem{
|
||||||
LotName: reqItem.LotName,
|
LotName: responseLotName,
|
||||||
Quantity: reqItem.Quantity,
|
Quantity: reqItem.Quantity,
|
||||||
PriceMissing: make([]string, 0, 3),
|
PriceMissing: make([]string, 0, 3),
|
||||||
}
|
}
|
||||||
@@ -388,13 +402,14 @@ func (s *QuoteService) lookupPricesByPricelistID(pricelistID uint, lotNames []st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback path (usually offline): local per-lot lookup.
|
// Fallback path (usually offline): batch local lookup (single query via index).
|
||||||
if s.localDB != nil {
|
if s.localDB != nil {
|
||||||
for _, lotName := range missing {
|
if localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID); err == nil {
|
||||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
if batchPrices, err := s.localDB.GetLocalPricesForLots(localPL.ID, missing); err == nil {
|
||||||
if found && price > 0 {
|
for lotName, price := range batchPrices {
|
||||||
result[lotName] = price
|
result[lotName] = price
|
||||||
loaded[lotName] = price
|
loaded[lotName] = price
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.updateCache(pricelistID, missing, loaded)
|
s.updateCache(pricelistID, missing, loaded)
|
||||||
@@ -503,18 +518,3 @@ func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordUsage records that components were used in a quote
|
|
||||||
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
|
|
||||||
if s.statsRepo == nil {
|
|
||||||
// Offline mode: usage stats are unavailable and should not block config saves.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range items {
|
|
||||||
revenue := item.UnitPrice * float64(item.Quantity)
|
|
||||||
if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
||||||
db := newPriceLevelsTestDB(t)
|
db := newPriceLevelsTestDB(t)
|
||||||
repo := repository.NewPricelistRepository(db)
|
repo := repository.NewPricelistRepository(db)
|
||||||
service := NewQuoteService(nil, nil, repo, nil, nil)
|
service := NewQuoteService(nil, repo, nil, nil)
|
||||||
|
|
||||||
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
|
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
|
||||||
_ = estimate
|
_ = estimate
|
||||||
@@ -57,7 +57,7 @@ func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
|||||||
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
|
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
|
||||||
db := newPriceLevelsTestDB(t)
|
db := newPriceLevelsTestDB(t)
|
||||||
repo := repository.NewPricelistRepository(db)
|
repo := repository.NewPricelistRepository(db)
|
||||||
service := NewQuoteService(nil, nil, repo, nil, nil)
|
service := NewQuoteService(nil, repo, nil, nil)
|
||||||
|
|
||||||
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
|
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
|
||||||
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
|
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
package sync
|
package sync
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SeenPartnumber represents an unresolved vendor partnumber to report.
|
// SeenPartnumber represents an unresolved vendor partnumber to report.
|
||||||
type SeenPartnumber struct {
|
type SeenPartnumber struct {
|
||||||
Partnumber string
|
Partnumber string
|
||||||
Description string
|
Description string
|
||||||
Ignored bool
|
Ignored bool
|
||||||
|
LotSuggestion []LotSuggestionEntry // optional; set when user manually mapped PN → LOT in UI
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotSuggestionEntry is one suggested LOT mapping for a vendor partnumber.
|
||||||
|
// JSON shape mirrors qt_partnumber_book_items.lots_json: {"lot_name", "qty"}.
|
||||||
|
type LotSuggestionEntry struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Qty int `json:"qty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
||||||
// Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
|
// When LotSuggestion is provided the column is updated too; if the column does not exist yet
|
||||||
|
// (migration pending) the write is retried without it and a warning is logged — the app never panics.
|
||||||
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -30,7 +41,43 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
|||||||
if item.Partnumber == "" {
|
if item.Partnumber == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err := mariaDB.Exec(`
|
|
||||||
|
if len(item.LotSuggestion) > 0 {
|
||||||
|
suggJSON, marshalErr := json.Marshal(item.LotSuggestion)
|
||||||
|
if marshalErr != nil {
|
||||||
|
slog.Error("partnumber_seen: failed to marshal lot_suggestion, skipping suggestion",
|
||||||
|
"partnumber", item.Partnumber, "error", marshalErr)
|
||||||
|
suggJSON = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if suggJSON != nil {
|
||||||
|
err = mariaDB.Exec(`
|
||||||
|
INSERT INTO qt_vendor_partnumber_seen
|
||||||
|
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
|
||||||
|
VALUES
|
||||||
|
('manual', '', ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
lot_suggestion = VALUES(lot_suggestion),
|
||||||
|
last_seen_at = NOW(3)
|
||||||
|
`, item.Partnumber, item.Description, item.Ignored, now, string(suggJSON)).Error
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column not yet migrated — fall through to insert without lot_suggestion.
|
||||||
|
if !isUnknownColumnError(err) {
|
||||||
|
slog.Error("partnumber_seen: failed to upsert with lot_suggestion",
|
||||||
|
"partnumber", item.Partnumber, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Warn("partnumber_seen: lot_suggestion column missing (migration pending), inserting without it",
|
||||||
|
"partnumber", item.Partnumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert without lot_suggestion (baseline behaviour or fallback).
|
||||||
|
err = mariaDB.Exec(`
|
||||||
INSERT INTO qt_vendor_partnumber_seen
|
INSERT INTO qt_vendor_partnumber_seen
|
||||||
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
||||||
VALUES
|
VALUES
|
||||||
@@ -40,10 +87,18 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
|||||||
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||||
// Continue with remaining items
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("partnumber_seen pushed to server", "count", len(items))
|
slog.Info("partnumber_seen pushed to server", "count", len(items))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isUnknownColumnError returns true when MariaDB reports that a column does not exist.
|
||||||
|
func isUnknownColumnError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "unknown column") || strings.Contains(msg, "1054")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sync
|
package sync
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -76,6 +77,11 @@ 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 {
|
if err := s.reportClientSchemaState(mariaDB, now); err != nil {
|
||||||
slog.Warn("failed to report client schema state", "error", err)
|
slog.Warn("failed to report client schema state", "error", err)
|
||||||
}
|
}
|
||||||
@@ -141,35 +147,51 @@ func ensureClientSchemaStateTable(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if tableExists(db, "qt_client_schema_state") {
|
if tableExists(db, "qt_client_schema_state") {
|
||||||
if err := db.Exec(`
|
// Each ALTER is guarded by a column existence check so users without DDL
|
||||||
ALTER TABLE qt_client_schema_state
|
// rights don't get a permission error on every sync cycle — the server
|
||||||
ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER username
|
// migration tool is the authoritative path for schema changes.
|
||||||
`).Error; err != nil {
|
if !columnExists(db, "qt_client_schema_state", "hostname") {
|
||||||
return fmt.Errorf("add qt_client_schema_state.hostname: %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)
|
||||||
|
}
|
||||||
|
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(`
|
type colMigration struct {
|
||||||
ALTER TABLE qt_client_schema_state
|
column string
|
||||||
DROP PRIMARY KEY,
|
stmt string
|
||||||
ADD PRIMARY KEY (username, hostname)
|
|
||||||
`).Error; err != nil && !isDuplicatePrimaryKeyDefinition(err) {
|
|
||||||
return fmt.Errorf("set qt_client_schema_state primary key: %w", err)
|
|
||||||
}
|
}
|
||||||
|
migrations := []colMigration{
|
||||||
for _, stmt := range []string{
|
{"last_sync_at", "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_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"},
|
||||||
"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"},
|
||||||
"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"},
|
||||||
"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"},
|
||||||
"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"},
|
||||||
"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"},
|
||||||
"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"},
|
||||||
"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"},
|
||||||
"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"},
|
||||||
"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"},
|
||||||
"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"},
|
||||||
if err := db.Exec(stmt).Error; err != nil {
|
{"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 {
|
||||||
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,6 +199,17 @@ func ensureClientSchemaStateTable(db *gorm.DB) error {
|
|||||||
return nil
|
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 {
|
func tableExists(db *gorm.DB, tableName string) bool {
|
||||||
var count int64
|
var count int64
|
||||||
// For MariaDB/MySQL, check information_schema
|
// For MariaDB/MySQL, check information_schema
|
||||||
@@ -193,9 +226,6 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
|
if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := ensureClientSchemaStateTable(mariaDB); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
username := strings.TrimSpace(s.localDB.GetDBUser())
|
username := strings.TrimSpace(s.localDB.GetDBUser())
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -215,6 +245,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
|
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
|
||||||
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
|
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
|
||||||
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
||||||
|
localPricelistCount := s.localDB.CountLocalPricelists()
|
||||||
|
pricelistItemsCount := s.localDB.CountAllPricelistItems()
|
||||||
|
componentsCount := s.localDB.CountComponents()
|
||||||
|
dbSizeBytes := s.localDB.DBFileSizeBytes()
|
||||||
return mariaDB.Exec(`
|
return mariaDB.Exec(`
|
||||||
INSERT INTO qt_client_schema_state (
|
INSERT INTO qt_client_schema_state (
|
||||||
username, hostname, app_version,
|
username, hostname, app_version,
|
||||||
@@ -222,9 +256,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
configurations_count, projects_count,
|
configurations_count, projects_count,
|
||||||
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
|
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
|
||||||
last_sync_error_code, last_sync_error_text,
|
last_sync_error_code, last_sync_error_text,
|
||||||
|
local_pricelist_count, pricelist_items_count, components_count, db_size_bytes,
|
||||||
last_checked_at, updated_at
|
last_checked_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
app_version = VALUES(app_version),
|
app_version = VALUES(app_version),
|
||||||
last_sync_at = VALUES(last_sync_at),
|
last_sync_at = VALUES(last_sync_at),
|
||||||
@@ -238,6 +273,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
competitor_pricelist_version = VALUES(competitor_pricelist_version),
|
competitor_pricelist_version = VALUES(competitor_pricelist_version),
|
||||||
last_sync_error_code = VALUES(last_sync_error_code),
|
last_sync_error_code = VALUES(last_sync_error_code),
|
||||||
last_sync_error_text = VALUES(last_sync_error_text),
|
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),
|
last_checked_at = VALUES(last_checked_at),
|
||||||
updated_at = VALUES(updated_at)
|
updated_at = VALUES(updated_at)
|
||||||
`, username, hostname, appmeta.Version(),
|
`, username, hostname, appmeta.Version(),
|
||||||
@@ -245,6 +284,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
configurationsCount, projectsCount,
|
configurationsCount, projectsCount,
|
||||||
estimateVersion, warehouseVersion, competitorVersion,
|
estimateVersion, warehouseVersion, competitorVersion,
|
||||||
lastSyncErrorCode, lastSyncErrorText,
|
lastSyncErrorCode, lastSyncErrorText,
|
||||||
|
localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes,
|
||||||
checkedAt, checkedAt).Error
|
checkedAt, checkedAt).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,14 +321,37 @@ func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
|
|||||||
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
|
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
|
||||||
}
|
}
|
||||||
|
|
||||||
var pending localdb.PendingChange
|
var errored []localdb.PendingChange
|
||||||
if err := local.DB().
|
if err := local.DB().
|
||||||
Where("TRIM(COALESCE(last_error, '')) <> ''").
|
Where("TRIM(COALESCE(last_error, '')) <> ''").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
First(&pending).Error; err == nil {
|
Limit(20).
|
||||||
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
|
Find(&errored).Error; err != nil || len(errored) == 0 {
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, nil
|
|
||||||
|
type errorEntry struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
entries := make([]errorEntry, 0, len(errored))
|
||||||
|
for _, ch := range errored {
|
||||||
|
entries = append(entries, errorEntry{
|
||||||
|
Type: ch.EntityType,
|
||||||
|
UUID: ch.EntityUUID,
|
||||||
|
Op: ch.Operation,
|
||||||
|
Attempts: ch.Attempts,
|
||||||
|
Error: strings.TrimSpace(ch.LastError),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
detail, jsonErr := json.Marshal(entries)
|
||||||
|
if jsonErr != nil {
|
||||||
|
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(errored[0].LastError))
|
||||||
|
}
|
||||||
|
return optionalString("PENDING_CHANGE_ERROR"), optionalString(string(detail))
|
||||||
}
|
}
|
||||||
|
|
||||||
func optionalString(value string) *string {
|
func optionalString(value string) *string {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
@@ -16,15 +17,18 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrOffline = errors.New("database is offline")
|
var ErrOffline = errors.New("database is offline")
|
||||||
|
|
||||||
// Service handles synchronization between MariaDB and local SQLite
|
// Service handles synchronization between MariaDB and local SQLite
|
||||||
type Service struct {
|
type Service struct {
|
||||||
connMgr *db.ConnectionManager
|
connMgr *db.ConnectionManager
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
directDB *gorm.DB
|
directDB *gorm.DB
|
||||||
|
pricelistMu sync.Mutex // prevents concurrent pricelist syncs
|
||||||
|
schemaOnce sync.Once // ensures ensureClientSchemaStateTable runs at most once per process
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new sync service
|
// NewService creates a new sync service
|
||||||
@@ -45,10 +49,15 @@ func NewServiceWithDB(mariaDB *gorm.DB, localDB *localdb.LocalDB) *Service {
|
|||||||
|
|
||||||
// SyncStatus represents the current sync status
|
// SyncStatus represents the current sync status
|
||||||
type SyncStatus struct {
|
type SyncStatus struct {
|
||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
ServerPricelists int `json:"server_pricelists"`
|
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
|
||||||
LocalPricelists int `json:"local_pricelists"`
|
LastSyncStatus string `json:"last_sync_status,omitempty"`
|
||||||
NeedsSync bool `json:"needs_sync"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSyncStatus struct {
|
type UserSyncStatus struct {
|
||||||
@@ -215,7 +224,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
|||||||
existing.SyncStatus = "synced"
|
existing.SyncStatus = "synced"
|
||||||
existing.SyncedAt = &now
|
existing.SyncedAt = &now
|
||||||
|
|
||||||
if err := s.localDB.SaveProject(existing); err != nil {
|
if err := s.localDB.SaveProjectPreservingUpdatedAt(existing); err != nil {
|
||||||
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
||||||
}
|
}
|
||||||
result.Updated++
|
result.Updated++
|
||||||
@@ -225,7 +234,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
|||||||
localProject := localdb.ProjectToLocal(&project)
|
localProject := localdb.ProjectToLocal(&project)
|
||||||
localProject.SyncStatus = "synced"
|
localProject.SyncStatus = "synced"
|
||||||
localProject.SyncedAt = &now
|
localProject.SyncedAt = &now
|
||||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
if err := s.localDB.SaveProjectPreservingUpdatedAt(localProject); err != nil {
|
||||||
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
||||||
}
|
}
|
||||||
result.Imported++
|
result.Imported++
|
||||||
@@ -240,30 +249,23 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
|||||||
// GetStatus returns the current sync status
|
// GetStatus returns the current sync status
|
||||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||||
lastSync := s.localDB.GetLastSyncTime()
|
lastSync := s.localDB.GetLastSyncTime()
|
||||||
|
lastAttempt := s.localDB.GetLastPricelistSyncAttemptAt()
|
||||||
// Count server pricelists (only if already connected, don't reconnect)
|
lastSyncStatus := s.localDB.GetLastPricelistSyncStatus()
|
||||||
serverCount := 0
|
lastSyncError := s.localDB.GetLastPricelistSyncError()
|
||||||
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()
|
localCount := s.localDB.CountLocalPricelists()
|
||||||
|
hasFailedSync := strings.EqualFold(lastSyncStatus, "failed")
|
||||||
needsSync, _ := s.NeedSync()
|
needsSync := lastSync == nil || hasFailedSync
|
||||||
|
|
||||||
return &SyncStatus{
|
return &SyncStatus{
|
||||||
LastSyncAt: lastSync,
|
LastSyncAt: lastSync,
|
||||||
ServerPricelists: serverCount,
|
LastAttemptAt: lastAttempt,
|
||||||
LocalPricelists: int(localCount),
|
LastSyncStatus: lastSyncStatus,
|
||||||
NeedsSync: needsSync,
|
LastSyncError: lastSyncError,
|
||||||
|
ServerPricelists: 0,
|
||||||
|
LocalPricelists: int(localCount),
|
||||||
|
NeedsSync: needsSync,
|
||||||
|
IncompleteServerSync: hasFailedSync,
|
||||||
|
KnownServerChangesMiss: hasFailedSync,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,60 +274,62 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
|
|||||||
func (s *Service) NeedSync() (bool, error) {
|
func (s *Service) NeedSync() (bool, error) {
|
||||||
lastSync := s.localDB.GetLastSyncTime()
|
lastSync := s.localDB.GetLastSyncTime()
|
||||||
|
|
||||||
// If never synced, need sync
|
// If never synced, always need sync.
|
||||||
if lastSync == nil {
|
if lastSync == nil {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If last sync was more than 1 hour ago, suggest sync
|
// 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 time.Since(*lastSync) > time.Hour {
|
if time.Since(*lastSync) > time.Hour {
|
||||||
return true, nil
|
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
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
||||||
func (s *Service) SyncPricelists() (int, error) {
|
func (s *Service) SyncPricelists() (int, error) {
|
||||||
|
s.pricelistMu.Lock()
|
||||||
|
defer s.pricelistMu.Unlock()
|
||||||
|
return s.syncPricelists()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncPricelists() (int, error) {
|
||||||
slog.Info("starting pricelist sync")
|
slog.Info("starting pricelist sync")
|
||||||
|
plSyncStart := time.Now()
|
||||||
if _, err := s.EnsureReadinessForSync(); err != nil {
|
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -333,15 +337,24 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
// Get database connection
|
// Get database connection
|
||||||
mariaDB, err := s.getDB()
|
mariaDB, err := s.getDB()
|
||||||
if err != nil {
|
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)
|
return 0, fmt.Errorf("database not available: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if reportErr := s.reportClientSchemaState(mariaDB, time.Now().UTC()); reportErr != nil {
|
||||||
|
slog.Warn("failed to report client state after pricelist sync", "error", reportErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Create repository
|
// Create repository
|
||||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||||
|
|
||||||
// Get active pricelists from server (up to 100)
|
// Get active pricelists from server (up to 100)
|
||||||
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
|
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.recordPricelistSyncFailure(err)
|
||||||
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
||||||
}
|
}
|
||||||
serverPricelistIDs := make([]uint, 0, len(serverPricelists))
|
serverPricelistIDs := make([]uint, 0, len(serverPricelists))
|
||||||
@@ -350,14 +363,30 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
synced := 0
|
synced := 0
|
||||||
|
var syncErr error
|
||||||
for _, pl := range serverPricelists {
|
for _, pl := range serverPricelists {
|
||||||
// Check if pricelist already exists locally
|
// Check if pricelist already exists locally
|
||||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||||
if existing != nil {
|
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.
|
// Backfill items for legacy/partial local caches where only pricelist metadata exists.
|
||||||
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
|
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
|
||||||
itemCount, err := s.SyncPricelistItems(existing.ID)
|
itemCount, err := s.SyncPricelistItems(existing.ID)
|
||||||
if err != nil {
|
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)
|
slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount)
|
slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount)
|
||||||
@@ -375,21 +404,18 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
CreatedAt: pl.CreatedAt,
|
CreatedAt: pl.CreatedAt,
|
||||||
SyncedAt: time.Now(),
|
SyncedAt: time.Now(),
|
||||||
IsUsed: false,
|
IsUsed: false,
|
||||||
|
IsActive: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
|
itemCount, err := s.syncNewPricelistSnapshot(localPL)
|
||||||
slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
|
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)
|
||||||
continue
|
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++
|
synced++
|
||||||
}
|
}
|
||||||
@@ -401,17 +427,132 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
slog.Info("deleted stale local pricelists", "deleted", removed)
|
slog.Info("deleted stale local pricelists", "deleted", removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirror server-side deactivations: any local pricelist not in the current active set
|
||||||
|
// is marked is_active=false so offline lookups skip it.
|
||||||
|
if err := s.localDB.DeactivateLocalPricelistsNotIn(serverPricelistIDs); err != nil {
|
||||||
|
slog.Warn("failed to deactivate stale local pricelists", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Backfill lot_category for used pricelists (older local caches may miss the column values).
|
// Backfill lot_category for used pricelists (older local caches may miss the column values).
|
||||||
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
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
|
// Update last sync time
|
||||||
s.localDB.SetLastSyncTime(time.Now())
|
now := time.Now()
|
||||||
s.RecordSyncHeartbeat()
|
s.localDB.SetLastSyncTime(now)
|
||||||
|
s.recordPricelistSyncSuccess(now)
|
||||||
|
s.localDB.AppendSyncLog("pricelists", "ok", "", synced, plSyncStart, time.Since(plSyncStart).Milliseconds())
|
||||||
|
|
||||||
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
||||||
return synced, nil
|
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) {
|
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
|
||||||
if s.localDB == nil || pricelistRepo == nil {
|
if s.localDB == nil || pricelistRepo == nil {
|
||||||
return
|
return
|
||||||
@@ -489,58 +630,29 @@ func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
|
// ListUserSyncStatuses returns users who have recorded a client schema state check.
|
||||||
// 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) {
|
func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) {
|
||||||
mariaDB, err := s.getDB()
|
mariaDB, err := s.getDB()
|
||||||
if err != nil || mariaDB == nil {
|
if err != nil || mariaDB == nil {
|
||||||
return nil, ErrOffline
|
return nil, ErrOffline
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
|
|
||||||
return nil, fmt.Errorf("ensure sync status table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
type row struct {
|
type row struct {
|
||||||
Username string `gorm:"column:username"`
|
Username string `gorm:"column:username"`
|
||||||
LastSyncAt time.Time `gorm:"column:last_sync_at"`
|
LastCheckedAt time.Time `gorm:"column:last_checked_at"`
|
||||||
AppVersion string `gorm:"column:app_version"`
|
AppVersion string `gorm:"column:app_version"`
|
||||||
}
|
}
|
||||||
var rows []row
|
var rows []row
|
||||||
if err := mariaDB.Raw(`
|
if err := mariaDB.Raw(`
|
||||||
SELECT username, last_sync_at, COALESCE(app_version, '') AS app_version
|
SELECT s.username, s.last_checked_at, COALESCE(s.app_version, '') AS app_version
|
||||||
FROM qt_pricelist_sync_status
|
FROM qt_client_schema_state s
|
||||||
ORDER BY last_sync_at DESC, username ASC
|
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
|
||||||
`).Scan(&rows).Error; err != nil {
|
`).Scan(&rows).Error; err != nil {
|
||||||
return nil, fmt.Errorf("load sync status rows: %w", err)
|
return nil, fmt.Errorf("load sync status rows: %w", err)
|
||||||
}
|
}
|
||||||
@@ -560,7 +672,7 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
isOnline := now.Sub(r.LastSyncAt) <= onlineThreshold
|
isOnline := now.Sub(r.LastCheckedAt) <= onlineThreshold
|
||||||
if _, connected := activeUsers[username]; connected {
|
if _, connected := activeUsers[username]; connected {
|
||||||
isOnline = true
|
isOnline = true
|
||||||
delete(activeUsers, username)
|
delete(activeUsers, username)
|
||||||
@@ -570,7 +682,7 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn
|
|||||||
|
|
||||||
result = append(result, UserSyncStatus{
|
result = append(result, UserSyncStatus{
|
||||||
Username: username,
|
Username: username,
|
||||||
LastSyncAt: r.LastSyncAt,
|
LastSyncAt: r.LastCheckedAt,
|
||||||
AppVersion: appVersion,
|
AppVersion: appVersion,
|
||||||
IsOnline: isOnline,
|
IsOnline: isOnline,
|
||||||
})
|
})
|
||||||
@@ -624,36 +736,6 @@ func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, e
|
|||||||
return users, nil
|
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
|
// SyncPricelistItems synchronizes items for a specific pricelist
|
||||||
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||||
@@ -670,30 +752,13 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
|||||||
return int(existingCount), nil
|
return int(existingCount), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get database connection
|
localItems, err := s.fetchServerPricelistItems(localPL.ServerID)
|
||||||
mariaDB, err := s.getDB()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("database not available: %w", err)
|
return 0, err
|
||||||
}
|
}
|
||||||
|
for i := range localItems {
|
||||||
// Create repository
|
localItems[i].PricelistID = localPricelistID
|
||||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
|
||||||
|
|
||||||
// Get items from server
|
|
||||||
serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
|
||||||
if err != nil {
|
|
||||||
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, localPricelistID)
|
|
||||||
}
|
|
||||||
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
|
|
||||||
slog.Warn("pricelist stock enrichment skipped", "pricelist_id", localPricelistID, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||||
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
||||||
}
|
}
|
||||||
@@ -702,6 +767,37 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
|||||||
return len(localItems), nil
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create repository
|
||||||
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||||
|
|
||||||
|
// Get items from server
|
||||||
|
serverItems, _, err := pricelistRepo.GetItems(serverPricelistID, 0, 10000, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting server pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(serverItems))
|
||||||
|
localItems := make([]localdb.LocalPricelistItem, 0, len(serverItems))
|
||||||
|
for i := range serverItems {
|
||||||
|
lotName := serverItems[i].LotName
|
||||||
|
if _, dup := seen[lotName]; dup {
|
||||||
|
slog.Warn("duplicate lot_name in server pricelist, skipping", "pricelist_id", serverPricelistID, "lot_name", lotName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[lotName] = struct{}{}
|
||||||
|
localItems = append(localItems, *localdb.PricelistItemToLocal(&serverItems[i], 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
return localItems, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
|
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
|
||||||
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
|
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
|
||||||
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||||
@@ -711,111 +807,6 @@ func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, err
|
|||||||
return s.SyncPricelistItems(localPL.ID)
|
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
|
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
||||||
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
||||||
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
||||||
@@ -847,9 +838,15 @@ func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.Local
|
|||||||
return localPL, nil
|
return localPL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed
|
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed.
|
||||||
// This should be called before creating a new configuration when online
|
// If a sync is already in progress, returns immediately without blocking.
|
||||||
func (s *Service) SyncPricelistsIfNeeded() error {
|
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()
|
needSync, err := s.NeedSync()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to check if sync needed", "error", err)
|
slog.Warn("failed to check if sync needed", "error", err)
|
||||||
@@ -858,11 +855,21 @@ func (s *Service) SyncPricelistsIfNeeded() error {
|
|||||||
|
|
||||||
if !needSync {
|
if !needSync {
|
||||||
slog.Debug("pricelists are up to date, no sync needed")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("new pricelists detected, syncing...")
|
slog.Info("new pricelists detected, syncing...")
|
||||||
_, err = s.SyncPricelists()
|
_, err = s.syncPricelists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("syncing pricelists: %w", err)
|
return fmt.Errorf("syncing pricelists: %w", err)
|
||||||
}
|
}
|
||||||
@@ -870,6 +877,11 @@ func (s *Service) SyncPricelistsIfNeeded() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxPendingChangeAttempts is the number of failed attempts after which a pending change
|
||||||
|
// is considered unrecoverable and removed from the queue. Applies only to changes that
|
||||||
|
// fail with a non-transient error (e.g. corrupt payload, unknown operation).
|
||||||
|
const maxPendingChangeAttempts = 20
|
||||||
|
|
||||||
// PushPendingChanges pushes all pending changes to the server
|
// PushPendingChanges pushes all pending changes to the server
|
||||||
func (s *Service) PushPendingChanges() (int, error) {
|
func (s *Service) PushPendingChanges() (int, error) {
|
||||||
if _, err := s.EnsureReadinessForSync(); err != nil {
|
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||||
@@ -883,6 +895,14 @@ func (s *Service) PushPendingChanges() (int, error) {
|
|||||||
slog.Info("purged orphan configuration pending changes", "removed", removed)
|
slog.Info("purged orphan configuration pending changes", "removed", removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-repair locally-fixable problems (e.g. stale project references)
|
||||||
|
// before attempting to push, so that repaired changes succeed on this cycle.
|
||||||
|
if repaired, _, repairErr := s.localDB.RepairPendingChanges(); repairErr != nil {
|
||||||
|
slog.Warn("auto-repair of errored pending changes failed", "error", repairErr)
|
||||||
|
} else if repaired > 0 {
|
||||||
|
slog.Info("auto-repaired errored pending changes", "repaired", repaired)
|
||||||
|
}
|
||||||
|
|
||||||
changes, err := s.localDB.GetPendingChanges()
|
changes, err := s.localDB.GetPendingChanges()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("getting pending changes: %w", err)
|
return 0, fmt.Errorf("getting pending changes: %w", err)
|
||||||
@@ -894,16 +914,30 @@ func (s *Service) PushPendingChanges() (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("pushing pending changes", "count", len(changes))
|
slog.Info("pushing pending changes", "count", len(changes))
|
||||||
|
pushStart := time.Now()
|
||||||
pushed := 0
|
pushed := 0
|
||||||
|
failed := 0
|
||||||
|
var firstErr string
|
||||||
var syncedIDs []int64
|
var syncedIDs []int64
|
||||||
sortedChanges := prioritizeProjectChanges(changes)
|
sortedChanges := prioritizeProjectChanges(changes)
|
||||||
|
|
||||||
for _, change := range sortedChanges {
|
for _, change := range sortedChanges {
|
||||||
err := s.pushSingleChange(&change)
|
err := s.pushSingleChange(&change)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.markConnectionBroken(err)
|
||||||
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
||||||
// Increment attempts
|
newAttempts := change.Attempts + 1
|
||||||
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
|
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
|
||||||
|
if firstErr == "" {
|
||||||
|
firstErr = err.Error()
|
||||||
|
}
|
||||||
|
failed++
|
||||||
|
if newAttempts >= maxPendingChangeAttempts {
|
||||||
|
slog.Error("abandoning pending change after max attempts",
|
||||||
|
"id", change.ID, "type", change.EntityType, "op", change.Operation,
|
||||||
|
"attempts", newAttempts, "last_error", err.Error())
|
||||||
|
syncedIDs = append(syncedIDs, change.ID)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,7 +952,13 @@ func (s *Service) PushPendingChanges() (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("pending changes pushed", "pushed", pushed, "failed", len(changes)-pushed)
|
if failed > 0 {
|
||||||
|
s.localDB.AppendSyncLog("changes", "error", firstErr, pushed, pushStart, time.Since(pushStart).Milliseconds())
|
||||||
|
} else {
|
||||||
|
s.localDB.AppendSyncLog("changes", "ok", "", pushed, pushStart, time.Since(pushStart).Milliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("pending changes pushed", "pushed", pushed, "failed", failed)
|
||||||
return pushed, nil
|
return pushed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,7 +970,11 @@ func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
|
|||||||
case "configuration":
|
case "configuration":
|
||||||
return s.pushConfigurationChange(change)
|
return s.pushConfigurationChange(change)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown entity type: %s", change.EntityType)
|
// Unknown entity type: this change was queued by a newer or different build
|
||||||
|
// and cannot be processed. Remove it from the queue.
|
||||||
|
slog.Warn("dropping pending change with unknown entity type",
|
||||||
|
"id", change.ID, "type", change.EntityType, "op", change.Operation)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,7 +1052,7 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
|||||||
localProject.SyncStatus = "synced"
|
localProject.SyncStatus = "synced"
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
localProject.SyncedAt = &now
|
localProject.SyncedAt = &now
|
||||||
_ = s.localDB.SaveProject(localProject)
|
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1063,7 +1107,10 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
|||||||
case "delete":
|
case "delete":
|
||||||
return s.pushConfigurationDelete(change)
|
return s.pushConfigurationDelete(change)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown operation: %s", change.Operation)
|
// Unknown operation: queued by a newer or different build. Drop from queue.
|
||||||
|
slog.Warn("dropping pending change with unknown operation",
|
||||||
|
"id", change.ID, "type", change.EntityType, "op", change.Operation)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1263,24 +1310,30 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
|||||||
|
|
||||||
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
|
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
|
||||||
if localErr != nil {
|
if localErr != nil {
|
||||||
return err
|
// Project not found locally either: stale reference (project was deleted).
|
||||||
|
// Fall through to system project so this configuration is not stuck forever.
|
||||||
|
slog.Warn("configuration references missing project, assigning to system project",
|
||||||
|
"cfg_uuid", cfg.UUID,
|
||||||
|
"project_uuid", *cfg.ProjectUUID,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
modelProject := localdb.LocalToProject(localProject)
|
||||||
|
if modelProject.OwnerUsername == "" {
|
||||||
|
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||||
|
}
|
||||||
|
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
||||||
|
return createErr
|
||||||
|
}
|
||||||
|
if modelProject.ID > 0 {
|
||||||
|
serverID := modelProject.ID
|
||||||
|
localProject.ServerID = &serverID
|
||||||
|
localProject.SyncStatus = "synced"
|
||||||
|
now := time.Now()
|
||||||
|
localProject.SyncedAt = &now
|
||||||
|
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
modelProject := localdb.LocalToProject(localProject)
|
|
||||||
if modelProject.OwnerUsername == "" {
|
|
||||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
|
||||||
}
|
|
||||||
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
|
||||||
return createErr
|
|
||||||
}
|
|
||||||
if modelProject.ID > 0 {
|
|
||||||
serverID := modelProject.ID
|
|
||||||
localProject.ServerID = &serverID
|
|
||||||
localProject.SyncStatus = "synced"
|
|
||||||
now := time.Now()
|
|
||||||
localProject.SyncedAt = &now
|
|
||||||
_ = s.localDB.SaveProject(localProject)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
systemProject := &models.Project{}
|
systemProject := &models.Project{}
|
||||||
@@ -1591,3 +1644,4 @@ func (s *Service) getConnectionStatus() db.ConnectionStatus {
|
|||||||
}
|
}
|
||||||
return s.connMgr.GetStatus()
|
return s.connMgr.GetStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
|
|||||||
&models.Pricelist{},
|
&models.Pricelist{},
|
||||||
&models.PricelistItem{},
|
&models.PricelistItem{},
|
||||||
&models.Lot{},
|
&models.Lot{},
|
||||||
&models.StockLog{},
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("migrate server tables: %v", err)
|
t.Fatalf("migrate server tables: %v", err)
|
||||||
}
|
}
|
||||||
@@ -103,103 +102,3 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
|
|||||||
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
|
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,12 +1,15 @@
|
|||||||
package sync_test
|
package sync_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
||||||
@@ -83,3 +86,58 @@ func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
|||||||
t.Fatalf("expected server pricelist to be synced locally: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
118
internal/services/sync/service_pricelist_upsert_test.go
Normal file
118
internal/services/sync/service_pricelist_upsert_test.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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,54 +434,14 @@ func newServerDBForSyncTest(t *testing.T) *gorm.DB {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open server sqlite: %v", err)
|
t.Fatalf("open server sqlite: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.Exec(`
|
if err := db.AutoMigrate(
|
||||||
CREATE TABLE qt_projects (
|
&models.Project{},
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
&models.Configuration{},
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
&models.Pricelist{},
|
||||||
owner_username TEXT NOT NULL,
|
&models.PricelistItem{},
|
||||||
code TEXT NOT NULL,
|
&models.Lot{},
|
||||||
variant TEXT NOT NULL DEFAULT '',
|
); err != nil {
|
||||||
name TEXT NOT NULL,
|
t.Fatalf("migrate server test schema: %v", err)
|
||||||
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
|
return db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,8 +95,10 @@ func (w *Worker) runSync() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark user's sync heartbeat (used for online/offline status in UI).
|
// Pull partnumber books together with pricelists
|
||||||
w.service.RecordSyncHeartbeat()
|
if _, err := w.service.PullPartnumberBooks(); err != nil {
|
||||||
|
w.logger.Warn("background sync: failed to pull partnumber books", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
w.logger.Info("background sync cycle completed")
|
w.logger.Info("background sync cycle completed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -47,7 +49,8 @@ type importedConfiguration struct {
|
|||||||
ServerModel string
|
ServerModel string
|
||||||
Article string
|
Article string
|
||||||
CurrencyCode string
|
CurrencyCode string
|
||||||
Rows []localdb.VendorSpecItem
|
Rows []localdb.VendorSpecItem // vendor BOM formats (CFXML, Inspur)
|
||||||
|
DirectItems localdb.LocalConfigItems // direct LOT formats (QuoteForge CSV)
|
||||||
TotalPrice *float64
|
TotalPrice *float64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +127,21 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
|||||||
return nil, ErrProjectNotFound
|
return nil, ErrProjectNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
workspace, err := parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
|
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 IsNxBOM(data):
|
||||||
|
workspace, err = parseNxBOM(data, filepath.Base(sourceFileName))
|
||||||
|
case IsTextBOM(data):
|
||||||
|
workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported vendor export format")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -140,10 +157,28 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
|||||||
for _, imported := range workspace.Configurations {
|
for _, imported := range workspace.Configurations {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
cfgUUID := uuid.NewString()
|
cfgUUID := uuid.NewString()
|
||||||
groupRows, items, totalPrice, estimatePricelistID, err := s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
|
|
||||||
if err != nil {
|
var groupRows localdb.VendorSpec
|
||||||
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localCfg := &localdb.LocalConfiguration{
|
localCfg := &localdb.LocalConfiguration{
|
||||||
UUID: cfgUUID,
|
UUID: cfgUUID,
|
||||||
ProjectUUID: &projectUUID,
|
ProjectUUID: &projectUUID,
|
||||||
@@ -239,13 +274,17 @@ func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *loca
|
|||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(order)
|
sort.Strings(order)
|
||||||
|
|
||||||
|
var priceMap map[string]float64
|
||||||
|
if estimatePricelist != nil && local != nil && len(order) > 0 {
|
||||||
|
priceMap, _ = local.GetLocalPricesForLots(estimatePricelist.ID, order)
|
||||||
|
}
|
||||||
|
|
||||||
items := make(localdb.LocalConfigItems, 0, len(order))
|
items := make(localdb.LocalConfigItems, 0, len(order))
|
||||||
for _, lotName := range order {
|
for _, lotName := range order {
|
||||||
unitPrice := 0.0
|
unitPrice := 0.0
|
||||||
if estimatePricelist != nil && local != nil {
|
if priceMap != nil {
|
||||||
if price, err := local.GetLocalPriceForLot(estimatePricelist.ID, lotName); err == nil && price > 0 {
|
unitPrice = priceMap[lotName]
|
||||||
unitPrice = price
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
items = append(items, localdb.LocalConfigItem{
|
items = append(items, localdb.LocalConfigItem{
|
||||||
LotName: lotName,
|
LotName: lotName,
|
||||||
@@ -558,3 +597,427 @@ func normalizeTopLevelQuantity(raw string, serverCount int) int {
|
|||||||
func IsCFXMLWorkspace(data []byte) bool {
|
func IsCFXMLWorkspace(data []byte) bool {
|
||||||
return bytes.Contains(data, []byte("<CFXML>")) || bytes.Contains(data, []byte("<CFXML "))
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// nxBOMItemLine matches a quantity-first BOM line: "<qty>x <description>"
|
||||||
|
// where the quantity prefix is digits followed immediately by "x" (case-insensitive).
|
||||||
|
// Parentheses, commas, and hyphens inside the description are preserved.
|
||||||
|
var nxBOMItemLine = regexp.MustCompile(`(?i)^(\d+)[xX]\s+(.+\S)\s*$`)
|
||||||
|
|
||||||
|
// IsNxBOM reports whether data looks like a quantity-first "Nx" BOM where each
|
||||||
|
// item line begins with "<qty>x <description>" (e.g. "2x Intel Xeon 8570 ...").
|
||||||
|
func IsNxBOM(data []byte) bool {
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
if nxBOMItemLine.MatchString(strings.TrimSpace(raw)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNxBOM parses a quantity-first "Nx" BOM into a single configuration.
|
||||||
|
// An optional header line ending with ", в составе:" supplies server_model and name.
|
||||||
|
// Each "<qty>x <description>" line becomes one vendor spec row; description is stored
|
||||||
|
// as both vendor_partnumber and description so rows resolve through the active
|
||||||
|
// partnumber book when matched and otherwise stay unresolved and editable in the UI.
|
||||||
|
func parseNxBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
rows := make([]localdb.VendorSpecItem, 0, len(lines))
|
||||||
|
sortOrder := 10
|
||||||
|
serverModel := ""
|
||||||
|
|
||||||
|
for _, raw := range lines {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
|
||||||
|
if fields := strings.Fields(m[1]); len(fields) > 0 {
|
||||||
|
serverModel = fields[len(fields)-1]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := nxBOMItemLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty, err := strconv.Atoi(m[1])
|
||||||
|
if err != nil || qty <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
description := strings.TrimSpace(m[2])
|
||||||
|
if description == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, localdb.VendorSpecItem{
|
||||||
|
SortOrder: sortOrder,
|
||||||
|
VendorPartnumber: description,
|
||||||
|
Quantity: qty,
|
||||||
|
Description: description,
|
||||||
|
})
|
||||||
|
sortOrder += 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, fmt.Errorf("Nx BOM has no importable rows")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := serverModel
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "Nx BOM Import"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &importedWorkspace{
|
||||||
|
SourceFormat: "Nx",
|
||||||
|
SourceFileName: sourceFileName,
|
||||||
|
Configurations: []importedConfiguration{
|
||||||
|
{
|
||||||
|
GroupID: "nx-0",
|
||||||
|
Name: name,
|
||||||
|
Line: 10,
|
||||||
|
ServerCount: 1,
|
||||||
|
ServerModel: serverModel,
|
||||||
|
Rows: rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// textBOMItemLine matches a human-readable BOM line of the form
|
||||||
|
// "<description> - <quantity> шт." where the separator may be a hyphen,
|
||||||
|
// en-dash or em-dash and the quantity may have an optional space before "шт".
|
||||||
|
// The quantity anchor at the end keeps internal hyphens/digits in the
|
||||||
|
// description (e.g. "8-GPU-2304GB") from being mistaken for the separator.
|
||||||
|
var textBOMItemLine = regexp.MustCompile(`(?i)^(.*\S)\s*[-–—]\s*(\d+)\s*шт\.?\s*$`)
|
||||||
|
|
||||||
|
// textBOMHeaderLine matches a configuration header ending with ", в составе:"
|
||||||
|
// regardless of the leading words (e.g. "Сервер <model>" or
|
||||||
|
// "Вычислительный GPU сервер <model>"). The captured group is everything before
|
||||||
|
// the comma; the model is its last whitespace-separated token.
|
||||||
|
var textBOMHeaderLine = regexp.MustCompile(`(?i)^(.*?)\s*,\s*в\s+составе`)
|
||||||
|
|
||||||
|
// ParsePastedBOMText detects and parses a single-column text BOM (Inspur or
|
||||||
|
// Russian text BOM) pasted into the configurator. It shares the same detectors
|
||||||
|
// and parsers as the vendor file-import path, so paste and upload behave
|
||||||
|
// identically. It returns the parsed vendor spec rows and the detected format,
|
||||||
|
// or (nil, "") when the text is not a recognized single-column format and the
|
||||||
|
// caller should fall back to manual column mapping.
|
||||||
|
func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) {
|
||||||
|
data := []byte(text)
|
||||||
|
var ws *importedWorkspace
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case IsInspurBOM(data):
|
||||||
|
ws, err = parseInspurBOM(data, "")
|
||||||
|
case IsNxBOM(data):
|
||||||
|
ws, err = parseNxBOM(data, "")
|
||||||
|
case IsTextBOM(data):
|
||||||
|
ws, err = parseTextBOM(data, "")
|
||||||
|
default:
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
if err != nil || ws == nil || len(ws.Configurations) == 0 {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
return ws.Configurations[0].Rows, ws.SourceFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTextBOM reports whether data looks like a human-readable Russian text BOM,
|
||||||
|
// i.e. it contains at least one "<description> - <quantity> шт." line.
|
||||||
|
func IsTextBOM(data []byte) bool {
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
if textBOMItemLine.MatchString(strings.TrimSpace(raw)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTextBOM parses a human-readable Russian text BOM into a single configuration.
|
||||||
|
// The optional "Сервер <model>, в составе:" header provides the configuration name and
|
||||||
|
// server model. Each "<description> - <quantity> шт." line becomes one vendor spec row.
|
||||||
|
// The format carries no partnumbers, so rows stay unresolved and editable in the UI
|
||||||
|
// until mapped through the active partnumber book.
|
||||||
|
func parseTextBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
rows := make([]localdb.VendorSpecItem, 0, len(lines))
|
||||||
|
sortOrder := 10
|
||||||
|
serverModel := ""
|
||||||
|
|
||||||
|
for _, raw := range lines {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
|
||||||
|
if fields := strings.Fields(m[1]); len(fields) > 0 {
|
||||||
|
serverModel = fields[len(fields)-1]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := textBOMItemLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
description := strings.TrimSpace(m[1])
|
||||||
|
qty, err := strconv.Atoi(m[2])
|
||||||
|
if err != nil || qty <= 0 || description == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, localdb.VendorSpecItem{
|
||||||
|
SortOrder: sortOrder,
|
||||||
|
VendorPartnumber: description,
|
||||||
|
Quantity: qty,
|
||||||
|
Description: description,
|
||||||
|
})
|
||||||
|
sortOrder += 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, fmt.Errorf("text BOM has no importable rows")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := serverModel
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "Text BOM Import"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &importedWorkspace{
|
||||||
|
SourceFormat: "Text",
|
||||||
|
SourceFileName: sourceFileName,
|
||||||
|
Configurations: []importedConfiguration{
|
||||||
|
{
|
||||||
|
GroupID: "text-0",
|
||||||
|
Name: name,
|
||||||
|
Line: 10,
|
||||||
|
ServerCount: 1,
|
||||||
|
ServerModel: serverModel,
|
||||||
|
Rows: rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export.
|
||||||
|
// The file starts (after optional UTF-8 BOM) with the header line:
|
||||||
|
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."
|
||||||
|
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,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -358,3 +359,462 @@ func TestImportVendorWorkspaceToProject_AutoResolvesAndAppliesEstimate(t *testin
|
|||||||
t.Fatalf("expected resolved rows for CPU and LIC in vendor spec")
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
41
memory.md
41
memory.md
@@ -1,41 +0,0 @@
|
|||||||
# Changes summary (2026-02-11)
|
|
||||||
|
|
||||||
Implemented strict `lot_category` flow using `pricelist_items.lot_category` only (no parsing from `lot_name`), plus local caching and backfill:
|
|
||||||
|
|
||||||
1. Local DB schema + migrations
|
|
||||||
- Added `lot_category` column to `local_pricelist_items` via `LocalPricelistItem` model.
|
|
||||||
- Added local migration `2026_02_11_local_pricelist_item_category` to add the column if missing and create indexes:
|
|
||||||
- `idx_local_pricelist_items_pricelist_lot (pricelist_id, lot_name)`
|
|
||||||
- `idx_local_pricelist_items_lot_category (lot_category)`
|
|
||||||
|
|
||||||
2. Server model/repository
|
|
||||||
- Added `LotCategory` field to `models.PricelistItem`.
|
|
||||||
- `PricelistRepository.GetItems` now sets `Category` from `LotCategory` (no parsing from `lot_name`).
|
|
||||||
|
|
||||||
3. Sync + local DB helpers
|
|
||||||
- `SyncPricelistItems` now saves `lot_category` into local cache via `PricelistItemToLocal`.
|
|
||||||
- Added `LocalDB.CountLocalPricelistItemsWithEmptyCategory` and `LocalDB.ReplaceLocalPricelistItems`.
|
|
||||||
- Added `LocalDB.GetLocalLotCategoriesByServerPricelistID` for strict category lookup.
|
|
||||||
- Added `SyncPricelists` backfill step: for used active pricelists with empty categories, force refresh items from server.
|
|
||||||
|
|
||||||
4. API handler
|
|
||||||
- `GET /api/pricelists/:id/items` returns `category` from `local_pricelist_items.lot_category` (no parsing from `lot_name`).
|
|
||||||
|
|
||||||
5. Article category foundation
|
|
||||||
- New package `internal/article`:
|
|
||||||
- `ResolveLotCategoriesStrict` pulls categories from local pricelist items and errors on missing category.
|
|
||||||
- `GroupForLotCategory` maps only allowed codes (CPU/MEM/GPU/M2/SSD/HDD/EDSFF/HHHL/NIC/HCA/DPU/PSU/PS) to article groups; excludes `SFP`.
|
|
||||||
- Error type `MissingCategoryForLotError` with base `ErrMissingCategoryForLot`.
|
|
||||||
|
|
||||||
6. Tests
|
|
||||||
- Added unit tests for converters and article category resolver.
|
|
||||||
- Added handler test to ensure `/api/pricelists/:id/items` returns `lot_category`.
|
|
||||||
- Added sync test for category backfill on used pricelist items.
|
|
||||||
- `go test ./...` passed.
|
|
||||||
|
|
||||||
Additional fixes (2026-02-11):
|
|
||||||
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
|
|
||||||
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).
|
|
||||||
|
|
||||||
UI rule (2026-02-19):
|
|
||||||
- In all breadcrumbs, truncate long specification/configuration names to 16 characters using ellipsis.
|
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: lot
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if lot_category already exists
|
||||||
|
-- recovery.partial: DROP INDEX IF EXISTS idx_lot_category ON lot; ALTER TABLE lot DROP COLUMN lot_category;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: lot_category column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='lot' AND column_name='lot_category' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Migration: Add lot_category column to lot table
|
-- Migration: Add lot_category column to lot table
|
||||||
-- Run this migration manually on the database
|
-- Run this migration manually on the database
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if custom_price already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN custom_price;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: custom_price column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='custom_price' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add custom_price column to qt_configurations table
|
-- Add custom_price column to qt_configurations table
|
||||||
ALTER TABLE qt_configurations ADD COLUMN custom_price DECIMAL(12,2) NULL COMMENT 'User-defined custom total price';
|
ALTER TABLE qt_configurations ADD COLUMN custom_price DECIMAL(12,2) NULL COMMENT 'User-defined custom total price';
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
|
-- Tables affected: qt_lot_metadata
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if is_hidden already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_lot_metadata DROP COLUMN is_hidden;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: is_hidden column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_lot_metadata' AND column_name='is_hidden' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add is_hidden column to qt_lot_metadata table
|
-- Add is_hidden column to qt_lot_metadata table
|
||||||
ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator';
|
ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator';
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if price_updated_at already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN price_updated_at;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: price_updated_at column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='price_updated_at' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Add price_updated_at column to qt_configurations table
|
-- Add price_updated_at column to qt_configurations table
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL
|
ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
-- Tables affected: qt_configurations
|
||||||
|
-- recovery.not-started: check first; ADD COLUMN fails if owner_username already exists
|
||||||
|
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN owner_username;
|
||||||
|
-- recovery.completed: no action needed
|
||||||
|
-- verify: owner_username column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='owner_username' HAVING COUNT(*)=0
|
||||||
|
|
||||||
-- Store configuration owner as username (instead of relying on numeric user_id)
|
-- Store configuration owner as username (instead of relying on numeric user_id)
|
||||||
ALTER TABLE qt_configurations
|
ALTER TABLE qt_configurations
|
||||||
ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id,
|
ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user