Compare commits
6 Commits
42458455f7
...
v1.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2da8b4253 | ||
|
|
06397a6bd1 | ||
|
|
4e977737ee | ||
|
|
7c3752f110 | ||
|
|
08ecfd0826 | ||
|
|
8663a87d28 |
33
acc_lot_log_import.sql
Normal file
33
acc_lot_log_import.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Generated from /Users/mchusavitin/Downloads/acc.csv
|
||||
-- Unambiguous rows only. Rows from headers without a date were skipped.
|
||||
INSERT INTO lot_log (`lot`, `supplier`, `date`, `price`, `quality`, `comments`) VALUES
|
||||
('ACC_RMK_L_Type', '', '2024-04-01', 19, NULL, 'header supplier missing in source (45383)'),
|
||||
('ACC_RMK_SLIDE', '', '2024-04-01', 31, NULL, 'header supplier missing in source (45383)'),
|
||||
('NVLINK_2S_Bridge', '', '2023-01-01', 431, NULL, 'header supplier missing in source (44927)'),
|
||||
('NVLINK_2S_Bridge', 'Jevy Yang', '2025-01-15', 139, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Wendy', '2025-01-15', 143, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'HONCH (Darian)', '2025-05-06', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'HONCH (Sunny)', '2025-06-17', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Wendy', '2025-07-02', 145, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Honch (Sunny)', '2025-07-10', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Honch (Yan)', '2025-08-07', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Jevy', '2025-09-09', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Honch (Darian)', '2025-11-17', 102, NULL, NULL),
|
||||
('NVLINK_2W_Bridge(H200)', '', '2023-01-01', 405, NULL, 'header supplier missing in source (44927)'),
|
||||
('NVLINK_2W_Bridge(H200)', 'network logic / Stephen', '2025-02-10', 305, NULL, NULL),
|
||||
('NVLINK_2W_Bridge(H200)', 'JEVY', '2025-02-18', 411, NULL, NULL),
|
||||
('NVLINK_4W_Bridge(H200)', '', '2023-01-01', 820, NULL, 'header supplier missing in source (44927)'),
|
||||
('NVLINK_4W_Bridge(H200)', 'network logic / Stephen', '2025-02-10', 610, NULL, NULL),
|
||||
('NVLINK_4W_Bridge(H200)', 'JEVY', '2025-02-18', 754, NULL, NULL),
|
||||
('25G_SFP28_MMA2P00-AS', 'HONCH (Doris)', '2025-02-19', 65, NULL, NULL),
|
||||
('ACC_SuperCap', '', '2024-04-01', 59, NULL, 'header supplier missing in source (45383)'),
|
||||
('ACC_SuperCap', 'Chiphome', '2025-02-28', 48, NULL, NULL);
|
||||
|
||||
-- Skipped source values due to missing date in header:
|
||||
-- lot=ACC_RMK_L_Type; header=FOB; price=19; reason=header has supplier but no date
|
||||
-- lot=ACC_RMK_SLIDE; header=FOB; price=31; reason=header has supplier but no date
|
||||
-- lot=NVLINK_2S_Bridge; header=FOB; price=155; reason=header has supplier but no date
|
||||
-- lot=NVLINK_2W_Bridge(H200); header=FOB; price=405; reason=header has supplier but no date
|
||||
-- lot=NVLINK_4W_Bridge(H200); header=FOB; price=754; reason=header has supplier but no date
|
||||
-- lot=25G_SFP28_MMA2P00-AS; header=FOB; price=65; reason=header has supplier but no date
|
||||
-- lot=ACC_SuperCap; header=FOB; price=48; reason=header has supplier but no date
|
||||
2
bible
2
bible
Submodule bible updated: 34b457d654...5a69e0bba8
@@ -3,7 +3,7 @@
|
||||
## What is QuoteForge
|
||||
|
||||
A corporate server configuration and quotation tool.
|
||||
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only for synchronization.
|
||||
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only by synchronization and dedicated setup/migration tooling.
|
||||
|
||||
---
|
||||
|
||||
@@ -18,14 +18,14 @@ Operates in **strict local-first** mode: all user operations go through local SQ
|
||||
- Full offline operation — continue working without network, sync later
|
||||
- Guarded synchronization — sync is blocked by preflight check if local schema is not ready
|
||||
|
||||
### User Roles
|
||||
### Local Client Security Model
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| `viewer` | View, create quotes, export |
|
||||
| `editor` | + save configurations |
|
||||
| `pricing_admin` | + manage prices and alerts |
|
||||
| `admin` | Full access, user management |
|
||||
QuoteForge is currently a **single-user thick client** bound to `localhost`.
|
||||
|
||||
- The local HTTP/UI layer is not treated as a multi-user security boundary.
|
||||
- RBAC is not part of the active product contract for the local client.
|
||||
- The authoritative authentication boundary is the remote sync server and its DB credentials captured during setup.
|
||||
- If the app is ever exposed beyond `localhost`, auth/RBAC must be reintroduced as an enforced perimeter before release.
|
||||
|
||||
### Price Freshness Indicators
|
||||
|
||||
@@ -45,7 +45,7 @@ Operates in **strict local-first** mode: all user operations go through local SQ
|
||||
| Backend | Go 1.22+, Gin, GORM |
|
||||
| Frontend | HTML, Tailwind CSS, htmx |
|
||||
| Local DB | SQLite (`qfs.db`) |
|
||||
| Server DB | MariaDB 11+ (sync + server admin) |
|
||||
| Server DB | MariaDB 11+ (sync transport only for app runtime) |
|
||||
| Export | encoding/csv, excelize (XLSX) |
|
||||
|
||||
---
|
||||
@@ -82,7 +82,6 @@ quoteforge/
|
||||
│ ├── db/ # DB initialization
|
||||
│ ├── handlers/ # HTTP handlers
|
||||
│ ├── localdb/ # SQLite layer
|
||||
│ ├── lotmatch/ # Lot matching logic
|
||||
│ ├── middleware/ # Auth, CORS, etc.
|
||||
│ ├── models/ # GORM models
|
||||
│ ├── repository/ # Repository layer
|
||||
@@ -101,19 +100,31 @@ quoteforge/
|
||||
|
||||
## Integration with Existing DB
|
||||
|
||||
QuoteForge integrates with the existing `RFQ_LOG` database:
|
||||
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_local_migrations` — migration catalog (SELECT only)
|
||||
- `qt_client_schema_state` — applied migrations state
|
||||
- `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
|
||||
|
||||
@@ -21,6 +21,36 @@ MariaDB (RFQ_LOG) ← pull/push only
|
||||
- If MariaDB is unavailable → local work continues without restrictions
|
||||
- Changes are queued in `pending_changes` and pushed on next sync
|
||||
|
||||
## MariaDB Boundary
|
||||
|
||||
MariaDB is not part of the runtime read/write path for user features.
|
||||
|
||||
Hard rules:
|
||||
|
||||
- HTTP handlers, web pages, quote calculation, export, vendor BOM resolution, pricelist browsing, project browsing, and configuration CRUD must read/write SQLite only.
|
||||
- MariaDB access from the app runtime is allowed only inside the sync subsystem (`internal/services/sync/*`) for explicit pull/push work.
|
||||
- Dedicated tooling under `cmd/migrate` and `cmd/migrate_ops_projects` may access MariaDB for operator-run schema/data migration tasks.
|
||||
- Setup may test/store connection settings, but after setup the application must treat MariaDB as sync transport only.
|
||||
- Any new repository/service/handler that issues MariaDB queries outside sync is a regression and must be rejected in review.
|
||||
- Local SQLite migrations are code-defined only (`AutoMigrate` + `runLocalMigrations`); there is no server-driven client migration registry.
|
||||
- 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.
|
||||
|
||||
Forbidden patterns:
|
||||
|
||||
- calling `connMgr.GetDB()` from non-sync runtime business code;
|
||||
- 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
|
||||
|
||||
The running app is a localhost-only thick client.
|
||||
|
||||
- Browser/UI requests on the local machine are treated as part of the same trusted user session.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## Synchronization
|
||||
@@ -61,6 +91,7 @@ pending_changes pending_changes
|
||||
| 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.
|
||||
|
||||
@@ -75,8 +106,7 @@ Configurations and projects are **never hard-deleted**. Deletion is archive via
|
||||
|
||||
Before every push/pull, a preflight check runs:
|
||||
1. Is the server (MariaDB) reachable?
|
||||
2. Can centralized local DB migrations be applied?
|
||||
3. Does the application version satisfy `min_app_version` of pending migrations?
|
||||
2. Is the local client schema initialized and writable?
|
||||
|
||||
**If the check fails:**
|
||||
- Local CRUD continues without restriction
|
||||
@@ -91,6 +121,7 @@ Before every push/pull, a preflight check runs:
|
||||
|
||||
**Prices come only from `local_pricelist_items`.**
|
||||
Components (`local_components`) are metadata-only — they contain no pricing information.
|
||||
Stock enrichment for pricelist rows is persisted into `local_pricelist_items` during sync; UI/runtime must not resolve it live from MariaDB.
|
||||
|
||||
### Lookup Pattern
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
||||
| `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 |
|
||||
@@ -26,12 +32,10 @@ File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
||||
| 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` | PN→LOT mapping rows | `id`, `book_id` (FK), `partnumber`, `lot_name`, `is_primary_pn` |
|
||||
| `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`
|
||||
|
||||
`is_primary_pn=1` means this PN's quantity in the vendor BOM determines qty(LOT). If only non-primary PNs are present for a LOT, qty defaults to 1.
|
||||
|
||||
#### Configurations and Projects
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
@@ -100,18 +104,72 @@ Database: `RFQ_LOG`
|
||||
| `qt_categories` | Component categories | SELECT |
|
||||
| `qt_pricelists` | Pricelists | SELECT |
|
||||
| `qt_pricelist_items` | Pricelist line items | SELECT |
|
||||
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
|
||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
|
||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment during sync only) | SELECT |
|
||||
| `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE |
|
||||
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
|
||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
|
||||
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
||||
| `qt_client_local_migrations` | Migration catalog | SELECT only |
|
||||
| `qt_client_schema_state` | Applied migrations state | 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 snapshots (written by PriceForge) | SELECT |
|
||||
| `qt_partnumber_book_items` | PN→LOT mapping rows (written by PriceForge) | SELECT |
|
||||
| `qt_vendor_partnumber_seen` | Vendor PN tracking for unresolved/ignored BOM rows (`is_ignored`) | 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
|
||||
|
||||
@@ -121,13 +179,11 @@ 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.lot_partnumbers 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 ON RFQ_LOG.qt_client_local_migrations 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>'@'%';
|
||||
|
||||
@@ -148,11 +204,9 @@ 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.lot_partnumbers 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 ON RFQ_LOG.qt_client_local_migrations 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'@'%';
|
||||
@@ -163,7 +217,7 @@ FLUSH PRIVILEGES;
|
||||
SHOW GRANTS FOR 'quote_user'@'%';
|
||||
```
|
||||
|
||||
**Note:** If pricelists sync but show `0` positions (or logs contain `enriching pricelist items with stock` + `SELECT denied`), verify `SELECT` on `lot_partnumbers` and `stock_log` in addition to `qt_pricelist_items`.
|
||||
**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@'%').
|
||||
|
||||
@@ -171,17 +225,24 @@ SHOW GRANTS FOR 'quote_user'@'%';
|
||||
|
||||
## Migrations
|
||||
|
||||
### SQLite Migrations (local) — три уровня, выполняются при каждом старте
|
||||
### SQLite Migrations (local) — два уровня, выполняются при каждом старте
|
||||
|
||||
**1. GORM AutoMigrate** (`internal/localdb/localdb.go`) — первый и основной уровень.
|
||||
Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**.
|
||||
|
||||
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`.
|
||||
|
||||
**3. Централизованные (server-side)** — третий уровень, при проверке готовности к синку.
|
||||
SQL-тексты хранятся в `qt_client_local_migrations` (MariaDB, пишет только PriceForge). Клиент читает, применяет к локальной SQLite, записывает в `local_remote_migrations_applied` + `qt_client_schema_state`.
|
||||
QuoteForge does not use centralized server-driven SQLite migrations.
|
||||
All local SQLite schema/data migrations live in the client codebase.
|
||||
|
||||
### MariaDB Migrations (server-side)
|
||||
|
||||
|
||||
@@ -70,10 +70,15 @@
|
||||
| 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:
|
||||
`line ASC`, then `created_at DESC`, then `id DESC`.
|
||||
|
||||
`POST /api/projects/:uuid/vendor-import` accepts `multipart/form-data` with one required file field:
|
||||
|
||||
- `file` — vendor configurator export in `CFXML` format
|
||||
|
||||
### Sync
|
||||
|
||||
| Method | Endpoint | Purpose | Flow |
|
||||
@@ -115,7 +120,7 @@ Notes:
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/partnumber-books` | List local book snapshots |
|
||||
| GET | `/api/partnumber-books/:id` | Items for a book by `server_id` |
|
||||
| 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.
|
||||
@@ -126,6 +131,8 @@ See [09-vendor-spec.md](09-vendor-spec.md) for `vendor_spec` JSON schema and BOM
|
||||
| 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`)
|
||||
|
||||
@@ -23,6 +23,18 @@ 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
|
||||
@@ -53,12 +65,12 @@ backup:
|
||||
| `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_JWT_SECRET` | JWT secret | — |
|
||||
| `QF_SERVER_PORT` | HTTP server port | 8080 |
|
||||
|
||||
`QFS_BACKUP_DISABLE` accepts: `1`, `true`, `yes`.
|
||||
|
||||
@@ -34,6 +34,11 @@ backup:
|
||||
- `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
|
||||
@@ -76,6 +81,7 @@ type BackupConfig struct {
|
||||
- `.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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -60,6 +60,11 @@ The following components were **intentionally removed** and must not be brought
|
||||
|
||||
- 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
|
||||
|
||||
@@ -108,7 +113,7 @@ if found && price > 0 {
|
||||
|
||||
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 offline-first (SQLite); online path is separate
|
||||
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`)
|
||||
|
||||
@@ -15,7 +15,16 @@ The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a
|
||||
| `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.
|
||||
`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
|
||||
|
||||
@@ -96,13 +105,11 @@ CREATE TABLE local_partnumber_books (
|
||||
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
book_id INTEGER NOT NULL, -- FK → local_partnumber_books.id
|
||||
partnumber TEXT NOT NULL,
|
||||
lot_name TEXT NOT NULL,
|
||||
is_primary_pn INTEGER NOT NULL DEFAULT 0,
|
||||
lots_json TEXT NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
CREATE INDEX idx_local_book_pn ON local_partnumber_book_items(book_id, partnumber);
|
||||
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`
|
||||
@@ -116,18 +123,16 @@ 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
|
||||
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,
|
||||
book_id INT NOT NULL,
|
||||
partnumber VARCHAR(255) NOT NULL,
|
||||
lot_name VARCHAR(255) NOT NULL,
|
||||
is_primary_pn TINYINT(1) NOT NULL DEFAULT 0,
|
||||
lots_json LONGTEXT NOT NULL,
|
||||
description VARCHAR(10000) NULL,
|
||||
INDEX idx_book_pn (book_id, partnumber),
|
||||
FOREIGN KEY (book_id) REFERENCES qt_partnumber_books(id)
|
||||
UNIQUE KEY uq_qt_partnumber_book_items_partnumber (partnumber)
|
||||
);
|
||||
|
||||
ALTER TABLE qt_configurations ADD COLUMN vendor_spec JSON NULL;
|
||||
@@ -141,40 +146,226 @@ GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
|
||||
```
|
||||
|
||||
### `is_primary_pn` semantics
|
||||
|
||||
- `1` = primary PN for this `lot_name`. Its quantity in the vendor BOM determines `qty(LOT)`.
|
||||
- `0` = non-primary (trigger) PN. If no primary PN for the LOT is found in BOM, contributes `qty=1`.
|
||||
|
||||
---
|
||||
|
||||
## Resolution Algorithm (3-step)
|
||||
|
||||
For each `vendor_partnumber` in the BOM, QuoteForge builds/updates UI-visible LOT mappings:
|
||||
|
||||
1. **Active book lookup** — query `local_partnumber_book_items WHERE book_id = activeBook.id AND partnumber = ?`.
|
||||
2. **Populate BOM UI** — if a match exists, BOM row shows a LOT value (user can still edit it).
|
||||
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:
|
||||
|
||||
1. Read all top-level `ProductLineItem` rows in document order.
|
||||
2. Group them by `ProprietaryGroupIdentifier`.
|
||||
3. Preserve document order of groups by the first encountered `ProductLineNumber`.
|
||||
4. Import each group as exactly one QuoteForge configuration.
|
||||
|
||||
`ConfigurationGroupLineNumberReference` is not sufficient for grouping imported configurations because
|
||||
multiple independent configuration groups may share the same value in one workspace.
|
||||
|
||||
### Primary Row Selection (no SKU hardcode)
|
||||
|
||||
The importer must not hardcode vendor, model, or server SKU values.
|
||||
|
||||
Within each `ProprietaryGroupIdentifier` group, the importer selects one primary top-level row using
|
||||
structural rules only:
|
||||
|
||||
1. Prefer rows with `ProductTypeCode = Hardware`.
|
||||
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`.
|
||||
|
||||
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
|
||||
|
||||
For QuoteForge product behavior, the correct user-facing interpretation is:
|
||||
|
||||
- one external project/workspace contains several configurations;
|
||||
- each configuration contains both hardware and software rows that belong to it;
|
||||
- the importer must preserve that grouping exactly.
|
||||
|
||||
---
|
||||
|
||||
## Qty Aggregation Logic
|
||||
|
||||
After resolution, qty per LOT is computed as:
|
||||
After resolution, qty per LOT is computed from the BOM row quantity multiplied by the matched `lots_json.qty`:
|
||||
|
||||
```
|
||||
qty(lot) = SUM(quantity of rows where is_primary_pn=1 AND resolved_lot=lot)
|
||||
if at least one primary PN for this lot was found in BOM
|
||||
= 1
|
||||
if only non-primary PNs for this lot were found
|
||||
qty(lot) = SUM(quantity_of_pn_row * quantity_of_lot_inside_lots_json)
|
||||
```
|
||||
|
||||
Examples (book: LOT_A → x1[primary], x2, x3):
|
||||
- BOM: x2×1, x3×2 → 1×LOT_A (no primary PN)
|
||||
- BOM: x1×2, x2×1 → 2×LOT_A (primary qty=2)
|
||||
- BOM: x1×1, x2×3 → 1×LOT_A (primary qty=1)
|
||||
Examples (book: PN_X → `[{LOT_A, qty:2}, {LOT_B, qty:1}]`):
|
||||
- BOM: PN_X ×3 → `LOT_A ×6`, `LOT_B ×3`
|
||||
- BOM: PN_X ×1 and PN_X ×2 → `LOT_A ×6`, `LOT_B ×3`
|
||||
|
||||
---
|
||||
|
||||
@@ -294,6 +485,7 @@ Estimate-only rows are shown as separate rows with:
|
||||
| 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 |
|
||||
@@ -306,19 +498,31 @@ After each `resolveBOM()` call, QuoteForge pushes PN rows to `POST /api/sync/par
|
||||
- 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 upserts into `qt_vendor_partnumber_seen`:
|
||||
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
|
||||
last_seen_at = VALUES(last_seen_at),
|
||||
is_ignored = VALUES(is_ignored),
|
||||
description = COALESCE(NULLIF(VALUES(description), ''), description)
|
||||
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`.
|
||||
|
||||
163
cmd/qfs/main.go
163
cmd/qfs/main.go
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"math"
|
||||
@@ -31,7 +32,6 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -150,29 +150,25 @@ func main() {
|
||||
|
||||
setupLogger(cfg.Logging)
|
||||
|
||||
// Create connection manager and try to connect immediately if settings exist
|
||||
// Create connection manager. Runtime stays local-first; MariaDB is used on demand by sync/setup only.
|
||||
connMgr := db.NewConnectionManager(local)
|
||||
|
||||
dbUser := local.GetDBUser()
|
||||
|
||||
// Try to connect to MariaDB on startup
|
||||
mariaDB, err := connMgr.GetDB()
|
||||
if err != nil {
|
||||
slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err)
|
||||
mariaDB = nil
|
||||
} else {
|
||||
slog.Info("successfully connected to MariaDB on startup")
|
||||
}
|
||||
|
||||
slog.Info("starting QuoteForge server",
|
||||
"version", Version,
|
||||
"host", cfg.Server.Host,
|
||||
"port", cfg.Server.Port,
|
||||
"db_user", dbUser,
|
||||
"online", mariaDB != nil,
|
||||
"online", false,
|
||||
)
|
||||
|
||||
if *migrate {
|
||||
mariaDB, err := connMgr.GetDB()
|
||||
if err != nil {
|
||||
slog.Error("cannot run migrations: database not available", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if mariaDB == nil {
|
||||
slog.Error("cannot run migrations: database not available")
|
||||
os.Exit(1)
|
||||
@@ -189,39 +185,10 @@ func main() {
|
||||
slog.Info("migrations completed")
|
||||
}
|
||||
|
||||
// Always apply SQL migrations on startup when database is available.
|
||||
// This keeps schema in sync for long-running installations without manual steps.
|
||||
// If current DB user does not have enough privileges, continue startup in normal mode.
|
||||
if mariaDB != nil {
|
||||
sqlMigrationsPath := filepath.Join("migrations")
|
||||
needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath)
|
||||
if err != nil {
|
||||
if models.IsMigrationPermissionError(err) {
|
||||
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
|
||||
} else {
|
||||
slog.Error("startup SQL migrations check failed", "path", sqlMigrationsPath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else if needsMigrations {
|
||||
if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil {
|
||||
if models.IsMigrationPermissionError(err) {
|
||||
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
|
||||
} else {
|
||||
slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
slog.Info("startup SQL migrations applied", "path", sqlMigrationsPath)
|
||||
}
|
||||
} else {
|
||||
slog.Debug("startup SQL migrations not needed", "path", sqlMigrationsPath)
|
||||
}
|
||||
}
|
||||
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
restartSig := make(chan struct{}, 1)
|
||||
|
||||
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser, restartSig)
|
||||
router, syncService, err := setupRouter(cfg, local, connMgr, dbUser, restartSig)
|
||||
if err != nil {
|
||||
slog.Error("failed to setup router", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -671,46 +638,14 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string, restartSig chan struct{}) (*gin.Engine, *sync.Service, error) {
|
||||
// mariaDB may be nil if we're in offline mode
|
||||
|
||||
// Repositories
|
||||
var componentRepo *repository.ComponentRepository
|
||||
var categoryRepo *repository.CategoryRepository
|
||||
var statsRepo *repository.StatsRepository
|
||||
var pricelistRepo *repository.PricelistRepository
|
||||
|
||||
// Only initialize repositories if we have a database connection
|
||||
if mariaDB != nil {
|
||||
componentRepo = repository.NewComponentRepository(mariaDB)
|
||||
categoryRepo = repository.NewCategoryRepository(mariaDB)
|
||||
statsRepo = repository.NewStatsRepository(mariaDB)
|
||||
pricelistRepo = repository.NewPricelistRepository(mariaDB)
|
||||
} else {
|
||||
// In offline mode, we'll use nil repositories or handle them differently
|
||||
// This is handled in the sync service and other components
|
||||
}
|
||||
|
||||
// Services
|
||||
var componentService *services.ComponentService
|
||||
var quoteService *services.QuoteService
|
||||
var exportService *services.ExportService
|
||||
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, dbUsername string, restartSig chan struct{}) (*gin.Engine, *sync.Service, error) {
|
||||
var syncService *sync.Service
|
||||
var projectService *services.ProjectService
|
||||
|
||||
// Sync service always uses ConnectionManager (works offline and online)
|
||||
syncService = sync.NewService(connMgr, local)
|
||||
|
||||
if mariaDB != nil {
|
||||
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
|
||||
exportService = services.NewExportService(cfg.Export, categoryRepo, local)
|
||||
} else {
|
||||
// In offline mode, we still need to create services that don't require DB.
|
||||
componentService = services.NewComponentService(nil, nil, nil)
|
||||
quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
|
||||
exportService = services.NewExportService(cfg.Export, nil, local)
|
||||
}
|
||||
componentService := services.NewComponentService(nil, nil, nil)
|
||||
quoteService := services.NewQuoteService(nil, nil, nil, local, nil)
|
||||
exportService := services.NewExportService(cfg.Export, nil, local)
|
||||
|
||||
// isOnline function for local-first architecture
|
||||
isOnline := func() bool {
|
||||
@@ -731,16 +666,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
if err := local.BackfillConfigurationProjects(dbUsername); err != nil {
|
||||
slog.Warn("failed to backfill local configuration projects", "error", err)
|
||||
}
|
||||
if mariaDB != nil {
|
||||
serverProjectRepo := repository.NewProjectRepository(mariaDB)
|
||||
if removed, err := serverProjectRepo.PurgeEmptyNamelessProjects(); err == nil && removed > 0 {
|
||||
slog.Info("purged empty nameless server projects", "removed", removed)
|
||||
}
|
||||
if err := serverProjectRepo.EnsureSystemProjectsAndBackfillConfigurations(); err != nil {
|
||||
slog.Warn("failed to backfill server configuration projects", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
type pullState struct {
|
||||
mu syncpkg.Mutex
|
||||
running bool
|
||||
@@ -818,7 +743,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
// Handlers
|
||||
componentHandler := handlers.NewComponentHandler(componentService, local)
|
||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, projectService)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
|
||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||
vendorSpecHandler := handlers.NewVendorSpecHandler(local)
|
||||
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
|
||||
@@ -834,7 +759,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
}
|
||||
|
||||
// Web handler (templates)
|
||||
webHandler, err := handlers.NewWebHandler(templatesPath, componentService)
|
||||
webHandler, err := handlers.NewWebHandler(templatesPath, local)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -890,20 +815,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
}
|
||||
}
|
||||
|
||||
// Optional diagnostics mode with server table counts.
|
||||
if includeCounts && status.IsConnected {
|
||||
if db, err := connMgr.GetDB(); err == nil && db != nil {
|
||||
_ = db.Table("lot").Count(&lotCount)
|
||||
_ = db.Table("lot_log").Count(&lotLogCount)
|
||||
_ = db.Table("qt_lot_metadata").Count(&metadataCount)
|
||||
} else if err != nil {
|
||||
dbOK = false
|
||||
dbError = err.Error()
|
||||
} else {
|
||||
dbOK = false
|
||||
dbError = "Database not connected (offline mode)"
|
||||
}
|
||||
} else {
|
||||
// Runtime diagnostics stay local-only. Server table counts are intentionally unavailable here.
|
||||
if !includeCounts || !status.IsConnected {
|
||||
lotCount = 0
|
||||
lotLogCount = 0
|
||||
metadataCount = 0
|
||||
@@ -919,11 +832,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
})
|
||||
})
|
||||
|
||||
// Current user info (DB user, not app user)
|
||||
// Current user info (local DB username)
|
||||
router.GET("/api/current-user", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": local.GetDBUser(),
|
||||
"role": "db_user",
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1730,7 +1642,46 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusCreated, config)
|
||||
})
|
||||
|
||||
projects.POST("/:uuid/vendor-import", func(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read uploaded file"})
|
||||
return
|
||||
}
|
||||
if !services.IsCFXMLWorkspace(data) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := configService.ImportVendorWorkspaceToProject(c.Param("uuid"), fileHeader.Filename, data, dbUsername)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, result)
|
||||
})
|
||||
|
||||
projects.GET("/:uuid/export", exportHandler.ExportProjectCSV)
|
||||
projects.POST("/:uuid/export", exportHandler.ExportProjectPricingCSV)
|
||||
|
||||
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
|
||||
var req struct {
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestConfigurationVersioningAPI(t *testing.T) {
|
||||
|
||||
cfg := &config.Config{}
|
||||
setConfigDefaults(cfg)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
|
||||
|
||||
cfg := &config.Config{}
|
||||
setConfigDefaults(cfg)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
|
||||
local, connMgr, _ := newAPITestStack(t)
|
||||
cfg := &config.Config{}
|
||||
setConfigDefaults(cfg)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
|
||||
@@ -18,11 +18,6 @@ database:
|
||||
max_idle_conns: 5
|
||||
conn_max_lifetime: "5m"
|
||||
|
||||
auth:
|
||||
jwt_secret: "CHANGE_ME_MIN_32_CHARACTERS_LONG"
|
||||
token_expiry: "24h"
|
||||
refresh_expiry: "168h" # 7 days
|
||||
|
||||
pricing:
|
||||
default_method: "weighted_median" # median | average | weighted_median
|
||||
default_period_days: 90
|
||||
|
||||
5
go.mod
5
go.mod
@@ -5,9 +5,8 @@ go 1.24.0
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/google/uuid v1.6.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.5.2
|
||||
gorm.io/gorm v1.25.7
|
||||
@@ -23,7 +22,6 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -39,6 +37,7 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -32,8 +32,6 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
|
||||
@@ -88,6 +88,9 @@ func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
||||
}
|
||||
|
||||
root := resolveBackupRoot(dbPath)
|
||||
if err := validateBackupRoot(root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := backupNow()
|
||||
|
||||
created := make([]string, 0)
|
||||
@@ -111,6 +114,40 @@ func resolveBackupRoot(dbPath string) string {
|
||||
return filepath.Join(filepath.Dir(dbPath), "backups")
|
||||
}
|
||||
|
||||
func validateBackupRoot(root string) error {
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve backup root: %w", err)
|
||||
}
|
||||
|
||||
if gitRoot, ok := findGitWorktreeRoot(absRoot); ok {
|
||||
return fmt.Errorf("backup root must stay outside git worktree: %s is inside %s", absRoot, gitRoot)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func findGitWorktreeRoot(path string) (string, bool) {
|
||||
current := filepath.Clean(path)
|
||||
info, err := os.Stat(current)
|
||||
if err == nil && !info.IsDir() {
|
||||
current = filepath.Dir(current)
|
||||
}
|
||||
|
||||
for {
|
||||
gitPath := filepath.Join(current, ".git")
|
||||
if _, err := os.Stat(gitPath); err == nil {
|
||||
return current, true
|
||||
}
|
||||
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return "", false
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func isBackupDisabled() bool {
|
||||
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
|
||||
return val == "1" || val == "true" || val == "yes"
|
||||
|
||||
@@ -3,6 +3,7 @@ package appstate
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -69,7 +70,7 @@ func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
|
||||
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||
t.Fatalf("backup with env: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(backupRoot, "daily", ".period.json")); err != nil {
|
||||
t.Fatalf("expected backup in custom dir: %v", err)
|
||||
}
|
||||
|
||||
@@ -77,7 +78,35 @@ func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
|
||||
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||
t.Fatalf("backup disabled: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(backupRoot, "daily", ".period.json")); err != nil {
|
||||
t.Fatalf("backup should remain from previous run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
repoRoot := filepath.Join(temp, "repo")
|
||||
if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0755); err != nil {
|
||||
t.Fatalf("mkdir git dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(repoRoot, "data", "qfs.db")
|
||||
cfgPath := filepath.Join(repoRoot, "data", "config.yaml")
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||
t.Fatalf("mkdir data dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
||||
t.Fatalf("write db: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||
t.Fatalf("write cfg: %v", err)
|
||||
}
|
||||
|
||||
_, err := EnsureRotatingLocalBackup(dbPath, cfgPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected git worktree backup root to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outside git worktree") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Pricing PricingConfig `yaml:"pricing"`
|
||||
Export ExportConfig `yaml:"export"`
|
||||
Alerts AlertsConfig `yaml:"alerts"`
|
||||
@@ -57,12 +56,6 @@ func (d *DatabaseConfig) DSN() string {
|
||||
return cfg.FormatDSN()
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
JWTSecret string `yaml:"jwt_secret"`
|
||||
TokenExpiry time.Duration `yaml:"token_expiry"`
|
||||
RefreshExpiry time.Duration `yaml:"refresh_expiry"`
|
||||
}
|
||||
|
||||
type PricingConfig struct {
|
||||
DefaultMethod string `yaml:"default_method"`
|
||||
DefaultPeriodDays int `yaml:"default_period_days"`
|
||||
@@ -152,13 +145,6 @@ func (c *Config) setDefaults() {
|
||||
c.Database.ConnMaxLifetime = 5 * time.Minute
|
||||
}
|
||||
|
||||
if c.Auth.TokenExpiry == 0 {
|
||||
c.Auth.TokenExpiry = 24 * time.Hour
|
||||
}
|
||||
if c.Auth.RefreshExpiry == 0 {
|
||||
c.Auth.RefreshExpiry = 7 * 24 * time.Hour
|
||||
}
|
||||
|
||||
if c.Pricing.DefaultMethod == "" {
|
||||
c.Pricing.DefaultMethod = "weighted_median"
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *services.AuthService
|
||||
userRepo *repository.UserRepository
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService *services.AuthService, userRepo *repository.UserRepository) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
User UserResponse `json:"user"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, user, err := h.authService.Login(req.Username, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, LoginResponse{
|
||||
AccessToken: tokens.AccessToken,
|
||||
RefreshToken: tokens.RefreshToken,
|
||||
ExpiresAt: tokens.ExpiresAt,
|
||||
User: UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: string(user.Role),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
var req RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.authService.RefreshTokens(req.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
claims := middleware.GetClaims(c)
|
||||
if claims == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userRepo.GetByID(claims.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: string(user.Role),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
// JWT is stateless, logout is handled on client by discarding tokens
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ConfigurationHandler struct {
|
||||
configService *services.ConfigurationService
|
||||
exportService *services.ExportService
|
||||
}
|
||||
|
||||
func NewConfigurationHandler(
|
||||
configService *services.ConfigurationService,
|
||||
exportService *services.ExportService,
|
||||
) *ConfigurationHandler {
|
||||
return &ConfigurationHandler{
|
||||
configService: configService,
|
||||
exportService: exportService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) List(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
configs, total, err := h.configService.ListByUser(username, page, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"configurations": configs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Create(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
|
||||
var req services.CreateConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.Create(username, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, config)
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Get(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
config, err := h.configService.GetByUUID(uuid, username)
|
||||
if err != nil {
|
||||
status := http.StatusNotFound
|
||||
if err == services.ErrConfigForbidden {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Update(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
var req services.CreateConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.Update(uuid, username, &req)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
status = http.StatusNotFound
|
||||
} else if err == services.ErrConfigForbidden {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Delete(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
err := h.configService.Delete(uuid, username)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
status = http.StatusNotFound
|
||||
} else if err == services.ErrConfigForbidden {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
type RenameConfigRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Rename(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
var req RenameConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.Rename(uuid, username, req.Name)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
status = http.StatusNotFound
|
||||
} else if err == services.ErrConfigForbidden {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
type CloneConfigRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Clone(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
var req CloneConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.Clone(uuid, username, req.Name)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
status = http.StatusNotFound
|
||||
} else if err == services.ErrConfigForbidden {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, config)
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) RefreshPrices(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
config, err := h.configService.RefreshPrices(uuid, username)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
status = http.StatusNotFound
|
||||
} else if err == services.ErrConfigForbidden {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
// func (h *ConfigurationHandler) ExportJSON(c *gin.Context) {
|
||||
// userID := middleware.GetUserID(c)
|
||||
// uuid := c.Param("uuid")
|
||||
//
|
||||
// config, err := h.configService.GetByUUID(uuid, userID)
|
||||
// if err != nil {
|
||||
// c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// data, err := h.configService.ExportJSON(uuid, userID)
|
||||
// if err != nil {
|
||||
// c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// filename := fmt.Sprintf("%s %s SPEC.json", config.CreatedAt.Format("2006-01-02"), config.Name)
|
||||
// c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
// c.Data(http.StatusOK, "application/json", data)
|
||||
// }
|
||||
|
||||
// func (h *ConfigurationHandler) ImportJSON(c *gin.Context) {
|
||||
// userID := middleware.GetUserID(c)
|
||||
//
|
||||
// data, err := io.ReadAll(c.Request.Body)
|
||||
// if err != nil {
|
||||
// c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// config, err := h.configService.ImportJSON(userID, data)
|
||||
// if err != nil {
|
||||
// c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// c.JSON(http.StatusCreated, config)
|
||||
// }
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -16,17 +15,20 @@ type ExportHandler struct {
|
||||
exportService *services.ExportService
|
||||
configService services.ConfigurationGetter
|
||||
projectService *services.ProjectService
|
||||
dbUsername string
|
||||
}
|
||||
|
||||
func NewExportHandler(
|
||||
exportService *services.ExportService,
|
||||
configService services.ConfigurationGetter,
|
||||
projectService *services.ProjectService,
|
||||
dbUsername string,
|
||||
) *ExportHandler {
|
||||
return &ExportHandler{
|
||||
exportService: exportService,
|
||||
configService: configService,
|
||||
projectService: projectService,
|
||||
dbUsername: dbUsername,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +47,14 @@ type ExportRequest struct {
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type ProjectExportOptionsRequest struct {
|
||||
IncludeLOT bool `json:"include_lot"`
|
||||
IncludeBOM bool `json:"include_bom"`
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
var req ExportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -63,8 +73,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
// Get project code for filename
|
||||
projectCode := req.ProjectName // legacy field: may contain code from frontend
|
||||
if projectCode == "" && req.ProjectUUID != "" {
|
||||
username := middleware.GetUsername(c)
|
||||
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
||||
if project, err := h.projectService.GetByUUID(req.ProjectUUID, h.dbUsername); err == nil && project != nil {
|
||||
projectCode = project.Code
|
||||
}
|
||||
}
|
||||
@@ -136,11 +145,10 @@ func sanitizeFilenameSegment(value string) string {
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
// Get config before streaming (can return JSON error)
|
||||
config, err := h.configService.GetByUUID(uuid, username)
|
||||
config, err := h.configService.GetByUUID(uuid, h.dbUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -157,7 +165,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
// Get project code for filename
|
||||
projectCode := config.Name // fallback: use config name if no project
|
||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
|
||||
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, h.dbUsername); err == nil && project != nil {
|
||||
projectCode = project.Code
|
||||
}
|
||||
}
|
||||
@@ -181,16 +189,15 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
|
||||
// ExportProjectCSV exports all active configurations of a project as a single CSV file.
|
||||
func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
projectUUID := c.Param("uuid")
|
||||
|
||||
project, err := h.projectService.GetByUUID(projectUUID, username)
|
||||
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.projectService.ListConfigurations(projectUUID, username, "active")
|
||||
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -213,3 +220,52 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||
projectUUID := c.Param("uuid")
|
||||
|
||||
var req ProjectExportOptionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
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
|
||||
}
|
||||
|
||||
opts := services.ProjectPricingExportOptions{
|
||||
IncludeLOT: req.IncludeLOT,
|
||||
IncludeBOM: req.IncludeBOM,
|
||||
IncludeEstimate: req.IncludeEstimate,
|
||||
IncludeStock: req.IncludeStock,
|
||||
IncludeCompetitor: req.IncludeCompetitor,
|
||||
}
|
||||
|
||||
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s (%s) pricing.csv", time.Now().Format("2006-01-02"), project.Code)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
if err := h.exportService.ToPricingCSV(c.Writer, data, opts); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*model
|
||||
return m.config, m.err
|
||||
}
|
||||
|
||||
|
||||
func TestExportCSV_Success(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -36,6 +35,7 @@ func TestExportCSV_Success(t *testing.T) {
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
nil,
|
||||
"testuser",
|
||||
)
|
||||
|
||||
// Create JSON request body
|
||||
@@ -110,6 +110,7 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
nil,
|
||||
"testuser",
|
||||
)
|
||||
|
||||
// Create invalid request (missing required field)
|
||||
@@ -143,6 +144,7 @@ func TestExportCSV_EmptyItems(t *testing.T) {
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
nil,
|
||||
"testuser",
|
||||
)
|
||||
|
||||
// Create request with empty items array - should fail binding validation
|
||||
@@ -184,6 +186,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
nil,
|
||||
"testuser",
|
||||
)
|
||||
|
||||
// Create HTTP request
|
||||
@@ -196,9 +199,6 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
||||
{Key: "uuid", Value: "test-uuid"},
|
||||
}
|
||||
|
||||
// Mock middleware.GetUsername
|
||||
c.Set("username", "testuser")
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Check status code
|
||||
@@ -233,6 +233,7 @@ func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||
exportSvc,
|
||||
&mockConfigService{err: errors.New("config not found")},
|
||||
nil,
|
||||
"testuser",
|
||||
)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
|
||||
@@ -243,8 +244,6 @@ func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "nonexistent-uuid"},
|
||||
}
|
||||
c.Set("username", "testuser")
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Should return 404 Not Found
|
||||
@@ -277,6 +276,7 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
nil,
|
||||
"testuser",
|
||||
)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||
@@ -287,8 +287,6 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "test-uuid"},
|
||||
}
|
||||
c.Set("username", "testuser")
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
@@ -66,6 +67,15 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
||||
}
|
||||
|
||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "100"))
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 500 {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
// Find local book by server_id
|
||||
var book localdb.LocalPartnumberBook
|
||||
@@ -74,17 +84,23 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
items, err := bookRepo.GetBookItems(book.ID)
|
||||
items, total, err := bookRepo.GetBookItemsPage(book.ID, search, page, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"book_id": book.ServerID,
|
||||
"version": book.Version,
|
||||
"is_active": book.IsActive,
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
"book_id": book.ServerID,
|
||||
"version": book.Version,
|
||||
"is_active": book.IsActive,
|
||||
"partnumbers": book.PartnumbersJSON,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"search": search,
|
||||
"book_total": bookRepo.CountBookItems(book.ID),
|
||||
"lot_count": bookRepo.CountDistinctLots(book.ID),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -516,18 +516,11 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
// Get sync times
|
||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||
|
||||
// Get MariaDB counts (if online)
|
||||
var lotCount, lotLogCount int64
|
||||
if isOnline {
|
||||
if mariaDB, err := h.connMgr.GetDB(); err == nil {
|
||||
mariaDB.Table("lot").Count(&lotCount)
|
||||
mariaDB.Table("lot_log").Count(&lotLogCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Get local counts
|
||||
configCount := h.localDB.CountConfigurations()
|
||||
projectCount := h.localDB.CountProjects()
|
||||
componentCount := h.localDB.CountLocalComponents()
|
||||
pricelistCount := h.localDB.CountLocalPricelists()
|
||||
|
||||
// Get error count (only changes with LastError != "")
|
||||
errorCount := int(h.localDB.CountErroredChanges())
|
||||
@@ -562,8 +555,8 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
DBName: dbName,
|
||||
IsOnline: isOnline,
|
||||
LastSyncAt: lastPricelistSync,
|
||||
LotCount: lotCount,
|
||||
LotLogCount: lotLogCount,
|
||||
LotCount: componentCount,
|
||||
LotLogCount: pricelistCount,
|
||||
ConfigCount: configCount,
|
||||
ProjectCount: projectCount,
|
||||
PendingChanges: changes,
|
||||
|
||||
@@ -3,19 +3,20 @@ package handlers
|
||||
import (
|
||||
"html/template"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WebHandler struct {
|
||||
templates map[string]*template.Template
|
||||
componentService *services.ComponentService
|
||||
templates map[string]*template.Template
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewWebHandler(_ string, componentService *services.ComponentService) (*WebHandler, error) {
|
||||
func NewWebHandler(_ string, localDB *localdb.LocalDB) (*WebHandler, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
@@ -59,7 +60,7 @@ func NewWebHandler(_ string, componentService *services.ComponentService) (*WebH
|
||||
|
||||
templates := make(map[string]*template.Template)
|
||||
// Load each page template with base
|
||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html", "partnumber_books.html"}
|
||||
simplePages := []string{"configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html", "partnumber_books.html"}
|
||||
for _, page := range simplePages {
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
@@ -104,8 +105,8 @@ func NewWebHandler(_ string, componentService *services.ComponentService) (*WebH
|
||||
}
|
||||
|
||||
return &WebHandler{
|
||||
templates: templates,
|
||||
componentService: componentService,
|
||||
templates: templates,
|
||||
localDB: localDB,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -128,36 +129,28 @@ func (h *WebHandler) Index(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *WebHandler) Configurator(c *gin.Context) {
|
||||
categories, _ := h.componentService.GetCategories()
|
||||
uuid := c.Query("uuid")
|
||||
|
||||
filter := repository.ComponentFilter{}
|
||||
result, err := h.componentService.List(filter, 1, 20)
|
||||
categories, _ := h.localCategories()
|
||||
components, total, err := h.localDB.ListComponents(localdb.ComponentFilter{}, 0, 20)
|
||||
|
||||
data := gin.H{
|
||||
"ActivePage": "configurator",
|
||||
"Categories": categories,
|
||||
"Components": []interface{}{},
|
||||
"Components": []localComponentView{},
|
||||
"Total": int64(0),
|
||||
"Page": 1,
|
||||
"PerPage": 20,
|
||||
"ConfigUUID": uuid,
|
||||
}
|
||||
|
||||
if err == nil && result != nil {
|
||||
data["Components"] = result.Components
|
||||
data["Total"] = result.Total
|
||||
data["Page"] = result.Page
|
||||
data["PerPage"] = result.PerPage
|
||||
if err == nil {
|
||||
data["Components"] = toLocalComponentViews(components)
|
||||
data["Total"] = total
|
||||
}
|
||||
|
||||
h.render(c, "index.html", data)
|
||||
}
|
||||
|
||||
func (h *WebHandler) Login(c *gin.Context) {
|
||||
h.render(c, "login.html", nil)
|
||||
}
|
||||
|
||||
func (h *WebHandler) Configs(c *gin.Context) {
|
||||
h.render(c, "configs.html", gin.H{"ActivePage": "configs"})
|
||||
}
|
||||
@@ -196,25 +189,30 @@ func (h *WebHandler) PartnumberBooks(c *gin.Context) {
|
||||
|
||||
func (h *WebHandler) ComponentsPartial(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
filter := repository.ComponentFilter{
|
||||
filter := localdb.ComponentFilter{
|
||||
Category: c.Query("category"),
|
||||
Search: c.Query("search"),
|
||||
}
|
||||
if c.Query("has_price") == "true" {
|
||||
filter.HasPrice = true
|
||||
}
|
||||
offset := (page - 1) * 20
|
||||
|
||||
data := gin.H{
|
||||
"Components": []interface{}{},
|
||||
"Components": []localComponentView{},
|
||||
"Total": int64(0),
|
||||
"Page": page,
|
||||
"PerPage": 20,
|
||||
}
|
||||
|
||||
result, err := h.componentService.List(filter, page, 20)
|
||||
if err == nil && result != nil {
|
||||
data["Components"] = result.Components
|
||||
data["Total"] = result.Total
|
||||
data["Page"] = result.Page
|
||||
data["PerPage"] = result.PerPage
|
||||
components, total, err := h.localDB.ListComponents(filter, offset, 20)
|
||||
if err == nil {
|
||||
data["Components"] = toLocalComponentViews(components)
|
||||
data["Total"] = total
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -222,3 +220,46 @@ func (h *WebHandler) ComponentsPartial(c *gin.Context) {
|
||||
tmpl.ExecuteTemplate(c.Writer, "components_list.html", data)
|
||||
}
|
||||
}
|
||||
|
||||
type localComponentView struct {
|
||||
LotName string
|
||||
Description string
|
||||
Category string
|
||||
CategoryName string
|
||||
Model string
|
||||
CurrentPrice *float64
|
||||
}
|
||||
|
||||
func toLocalComponentViews(items []localdb.LocalComponent) []localComponentView {
|
||||
result := make([]localComponentView, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, localComponentView{
|
||||
LotName: item.LotName,
|
||||
Description: item.LotDescription,
|
||||
Category: item.Category,
|
||||
CategoryName: item.Category,
|
||||
Model: item.Model,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *WebHandler) localCategories() ([]models.Category, error) {
|
||||
codes, err := h.localDB.GetLocalComponentCategories()
|
||||
if err != nil || len(codes) == 0 {
|
||||
return []models.Category{}, err
|
||||
}
|
||||
|
||||
categories := make([]models.Category, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
trimmed := strings.TrimSpace(code)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
categories = append(categories, models.Category{
|
||||
Code: trimmed,
|
||||
Name: trimmed,
|
||||
})
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
97
internal/localdb/configuration_business_fields_test.go
Normal file
97
internal/localdb/configuration_business_fields_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
func TestConfigurationConvertersPreserveBusinessFields(t *testing.T) {
|
||||
estimateID := uint(11)
|
||||
warehouseID := uint(22)
|
||||
competitorID := uint(33)
|
||||
|
||||
cfg := &models.Configuration{
|
||||
UUID: "cfg-1",
|
||||
OwnerUsername: "tester",
|
||||
Name: "Config",
|
||||
PricelistID: &estimateID,
|
||||
WarehousePricelistID: &warehouseID,
|
||||
CompetitorPricelistID: &competitorID,
|
||||
DisablePriceRefresh: true,
|
||||
OnlyInStock: true,
|
||||
}
|
||||
|
||||
local := ConfigurationToLocal(cfg)
|
||||
if local.WarehousePricelistID == nil || *local.WarehousePricelistID != warehouseID {
|
||||
t.Fatalf("warehouse pricelist lost in ConfigurationToLocal: %+v", local.WarehousePricelistID)
|
||||
}
|
||||
if local.CompetitorPricelistID == nil || *local.CompetitorPricelistID != competitorID {
|
||||
t.Fatalf("competitor pricelist lost in ConfigurationToLocal: %+v", local.CompetitorPricelistID)
|
||||
}
|
||||
if !local.DisablePriceRefresh {
|
||||
t.Fatalf("disable_price_refresh lost in ConfigurationToLocal")
|
||||
}
|
||||
|
||||
back := LocalToConfiguration(local)
|
||||
if back.WarehousePricelistID == nil || *back.WarehousePricelistID != warehouseID {
|
||||
t.Fatalf("warehouse pricelist lost in LocalToConfiguration: %+v", back.WarehousePricelistID)
|
||||
}
|
||||
if back.CompetitorPricelistID == nil || *back.CompetitorPricelistID != competitorID {
|
||||
t.Fatalf("competitor pricelist lost in LocalToConfiguration: %+v", back.CompetitorPricelistID)
|
||||
}
|
||||
if !back.DisablePriceRefresh {
|
||||
t.Fatalf("disable_price_refresh lost in LocalToConfiguration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
|
||||
estimateID := uint(11)
|
||||
warehouseID := uint(22)
|
||||
competitorID := uint(33)
|
||||
|
||||
cfg := &LocalConfiguration{
|
||||
UUID: "cfg-1",
|
||||
Name: "Config",
|
||||
PricelistID: &estimateID,
|
||||
WarehousePricelistID: &warehouseID,
|
||||
CompetitorPricelistID: &competitorID,
|
||||
DisablePriceRefresh: true,
|
||||
OnlyInStock: true,
|
||||
VendorSpec: VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "PN-1",
|
||||
Quantity: 1,
|
||||
LotMappings: []VendorSpecLotMapping{
|
||||
{LotName: "LOT_A", QuantityPerPN: 2},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
raw, err := BuildConfigurationSnapshot(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfigurationSnapshot: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := DecodeConfigurationSnapshot(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeConfigurationSnapshot: %v", err)
|
||||
}
|
||||
if decoded.WarehousePricelistID == nil || *decoded.WarehousePricelistID != warehouseID {
|
||||
t.Fatalf("warehouse pricelist lost in snapshot: %+v", decoded.WarehousePricelistID)
|
||||
}
|
||||
if decoded.CompetitorPricelistID == nil || *decoded.CompetitorPricelistID != competitorID {
|
||||
t.Fatalf("competitor pricelist lost in snapshot: %+v", decoded.CompetitorPricelistID)
|
||||
}
|
||||
if !decoded.DisablePriceRefresh {
|
||||
t.Fatalf("disable_price_refresh lost in snapshot")
|
||||
}
|
||||
if len(decoded.VendorSpec) != 1 || decoded.VendorSpec[0].VendorPartnumber != "PN-1" {
|
||||
t.Fatalf("vendor_spec lost in snapshot: %+v", decoded.VendorSpec)
|
||||
}
|
||||
if len(decoded.VendorSpec[0].LotMappings) != 1 || decoded.VendorSpec[0].LotMappings[0].LotName != "LOT_A" {
|
||||
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
|
||||
}
|
||||
}
|
||||
@@ -18,32 +18,32 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
}
|
||||
|
||||
local := &LocalConfiguration{
|
||||
UUID: cfg.UUID,
|
||||
ProjectUUID: cfg.ProjectUUID,
|
||||
IsActive: true,
|
||||
Name: cfg.Name,
|
||||
Items: items,
|
||||
TotalPrice: cfg.TotalPrice,
|
||||
CustomPrice: cfg.CustomPrice,
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
ServerModel: cfg.ServerModel,
|
||||
SupportCode: cfg.SupportCode,
|
||||
Article: cfg.Article,
|
||||
PricelistID: cfg.PricelistID,
|
||||
OnlyInStock: cfg.OnlyInStock,
|
||||
Line: cfg.Line,
|
||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
OriginalUserID: derefUint(cfg.UserID),
|
||||
OriginalUsername: cfg.OwnerUsername,
|
||||
}
|
||||
|
||||
if local.OriginalUsername == "" && cfg.User != nil {
|
||||
local.OriginalUsername = cfg.User.Username
|
||||
UUID: cfg.UUID,
|
||||
ProjectUUID: cfg.ProjectUUID,
|
||||
IsActive: true,
|
||||
Name: cfg.Name,
|
||||
Items: items,
|
||||
TotalPrice: cfg.TotalPrice,
|
||||
CustomPrice: cfg.CustomPrice,
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
ServerModel: cfg.ServerModel,
|
||||
SupportCode: cfg.SupportCode,
|
||||
Article: cfg.Article,
|
||||
PricelistID: cfg.PricelistID,
|
||||
WarehousePricelistID: cfg.WarehousePricelistID,
|
||||
CompetitorPricelistID: cfg.CompetitorPricelistID,
|
||||
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
|
||||
DisablePriceRefresh: cfg.DisablePriceRefresh,
|
||||
OnlyInStock: cfg.OnlyInStock,
|
||||
Line: cfg.Line,
|
||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
OriginalUserID: derefUint(cfg.UserID),
|
||||
OriginalUsername: cfg.OwnerUsername,
|
||||
}
|
||||
|
||||
if cfg.ID > 0 {
|
||||
@@ -66,24 +66,28 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
}
|
||||
|
||||
cfg := &models.Configuration{
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OriginalUsername,
|
||||
ProjectUUID: local.ProjectUUID,
|
||||
Name: local.Name,
|
||||
Items: items,
|
||||
TotalPrice: local.TotalPrice,
|
||||
CustomPrice: local.CustomPrice,
|
||||
Notes: local.Notes,
|
||||
IsTemplate: local.IsTemplate,
|
||||
ServerCount: local.ServerCount,
|
||||
ServerModel: local.ServerModel,
|
||||
SupportCode: local.SupportCode,
|
||||
Article: local.Article,
|
||||
PricelistID: local.PricelistID,
|
||||
OnlyInStock: local.OnlyInStock,
|
||||
Line: local.Line,
|
||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||
CreatedAt: local.CreatedAt,
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OriginalUsername,
|
||||
ProjectUUID: local.ProjectUUID,
|
||||
Name: local.Name,
|
||||
Items: items,
|
||||
TotalPrice: local.TotalPrice,
|
||||
CustomPrice: local.CustomPrice,
|
||||
Notes: local.Notes,
|
||||
IsTemplate: local.IsTemplate,
|
||||
ServerCount: local.ServerCount,
|
||||
ServerModel: local.ServerModel,
|
||||
SupportCode: local.SupportCode,
|
||||
Article: local.Article,
|
||||
PricelistID: local.PricelistID,
|
||||
WarehousePricelistID: local.WarehousePricelistID,
|
||||
CompetitorPricelistID: local.CompetitorPricelistID,
|
||||
VendorSpec: localVendorSpecToModel(local.VendorSpec),
|
||||
DisablePriceRefresh: local.DisablePriceRefresh,
|
||||
OnlyInStock: local.OnlyInStock,
|
||||
Line: local.Line,
|
||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||
CreatedAt: local.CreatedAt,
|
||||
}
|
||||
|
||||
if local.ServerID != nil {
|
||||
@@ -107,6 +111,88 @@ func derefUint(v *uint) uint {
|
||||
return *v
|
||||
}
|
||||
|
||||
func modelVendorSpecToLocal(spec models.VendorSpec) VendorSpec {
|
||||
if len(spec) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(VendorSpec, 0, len(spec))
|
||||
for _, item := range spec {
|
||||
row := VendorSpecItem{
|
||||
SortOrder: item.SortOrder,
|
||||
VendorPartnumber: item.VendorPartnumber,
|
||||
Quantity: item.Quantity,
|
||||
Description: item.Description,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: item.TotalPrice,
|
||||
ResolvedLotName: item.ResolvedLotName,
|
||||
ResolutionSource: item.ResolutionSource,
|
||||
ManualLotSuggestion: item.ManualLotSuggestion,
|
||||
LotQtyPerPN: item.LotQtyPerPN,
|
||||
}
|
||||
if len(item.LotAllocations) > 0 {
|
||||
row.LotAllocations = make([]VendorSpecLotAllocation, 0, len(item.LotAllocations))
|
||||
for _, alloc := range item.LotAllocations {
|
||||
row.LotAllocations = append(row.LotAllocations, VendorSpecLotAllocation{
|
||||
LotName: alloc.LotName,
|
||||
Quantity: alloc.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(item.LotMappings) > 0 {
|
||||
row.LotMappings = make([]VendorSpecLotMapping, 0, len(item.LotMappings))
|
||||
for _, mapping := range item.LotMappings {
|
||||
row.LotMappings = append(row.LotMappings, VendorSpecLotMapping{
|
||||
LotName: mapping.LotName,
|
||||
QuantityPerPN: mapping.QuantityPerPN,
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func localVendorSpecToModel(spec VendorSpec) models.VendorSpec {
|
||||
if len(spec) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(models.VendorSpec, 0, len(spec))
|
||||
for _, item := range spec {
|
||||
row := models.VendorSpecItem{
|
||||
SortOrder: item.SortOrder,
|
||||
VendorPartnumber: item.VendorPartnumber,
|
||||
Quantity: item.Quantity,
|
||||
Description: item.Description,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: item.TotalPrice,
|
||||
ResolvedLotName: item.ResolvedLotName,
|
||||
ResolutionSource: item.ResolutionSource,
|
||||
ManualLotSuggestion: item.ManualLotSuggestion,
|
||||
LotQtyPerPN: item.LotQtyPerPN,
|
||||
}
|
||||
if len(item.LotAllocations) > 0 {
|
||||
row.LotAllocations = make([]models.VendorSpecLotAllocation, 0, len(item.LotAllocations))
|
||||
for _, alloc := range item.LotAllocations {
|
||||
row.LotAllocations = append(row.LotAllocations, models.VendorSpecLotAllocation{
|
||||
LotName: alloc.LotName,
|
||||
Quantity: alloc.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(item.LotMappings) > 0 {
|
||||
row.LotMappings = make([]models.VendorSpecLotMapping, 0, len(item.LotMappings))
|
||||
for _, mapping := range item.LotMappings {
|
||||
row.LotMappings = append(row.LotMappings, models.VendorSpecLotMapping{
|
||||
LotName: mapping.LotName,
|
||||
QuantityPerPN: mapping.QuantityPerPN,
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ProjectToLocal(project *models.Project) *LocalProject {
|
||||
local := &LocalProject{
|
||||
UUID: project.UUID,
|
||||
|
||||
@@ -7,19 +7,104 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
)
|
||||
|
||||
// getEncryptionKey derives a 32-byte key from environment variable or machine ID
|
||||
func getEncryptionKey() []byte {
|
||||
const encryptionKeyFileName = "local_encryption.key"
|
||||
|
||||
// getEncryptionKey resolves the active encryption key.
|
||||
// Preference order:
|
||||
// 1. QUOTEFORGE_ENCRYPTION_KEY env var
|
||||
// 2. application-managed random key file in the user state directory
|
||||
func getEncryptionKey() ([]byte, error) {
|
||||
key := os.Getenv("QUOTEFORGE_ENCRYPTION_KEY")
|
||||
if key == "" {
|
||||
// Fallback to a machine-based key (hostname + fixed salt)
|
||||
hostname, _ := os.Hostname()
|
||||
key = hostname + "quoteforge-salt-2024"
|
||||
if key != "" {
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return hash[:], nil
|
||||
}
|
||||
// Hash to get exactly 32 bytes for AES-256
|
||||
|
||||
stateDir, err := resolveEncryptionStateDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve encryption state dir: %w", err)
|
||||
}
|
||||
|
||||
return loadOrCreateEncryptionKey(filepath.Join(stateDir, encryptionKeyFileName))
|
||||
}
|
||||
|
||||
func resolveEncryptionStateDir() (string, error) {
|
||||
configPath, err := appstate.ResolveConfigPath("")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Dir(configPath), nil
|
||||
}
|
||||
|
||||
func loadOrCreateEncryptionKey(path string) ([]byte, error) {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
return parseEncryptionKeyFile(data)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("read encryption key: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return nil, fmt.Errorf("create encryption key dir: %w", err)
|
||||
}
|
||||
|
||||
raw := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, raw); err != nil {
|
||||
return nil, fmt.Errorf("generate encryption key: %w", err)
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(raw)
|
||||
if err := writeKeyFile(path, []byte(encoded+"\n")); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read concurrent encryption key: %w", readErr)
|
||||
}
|
||||
return parseEncryptionKeyFile(data)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func writeKeyFile(path string, data []byte) error {
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := file.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func parseEncryptionKeyFile(data []byte) ([]byte, error) {
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
decoded, err := base64.StdEncoding.DecodeString(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode encryption key file: %w", err)
|
||||
}
|
||||
if len(decoded) != 32 {
|
||||
return nil, fmt.Errorf("invalid encryption key length: %d", len(decoded))
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func getLegacyEncryptionKey() []byte {
|
||||
hostname, _ := os.Hostname()
|
||||
key := hostname + "quoteforge-salt-2024"
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return hash[:]
|
||||
}
|
||||
@@ -30,7 +115,10 @@ func Encrypt(plaintext string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key := getEncryptionKey()
|
||||
key, err := getEncryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -56,12 +144,50 @@ func Decrypt(ciphertext string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key := getEncryptionKey()
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
key, err := getEncryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, legacy, err := decryptWithKeys(ciphertext, key, getLegacyEncryptionKey())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = legacy
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func DecryptWithMetadata(ciphertext string) (string, bool, error) {
|
||||
if ciphertext == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
key, err := getEncryptionKey()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return decryptWithKeys(ciphertext, key, getLegacyEncryptionKey())
|
||||
}
|
||||
|
||||
func decryptWithKeys(ciphertext string, primaryKey, legacyKey []byte) (string, bool, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
plaintext, err := decryptWithKey(data, primaryKey)
|
||||
if err == nil {
|
||||
return plaintext, false, nil
|
||||
}
|
||||
|
||||
legacyPlaintext, legacyErr := decryptWithKey(data, legacyKey)
|
||||
if legacyErr == nil {
|
||||
return legacyPlaintext, true, nil
|
||||
}
|
||||
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
func decryptWithKey(data, key []byte) (string, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
97
internal/localdb/encryption_test.go
Normal file
97
internal/localdb/encryption_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptCreatesPersistentKeyFile(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
t.Setenv("QFS_STATE_DIR", stateDir)
|
||||
t.Setenv("QUOTEFORGE_ENCRYPTION_KEY", "")
|
||||
|
||||
ciphertext, err := Encrypt("secret-password")
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
if ciphertext == "" {
|
||||
t.Fatal("expected ciphertext")
|
||||
}
|
||||
|
||||
keyPath := filepath.Join(stateDir, encryptionKeyFileName)
|
||||
info, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat key file: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("expected 0600 key file, got %v", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptMigratesLegacyCiphertext(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
t.Setenv("QFS_STATE_DIR", stateDir)
|
||||
t.Setenv("QUOTEFORGE_ENCRYPTION_KEY", "")
|
||||
|
||||
legacyCiphertext := encryptWithKeyForTest(t, getLegacyEncryptionKey(), "legacy-password")
|
||||
|
||||
plaintext, migrated, err := DecryptWithMetadata(legacyCiphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt legacy: %v", err)
|
||||
}
|
||||
if plaintext != "legacy-password" {
|
||||
t.Fatalf("unexpected plaintext: %q", plaintext)
|
||||
}
|
||||
if !migrated {
|
||||
t.Fatal("expected legacy ciphertext to require migration")
|
||||
}
|
||||
|
||||
currentCiphertext, err := Encrypt("legacy-password")
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt current: %v", err)
|
||||
}
|
||||
plaintext, migrated, err = DecryptWithMetadata(currentCiphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt current: %v", err)
|
||||
}
|
||||
if migrated {
|
||||
t.Fatal("did not expect current ciphertext to require migration")
|
||||
}
|
||||
}
|
||||
|
||||
func encryptWithKeyForTest(t *testing.T, key []byte, plaintext string) string {
|
||||
t.Helper()
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatalf("new gcm: %v", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
t.Fatalf("read nonce: %v", err)
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext)
|
||||
}
|
||||
|
||||
func TestLegacyEncryptionKeyRemainsDeterministic(t *testing.T) {
|
||||
hostname, _ := os.Hostname()
|
||||
expected := sha256.Sum256([]byte(hostname + "quoteforge-salt-2024"))
|
||||
actual := getLegacyEncryptionKey()
|
||||
if string(actual) != string(expected[:]) {
|
||||
t.Fatal("legacy key derivation changed")
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||
@@ -313,3 +316,280 @@ func TestRunLocalMigrationsBackfillsConfigurationLineNo(t *testing.T) {
|
||||
t.Fatalf("expected line_no [10,20], got [%d,%d]", rows[0].Line, rows[1].Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLocalMigrationsDeduplicatesCanonicalPartnumberCatalog(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "partnumber_catalog_dedup.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
firstLots := LocalPartnumberBookLots{
|
||||
{LotName: "LOT-A", Qty: 1},
|
||||
}
|
||||
secondLots := LocalPartnumberBookLots{
|
||||
{LotName: "LOT-B", Qty: 2},
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partnumber TEXT NOT NULL,
|
||||
lots_json TEXT NOT NULL,
|
||||
description TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create dirty local_partnumber_book_items: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&LocalPartnumberBookItem{
|
||||
Partnumber: "PN-001",
|
||||
LotsJSON: firstLots,
|
||||
Description: "",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert first duplicate row: %v", err)
|
||||
}
|
||||
if err := db.Create(&LocalPartnumberBookItem{
|
||||
Partnumber: "PN-001",
|
||||
LotsJSON: secondLots,
|
||||
Description: "Canonical description",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert second duplicate row: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateLocalPartnumberBookCatalog(db); err != nil {
|
||||
t.Fatalf("migrate local partnumber catalog: %v", err)
|
||||
}
|
||||
|
||||
var items []LocalPartnumberBookItem
|
||||
if err := db.Order("partnumber ASC").Find(&items).Error; err != nil {
|
||||
t.Fatalf("load migrated partnumber items: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 deduplicated item, got %d", len(items))
|
||||
}
|
||||
if items[0].Partnumber != "PN-001" {
|
||||
t.Fatalf("unexpected partnumber: %s", items[0].Partnumber)
|
||||
}
|
||||
if items[0].Description != "Canonical description" {
|
||||
t.Fatalf("expected merged description, got %q", items[0].Description)
|
||||
}
|
||||
if len(items[0].LotsJSON) != 2 {
|
||||
t.Fatalf("expected merged lots from duplicates, got %d", len(items[0].LotsJSON))
|
||||
}
|
||||
|
||||
var duplicateCount int64
|
||||
if err := db.Model(&LocalPartnumberBookItem{}).
|
||||
Where("partnumber = ?", "PN-001").
|
||||
Count(&duplicateCount).Error; err != nil {
|
||||
t.Fatalf("count deduplicated partnumber: %v", err)
|
||||
}
|
||||
if duplicateCount != 1 {
|
||||
t.Fatalf("expected unique partnumber row after migration, got %d", duplicateCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLocalPartnumberBookCatalogRemovesRowsWithoutPartnumber(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "sanitize_partnumber_catalog.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partnumber TEXT NULL,
|
||||
lots_json TEXT NOT NULL,
|
||||
description TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create local_partnumber_book_items: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO local_partnumber_book_items (partnumber, lots_json, description) VALUES
|
||||
(NULL, '[]', 'null pn'),
|
||||
('', '[]', 'empty pn'),
|
||||
('PN-OK', '[]', 'valid pn')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("seed local_partnumber_book_items: %v", err)
|
||||
}
|
||||
|
||||
if err := sanitizeLocalPartnumberBookCatalog(db); err != nil {
|
||||
t.Fatalf("sanitize local partnumber catalog: %v", err)
|
||||
}
|
||||
|
||||
var items []LocalPartnumberBookItem
|
||||
if err := db.Order("id ASC").Find(&items).Error; err != nil {
|
||||
t.Fatalf("load sanitized items: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 valid item after sanitize, got %d", len(items))
|
||||
}
|
||||
if items[0].Partnumber != "PN-OK" {
|
||||
t.Fatalf("expected remaining partnumber PN-OK, got %q", items[0].Partnumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMigratesLegacyPartnumberBookCatalogBeforeAutoMigrate(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy_partnumber_catalog.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partnumber TEXT NOT NULL UNIQUE,
|
||||
lots_json TEXT NOT NULL,
|
||||
is_primary_pn INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create legacy local_partnumber_book_items: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO local_partnumber_book_items (partnumber, lots_json, is_primary_pn, description)
|
||||
VALUES ('PN-001', '[{"lot_name":"CPU_A","qty":1}]', 0, 'Legacy row')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("seed legacy local_partnumber_book_items: %v", err)
|
||||
}
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb with legacy catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
var columns []struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
if err := local.DB().Raw(`SELECT name FROM pragma_table_info('local_partnumber_book_items')`).Scan(&columns).Error; err != nil {
|
||||
t.Fatalf("load local_partnumber_book_items columns: %v", err)
|
||||
}
|
||||
for _, column := range columns {
|
||||
if column.Name == "is_primary_pn" {
|
||||
t.Fatalf("expected legacy is_primary_pn column to be removed before automigrate")
|
||||
}
|
||||
}
|
||||
|
||||
var items []LocalPartnumberBookItem
|
||||
if err := local.DB().Find(&items).Error; err != nil {
|
||||
t.Fatalf("load migrated local_partnumber_book_items: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Partnumber != "PN-001" {
|
||||
t.Fatalf("unexpected migrated rows: %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecoversBrokenPartnumberBookCatalogCache(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "broken_partnumber_catalog.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partnumber TEXT NOT NULL UNIQUE,
|
||||
lots_json TEXT NOT NULL,
|
||||
description TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create broken local_partnumber_book_items: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO local_partnumber_book_items (partnumber, lots_json, description)
|
||||
VALUES ('PN-001', '{not-json}', 'Broken cache row')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("seed broken local_partnumber_book_items: %v", err)
|
||||
}
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb with broken catalog cache: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
var count int64
|
||||
if err := local.DB().Model(&LocalPartnumberBookItem{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count recovered local_partnumber_book_items: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected empty recovered local_partnumber_book_items, got %d rows", count)
|
||||
}
|
||||
|
||||
var quarantineTables []struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
if err := local.DB().Raw(`
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name LIKE 'local_partnumber_book_items_broken_%'
|
||||
`).Scan(&quarantineTables).Error; err != nil {
|
||||
t.Fatalf("load quarantine tables: %v", err)
|
||||
}
|
||||
if len(quarantineTables) != 1 {
|
||||
t.Fatalf("expected one quarantined broken catalog table, got %d", len(quarantineTables))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleReadOnlyCacheTempTablesDropsShadowTempWhenBaseExists(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "stale_cache_temp.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_pricelist_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pricelist_id INTEGER NOT NULL,
|
||||
partnumber TEXT,
|
||||
brand TEXT NOT NULL DEFAULT '',
|
||||
lot_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price REAL NOT NULL DEFAULT 0,
|
||||
quantity INTEGER NOT NULL DEFAULT 0,
|
||||
reserve INTEGER NOT NULL DEFAULT 0,
|
||||
available_qty REAL,
|
||||
partnumbers TEXT,
|
||||
lot_category TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create local_pricelist_items: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_pricelist_items__temp (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
legacy TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create local_pricelist_items__temp: %v", err)
|
||||
}
|
||||
|
||||
if err := cleanupStaleReadOnlyCacheTempTables(db); err != nil {
|
||||
t.Fatalf("cleanup stale read-only cache temp tables: %v", err)
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable("local_pricelist_items__temp") {
|
||||
t.Fatalf("expected stale temp table to be dropped")
|
||||
}
|
||||
if !db.Migrator().HasTable("local_pricelist_items") {
|
||||
t.Fatalf("expected base local_pricelist_items table to remain")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -42,6 +43,14 @@ type LocalDB struct {
|
||||
path string
|
||||
}
|
||||
|
||||
var localReadOnlyCacheTables = []string{
|
||||
"local_pricelist_items",
|
||||
"local_pricelists",
|
||||
"local_components",
|
||||
"local_partnumber_book_items",
|
||||
"local_partnumber_books",
|
||||
}
|
||||
|
||||
// ResetData clears local data tables while keeping connection settings.
|
||||
// It does not drop schema or connection_settings.
|
||||
func ResetData(dbPath string) error {
|
||||
@@ -70,7 +79,6 @@ func ResetData(dbPath string) error {
|
||||
"local_pricelists",
|
||||
"local_pricelist_items",
|
||||
"local_components",
|
||||
"local_remote_migrations_applied",
|
||||
"local_sync_guard_state",
|
||||
"pending_changes",
|
||||
"app_settings",
|
||||
@@ -111,6 +119,12 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
if err := ensureLocalProjectsTable(db); err != nil {
|
||||
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
||||
}
|
||||
if err := prepareLocalPartnumberBookCatalog(db); err != nil {
|
||||
return nil, fmt.Errorf("prepare local partnumber book catalog: %w", err)
|
||||
}
|
||||
if err := cleanupStaleReadOnlyCacheTempTables(db); err != nil {
|
||||
return nil, fmt.Errorf("cleanup stale read-only cache temp tables: %w", err)
|
||||
}
|
||||
|
||||
// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
|
||||
if db.Migrator().HasTable(&LocalProject{}) {
|
||||
@@ -131,24 +145,28 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
}
|
||||
|
||||
// Auto-migrate all local tables
|
||||
if err := db.AutoMigrate(
|
||||
&ConnectionSettings{},
|
||||
&LocalConfiguration{},
|
||||
&LocalConfigurationVersion{},
|
||||
&LocalPricelist{},
|
||||
&LocalPricelistItem{},
|
||||
&LocalComponent{},
|
||||
&AppSetting{},
|
||||
&LocalRemoteMigrationApplied{},
|
||||
&LocalSyncGuardState{},
|
||||
&PendingChange{},
|
||||
&LocalPartnumberBook{},
|
||||
&LocalPartnumberBookItem{},
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("migrating sqlite database: %w", err)
|
||||
if err := autoMigrateLocalSchema(db); err != nil {
|
||||
if recovered, recoveryErr := recoverFromReadOnlyCacheInitFailure(db, err); recoveryErr != nil {
|
||||
return nil, fmt.Errorf("migrating sqlite database: %w (recovery failed: %v)", err, recoveryErr)
|
||||
} else if !recovered {
|
||||
return nil, fmt.Errorf("migrating sqlite database: %w", err)
|
||||
}
|
||||
if err := autoMigrateLocalSchema(db); err != nil {
|
||||
return nil, fmt.Errorf("migrating sqlite database after cache recovery: %w", err)
|
||||
}
|
||||
}
|
||||
if err := ensureLocalPartnumberBookItemTable(db); err != nil {
|
||||
return nil, fmt.Errorf("ensure local partnumber book item table: %w", err)
|
||||
}
|
||||
if err := runLocalMigrations(db); err != nil {
|
||||
return nil, fmt.Errorf("running local sqlite migrations: %w", err)
|
||||
if recovered, recoveryErr := recoverFromReadOnlyCacheInitFailure(db, err); recoveryErr != nil {
|
||||
return nil, fmt.Errorf("running local sqlite migrations: %w (recovery failed: %v)", err, recoveryErr)
|
||||
} else if !recovered {
|
||||
return nil, fmt.Errorf("running local sqlite migrations: %w", err)
|
||||
}
|
||||
if err := runLocalMigrations(db); err != nil {
|
||||
return nil, fmt.Errorf("running local sqlite migrations after cache recovery: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("local SQLite database initialized", "path", dbPath)
|
||||
@@ -191,6 +209,282 @@ CREATE TABLE local_projects (
|
||||
return nil
|
||||
}
|
||||
|
||||
func autoMigrateLocalSchema(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&ConnectionSettings{},
|
||||
&LocalConfiguration{},
|
||||
&LocalConfigurationVersion{},
|
||||
&LocalPricelist{},
|
||||
&LocalPricelistItem{},
|
||||
&LocalComponent{},
|
||||
&AppSetting{},
|
||||
&LocalSyncGuardState{},
|
||||
&PendingChange{},
|
||||
&LocalPartnumberBook{},
|
||||
)
|
||||
}
|
||||
|
||||
func sanitizeLocalPartnumberBookCatalog(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Old local databases may contain partially migrated catalog rows with NULL/empty
|
||||
// partnumber values. SQLite table rebuild during AutoMigrate fails on such rows once
|
||||
// the schema enforces NOT NULL, so remove them before AutoMigrate touches the table.
|
||||
if err := db.Exec(`
|
||||
DELETE FROM local_partnumber_book_items
|
||||
WHERE partnumber IS NULL OR TRIM(partnumber) = ''
|
||||
`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareLocalPartnumberBookCatalog(db *gorm.DB) error {
|
||||
if err := cleanupStaleLocalPartnumberBookCatalogTempTable(db); err != nil {
|
||||
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("cleanup stale temp table: %w", err)); recoveryErr != nil {
|
||||
return recoveryErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := sanitizeLocalPartnumberBookCatalog(db); err != nil {
|
||||
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("sanitize catalog: %w", err)); recoveryErr != nil {
|
||||
return recoveryErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := migrateLegacyPartnumberBookCatalogBeforeAutoMigrate(db); err != nil {
|
||||
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("migrate legacy catalog: %w", err)); recoveryErr != nil {
|
||||
return recoveryErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := ensureLocalPartnumberBookItemTable(db); err != nil {
|
||||
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("ensure canonical catalog table: %w", err)); recoveryErr != nil {
|
||||
return recoveryErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := validateLocalPartnumberBookCatalog(db); err != nil {
|
||||
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("validate canonical catalog: %w", err)); recoveryErr != nil {
|
||||
return recoveryErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupStaleReadOnlyCacheTempTables(db *gorm.DB) error {
|
||||
for _, tableName := range localReadOnlyCacheTables {
|
||||
tempName := tableName + "__temp"
|
||||
if !db.Migrator().HasTable(tempName) {
|
||||
continue
|
||||
}
|
||||
if db.Migrator().HasTable(tableName) {
|
||||
if err := db.Exec(`DROP TABLE ` + tempName).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := quarantineSQLiteTable(db, tempName, localReadOnlyCacheQuarantineTableName(tableName, "temp")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupStaleLocalPartnumberBookCatalogTempTable(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable("local_partnumber_book_items__temp") {
|
||||
return nil
|
||||
}
|
||||
if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
|
||||
return db.Exec(`DROP TABLE local_partnumber_book_items__temp`).Error
|
||||
}
|
||||
return quarantineSQLiteTable(db, "local_partnumber_book_items__temp", localPartnumberBookCatalogQuarantineTableName("temp"))
|
||||
}
|
||||
|
||||
func migrateLegacyPartnumberBookCatalogBeforeAutoMigrate(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Legacy databases may still have the pre-catalog shape (`book_id`/`lot_name`) or the
|
||||
// intermediate canonical shape with obsolete columns like `is_primary_pn`. Let the
|
||||
// explicit rebuild logic normalize this table before GORM AutoMigrate attempts a
|
||||
// table-copy migration on its own.
|
||||
return migrateLocalPartnumberBookCatalog(db)
|
||||
}
|
||||
|
||||
func ensureLocalPartnumberBookItemTable(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
|
||||
return nil
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partnumber TEXT NOT NULL UNIQUE,
|
||||
lots_json TEXT NOT NULL,
|
||||
description TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_partnumber_book_items_partnumber ON local_partnumber_book_items(partnumber)`).Error
|
||||
}
|
||||
|
||||
func validateLocalPartnumberBookCatalog(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
type rawCatalogRow struct {
|
||||
Partnumber string `gorm:"column:partnumber"`
|
||||
LotsJSON string `gorm:"column:lots_json"`
|
||||
Description string `gorm:"column:description"`
|
||||
}
|
||||
|
||||
var rows []rawCatalogRow
|
||||
if err := db.Raw(`
|
||||
SELECT partnumber, lots_json, COALESCE(description, '') AS description
|
||||
FROM local_partnumber_book_items
|
||||
ORDER BY id ASC
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return fmt.Errorf("load canonical catalog rows: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
partnumber := strings.TrimSpace(row.Partnumber)
|
||||
if partnumber == "" {
|
||||
return errors.New("catalog contains empty partnumber")
|
||||
}
|
||||
if _, exists := seen[partnumber]; exists {
|
||||
return fmt.Errorf("catalog contains duplicate partnumber %q", partnumber)
|
||||
}
|
||||
seen[partnumber] = struct{}{}
|
||||
if strings.TrimSpace(row.LotsJSON) == "" {
|
||||
return fmt.Errorf("catalog row %q has empty lots_json", partnumber)
|
||||
}
|
||||
var lots LocalPartnumberBookLots
|
||||
if err := json.Unmarshal([]byte(row.LotsJSON), &lots); err != nil {
|
||||
return fmt.Errorf("catalog row %q has invalid lots_json: %w", partnumber, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func recoverLocalPartnumberBookCatalog(db *gorm.DB, cause error) error {
|
||||
slog.Warn("recovering broken local partnumber book catalog", "error", cause.Error())
|
||||
|
||||
if err := ensureLocalPartnumberBooksCatalogColumn(db); err != nil {
|
||||
return fmt.Errorf("ensure local_partnumber_books.partnumbers_json during recovery: %w", err)
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable("local_partnumber_book_items__temp") {
|
||||
if err := quarantineSQLiteTable(db, "local_partnumber_book_items__temp", localPartnumberBookCatalogQuarantineTableName("temp")); err != nil {
|
||||
return fmt.Errorf("quarantine local_partnumber_book_items__temp: %w", err)
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
|
||||
if err := quarantineSQLiteTable(db, "local_partnumber_book_items", localPartnumberBookCatalogQuarantineTableName("broken")); err != nil {
|
||||
return fmt.Errorf("quarantine local_partnumber_book_items: %w", err)
|
||||
}
|
||||
}
|
||||
if err := ensureLocalPartnumberBookItemTable(db); err != nil {
|
||||
return fmt.Errorf("recreate local_partnumber_book_items after recovery: %w", err)
|
||||
}
|
||||
|
||||
slog.Warn("local partnumber book catalog reset to empty cache; next sync will rebuild it")
|
||||
return nil
|
||||
}
|
||||
|
||||
func recoverFromReadOnlyCacheInitFailure(db *gorm.DB, cause error) (bool, error) {
|
||||
lowerCause := strings.ToLower(cause.Error())
|
||||
recoveredAny := false
|
||||
|
||||
for _, tableName := range affectedReadOnlyCacheTables(lowerCause) {
|
||||
if err := resetReadOnlyCacheTable(db, tableName); err != nil {
|
||||
return recoveredAny, err
|
||||
}
|
||||
recoveredAny = true
|
||||
}
|
||||
|
||||
if strings.Contains(lowerCause, "local_partnumber_book_items") || strings.Contains(lowerCause, "local_partnumber_books") {
|
||||
if err := recoverLocalPartnumberBookCatalog(db, cause); err != nil {
|
||||
return recoveredAny, err
|
||||
}
|
||||
recoveredAny = true
|
||||
}
|
||||
|
||||
if recoveredAny {
|
||||
slog.Warn("recovered read-only local cache tables after startup failure", "error", cause.Error())
|
||||
}
|
||||
return recoveredAny, nil
|
||||
}
|
||||
|
||||
func affectedReadOnlyCacheTables(lowerCause string) []string {
|
||||
affected := make([]string, 0, len(localReadOnlyCacheTables))
|
||||
for _, tableName := range localReadOnlyCacheTables {
|
||||
if tableName == "local_partnumber_book_items" || tableName == "local_partnumber_books" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(lowerCause, tableName) {
|
||||
affected = append(affected, tableName)
|
||||
}
|
||||
}
|
||||
return affected
|
||||
}
|
||||
|
||||
func resetReadOnlyCacheTable(db *gorm.DB, tableName string) error {
|
||||
slog.Warn("resetting read-only local cache table", "table", tableName)
|
||||
tempName := tableName + "__temp"
|
||||
if db.Migrator().HasTable(tempName) {
|
||||
if err := quarantineSQLiteTable(db, tempName, localReadOnlyCacheQuarantineTableName(tableName, "temp")); err != nil {
|
||||
return fmt.Errorf("quarantine temp table %s: %w", tempName, err)
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable(tableName) {
|
||||
if err := quarantineSQLiteTable(db, tableName, localReadOnlyCacheQuarantineTableName(tableName, "broken")); err != nil {
|
||||
return fmt.Errorf("quarantine table %s: %w", tableName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureLocalPartnumberBooksCatalogColumn(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&LocalPartnumberBook{}) {
|
||||
return nil
|
||||
}
|
||||
if db.Migrator().HasColumn(&LocalPartnumberBook{}, "partnumbers_json") {
|
||||
return nil
|
||||
}
|
||||
return db.Exec(`ALTER TABLE local_partnumber_books ADD COLUMN partnumbers_json TEXT NOT NULL DEFAULT '[]'`).Error
|
||||
}
|
||||
|
||||
func quarantineSQLiteTable(db *gorm.DB, tableName string, quarantineName string) error {
|
||||
if !db.Migrator().HasTable(tableName) {
|
||||
return nil
|
||||
}
|
||||
if tableName == quarantineName {
|
||||
return nil
|
||||
}
|
||||
if db.Migrator().HasTable(quarantineName) {
|
||||
if err := db.Exec(`DROP TABLE ` + quarantineName).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Exec(`ALTER TABLE ` + tableName + ` RENAME TO ` + quarantineName).Error
|
||||
}
|
||||
|
||||
func localPartnumberBookCatalogQuarantineTableName(kind string) string {
|
||||
return "local_partnumber_book_items_" + kind + "_" + time.Now().UTC().Format("20060102150405")
|
||||
}
|
||||
|
||||
func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string {
|
||||
return tableName + "_" + kind + "_" + time.Now().UTC().Format("20060102150405")
|
||||
}
|
||||
|
||||
// HasSettings returns true if connection settings exist
|
||||
func (l *LocalDB) HasSettings() bool {
|
||||
var count int64
|
||||
@@ -206,10 +500,23 @@ func (l *LocalDB) GetSettings() (*ConnectionSettings, error) {
|
||||
}
|
||||
|
||||
// Decrypt password
|
||||
password, err := Decrypt(settings.PasswordEncrypted)
|
||||
password, migrated, err := DecryptWithMetadata(settings.PasswordEncrypted)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypting password: %w", err)
|
||||
}
|
||||
|
||||
if migrated {
|
||||
encrypted, encryptErr := Encrypt(password)
|
||||
if encryptErr != nil {
|
||||
return nil, fmt.Errorf("re-encrypting legacy password: %w", encryptErr)
|
||||
}
|
||||
if err := l.db.Model(&ConnectionSettings{}).
|
||||
Where("id = ?", settings.ID).
|
||||
Update("password_encrypted", encrypted).Error; err != nil {
|
||||
return nil, fmt.Errorf("upgrading legacy password encryption: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
settings.PasswordEncrypted = password // Return decrypted password in this field
|
||||
|
||||
return &settings, nil
|
||||
@@ -840,20 +1147,20 @@ func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (i
|
||||
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 {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Batch insert
|
||||
batchSize := 500
|
||||
for i := 0; i < len(items); i += batchSize {
|
||||
end := i + batchSize
|
||||
if 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
|
||||
}
|
||||
}
|
||||
@@ -1235,42 +1542,6 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
|
||||
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
|
||||
var migration LocalRemoteMigrationApplied
|
||||
if err := l.db.Where("id = ?", id).First(&migration).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &migration, nil
|
||||
}
|
||||
|
||||
// UpsertRemoteMigrationApplied writes applied migration metadata.
|
||||
func (l *LocalDB) UpsertRemoteMigrationApplied(id, checksum, appVersion string, appliedAt time.Time) error {
|
||||
record := &LocalRemoteMigrationApplied{
|
||||
ID: id,
|
||||
Checksum: checksum,
|
||||
AppVersion: appVersion,
|
||||
AppliedAt: appliedAt,
|
||||
}
|
||||
return l.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"checksum": checksum,
|
||||
"app_version": appVersion,
|
||||
"applied_at": appliedAt,
|
||||
}),
|
||||
}).Create(record).Error
|
||||
}
|
||||
|
||||
// GetLatestAppliedRemoteMigrationID returns last applied remote migration id.
|
||||
func (l *LocalDB) GetLatestAppliedRemoteMigrationID() (string, error) {
|
||||
var record LocalRemoteMigrationApplied
|
||||
if err := l.db.Order("applied_at DESC").First(&record).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return record.ID, nil
|
||||
}
|
||||
|
||||
// GetSyncGuardState returns the latest readiness guard state.
|
||||
func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) {
|
||||
var state LocalSyncGuardState
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -113,6 +114,24 @@ var localMigrations = []localMigration{
|
||||
name: "Add line_no to local_configurations and backfill ordering",
|
||||
run: addLocalConfigurationLineNo,
|
||||
},
|
||||
{
|
||||
id: "2026_03_07_local_partnumber_book_catalog",
|
||||
name: "Convert local partnumber book cache to book membership + deduplicated PN catalog",
|
||||
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 {
|
||||
Partnumber string
|
||||
LotsJSON LocalPartnumberBookLots
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
ServerID int
|
||||
}
|
||||
|
||||
func runLocalMigrations(db *gorm.DB) error {
|
||||
@@ -865,3 +884,239 @@ WHERE id IN (SELECT id FROM ranked)
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLocalPartnumberBookCatalog(tx *gorm.DB) error {
|
||||
type columnInfo struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
hasBooksTable := tx.Migrator().HasTable(&LocalPartnumberBook{})
|
||||
hasItemsTable := tx.Migrator().HasTable(&LocalPartnumberBookItem{})
|
||||
if !hasItemsTable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if hasBooksTable {
|
||||
var bookCols []columnInfo
|
||||
if err := tx.Raw(`SELECT name FROM pragma_table_info('local_partnumber_books')`).Scan(&bookCols).Error; err != nil {
|
||||
return fmt.Errorf("load local_partnumber_books columns: %w", err)
|
||||
}
|
||||
hasPartnumbersJSON := false
|
||||
for _, c := range bookCols {
|
||||
if c.Name == "partnumbers_json" {
|
||||
hasPartnumbersJSON = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasPartnumbersJSON {
|
||||
if err := tx.Exec(`ALTER TABLE local_partnumber_books ADD COLUMN partnumbers_json TEXT NOT NULL DEFAULT '[]'`).Error; err != nil {
|
||||
return fmt.Errorf("add local_partnumber_books.partnumbers_json: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var itemCols []columnInfo
|
||||
if err := tx.Raw(`SELECT name FROM pragma_table_info('local_partnumber_book_items')`).Scan(&itemCols).Error; err != nil {
|
||||
return fmt.Errorf("load local_partnumber_book_items columns: %w", err)
|
||||
}
|
||||
hasBookID := false
|
||||
hasLotName := false
|
||||
hasLotsJSON := false
|
||||
for _, c := range itemCols {
|
||||
if c.Name == "book_id" {
|
||||
hasBookID = true
|
||||
}
|
||||
if c.Name == "lot_name" {
|
||||
hasLotName = true
|
||||
}
|
||||
if c.Name == "lots_json" {
|
||||
hasLotsJSON = true
|
||||
}
|
||||
}
|
||||
if !hasBookID && !hasLotName && !hasLotsJSON {
|
||||
return nil
|
||||
}
|
||||
|
||||
type legacyRow struct {
|
||||
BookID uint
|
||||
Partnumber string
|
||||
LotName string
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
ServerID int
|
||||
}
|
||||
bookPNs := make(map[uint]map[string]struct{})
|
||||
catalog := make(map[string]*localPartnumberCatalogRow)
|
||||
|
||||
if hasBookID || hasLotName {
|
||||
var rows []legacyRow
|
||||
if err := tx.Raw(`
|
||||
SELECT
|
||||
i.book_id,
|
||||
i.partnumber,
|
||||
i.lot_name,
|
||||
COALESCE(i.description, '') AS description,
|
||||
b.created_at,
|
||||
b.server_id
|
||||
FROM local_partnumber_book_items i
|
||||
INNER JOIN local_partnumber_books b ON b.id = i.book_id
|
||||
ORDER BY b.created_at DESC, b.id DESC, i.partnumber ASC, i.id ASC
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return fmt.Errorf("load legacy local partnumber book items: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if _, ok := bookPNs[row.BookID]; !ok {
|
||||
bookPNs[row.BookID] = make(map[string]struct{})
|
||||
}
|
||||
bookPNs[row.BookID][row.Partnumber] = struct{}{}
|
||||
|
||||
entry, ok := catalog[row.Partnumber]
|
||||
if !ok {
|
||||
entry = &localPartnumberCatalogRow{
|
||||
Partnumber: row.Partnumber,
|
||||
Description: row.Description,
|
||||
CreatedAt: row.CreatedAt,
|
||||
ServerID: row.ServerID,
|
||||
}
|
||||
catalog[row.Partnumber] = entry
|
||||
}
|
||||
if row.CreatedAt.After(entry.CreatedAt) || (row.CreatedAt.Equal(entry.CreatedAt) && row.ServerID >= entry.ServerID) {
|
||||
entry.Description = row.Description
|
||||
entry.CreatedAt = row.CreatedAt
|
||||
entry.ServerID = row.ServerID
|
||||
}
|
||||
found := false
|
||||
for i := range entry.LotsJSON {
|
||||
if entry.LotsJSON[i].LotName == row.LotName {
|
||||
entry.LotsJSON[i].Qty += 1
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && row.LotName != "" {
|
||||
entry.LotsJSON = append(entry.LotsJSON, LocalPartnumberBookLot{LotName: row.LotName, Qty: 1})
|
||||
}
|
||||
}
|
||||
|
||||
var books []LocalPartnumberBook
|
||||
if err := tx.Find(&books).Error; err != nil {
|
||||
return fmt.Errorf("load local partnumber books: %w", err)
|
||||
}
|
||||
for _, book := range books {
|
||||
pnSet := bookPNs[book.ID]
|
||||
partnumbers := make([]string, 0, len(pnSet))
|
||||
for pn := range pnSet {
|
||||
partnumbers = append(partnumbers, pn)
|
||||
}
|
||||
sort.Strings(partnumbers)
|
||||
if err := tx.Model(&LocalPartnumberBook{}).
|
||||
Where("id = ?", book.ID).
|
||||
Update("partnumbers_json", LocalStringList(partnumbers)).Error; err != nil {
|
||||
return fmt.Errorf("update partnumbers_json for local book %d: %w", book.ID, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var items []LocalPartnumberBookItem
|
||||
if err := tx.Order("id DESC").Find(&items).Error; err != nil {
|
||||
return fmt.Errorf("load canonical local partnumber book items: %w", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
entry, ok := catalog[item.Partnumber]
|
||||
if !ok {
|
||||
copiedLots := append(LocalPartnumberBookLots(nil), item.LotsJSON...)
|
||||
catalog[item.Partnumber] = &localPartnumberCatalogRow{
|
||||
Partnumber: item.Partnumber,
|
||||
LotsJSON: copiedLots,
|
||||
Description: item.Description,
|
||||
}
|
||||
continue
|
||||
}
|
||||
if entry.Description == "" && item.Description != "" {
|
||||
entry.Description = item.Description
|
||||
}
|
||||
for _, lot := range item.LotsJSON {
|
||||
merged := false
|
||||
for i := range entry.LotsJSON {
|
||||
if entry.LotsJSON[i].LotName == lot.LotName {
|
||||
if lot.Qty > entry.LotsJSON[i].Qty {
|
||||
entry.LotsJSON[i].Qty = lot.Qty
|
||||
}
|
||||
merged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !merged {
|
||||
entry.LotsJSON = append(entry.LotsJSON, lot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rebuildLocalPartnumberBookCatalog(tx, catalog)
|
||||
}
|
||||
|
||||
func rebuildLocalPartnumberBookCatalog(tx *gorm.DB, catalog map[string]*localPartnumberCatalogRow) error {
|
||||
if err := tx.Exec(`
|
||||
CREATE TABLE local_partnumber_book_items_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partnumber TEXT NOT NULL UNIQUE,
|
||||
lots_json TEXT NOT NULL,
|
||||
description TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("create new local_partnumber_book_items table: %w", err)
|
||||
}
|
||||
|
||||
orderedPartnumbers := make([]string, 0, len(catalog))
|
||||
for pn := range catalog {
|
||||
orderedPartnumbers = append(orderedPartnumbers, pn)
|
||||
}
|
||||
sort.Strings(orderedPartnumbers)
|
||||
for _, pn := range orderedPartnumbers {
|
||||
row := catalog[pn]
|
||||
sort.Slice(row.LotsJSON, func(i, j int) bool {
|
||||
return row.LotsJSON[i].LotName < row.LotsJSON[j].LotName
|
||||
})
|
||||
if err := tx.Table("local_partnumber_book_items_new").Create(&LocalPartnumberBookItem{
|
||||
Partnumber: row.Partnumber,
|
||||
LotsJSON: row.LotsJSON,
|
||||
Description: row.Description,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("insert new local_partnumber_book_items row for %s: %w", pn, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`DROP TABLE local_partnumber_book_items`).Error; err != nil {
|
||||
return fmt.Errorf("drop legacy local_partnumber_book_items: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE local_partnumber_book_items_new RENAME TO local_partnumber_book_items`).Error; err != nil {
|
||||
return fmt.Errorf("rename new local_partnumber_book_items table: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_partnumber_book_items_partnumber ON local_partnumber_book_items(partnumber)`).Error; err != nil {
|
||||
return fmt.Errorf("create local_partnumber_book_items partnumber index: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -102,8 +102,9 @@ type LocalConfiguration struct {
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
|
||||
VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
|
||||
Line int `gorm:"column:line_no;index" json:"line"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -202,18 +203,6 @@ func (LocalComponent) TableName() string {
|
||||
return "local_components"
|
||||
}
|
||||
|
||||
// LocalRemoteMigrationApplied tracks remote SQLite migrations received from server and applied locally.
|
||||
type LocalRemoteMigrationApplied struct {
|
||||
ID string `gorm:"primaryKey;size:128" json:"id"`
|
||||
Checksum string `gorm:"size:128;not null" json:"checksum"`
|
||||
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
|
||||
AppliedAt time.Time `gorm:"not null" json:"applied_at"`
|
||||
}
|
||||
|
||||
func (LocalRemoteMigrationApplied) TableName() string {
|
||||
return "local_remote_migrations_applied"
|
||||
}
|
||||
|
||||
// LocalSyncGuardState stores latest sync readiness decision for UI and preflight checks.
|
||||
type LocalSyncGuardState struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
@@ -247,25 +236,52 @@ func (PendingChange) TableName() string {
|
||||
|
||||
// LocalPartnumberBook stores a version snapshot of the PN→LOT mapping book (pull-only from PriceForge)
|
||||
type LocalPartnumberBook struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ServerID int `gorm:"uniqueIndex;not null" json:"server_id"`
|
||||
Version string `gorm:"not null" json:"version"`
|
||||
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||
IsActive bool `gorm:"not null;default:true" json:"is_active"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ServerID int `gorm:"uniqueIndex;not null" json:"server_id"`
|
||||
Version string `gorm:"not null" json:"version"`
|
||||
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||
IsActive bool `gorm:"not null;default:true" json:"is_active"`
|
||||
PartnumbersJSON LocalStringList `gorm:"column:partnumbers_json;type:text" json:"partnumbers_json"`
|
||||
}
|
||||
|
||||
func (LocalPartnumberBook) TableName() string {
|
||||
return "local_partnumber_books"
|
||||
}
|
||||
|
||||
// LocalPartnumberBookItem stores a single PN→LOT mapping within a book snapshot
|
||||
type LocalPartnumberBookLot struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Qty float64 `json:"qty"`
|
||||
}
|
||||
|
||||
type LocalPartnumberBookLots []LocalPartnumberBookLot
|
||||
|
||||
func (l LocalPartnumberBookLots) Value() (driver.Value, error) {
|
||||
return json.Marshal(l)
|
||||
}
|
||||
|
||||
func (l *LocalPartnumberBookLots) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*l = make(LocalPartnumberBookLots, 0)
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return errors.New("type assertion failed for LocalPartnumberBookLots")
|
||||
}
|
||||
return json.Unmarshal(bytes, l)
|
||||
}
|
||||
|
||||
// LocalPartnumberBookItem stores the canonical PN composition pulled from PriceForge.
|
||||
type LocalPartnumberBookItem struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
BookID uint `gorm:"not null;index:idx_local_book_pn,priority:1" json:"book_id"`
|
||||
Partnumber string `gorm:"not null;index:idx_local_book_pn,priority:2" json:"partnumber"`
|
||||
LotName string `gorm:"not null" json:"lot_name"`
|
||||
IsPrimaryPN bool `gorm:"not null;default:false" json:"is_primary_pn"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Partnumber string `gorm:"not null" json:"partnumber"`
|
||||
LotsJSON LocalPartnumberBookLots `gorm:"column:lots_json;type:text" json:"lots_json"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func (LocalPartnumberBookItem) TableName() string {
|
||||
@@ -274,18 +290,18 @@ func (LocalPartnumberBookItem) TableName() string {
|
||||
|
||||
// VendorSpecItem represents a single row in a vendor BOM specification
|
||||
type VendorSpecItem struct {
|
||||
SortOrder int `json:"sort_order"`
|
||||
VendorPartnumber string `json:"vendor_partnumber"`
|
||||
Quantity int `json:"quantity"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
|
||||
ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved"
|
||||
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
|
||||
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
VendorPartnumber string `json:"vendor_partnumber"`
|
||||
Quantity int `json:"quantity"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
|
||||
ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved"
|
||||
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
|
||||
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
|
||||
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
|
||||
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||
}
|
||||
|
||||
type VendorSpecLotAllocation struct {
|
||||
|
||||
@@ -10,32 +10,36 @@ import (
|
||||
// BuildConfigurationSnapshot serializes the full local configuration state.
|
||||
func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||
snapshot := map[string]interface{}{
|
||||
"id": localCfg.ID,
|
||||
"uuid": localCfg.UUID,
|
||||
"server_id": localCfg.ServerID,
|
||||
"project_uuid": localCfg.ProjectUUID,
|
||||
"current_version_id": localCfg.CurrentVersionID,
|
||||
"is_active": localCfg.IsActive,
|
||||
"name": localCfg.Name,
|
||||
"items": localCfg.Items,
|
||||
"total_price": localCfg.TotalPrice,
|
||||
"custom_price": localCfg.CustomPrice,
|
||||
"notes": localCfg.Notes,
|
||||
"is_template": localCfg.IsTemplate,
|
||||
"server_count": localCfg.ServerCount,
|
||||
"server_model": localCfg.ServerModel,
|
||||
"support_code": localCfg.SupportCode,
|
||||
"article": localCfg.Article,
|
||||
"pricelist_id": localCfg.PricelistID,
|
||||
"only_in_stock": localCfg.OnlyInStock,
|
||||
"line": localCfg.Line,
|
||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||
"created_at": localCfg.CreatedAt,
|
||||
"updated_at": localCfg.UpdatedAt,
|
||||
"synced_at": localCfg.SyncedAt,
|
||||
"sync_status": localCfg.SyncStatus,
|
||||
"original_user_id": localCfg.OriginalUserID,
|
||||
"original_username": localCfg.OriginalUsername,
|
||||
"id": localCfg.ID,
|
||||
"uuid": localCfg.UUID,
|
||||
"server_id": localCfg.ServerID,
|
||||
"project_uuid": localCfg.ProjectUUID,
|
||||
"current_version_id": localCfg.CurrentVersionID,
|
||||
"is_active": localCfg.IsActive,
|
||||
"name": localCfg.Name,
|
||||
"items": localCfg.Items,
|
||||
"total_price": localCfg.TotalPrice,
|
||||
"custom_price": localCfg.CustomPrice,
|
||||
"notes": localCfg.Notes,
|
||||
"is_template": localCfg.IsTemplate,
|
||||
"server_count": localCfg.ServerCount,
|
||||
"server_model": localCfg.ServerModel,
|
||||
"support_code": localCfg.SupportCode,
|
||||
"article": localCfg.Article,
|
||||
"pricelist_id": localCfg.PricelistID,
|
||||
"warehouse_pricelist_id": localCfg.WarehousePricelistID,
|
||||
"competitor_pricelist_id": localCfg.CompetitorPricelistID,
|
||||
"disable_price_refresh": localCfg.DisablePriceRefresh,
|
||||
"only_in_stock": localCfg.OnlyInStock,
|
||||
"vendor_spec": localCfg.VendorSpec,
|
||||
"line": localCfg.Line,
|
||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||
"created_at": localCfg.CreatedAt,
|
||||
"updated_at": localCfg.UpdatedAt,
|
||||
"synced_at": localCfg.SyncedAt,
|
||||
"sync_status": localCfg.SyncStatus,
|
||||
"original_user_id": localCfg.OriginalUserID,
|
||||
"original_username": localCfg.OriginalUsername,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(snapshot)
|
||||
@@ -48,24 +52,28 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||
// DecodeConfigurationSnapshot returns editable fields from one saved snapshot.
|
||||
func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
var snapshot struct {
|
||||
ProjectUUID *string `json:"project_uuid"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Name string `json:"name"`
|
||||
Items LocalConfigItems `json:"items"`
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
ServerModel string `json:"server_model"`
|
||||
SupportCode string `json:"support_code"`
|
||||
Article string `json:"article"`
|
||||
PricelistID *uint `json:"pricelist_id"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
Line int `json:"line"`
|
||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||
OriginalUserID uint `json:"original_user_id"`
|
||||
OriginalUsername string `json:"original_username"`
|
||||
ProjectUUID *string `json:"project_uuid"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Name string `json:"name"`
|
||||
Items LocalConfigItems `json:"items"`
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
ServerModel string `json:"server_model"`
|
||||
SupportCode string `json:"support_code"`
|
||||
Article string `json:"article"`
|
||||
PricelistID *uint `json:"pricelist_id"`
|
||||
WarehousePricelistID *uint `json:"warehouse_pricelist_id"`
|
||||
CompetitorPricelistID *uint `json:"competitor_pricelist_id"`
|
||||
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
VendorSpec VendorSpec `json:"vendor_spec"`
|
||||
Line int `json:"line"`
|
||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||
OriginalUserID uint `json:"original_user_id"`
|
||||
OriginalUsername string `json:"original_username"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
|
||||
@@ -78,24 +86,28 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
}
|
||||
|
||||
return &LocalConfiguration{
|
||||
IsActive: isActive,
|
||||
ProjectUUID: snapshot.ProjectUUID,
|
||||
Name: snapshot.Name,
|
||||
Items: snapshot.Items,
|
||||
TotalPrice: snapshot.TotalPrice,
|
||||
CustomPrice: snapshot.CustomPrice,
|
||||
Notes: snapshot.Notes,
|
||||
IsTemplate: snapshot.IsTemplate,
|
||||
ServerCount: snapshot.ServerCount,
|
||||
ServerModel: snapshot.ServerModel,
|
||||
SupportCode: snapshot.SupportCode,
|
||||
Article: snapshot.Article,
|
||||
PricelistID: snapshot.PricelistID,
|
||||
OnlyInStock: snapshot.OnlyInStock,
|
||||
Line: snapshot.Line,
|
||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||
OriginalUserID: snapshot.OriginalUserID,
|
||||
OriginalUsername: snapshot.OriginalUsername,
|
||||
IsActive: isActive,
|
||||
ProjectUUID: snapshot.ProjectUUID,
|
||||
Name: snapshot.Name,
|
||||
Items: snapshot.Items,
|
||||
TotalPrice: snapshot.TotalPrice,
|
||||
CustomPrice: snapshot.CustomPrice,
|
||||
Notes: snapshot.Notes,
|
||||
IsTemplate: snapshot.IsTemplate,
|
||||
ServerCount: snapshot.ServerCount,
|
||||
ServerModel: snapshot.ServerModel,
|
||||
SupportCode: snapshot.SupportCode,
|
||||
Article: snapshot.Article,
|
||||
PricelistID: snapshot.PricelistID,
|
||||
WarehousePricelistID: snapshot.WarehousePricelistID,
|
||||
CompetitorPricelistID: snapshot.CompetitorPricelistID,
|
||||
DisablePriceRefresh: snapshot.DisablePriceRefresh,
|
||||
OnlyInStock: snapshot.OnlyInStock,
|
||||
VendorSpec: snapshot.VendorSpec,
|
||||
Line: snapshot.Line,
|
||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||
OriginalUserID: snapshot.OriginalUserID,
|
||||
OriginalUsername: snapshot.OriginalUsername,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
package lotmatch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrResolveConflict = errors.New("multiple lot matches")
|
||||
ErrResolveNotFound = errors.New("lot not found")
|
||||
)
|
||||
|
||||
type LotResolver struct {
|
||||
partnumberToLots map[string][]string
|
||||
exactLots map[string]string
|
||||
allLots []string
|
||||
}
|
||||
|
||||
type MappingMatcher struct {
|
||||
exact map[string][]string
|
||||
exactLot map[string]string
|
||||
wildcard []wildcardMapping
|
||||
}
|
||||
|
||||
type wildcardMapping struct {
|
||||
lotName string
|
||||
re *regexp.Regexp
|
||||
}
|
||||
|
||||
func NewLotResolverFromDB(db *gorm.DB) (*LotResolver, error) {
|
||||
mappings, lots, err := loadMappingsAndLots(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewLotResolver(mappings, lots), nil
|
||||
}
|
||||
|
||||
func NewMappingMatcherFromDB(db *gorm.DB) (*MappingMatcher, error) {
|
||||
mappings, lots, err := loadMappingsAndLots(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewMappingMatcher(mappings, lots), nil
|
||||
}
|
||||
|
||||
func NewLotResolver(mappings []models.LotPartnumber, lots []models.Lot) *LotResolver {
|
||||
partnumberToLots := make(map[string][]string, len(mappings))
|
||||
for _, m := range mappings {
|
||||
pn := NormalizeKey(m.Partnumber)
|
||||
lot := strings.TrimSpace(m.LotName)
|
||||
if pn == "" || lot == "" {
|
||||
continue
|
||||
}
|
||||
partnumberToLots[pn] = append(partnumberToLots[pn], lot)
|
||||
}
|
||||
for key := range partnumberToLots {
|
||||
partnumberToLots[key] = uniqueCaseInsensitive(partnumberToLots[key])
|
||||
}
|
||||
|
||||
exactLots := make(map[string]string, len(lots))
|
||||
allLots := make([]string, 0, len(lots))
|
||||
for _, l := range lots {
|
||||
name := strings.TrimSpace(l.LotName)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
exactLots[NormalizeKey(name)] = name
|
||||
allLots = append(allLots, name)
|
||||
}
|
||||
sort.Slice(allLots, func(i, j int) bool {
|
||||
li := len([]rune(allLots[i]))
|
||||
lj := len([]rune(allLots[j]))
|
||||
if li == lj {
|
||||
return allLots[i] < allLots[j]
|
||||
}
|
||||
return li > lj
|
||||
})
|
||||
|
||||
return &LotResolver{
|
||||
partnumberToLots: partnumberToLots,
|
||||
exactLots: exactLots,
|
||||
allLots: allLots,
|
||||
}
|
||||
}
|
||||
|
||||
func NewMappingMatcher(mappings []models.LotPartnumber, lots []models.Lot) *MappingMatcher {
|
||||
exact := make(map[string][]string, len(mappings))
|
||||
wildcards := make([]wildcardMapping, 0, len(mappings))
|
||||
for _, m := range mappings {
|
||||
pn := NormalizeKey(m.Partnumber)
|
||||
lot := strings.TrimSpace(m.LotName)
|
||||
if pn == "" || lot == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(pn, "*") {
|
||||
pattern := "^" + regexp.QuoteMeta(pn) + "$"
|
||||
pattern = strings.ReplaceAll(pattern, "\\*", ".*")
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
wildcards = append(wildcards, wildcardMapping{lotName: lot, re: re})
|
||||
continue
|
||||
}
|
||||
exact[pn] = append(exact[pn], lot)
|
||||
}
|
||||
for key := range exact {
|
||||
exact[key] = uniqueCaseInsensitive(exact[key])
|
||||
}
|
||||
|
||||
exactLot := make(map[string]string, len(lots))
|
||||
for _, l := range lots {
|
||||
name := strings.TrimSpace(l.LotName)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
exactLot[NormalizeKey(name)] = name
|
||||
}
|
||||
|
||||
return &MappingMatcher{
|
||||
exact: exact,
|
||||
exactLot: exactLot,
|
||||
wildcard: wildcards,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LotResolver) Resolve(partnumber string) (string, string, error) {
|
||||
key := NormalizeKey(partnumber)
|
||||
if key == "" {
|
||||
return "", "", ErrResolveNotFound
|
||||
}
|
||||
|
||||
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
|
||||
if len(mapped) == 1 {
|
||||
return mapped[0], "mapping_table", nil
|
||||
}
|
||||
return "", "", ErrResolveConflict
|
||||
}
|
||||
if exact, ok := r.exactLots[key]; ok {
|
||||
return exact, "article_exact", nil
|
||||
}
|
||||
|
||||
best := ""
|
||||
bestLen := -1
|
||||
tie := false
|
||||
for _, lot := range r.allLots {
|
||||
lotKey := NormalizeKey(lot)
|
||||
if lotKey == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(key, lotKey) {
|
||||
l := len([]rune(lotKey))
|
||||
if l > bestLen {
|
||||
best = lot
|
||||
bestLen = l
|
||||
tie = false
|
||||
} else if l == bestLen && !strings.EqualFold(best, lot) {
|
||||
tie = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", "", ErrResolveNotFound
|
||||
}
|
||||
if tie {
|
||||
return "", "", ErrResolveConflict
|
||||
}
|
||||
return best, "prefix", nil
|
||||
}
|
||||
|
||||
func (m *MappingMatcher) MatchLots(partnumber string) []string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
key := NormalizeKey(partnumber)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lots := make([]string, 0, 2)
|
||||
if exact := m.exact[key]; len(exact) > 0 {
|
||||
lots = append(lots, exact...)
|
||||
}
|
||||
for _, wc := range m.wildcard {
|
||||
if wc.re == nil || !wc.re.MatchString(key) {
|
||||
continue
|
||||
}
|
||||
lots = append(lots, wc.lotName)
|
||||
}
|
||||
if lot, ok := m.exactLot[key]; ok && strings.TrimSpace(lot) != "" {
|
||||
lots = append(lots, lot)
|
||||
}
|
||||
return uniqueCaseInsensitive(lots)
|
||||
}
|
||||
|
||||
func NormalizeKey(v string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(v))
|
||||
replacer := strings.NewReplacer(" ", "", "-", "", "_", "", ".", "", "/", "", "\\", "", "\"", "", "'", "", "(", "", ")", "")
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
func loadMappingsAndLots(db *gorm.DB) ([]models.LotPartnumber, []models.Lot, error) {
|
||||
var mappings []models.LotPartnumber
|
||||
if err := db.Find(&mappings).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var lots []models.Lot
|
||||
if err := db.Select("lot_name").Find(&lots).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return mappings, lots, nil
|
||||
}
|
||||
|
||||
func uniqueCaseInsensitive(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(trimmed)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return strings.ToLower(out[i]) < strings.ToLower(out[j])
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package lotmatch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
func TestLotResolverPrecedence(t *testing.T) {
|
||||
resolver := NewLotResolver(
|
||||
[]models.LotPartnumber{
|
||||
{Partnumber: "PN-1", LotName: "LOT_A"},
|
||||
},
|
||||
[]models.Lot{
|
||||
{LotName: "CPU_X_LONG"},
|
||||
{LotName: "CPU_X"},
|
||||
},
|
||||
)
|
||||
|
||||
lot, by, err := resolver.Resolve("PN-1")
|
||||
if err != nil || lot != "LOT_A" || by != "mapping_table" {
|
||||
t.Fatalf("expected mapping_table LOT_A, got lot=%s by=%s err=%v", lot, by, err)
|
||||
}
|
||||
|
||||
lot, by, err = resolver.Resolve("CPU_X")
|
||||
if err != nil || lot != "CPU_X" || by != "article_exact" {
|
||||
t.Fatalf("expected article_exact CPU_X, got lot=%s by=%s err=%v", lot, by, err)
|
||||
}
|
||||
|
||||
lot, by, err = resolver.Resolve("CPU_X_LONG_001")
|
||||
if err != nil || lot != "CPU_X_LONG" || by != "prefix" {
|
||||
t.Fatalf("expected prefix CPU_X_LONG, got lot=%s by=%s err=%v", lot, by, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappingMatcherWildcardAndExactLot(t *testing.T) {
|
||||
matcher := NewMappingMatcher(
|
||||
[]models.LotPartnumber{
|
||||
{Partnumber: "R750*", LotName: "SERVER_R750"},
|
||||
{Partnumber: "HDD-01", LotName: "HDD_01"},
|
||||
},
|
||||
[]models.Lot{
|
||||
{LotName: "MEM_DDR5_16G_4800"},
|
||||
},
|
||||
)
|
||||
|
||||
check := func(partnumber string, want string) {
|
||||
t.Helper()
|
||||
got := matcher.MatchLots(partnumber)
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("partnumber %s: expected [%s], got %#v", partnumber, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
check("R750XD", "SERVER_R750")
|
||||
check("HDD-01", "HDD_01")
|
||||
check("MEM_DDR5_16G_4800", "MEM_DDR5_16G_4800")
|
||||
|
||||
if got := matcher.MatchLots("UNKNOWN"); len(got) != 0 {
|
||||
t.Fatalf("expected no matches for UNKNOWN, got %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthUserKey = "auth_user"
|
||||
AuthClaimsKey = "auth_claims"
|
||||
)
|
||||
|
||||
func Auth(authService *services.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "authorization header required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid authorization header format",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authService.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(AuthClaimsKey, claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireRole(roles ...models.UserRole) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, exists := c.Get(AuthClaimsKey)
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "authentication required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
authClaims := claims.(*services.Claims)
|
||||
|
||||
for _, role := range roles {
|
||||
if authClaims.Role == role {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "insufficient permissions",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequireEditor() gin.HandlerFunc {
|
||||
return RequireRole(models.RoleEditor, models.RolePricingAdmin, models.RoleAdmin)
|
||||
}
|
||||
|
||||
func RequirePricingAdmin() gin.HandlerFunc {
|
||||
return RequireRole(models.RolePricingAdmin, models.RoleAdmin)
|
||||
}
|
||||
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return RequireRole(models.RoleAdmin)
|
||||
}
|
||||
|
||||
// GetClaims extracts auth claims from context
|
||||
func GetClaims(c *gin.Context) *services.Claims {
|
||||
claims, exists := c.Get(AuthClaimsKey)
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
return claims.(*services.Claims)
|
||||
}
|
||||
|
||||
// GetUserID extracts user ID from context
|
||||
func GetUserID(c *gin.Context) uint {
|
||||
claims := GetClaims(c)
|
||||
if claims == nil {
|
||||
return 0
|
||||
}
|
||||
return claims.UserID
|
||||
}
|
||||
|
||||
// GetUsername extracts username from context
|
||||
func GetUsername(c *gin.Context) string {
|
||||
claims := GetClaims(c)
|
||||
if claims == nil {
|
||||
return ""
|
||||
}
|
||||
return claims.Username
|
||||
}
|
||||
@@ -39,6 +39,57 @@ func (c ConfigItems) Total() float64 {
|
||||
return total
|
||||
}
|
||||
|
||||
type VendorSpecLotAllocation struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
type VendorSpecLotMapping struct {
|
||||
LotName string `json:"lot_name"`
|
||||
QuantityPerPN int `json:"quantity_per_pn"`
|
||||
}
|
||||
|
||||
type VendorSpecItem struct {
|
||||
SortOrder int `json:"sort_order"`
|
||||
VendorPartnumber string `json:"vendor_partnumber"`
|
||||
Quantity int `json:"quantity"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
|
||||
ResolutionSource string `json:"resolution_source,omitempty"`
|
||||
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
|
||||
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
|
||||
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
|
||||
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||
}
|
||||
|
||||
type VendorSpec []VendorSpecItem
|
||||
|
||||
func (v VendorSpec) Value() (driver.Value, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (v *VendorSpec) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*v = nil
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch val := value.(type) {
|
||||
case []byte:
|
||||
bytes = val
|
||||
case string:
|
||||
bytes = []byte(val)
|
||||
default:
|
||||
return errors.New("type assertion failed for VendorSpec")
|
||||
}
|
||||
return json.Unmarshal(bytes, v)
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
@@ -59,14 +110,13 @@ type Configuration struct {
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||
VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,omitempty"`
|
||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
Line int `gorm:"column:line_no;index" json:"line"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`
|
||||
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
func (Configuration) TableName() string {
|
||||
@@ -81,8 +131,6 @@ type PriceOverride struct {
|
||||
ValidUntil *time.Time `gorm:"type:date" json:"valid_until"`
|
||||
Reason string `gorm:"type:text" json:"reason"`
|
||||
CreatedBy uint `gorm:"not null" json:"created_by"`
|
||||
|
||||
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"`
|
||||
}
|
||||
|
||||
func (PriceOverride) TableName() string {
|
||||
|
||||
@@ -55,17 +55,6 @@ func (StockLog) TableName() string {
|
||||
return "stock_log"
|
||||
}
|
||||
|
||||
// LotPartnumber maps external part numbers to internal lots.
|
||||
type LotPartnumber struct {
|
||||
Partnumber string `gorm:"column:partnumber;size:255;primaryKey" json:"partnumber"`
|
||||
LotName string `gorm:"column:lot_name;size:255;primaryKey" json:"lot_name"`
|
||||
Description *string `gorm:"column:description;size:10000" json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func (LotPartnumber) TableName() string {
|
||||
return "lot_partnumbers"
|
||||
}
|
||||
|
||||
// StockIgnoreRule contains import ignore pattern rules.
|
||||
type StockIgnoreRule struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
// AllModels returns all models for auto-migration
|
||||
func AllModels() []interface{} {
|
||||
return []interface{}{
|
||||
&User{},
|
||||
&Category{},
|
||||
&LotMetadata{},
|
||||
&Project{},
|
||||
@@ -52,54 +51,3 @@ func SeedCategories(db *gorm.DB) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedAdminUser creates default admin user if not exists
|
||||
// Default credentials: admin / admin123
|
||||
func SeedAdminUser(db *gorm.DB, passwordHash string) error {
|
||||
var count int64
|
||||
db.Model(&User{}).Where("username = ?", "admin").Count(&count)
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
admin := &User{
|
||||
Username: "admin",
|
||||
Email: "admin@example.com",
|
||||
PasswordHash: passwordHash,
|
||||
Role: RoleAdmin,
|
||||
IsActive: true,
|
||||
}
|
||||
return db.Create(admin).Error
|
||||
}
|
||||
|
||||
// EnsureDBUser creates or returns the user corresponding to the database connection username.
|
||||
// This is used when RBAC is disabled - configurations are owned by the DB user.
|
||||
// Returns the user ID that should be used for all operations.
|
||||
func EnsureDBUser(db *gorm.DB, dbUsername string) (uint, error) {
|
||||
if dbUsername == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var user User
|
||||
err := db.Where("username = ?", dbUsername).First(&user).Error
|
||||
if err == nil {
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
// User doesn't exist, create it
|
||||
user = User{
|
||||
Username: dbUsername,
|
||||
Email: dbUsername + "@db.local",
|
||||
PasswordHash: "-", // No password - this is a DB user, not an app user
|
||||
Role: RoleAdmin,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
slog.Error("failed to create DB user", "username", dbUsername, "error", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
slog.Info("created DB user for configurations", "username", dbUsername, "user_id", user.ID)
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleViewer UserRole = "viewer"
|
||||
RoleEditor UserRole = "editor"
|
||||
RolePricingAdmin UserRole = "pricing_admin"
|
||||
RoleAdmin UserRole = "admin"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"size:100;uniqueIndex;not null" json:"username"`
|
||||
Email string `gorm:"size:255;uniqueIndex;not null" json:"email"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Role UserRole `gorm:"type:enum('viewer','editor','pricing_admin','admin');default:'viewer'" json:"role"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "qt_users"
|
||||
}
|
||||
|
||||
func (u *User) CanEdit() bool {
|
||||
return u.Role == RoleEditor || u.Role == RolePricingAdmin || u.Role == RoleAdmin
|
||||
}
|
||||
|
||||
func (u *User) CanManagePricing() bool {
|
||||
return u.Role == RolePricingAdmin || u.Role == RoleAdmin
|
||||
}
|
||||
|
||||
func (u *User) CanManageUsers() bool {
|
||||
return u.Role == RoleAdmin
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// PartnumberBookRepository provides read-only access to local partnumber book snapshots.
|
||||
@@ -26,15 +27,48 @@ func (r *PartnumberBookRepository) GetActiveBook() (*localdb.LocalPartnumberBook
|
||||
|
||||
// GetBookItems returns all items for the given local book ID.
|
||||
func (r *PartnumberBookRepository) GetBookItems(bookID uint) ([]localdb.LocalPartnumberBookItem, error) {
|
||||
var items []localdb.LocalPartnumberBookItem
|
||||
err := r.db.Where("book_id = ?", bookID).Find(&items).Error
|
||||
book, err := r.getBook(bookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, _, err := r.listCatalogItems(book.PartnumbersJSON, "", 0, 0)
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetBookItemsPage returns items for the given local book ID with optional search and pagination.
|
||||
func (r *PartnumberBookRepository) GetBookItemsPage(bookID uint, search string, page, perPage int) ([]localdb.LocalPartnumberBookItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
book, err := r.getBook(bookID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return r.listCatalogItems(book.PartnumbersJSON, search, page, perPage)
|
||||
}
|
||||
|
||||
// FindLotByPartnumber looks up a partnumber in the active book and returns the matching items.
|
||||
func (r *PartnumberBookRepository) FindLotByPartnumber(bookID uint, partnumber string) ([]localdb.LocalPartnumberBookItem, error) {
|
||||
book, err := r.getBook(bookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := false
|
||||
for _, pn := range book.PartnumbersJSON {
|
||||
if pn == partnumber {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
var items []localdb.LocalPartnumberBookItem
|
||||
err := r.db.Where("book_id = ? AND partnumber = ?", bookID, partnumber).Find(&items).Error
|
||||
err = r.db.Where("partnumber = ?", partnumber).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
@@ -50,17 +84,91 @@ func (r *PartnumberBookRepository) SaveBook(book *localdb.LocalPartnumberBook) e
|
||||
return r.db.Save(book).Error
|
||||
}
|
||||
|
||||
// SaveBookItems bulk-inserts items for a book snapshot.
|
||||
// SaveBookItems upserts canonical PN catalog rows.
|
||||
func (r *PartnumberBookRepository) SaveBookItems(items []localdb.LocalPartnumberBookItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.CreateInBatches(items, 500).Error
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "partnumber"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"lots_json",
|
||||
"description",
|
||||
}),
|
||||
}).CreateInBatches(items, 500).Error
|
||||
}
|
||||
|
||||
// CountBookItems returns the number of items for a given local book ID.
|
||||
func (r *PartnumberBookRepository) CountBookItems(bookID uint) int64 {
|
||||
var count int64
|
||||
r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("book_id = ?", bookID).Count(&count)
|
||||
return count
|
||||
book, err := r.getBook(bookID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(len(book.PartnumbersJSON))
|
||||
}
|
||||
|
||||
func (r *PartnumberBookRepository) CountDistinctLots(bookID uint) int64 {
|
||||
items, err := r.GetBookItems(bookID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
for _, lot := range item.LotsJSON {
|
||||
if lot.LotName == "" {
|
||||
continue
|
||||
}
|
||||
seen[lot.LotName] = struct{}{}
|
||||
}
|
||||
}
|
||||
return int64(len(seen))
|
||||
}
|
||||
|
||||
func (r *PartnumberBookRepository) HasAllBookItems(bookID uint) bool {
|
||||
book, err := r.getBook(bookID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if len(book.PartnumbersJSON) == 0 {
|
||||
return true
|
||||
}
|
||||
var count int64
|
||||
if err := r.db.Model(&localdb.LocalPartnumberBookItem{}).
|
||||
Where("partnumber IN ?", []string(book.PartnumbersJSON)).
|
||||
Count(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count == int64(len(book.PartnumbersJSON))
|
||||
}
|
||||
|
||||
func (r *PartnumberBookRepository) getBook(bookID uint) (*localdb.LocalPartnumberBook, error) {
|
||||
var book localdb.LocalPartnumberBook
|
||||
if err := r.db.First(&book, bookID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &book, nil
|
||||
}
|
||||
|
||||
func (r *PartnumberBookRepository) listCatalogItems(partnumbers localdb.LocalStringList, search string, page, perPage int) ([]localdb.LocalPartnumberBookItem, int64, error) {
|
||||
if len(partnumbers) == 0 {
|
||||
return []localdb.LocalPartnumberBookItem{}, 0, nil
|
||||
}
|
||||
|
||||
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers))
|
||||
if search != "" {
|
||||
trimmedSearch := "%" + search + "%"
|
||||
query = query.Where("partnumber LIKE ? OR lots_json LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var items []localdb.LocalPartnumberBookItem
|
||||
if page > 0 && perPage > 0 {
|
||||
query = query.Offset((page - 1) * perPage).Limit(perPage)
|
||||
}
|
||||
err := query.Order("partnumber ASC, id ASC").Find(&items).Error
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
@@ -3,13 +3,10 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -246,94 +243,9 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
||||
items[i].Category = strings.TrimSpace(items[i].LotCategory)
|
||||
}
|
||||
|
||||
// Stock/partnumber enrichment is optional for pricelist item listing.
|
||||
// Return base pricelist rows (lot_name/price/category) even when DB user lacks
|
||||
// access to stock mapping tables (e.g. lot_partnumbers, stock_log).
|
||||
if err := r.enrichItemsWithStock(items); err != nil {
|
||||
slog.Warn("pricelist items stock enrichment skipped", "pricelist_id", pricelistID, "error", err)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (r *PricelistRepository) enrichItemsWithStock(items []models.PricelistItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
resolver, err := lotmatch.NewLotResolverFromDB(r.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type stockRow struct {
|
||||
Partnumber string `gorm:"column:partnumber"`
|
||||
Qty *float64 `gorm:"column:qty"`
|
||||
}
|
||||
rows := make([]stockRow, 0)
|
||||
if err := r.db.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 i := range rows {
|
||||
row := rows[i]
|
||||
if strings.TrimSpace(row.Partnumber) == "" {
|
||||
continue
|
||||
}
|
||||
lotName, _, resolveErr := resolver.Resolve(row.Partnumber)
|
||||
if resolveErr != nil || strings.TrimSpace(lotName) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if row.Qty != nil {
|
||||
lotTotals[lotName] += *row.Qty
|
||||
}
|
||||
|
||||
pn := strings.TrimSpace(row.Partnumber)
|
||||
if pn == "" {
|
||||
continue
|
||||
}
|
||||
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 := 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 = partnumbers
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLotNames returns distinct lot names from pricelist items.
|
||||
func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
|
||||
var lotNames []string
|
||||
|
||||
@@ -75,57 +75,6 @@ func TestGenerateVersion_IsolatedBySource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
db := repo.db
|
||||
|
||||
warehouse := models.Pricelist{
|
||||
Source: string(models.PricelistSourceWarehouse),
|
||||
Version: "S-2026-02-07-001",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(&warehouse).Error; err != nil {
|
||||
t.Fatalf("create pricelist: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: warehouse.ID,
|
||||
LotName: "SSD_NVME_03.2T",
|
||||
Price: 100,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create pricelist item: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.Lot{LotName: "SSD_NVME_03.2T"}).Error; err != nil {
|
||||
t.Fatalf("create lot: %v", err)
|
||||
}
|
||||
qty := 5.0
|
||||
if err := db.Create(&models.StockLog{
|
||||
Partnumber: "SSD_NVME_03.2T_GEN3_P4610",
|
||||
Date: time.Now(),
|
||||
Price: 200,
|
||||
Qty: &qty,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create stock log: %v", err)
|
||||
}
|
||||
|
||||
items, total, err := repo.GetItems(warehouse.ID, 0, 20, "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetItems: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("expected total=1, got %d", total)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
if items[0].AvailableQty == nil {
|
||||
t.Fatalf("expected available qty to be set")
|
||||
}
|
||||
if *items[0].AvailableQty != 5 {
|
||||
t.Fatalf("expected available qty=5, got %v", *items[0].AvailableQty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestActiveBySource_SkipsPricelistsWithoutItems(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
db := repo.db
|
||||
@@ -228,7 +177,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.StockLog{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return NewPricelistRepository(db)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(user *models.User) error {
|
||||
return r.db.Create(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByID(id uint) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.First(&user, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByUsername(username string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.Where("username = ?", username).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.Where("email = ?", email).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(user *models.User) error {
|
||||
return r.db.Save(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.User{}, id).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) List(offset, limit int) ([]models.User, int64, error) {
|
||||
var users []models.User
|
||||
var total int64
|
||||
|
||||
r.db.Model(&models.User{}).Count(&total)
|
||||
err := r.db.Offset(offset).Limit(limit).Find(&users).Error
|
||||
return users, total, err
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserInactive = errors.New("user account is inactive")
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
userRepo *repository.UserRepository
|
||||
config config.AuthConfig
|
||||
}
|
||||
|
||||
func NewAuthService(userRepo *repository.UserRepository, cfg config.AuthConfig) *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: userRepo,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role models.UserRole `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(username, password string) (*TokenPair, *models.User, error) {
|
||||
user, err := s.userRepo.GetByUsername(username)
|
||||
if err != nil {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, nil, ErrUserInactive
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
tokens, err := s.generateTokenPair(user)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return tokens, user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
|
||||
claims, err := s.ValidateToken(refreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetByID(claims.UserID)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, ErrUserInactive
|
||||
}
|
||||
|
||||
return s.generateTokenPair(user)
|
||||
}
|
||||
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(s.config.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) generateTokenPair(user *models.User) (*TokenPair, error) {
|
||||
now := time.Now()
|
||||
accessExpiry := now.Add(s.config.TokenExpiry)
|
||||
refreshExpiry := now.Add(s.config.RefreshExpiry)
|
||||
|
||||
accessClaims := &Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(accessExpiry),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Subject: user.Username,
|
||||
},
|
||||
}
|
||||
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||
accessTokenString, err := accessToken.SignedString([]byte(s.config.JWTSecret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshClaims := &Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(refreshExpiry),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Subject: user.Username,
|
||||
},
|
||||
}
|
||||
|
||||
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||
refreshTokenString, err := refreshToken.SignedString([]byte(s.config.JWTSecret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessTokenString,
|
||||
RefreshToken: refreshTokenString,
|
||||
ExpiresAt: accessExpiry.Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func (s *AuthService) CreateUser(username, email, password string, role models.UserRole) (*models.User, error) {
|
||||
hash, err := s.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: hash,
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -45,18 +45,21 @@ func NewConfigurationService(
|
||||
}
|
||||
|
||||
type CreateConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
Items models.ConfigItems `json:"items"`
|
||||
ProjectUUID *string `json:"project_uuid,omitempty"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
ServerModel string `json:"server_model,omitempty"`
|
||||
SupportCode string `json:"support_code,omitempty"`
|
||||
Article string `json:"article,omitempty"`
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
Name string `json:"name"`
|
||||
Items models.ConfigItems `json:"items"`
|
||||
ProjectUUID *string `json:"project_uuid,omitempty"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
ServerModel string `json:"server_model,omitempty"`
|
||||
SupportCode string `json:"support_code,omitempty"`
|
||||
Article string `json:"article,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"`
|
||||
}
|
||||
|
||||
type ArticlePreviewRequest struct {
|
||||
@@ -84,21 +87,24 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
||||
}
|
||||
|
||||
config := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: projectUUID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: req.CustomPrice,
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
Article: req.Article,
|
||||
PricelistID: pricelistID,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: projectUUID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: req.CustomPrice,
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
Article: req.Article,
|
||||
PricelistID: pricelistID,
|
||||
WarehousePricelistID: req.WarehousePricelistID,
|
||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(config); err != nil {
|
||||
@@ -163,6 +169,9 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
||||
config.SupportCode = req.SupportCode
|
||||
config.Article = req.Article
|
||||
config.PricelistID = pricelistID
|
||||
config.WarehousePricelistID = req.WarehousePricelistID
|
||||
config.CompetitorPricelistID = req.CompetitorPricelistID
|
||||
config.DisablePriceRefresh = req.DisablePriceRefresh
|
||||
config.OnlyInStock = req.OnlyInStock
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
@@ -230,18 +239,24 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
|
||||
}
|
||||
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false, // Clone is never a template
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false, // Clone is never a template
|
||||
ServerCount: original.ServerCount,
|
||||
ServerModel: original.ServerModel,
|
||||
SupportCode: original.SupportCode,
|
||||
Article: original.Article,
|
||||
PricelistID: original.PricelistID,
|
||||
WarehousePricelistID: original.WarehousePricelistID,
|
||||
CompetitorPricelistID: original.CompetitorPricelistID,
|
||||
DisablePriceRefresh: original.DisablePriceRefresh,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
@@ -314,7 +329,13 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
config.ServerModel = req.ServerModel
|
||||
config.SupportCode = req.SupportCode
|
||||
config.Article = req.Article
|
||||
config.PricelistID = pricelistID
|
||||
config.WarehousePricelistID = req.WarehousePricelistID
|
||||
config.CompetitorPricelistID = req.CompetitorPricelistID
|
||||
config.DisablePriceRefresh = req.DisablePriceRefresh
|
||||
config.OnlyInStock = req.OnlyInStock
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
@@ -587,13 +608,7 @@ func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUserna
|
||||
if config == nil || ownerUsername == "" {
|
||||
return false
|
||||
}
|
||||
if config.OwnerUsername != "" {
|
||||
return config.OwnerUsername == ownerUsername
|
||||
}
|
||||
if config.User != nil {
|
||||
return config.User.Username == ownerUsername
|
||||
}
|
||||
return false
|
||||
return config.OwnerUsername == ownerUsername
|
||||
}
|
||||
|
||||
// // Export configuration as JSON
|
||||
|
||||
@@ -55,6 +55,38 @@ type ProjectExportData struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ProjectPricingExportOptions struct {
|
||||
IncludeLOT bool `json:"include_lot"`
|
||||
IncludeBOM bool `json:"include_bom"`
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
}
|
||||
|
||||
type ProjectPricingExportData struct {
|
||||
Configs []ProjectPricingExportConfig
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ProjectPricingExportConfig struct {
|
||||
Name string
|
||||
Article string
|
||||
Line int
|
||||
ServerCount int
|
||||
Rows []ProjectPricingExportRow
|
||||
}
|
||||
|
||||
type ProjectPricingExportRow struct {
|
||||
LotDisplay string
|
||||
VendorPN string
|
||||
Description string
|
||||
Quantity int
|
||||
BOMTotal *float64
|
||||
Estimate *float64
|
||||
Stock *float64
|
||||
Competitor *float64
|
||||
}
|
||||
|
||||
// ToCSV writes project export data in the new structured CSV format.
|
||||
//
|
||||
// Format:
|
||||
@@ -168,6 +200,80 @@ func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
|
||||
sortedConfigs := make([]models.Configuration, len(configs))
|
||||
copy(sortedConfigs, configs)
|
||||
sort.Slice(sortedConfigs, func(i, j int) bool {
|
||||
leftLine := sortedConfigs[i].Line
|
||||
rightLine := sortedConfigs[j].Line
|
||||
|
||||
if leftLine <= 0 {
|
||||
leftLine = int(^uint(0) >> 1)
|
||||
}
|
||||
if rightLine <= 0 {
|
||||
rightLine = int(^uint(0) >> 1)
|
||||
}
|
||||
if leftLine != rightLine {
|
||||
return leftLine < rightLine
|
||||
}
|
||||
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
|
||||
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
|
||||
}
|
||||
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
|
||||
})
|
||||
|
||||
blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs))
|
||||
for i := range sortedConfigs {
|
||||
block, err := s.buildPricingExportBlock(&sortedConfigs[i], opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
|
||||
return &ProjectPricingExportData{
|
||||
Configs: blocks,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData, opts ProjectPricingExportOptions) error {
|
||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||
return fmt.Errorf("failed to write BOM: %w", err)
|
||||
}
|
||||
|
||||
csvWriter := csv.NewWriter(w)
|
||||
csvWriter.Comma = ';'
|
||||
defer csvWriter.Flush()
|
||||
|
||||
headers := pricingCSVHeaders(opts)
|
||||
if err := csvWriter.Write(headers); err != nil {
|
||||
return fmt.Errorf("failed to write pricing header: %w", err)
|
||||
}
|
||||
|
||||
for idx, cfg := range data.Configs {
|
||||
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
|
||||
return fmt.Errorf("failed to write config summary row: %w", err)
|
||||
}
|
||||
for _, row := range cfg.Rows {
|
||||
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
||||
return fmt.Errorf("failed to write pricing row: %w", err)
|
||||
}
|
||||
}
|
||||
if idx < len(data.Configs)-1 {
|
||||
if err := csvWriter.Write([]string{}); err != nil {
|
||||
return fmt.Errorf("failed to write separator row: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
csvWriter.Flush()
|
||||
if err := csvWriter.Error(); err != nil {
|
||||
return fmt.Errorf("csv writer error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigToExportData converts a single configuration into ProjectExportData.
|
||||
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
|
||||
block := s.buildExportBlock(cfg)
|
||||
@@ -247,6 +353,99 @@ func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExport
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts ProjectPricingExportOptions) (ProjectPricingExportConfig, error) {
|
||||
block := ProjectPricingExportConfig{
|
||||
Name: cfg.Name,
|
||||
Article: cfg.Article,
|
||||
Line: cfg.Line,
|
||||
ServerCount: exportPositiveInt(cfg.ServerCount, 1),
|
||||
Rows: make([]ProjectPricingExportRow, 0),
|
||||
}
|
||||
if s.localDB == nil {
|
||||
for _, item := range cfg.Items {
|
||||
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||
LotDisplay: item.LotName,
|
||||
VendorPN: "—",
|
||||
Quantity: item.Quantity,
|
||||
Estimate: floatPtr(item.UnitPrice * float64(item.Quantity)),
|
||||
})
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
||||
if err != nil {
|
||||
localCfg = nil
|
||||
}
|
||||
|
||||
priceMap := s.resolvePricingTotals(cfg, localCfg, opts)
|
||||
componentDescriptions := s.resolveLotDescriptions(cfg, localCfg)
|
||||
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
|
||||
coveredLots := make(map[string]struct{})
|
||||
for _, row := range localCfg.VendorSpec {
|
||||
rowMappings := normalizeLotMappings(row.LotMappings)
|
||||
for _, mapping := range rowMappings {
|
||||
coveredLots[mapping.LotName] = struct{}{}
|
||||
}
|
||||
|
||||
description := strings.TrimSpace(row.Description)
|
||||
if description == "" && len(rowMappings) > 0 {
|
||||
description = componentDescriptions[rowMappings[0].LotName]
|
||||
}
|
||||
|
||||
pricingRow := ProjectPricingExportRow{
|
||||
LotDisplay: formatLotDisplay(rowMappings),
|
||||
VendorPN: row.VendorPartnumber,
|
||||
Description: description,
|
||||
Quantity: exportPositiveInt(row.Quantity, 1),
|
||||
BOMTotal: vendorRowTotal(row),
|
||||
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
|
||||
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
|
||||
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
|
||||
}
|
||||
block.Rows = append(block.Rows, pricingRow)
|
||||
}
|
||||
|
||||
for _, item := range cfg.Items {
|
||||
if item.LotName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := coveredLots[item.LotName]; ok {
|
||||
continue
|
||||
}
|
||||
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
|
||||
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||
LotDisplay: item.LotName,
|
||||
VendorPN: "—",
|
||||
Description: componentDescriptions[item.LotName],
|
||||
Quantity: exportPositiveInt(item.Quantity, 1),
|
||||
Estimate: estimate,
|
||||
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
|
||||
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
||||
})
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
for _, item := range cfg.Items {
|
||||
if item.LotName == "" {
|
||||
continue
|
||||
}
|
||||
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
|
||||
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||
LotDisplay: item.LotName,
|
||||
VendorPN: "—",
|
||||
Description: componentDescriptions[item.LotName],
|
||||
Quantity: exportPositiveInt(item.Quantity, 1),
|
||||
Estimate: estimate,
|
||||
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
|
||||
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
||||
})
|
||||
}
|
||||
|
||||
return block, nil
|
||||
}
|
||||
|
||||
// resolveCategories returns lot_name → category map.
|
||||
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
||||
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
||||
@@ -303,6 +502,324 @@ func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
|
||||
}
|
||||
}
|
||||
|
||||
type pricingLevels struct {
|
||||
Estimate *float64
|
||||
Stock *float64
|
||||
Competitor *float64
|
||||
}
|
||||
|
||||
func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, opts ProjectPricingExportOptions) map[string]pricingLevels {
|
||||
result := map[string]pricingLevels{}
|
||||
lots := collectPricingLots(cfg, localCfg, opts.IncludeBOM)
|
||||
if len(lots) == 0 || s.localDB == nil {
|
||||
return result
|
||||
}
|
||||
|
||||
estimateID := cfg.PricelistID
|
||||
if estimateID == nil || *estimateID == 0 {
|
||||
if latest, err := s.localDB.GetLatestLocalPricelistBySource("estimate"); err == nil && latest != nil {
|
||||
estimateID = &latest.ServerID
|
||||
}
|
||||
}
|
||||
|
||||
var warehouseID *uint
|
||||
var competitorID *uint
|
||||
if localCfg != nil {
|
||||
warehouseID = localCfg.WarehousePricelistID
|
||||
competitorID = localCfg.CompetitorPricelistID
|
||||
}
|
||||
if warehouseID == nil || *warehouseID == 0 {
|
||||
if latest, err := s.localDB.GetLatestLocalPricelistBySource("warehouse"); err == nil && latest != nil {
|
||||
warehouseID = &latest.ServerID
|
||||
}
|
||||
}
|
||||
if competitorID == nil || *competitorID == 0 {
|
||||
if latest, err := s.localDB.GetLatestLocalPricelistBySource("competitor"); err == nil && latest != nil {
|
||||
competitorID = &latest.ServerID
|
||||
}
|
||||
}
|
||||
|
||||
for _, lot := range lots {
|
||||
level := pricingLevels{}
|
||||
level.Estimate = s.lookupPricePointer(estimateID, lot)
|
||||
level.Stock = s.lookupPricePointer(warehouseID, lot)
|
||||
level.Competitor = s.lookupPricePointer(competitorID, lot)
|
||||
result[lot] = level
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 {
|
||||
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" {
|
||||
return nil
|
||||
}
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
||||
if err != nil || price <= 0 {
|
||||
return nil
|
||||
}
|
||||
return floatPtr(price)
|
||||
}
|
||||
|
||||
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
|
||||
lots := collectPricingLots(cfg, localCfg, true)
|
||||
result := make(map[string]string, len(lots))
|
||||
if s.localDB == nil {
|
||||
return result
|
||||
}
|
||||
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 {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0)
|
||||
if includeBOM && localCfg != nil {
|
||||
for _, row := range localCfg.VendorSpec {
|
||||
for _, mapping := range normalizeLotMappings(row.LotMappings) {
|
||||
if _, ok := seen[mapping.LotName]; ok {
|
||||
continue
|
||||
}
|
||||
seen[mapping.LotName] = struct{}{}
|
||||
out = append(out, mapping.LotName)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range cfg.Items {
|
||||
lot := strings.TrimSpace(item.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[lot]; ok {
|
||||
continue
|
||||
}
|
||||
seen[lot] = struct{}{}
|
||||
out = append(out, lot)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||
if len(mappings) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]localdb.VendorSpecLotMapping, 0, len(mappings))
|
||||
for _, mapping := range mappings {
|
||||
lot := strings.TrimSpace(mapping.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
qty := mapping.QuantityPerPN
|
||||
if qty < 1 {
|
||||
qty = 1
|
||||
}
|
||||
out = append(out, localdb.VendorSpecLotMapping{
|
||||
LotName: lot,
|
||||
QuantityPerPN: qty,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
|
||||
if row.TotalPrice != nil {
|
||||
return floatPtr(*row.TotalPrice)
|
||||
}
|
||||
if row.UnitPrice == nil {
|
||||
return nil
|
||||
}
|
||||
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
|
||||
}
|
||||
|
||||
func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 {
|
||||
if len(mappings) == 0 {
|
||||
return nil
|
||||
}
|
||||
total := 0.0
|
||||
hasValue := false
|
||||
qty := exportPositiveInt(pnQty, 1)
|
||||
for _, mapping := range mappings {
|
||||
price := selector(priceMap[mapping.LotName])
|
||||
if price == nil || *price <= 0 {
|
||||
continue
|
||||
}
|
||||
total += *price * float64(qty*mapping.QuantityPerPN)
|
||||
hasValue = true
|
||||
}
|
||||
if !hasValue {
|
||||
return nil
|
||||
}
|
||||
return floatPtr(total)
|
||||
}
|
||||
|
||||
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
|
||||
if unitPrice == nil || *unitPrice <= 0 {
|
||||
return nil
|
||||
}
|
||||
total := *unitPrice * float64(exportPositiveInt(quantity, 1))
|
||||
return &total
|
||||
}
|
||||
|
||||
func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quantity int) *float64 {
|
||||
if estimatePrice != nil && *estimatePrice > 0 {
|
||||
return totalForUnitPrice(estimatePrice, quantity)
|
||||
}
|
||||
if fallbackUnitPrice <= 0 {
|
||||
return nil
|
||||
}
|
||||
total := fallbackUnitPrice * float64(maxInt(quantity, 1))
|
||||
return &total
|
||||
}
|
||||
|
||||
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||
headers := make([]string, 0, 8)
|
||||
headers = append(headers, "Line Item")
|
||||
if opts.IncludeLOT {
|
||||
headers = append(headers, "LOT")
|
||||
}
|
||||
headers = append(headers, "PN вендора", "Описание", "Кол-во")
|
||||
if opts.IncludeBOM {
|
||||
headers = append(headers, "BOM")
|
||||
}
|
||||
if opts.IncludeEstimate {
|
||||
headers = append(headers, "Estimate")
|
||||
}
|
||||
if opts.IncludeStock {
|
||||
headers = append(headers, "Stock")
|
||||
}
|
||||
if opts.IncludeCompetitor {
|
||||
headers = append(headers, "Конкуренты")
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
|
||||
record := make([]string, 0, 8)
|
||||
record = append(record, "")
|
||||
if opts.IncludeLOT {
|
||||
record = append(record, emptyDash(row.LotDisplay))
|
||||
}
|
||||
record = append(record,
|
||||
emptyDash(row.VendorPN),
|
||||
emptyDash(row.Description),
|
||||
fmt.Sprintf("%d", exportPositiveInt(row.Quantity, 1)),
|
||||
)
|
||||
if opts.IncludeBOM {
|
||||
record = append(record, formatMoneyValue(row.BOMTotal))
|
||||
}
|
||||
if opts.IncludeEstimate {
|
||||
record = append(record, formatMoneyValue(row.Estimate))
|
||||
}
|
||||
if opts.IncludeStock {
|
||||
record = append(record, formatMoneyValue(row.Stock))
|
||||
}
|
||||
if opts.IncludeCompetitor {
|
||||
record = append(record, formatMoneyValue(row.Competitor))
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
|
||||
record := make([]string, 0, 8)
|
||||
record = append(record, fmt.Sprintf("%d", cfg.Line))
|
||||
if opts.IncludeLOT {
|
||||
record = append(record, "")
|
||||
}
|
||||
record = append(record,
|
||||
"",
|
||||
emptyDash(cfg.Name),
|
||||
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
|
||||
)
|
||||
if opts.IncludeBOM {
|
||||
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.BOMTotal })))
|
||||
}
|
||||
if opts.IncludeEstimate {
|
||||
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Estimate })))
|
||||
}
|
||||
if opts.IncludeStock {
|
||||
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Stock })))
|
||||
}
|
||||
if opts.IncludeCompetitor {
|
||||
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
|
||||
switch len(mappings) {
|
||||
case 0:
|
||||
return "н/д"
|
||||
case 1:
|
||||
return mappings[0].LotName
|
||||
default:
|
||||
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func formatMoneyValue(value *float64) string {
|
||||
if value == nil {
|
||||
return "—"
|
||||
}
|
||||
n := math.Round(*value*100) / 100
|
||||
sign := ""
|
||||
if n < 0 {
|
||||
sign = "-"
|
||||
n = -n
|
||||
}
|
||||
whole := int64(n)
|
||||
fraction := int(math.Round((n - float64(whole)) * 100))
|
||||
if fraction == 100 {
|
||||
whole++
|
||||
fraction = 0
|
||||
}
|
||||
return fmt.Sprintf("%s%s,%02d", sign, formatIntWithSpace(whole), fraction)
|
||||
}
|
||||
|
||||
func emptyDash(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "—"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sumPricingColumn(rows []ProjectPricingExportRow, selector func(ProjectPricingExportRow) *float64) *float64 {
|
||||
total := 0.0
|
||||
hasValue := false
|
||||
for _, row := range rows {
|
||||
value := selector(row)
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
total += *value
|
||||
hasValue = true
|
||||
}
|
||||
if !hasValue {
|
||||
return nil
|
||||
}
|
||||
return floatPtr(total)
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 {
|
||||
v := value
|
||||
return &v
|
||||
}
|
||||
|
||||
func exportPositiveInt(value, fallback int) int {
|
||||
if value < 1 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").
|
||||
// Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown.
|
||||
func formatPriceComma(value float64) string {
|
||||
|
||||
@@ -444,6 +444,117 @@ func TestFormatPriceComma(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
data := &ProjectPricingExportData{
|
||||
Configs: []ProjectPricingExportConfig{
|
||||
{
|
||||
Name: "Config A",
|
||||
Article: "ART-1",
|
||||
Line: 10,
|
||||
ServerCount: 2,
|
||||
Rows: []ProjectPricingExportRow{
|
||||
{
|
||||
LotDisplay: "LOT_A +1",
|
||||
VendorPN: "PN-001",
|
||||
Description: "Bundle row",
|
||||
Quantity: 2,
|
||||
BOMTotal: floatPtr(2400.5),
|
||||
Estimate: floatPtr(2000),
|
||||
Stock: floatPtr(1800.25),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
opts := ProjectPricingExportOptions{
|
||||
IncludeLOT: true,
|
||||
IncludeBOM: true,
|
||||
IncludeEstimate: true,
|
||||
IncludeStock: true,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToPricingCSV(&buf, data, opts); err != nil {
|
||||
t.Fatalf("ToPricingCSV failed: %v", err)
|
||||
}
|
||||
|
||||
reader := csv.NewReader(bytes.NewReader(buf.Bytes()[3:]))
|
||||
reader.Comma = ';'
|
||||
reader.FieldsPerRecord = -1
|
||||
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("read header row: %v", err)
|
||||
}
|
||||
expectedHeader := []string{"Line Item", "LOT", "PN вендора", "Описание", "Кол-во", "BOM", "Estimate", "Stock"}
|
||||
for i, want := range expectedHeader {
|
||||
if header[i] != want {
|
||||
t.Fatalf("header[%d]: expected %q, got %q", i, want, header[i])
|
||||
}
|
||||
}
|
||||
|
||||
summary, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("read summary row: %v", err)
|
||||
}
|
||||
expectedSummary := []string{"10", "", "", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
|
||||
for i, want := range expectedSummary {
|
||||
if summary[i] != want {
|
||||
t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i])
|
||||
}
|
||||
}
|
||||
|
||||
row, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("read data row: %v", err)
|
||||
}
|
||||
expectedRow := []string{"", "LOT_A +1", "PN-001", "Bundle row", "2", "2 400,50", "2 000,00", "1 800,25"}
|
||||
for i, want := range expectedRow {
|
||||
if row[i] != want {
|
||||
t.Fatalf("row[%d]: expected %q, got %q", i, want, row[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectToPricingExportData_UsesCartRowsWithoutBOM(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
configs := []models.Configuration{
|
||||
{
|
||||
UUID: "cfg-1",
|
||||
Name: "Config A",
|
||||
Article: "ART-1",
|
||||
ServerCount: 1,
|
||||
Items: models.ConfigItems{
|
||||
{LotName: "LOT_A", Quantity: 2, UnitPrice: 300},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
data, err := svc.ProjectToPricingExportData(configs, ProjectPricingExportOptions{
|
||||
IncludeLOT: true,
|
||||
IncludeEstimate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectToPricingExportData failed: %v", err)
|
||||
}
|
||||
if len(data.Configs) != 1 || len(data.Configs[0].Rows) != 1 {
|
||||
t.Fatalf("unexpected rows count: %+v", data.Configs)
|
||||
}
|
||||
row := data.Configs[0].Rows[0]
|
||||
if row.LotDisplay != "LOT_A" {
|
||||
t.Fatalf("expected LOT_A, got %q", row.LotDisplay)
|
||||
}
|
||||
if row.VendorPN != "—" {
|
||||
t.Fatalf("expected vendor dash, got %q", row.VendorPN)
|
||||
}
|
||||
if row.Estimate == nil || *row.Estimate != 600 {
|
||||
t.Fatalf("expected estimate total 600, got %+v", row.Estimate)
|
||||
}
|
||||
}
|
||||
|
||||
// failingWriter always returns an error
|
||||
type failingWriter struct{}
|
||||
|
||||
|
||||
@@ -83,22 +83,25 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
}
|
||||
|
||||
cfg := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: projectUUID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: req.CustomPrice,
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
Article: req.Article,
|
||||
PricelistID: pricelistID,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: projectUUID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: req.CustomPrice,
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
Article: req.Article,
|
||||
PricelistID: pricelistID,
|
||||
WarehousePricelistID: req.WarehousePricelistID,
|
||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Convert to local model
|
||||
@@ -196,6 +199,9 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
localCfg.SupportCode = req.SupportCode
|
||||
localCfg.Article = req.Article
|
||||
localCfg.PricelistID = pricelistID
|
||||
localCfg.WarehousePricelistID = req.WarehousePricelistID
|
||||
localCfg.CompetitorPricelistID = req.CompetitorPricelistID
|
||||
localCfg.DisablePriceRefresh = req.DisablePriceRefresh
|
||||
localCfg.OnlyInStock = req.OnlyInStock
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
@@ -304,22 +310,25 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
||||
}
|
||||
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
ServerModel: original.ServerModel,
|
||||
SupportCode: original.SupportCode,
|
||||
Article: original.Article,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
ServerModel: original.ServerModel,
|
||||
SupportCode: original.SupportCode,
|
||||
Article: original.Article,
|
||||
PricelistID: original.PricelistID,
|
||||
WarehousePricelistID: original.WarehousePricelistID,
|
||||
CompetitorPricelistID: original.CompetitorPricelistID,
|
||||
DisablePriceRefresh: original.DisablePriceRefresh,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
localCfg := localdb.ConfigurationToLocal(clone)
|
||||
@@ -521,6 +530,9 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
localCfg.SupportCode = req.SupportCode
|
||||
localCfg.Article = req.Article
|
||||
localCfg.PricelistID = pricelistID
|
||||
localCfg.WarehousePricelistID = req.WarehousePricelistID
|
||||
localCfg.CompetitorPricelistID = req.CompetitorPricelistID
|
||||
localCfg.DisablePriceRefresh = req.DisablePriceRefresh
|
||||
localCfg.OnlyInStock = req.OnlyInStock
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
@@ -623,19 +635,25 @@ func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID s
|
||||
}
|
||||
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
ServerModel: original.ServerModel,
|
||||
SupportCode: original.SupportCode,
|
||||
Article: original.Article,
|
||||
PricelistID: original.PricelistID,
|
||||
WarehousePricelistID: original.WarehousePricelistID,
|
||||
CompetitorPricelistID: original.CompetitorPricelistID,
|
||||
DisablePriceRefresh: original.DisablePriceRefresh,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
localCfg := localdb.ConfigurationToLocal(clone)
|
||||
@@ -1053,39 +1071,43 @@ func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, own
|
||||
|
||||
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
|
||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
if localCfg.IsActive {
|
||||
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(localCfg).Error; err != nil {
|
||||
return fmt.Errorf("create local configuration: %w", err)
|
||||
}
|
||||
|
||||
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("append create 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: %w", err)
|
||||
}
|
||||
localCfg.CurrentVersionID = &version.ID
|
||||
localCfg.CurrentVersion = version
|
||||
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
||||
return fmt.Errorf("enqueue create pending change: %w", err)
|
||||
}
|
||||
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
|
||||
return fmt.Errorf("recalculate local pricelist usage: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.createWithVersionTx(tx, localCfg, createdBy)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) createWithVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration, createdBy string) error {
|
||||
if localCfg.IsActive {
|
||||
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(localCfg).Error; err != nil {
|
||||
return fmt.Errorf("create local configuration: %w", err)
|
||||
}
|
||||
|
||||
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("append create 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: %w", err)
|
||||
}
|
||||
localCfg.CurrentVersionID = &version.ID
|
||||
localCfg.CurrentVersion = version
|
||||
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
||||
return fmt.Errorf("enqueue create pending change: %w", err)
|
||||
}
|
||||
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
|
||||
return fmt.Errorf("recalculate local pricelist usage: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.LocalConfiguration, operation string, createdBy string) (*models.Configuration, error) {
|
||||
var cfg *models.Configuration
|
||||
|
||||
@@ -1183,6 +1205,7 @@ func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, nex
|
||||
current.ServerModel != next.ServerModel ||
|
||||
current.SupportCode != next.SupportCode ||
|
||||
current.Article != next.Article ||
|
||||
current.DisablePriceRefresh != next.DisablePriceRefresh ||
|
||||
current.OnlyInStock != next.OnlyInStock ||
|
||||
current.IsActive != next.IsActive ||
|
||||
current.Line != next.Line {
|
||||
@@ -1419,8 +1442,15 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
||||
current.Notes = rollbackData.Notes
|
||||
current.IsTemplate = rollbackData.IsTemplate
|
||||
current.ServerCount = rollbackData.ServerCount
|
||||
current.ServerModel = rollbackData.ServerModel
|
||||
current.SupportCode = rollbackData.SupportCode
|
||||
current.Article = rollbackData.Article
|
||||
current.PricelistID = rollbackData.PricelistID
|
||||
current.WarehousePricelistID = rollbackData.WarehousePricelistID
|
||||
current.CompetitorPricelistID = rollbackData.CompetitorPricelistID
|
||||
current.DisablePriceRefresh = rollbackData.DisablePriceRefresh
|
||||
current.OnlyInStock = rollbackData.OnlyInStock
|
||||
current.VendorSpec = rollbackData.VendorSpec
|
||||
if rollbackData.Line > 0 {
|
||||
current.Line = rollbackData.Line
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -23,13 +24,14 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
|
||||
|
||||
type serverBook struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Version string `gorm:"column:version"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
IsActive bool `gorm:"column:is_active"`
|
||||
ID int `gorm:"column:id"`
|
||||
Version string `gorm:"column:version"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
IsActive bool `gorm:"column:is_active"`
|
||||
PartnumbersJSON string `gorm:"column:partnumbers_json"`
|
||||
}
|
||||
var serverBooks []serverBook
|
||||
if err := mariaDB.Raw("SELECT id, version, created_at, is_active FROM qt_partnumber_books ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil {
|
||||
if err := mariaDB.Raw("SELECT id, version, created_at, is_active, partnumbers_json FROM qt_partnumber_books ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil {
|
||||
return 0, fmt.Errorf("querying server partnumber books: %w", err)
|
||||
}
|
||||
slog.Info("partnumber books found on server", "count", len(serverBooks))
|
||||
@@ -38,16 +40,28 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
for _, sb := range serverBooks {
|
||||
var existing localdb.LocalPartnumberBook
|
||||
err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error
|
||||
partnumbers, errPartnumbers := decodeServerPartnumbers(sb.PartnumbersJSON)
|
||||
if errPartnumbers != nil {
|
||||
slog.Error("failed to decode server partnumbers_json", "server_id", sb.ID, "error", errPartnumbers)
|
||||
continue
|
||||
}
|
||||
if err == nil {
|
||||
// Header exists — check whether items were saved
|
||||
existing.Version = sb.Version
|
||||
existing.CreatedAt = sb.CreatedAt
|
||||
existing.IsActive = sb.IsActive
|
||||
existing.PartnumbersJSON = partnumbers
|
||||
if err := localBookRepo.SaveBook(&existing); err != nil {
|
||||
slog.Error("failed to update local partnumber book header", "server_id", sb.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
localItemCount := localBookRepo.CountBookItems(existing.ID)
|
||||
if localItemCount > 0 {
|
||||
if localItemCount > 0 && localBookRepo.HasAllBookItems(existing.ID) {
|
||||
slog.Debug("partnumber book already synced, skipping", "server_id", sb.ID, "version", sb.Version, "items", localItemCount)
|
||||
continue
|
||||
}
|
||||
// Items missing — re-pull them
|
||||
slog.Info("partnumber book header exists but has no items, re-pulling items", "server_id", sb.ID, "version", sb.Version)
|
||||
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, existing.ID)
|
||||
slog.Info("partnumber book header exists but catalog items are missing, re-pulling items", "server_id", sb.ID, "version", sb.Version)
|
||||
n, err := pullBookItems(mariaDB, localBookRepo, existing.PartnumbersJSON)
|
||||
if err != nil {
|
||||
slog.Error("failed to re-pull items for existing book", "server_id", sb.ID, "error", err)
|
||||
} else {
|
||||
@@ -60,17 +74,18 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
slog.Info("pulling new partnumber book", "server_id", sb.ID, "version", sb.Version, "is_active", sb.IsActive)
|
||||
|
||||
localBook := &localdb.LocalPartnumberBook{
|
||||
ServerID: sb.ID,
|
||||
Version: sb.Version,
|
||||
CreatedAt: sb.CreatedAt,
|
||||
IsActive: sb.IsActive,
|
||||
ServerID: sb.ID,
|
||||
Version: sb.Version,
|
||||
CreatedAt: sb.CreatedAt,
|
||||
IsActive: sb.IsActive,
|
||||
PartnumbersJSON: partnumbers,
|
||||
}
|
||||
if err := localBookRepo.SaveBook(localBook); err != nil {
|
||||
slog.Error("failed to save local partnumber book", "server_id", sb.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, localBook.ID)
|
||||
n, err := pullBookItems(mariaDB, localBookRepo, localBook.PartnumbersJSON)
|
||||
if err != nil {
|
||||
slog.Error("failed to pull items for new book", "server_id", sb.ID, "error", err)
|
||||
continue
|
||||
@@ -84,39 +99,39 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
return pulled, nil
|
||||
}
|
||||
|
||||
// pullBookItems fetches items for a single book from MariaDB and saves them to SQLite.
|
||||
// pullBookItems fetches catalog items for a partnumber list from MariaDB and saves them to SQLite.
|
||||
// Returns the number of items saved.
|
||||
func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, serverBookID int, localBookID uint) (int, error) {
|
||||
func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, partnumbers localdb.LocalStringList) (int, error) {
|
||||
if len(partnumbers) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type serverItem struct {
|
||||
Partnumber string `gorm:"column:partnumber"`
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
IsPrimaryPN bool `gorm:"column:is_primary_pn"`
|
||||
LotsJSON string `gorm:"column:lots_json"`
|
||||
Description string `gorm:"column:description"`
|
||||
}
|
||||
// description column may not exist yet on older server schemas — query without it first,
|
||||
// then retry with it to populate descriptions if available.
|
||||
var serverItems []serverItem
|
||||
err := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn, description FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error
|
||||
err := mariaDB.Raw("SELECT partnumber, lots_json, description FROM qt_partnumber_book_items WHERE partnumber IN ?", []string(partnumbers)).Scan(&serverItems).Error
|
||||
if err != nil {
|
||||
slog.Warn("description column not available on server, retrying without it", "server_book_id", serverBookID, "error", err)
|
||||
if err2 := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error; err2 != nil {
|
||||
return 0, fmt.Errorf("querying items from server: %w", err2)
|
||||
}
|
||||
return 0, fmt.Errorf("querying items from server: %w", err)
|
||||
}
|
||||
slog.Info("partnumber book items fetched from server", "server_book_id", serverBookID, "count", len(serverItems))
|
||||
slog.Info("partnumber book items fetched from server", "count", len(serverItems), "requested_partnumbers", len(partnumbers))
|
||||
|
||||
if len(serverItems) == 0 {
|
||||
slog.Warn("server returned 0 items for book — check qt_partnumber_book_items on server", "server_book_id", serverBookID)
|
||||
slog.Warn("server returned 0 partnumber book items")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems))
|
||||
for _, si := range serverItems {
|
||||
var lots localdb.LocalPartnumberBookLots
|
||||
if err := json.Unmarshal([]byte(si.LotsJSON), &lots); err != nil {
|
||||
return 0, fmt.Errorf("decode lots_json for %s: %w", si.Partnumber, err)
|
||||
}
|
||||
localItems = append(localItems, localdb.LocalPartnumberBookItem{
|
||||
BookID: localBookID,
|
||||
Partnumber: si.Partnumber,
|
||||
LotName: si.LotName,
|
||||
IsPrimaryPN: si.IsPrimaryPN,
|
||||
LotsJSON: lots,
|
||||
Description: si.Description,
|
||||
})
|
||||
}
|
||||
@@ -125,3 +140,14 @@ func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository,
|
||||
}
|
||||
return len(localItems), nil
|
||||
}
|
||||
|
||||
func decodeServerPartnumbers(raw string) (localdb.LocalStringList, error) {
|
||||
if raw == "" {
|
||||
return localdb.LocalStringList{}, nil
|
||||
}
|
||||
var items []string
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return localdb.LocalStringList(items), nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type SeenPartnumber struct {
|
||||
}
|
||||
|
||||
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
||||
// Uses INSERT ... ON DUPLICATE KEY UPDATE so existing rows are updated (last_seen_at) without error.
|
||||
// Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
|
||||
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
@@ -36,12 +36,10 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
VALUES
|
||||
('manual', '', ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_seen_at = VALUES(last_seen_at),
|
||||
is_ignored = VALUES(is_ignored),
|
||||
description = COALESCE(NULLIF(VALUES(description), ''), description)
|
||||
partnumber = partnumber
|
||||
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
||||
if err != nil {
|
||||
slog.Error("failed to upsert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||
// Continue with remaining items
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -79,48 +76,6 @@ func (s *Service) GetReadiness() (*SyncReadiness, error) {
|
||||
)
|
||||
}
|
||||
|
||||
migrations, err := listActiveClientMigrations(mariaDB)
|
||||
if err != nil {
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"REMOTE_MIGRATION_REGISTRY_UNAVAILABLE",
|
||||
"Синхронизация заблокирована: не удалось проверить централизованные миграции локальной БД.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
for i := range migrations {
|
||||
m := migrations[i]
|
||||
if strings.TrimSpace(m.MinAppVersion) != "" {
|
||||
if compareVersions(appmeta.Version(), m.MinAppVersion) < 0 {
|
||||
min := m.MinAppVersion
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"MIN_APP_VERSION_REQUIRED",
|
||||
fmt.Sprintf("Требуется обновление приложения до версии %s для безопасной синхронизации.", m.MinAppVersion),
|
||||
&min,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.applyMissingRemoteMigrations(migrations); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "checksum") {
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"REMOTE_MIGRATION_CHECKSUM_MISMATCH",
|
||||
"Синхронизация заблокирована: контрольная сумма миграции не совпадает.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"LOCAL_MIGRATION_APPLY_FAILED",
|
||||
"Синхронизация заблокирована: не удалось применить миграции локальной БД.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
if err := s.reportClientSchemaState(mariaDB, now); err != nil {
|
||||
slog.Warn("failed to report client schema state", "error", err)
|
||||
}
|
||||
@@ -157,73 +112,68 @@ func (s *Service) isOnline() bool {
|
||||
return s.connMgr.IsOnline()
|
||||
}
|
||||
|
||||
type clientLocalMigration struct {
|
||||
ID string `gorm:"column:id"`
|
||||
Name string `gorm:"column:name"`
|
||||
SQLText string `gorm:"column:sql_text"`
|
||||
Checksum string `gorm:"column:checksum"`
|
||||
MinAppVersion string `gorm:"column:min_app_version"`
|
||||
OrderNo int `gorm:"column:order_no"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func listActiveClientMigrations(db *gorm.DB) ([]clientLocalMigration, error) {
|
||||
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
|
||||
return []clientLocalMigration{}, nil
|
||||
}
|
||||
if err := ensureClientMigrationRegistryTable(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]clientLocalMigration, 0)
|
||||
if err := db.Raw(`
|
||||
SELECT id, name, sql_text, checksum, COALESCE(min_app_version, '') AS min_app_version, order_no, created_at
|
||||
FROM qt_client_local_migrations
|
||||
WHERE is_active = 1
|
||||
ORDER BY order_no ASC, created_at ASC, id ASC
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("load client local migrations: %w", err)
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func ensureClientMigrationRegistryTable(db *gorm.DB) error {
|
||||
// Check if table exists instead of trying to create (avoids permission issues)
|
||||
if !tableExists(db, "qt_client_local_migrations") {
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
|
||||
id VARCHAR(128) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
sql_text LONGTEXT NOT NULL,
|
||||
checksum VARCHAR(128) NOT NULL,
|
||||
min_app_version VARCHAR(64) NULL,
|
||||
order_no INT NOT NULL DEFAULT 0,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("create qt_client_local_migrations table: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureClientSchemaStateTable(db *gorm.DB) error {
|
||||
if !tableExists(db, "qt_client_schema_state") {
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
|
||||
username VARCHAR(100) NOT NULL,
|
||||
last_applied_migration_id VARCHAR(128) NULL,
|
||||
hostname VARCHAR(255) NOT NULL DEFAULT '',
|
||||
app_version VARCHAR(64) NULL,
|
||||
last_sync_at DATETIME NULL,
|
||||
last_sync_status VARCHAR(32) NULL,
|
||||
pending_changes_count INT NOT NULL DEFAULT 0,
|
||||
pending_errors_count INT NOT NULL DEFAULT 0,
|
||||
configurations_count INT NOT NULL DEFAULT 0,
|
||||
projects_count INT NOT NULL DEFAULT 0,
|
||||
estimate_pricelist_version VARCHAR(128) NULL,
|
||||
warehouse_pricelist_version VARCHAR(128) NULL,
|
||||
competitor_pricelist_version VARCHAR(128) NULL,
|
||||
last_sync_error_code VARCHAR(128) NULL,
|
||||
last_sync_error_text TEXT NULL,
|
||||
last_checked_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (username),
|
||||
PRIMARY KEY (username, hostname),
|
||||
INDEX idx_qt_client_schema_state_checked (last_checked_at)
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("create qt_client_schema_state table: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if tableExists(db, "qt_client_schema_state") {
|
||||
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)
|
||||
}
|
||||
|
||||
for _, stmt := range []string{
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_status VARCHAR(32) NULL AFTER last_sync_at",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_changes_count INT NOT NULL DEFAULT 0 AFTER last_sync_status",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_errors_count INT NOT NULL DEFAULT 0 AFTER pending_changes_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS projects_count INT NOT NULL DEFAULT 0 AFTER configurations_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS estimate_pricelist_version VARCHAR(128) NULL AFTER projects_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS warehouse_pricelist_version VARCHAR(128) NULL AFTER estimate_pricelist_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code",
|
||||
} {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -239,159 +189,114 @@ func tableExists(db *gorm.DB, tableName string) bool {
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {
|
||||
for i := range migrations {
|
||||
m := migrations[i]
|
||||
computedChecksum := digestSQL(m.SQLText)
|
||||
checksum := strings.TrimSpace(m.Checksum)
|
||||
if checksum == "" {
|
||||
checksum = computedChecksum
|
||||
} else if !strings.EqualFold(checksum, computedChecksum) {
|
||||
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
|
||||
}
|
||||
|
||||
applied, err := s.localDB.GetRemoteMigrationApplied(m.ID)
|
||||
if err == nil {
|
||||
if strings.TrimSpace(applied.Checksum) != checksum {
|
||||
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("check local applied migration %s: %w", m.ID, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.SQLText) == "" {
|
||||
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("mark empty migration %s as applied: %w", m.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
statements := splitSQLStatementsLite(m.SQLText)
|
||||
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
for _, stmt := range statements {
|
||||
if err := tx.Exec(stmt).Error; err != nil {
|
||||
return fmt.Errorf("apply migration %s statement %q: %w", m.ID, stmt, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("record applied migration %s: %w", m.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitSQLStatementsLite(script string) []string {
|
||||
scanner := bufio.NewScanner(strings.NewReader(script))
|
||||
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||||
|
||||
lines := make([]string, 0, 64)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "--") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
combined := strings.Join(lines, "\n")
|
||||
raw := strings.Split(combined, ";")
|
||||
stmts := make([]string, 0, len(raw))
|
||||
for _, stmt := range raw {
|
||||
trimmed := strings.TrimSpace(stmt)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
stmts = append(stmts, trimmed)
|
||||
}
|
||||
return stmts
|
||||
}
|
||||
|
||||
func digestSQL(sqlText string) string {
|
||||
hash := sha256.Sum256([]byte(sqlText))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func compareVersions(left, right string) int {
|
||||
leftParts := normalizeVersionParts(left)
|
||||
rightParts := normalizeVersionParts(right)
|
||||
maxLen := len(leftParts)
|
||||
if len(rightParts) > maxLen {
|
||||
maxLen = len(rightParts)
|
||||
}
|
||||
for i := 0; i < maxLen; i++ {
|
||||
lv := 0
|
||||
rv := 0
|
||||
if i < len(leftParts) {
|
||||
lv = leftParts[i]
|
||||
}
|
||||
if i < len(rightParts) {
|
||||
rv = rightParts[i]
|
||||
}
|
||||
if lv < rv {
|
||||
return -1
|
||||
}
|
||||
if lv > rv {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) error {
|
||||
if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
|
||||
return nil
|
||||
}
|
||||
if err := ensureClientSchemaStateTable(mariaDB); err != nil {
|
||||
return err
|
||||
}
|
||||
username := strings.TrimSpace(s.localDB.GetDBUser())
|
||||
if username == "" {
|
||||
return nil
|
||||
}
|
||||
lastMigrationID := ""
|
||||
if id, err := s.localDB.GetLatestAppliedRemoteMigrationID(); err == nil {
|
||||
lastMigrationID = id
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
hostname = ""
|
||||
}
|
||||
hostname = strings.TrimSpace(hostname)
|
||||
lastSyncAt := s.localDB.GetLastSyncTime()
|
||||
lastSyncStatus := ReadinessReady
|
||||
pendingChangesCount := s.localDB.CountPendingChanges()
|
||||
pendingErrorsCount := s.localDB.CountErroredChanges()
|
||||
configurationsCount := s.localDB.CountConfigurations()
|
||||
projectsCount := s.localDB.CountProjects()
|
||||
estimateVersion := latestPricelistVersion(s.localDB, "estimate")
|
||||
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
|
||||
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
|
||||
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
||||
return mariaDB.Exec(`
|
||||
INSERT INTO qt_client_schema_state (username, last_applied_migration_id, app_version, last_checked_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO qt_client_schema_state (
|
||||
username, hostname, 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
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_applied_migration_id = VALUES(last_applied_migration_id),
|
||||
app_version = VALUES(app_version),
|
||||
last_sync_at = VALUES(last_sync_at),
|
||||
last_sync_status = VALUES(last_sync_status),
|
||||
pending_changes_count = VALUES(pending_changes_count),
|
||||
pending_errors_count = VALUES(pending_errors_count),
|
||||
configurations_count = VALUES(configurations_count),
|
||||
projects_count = VALUES(projects_count),
|
||||
estimate_pricelist_version = VALUES(estimate_pricelist_version),
|
||||
warehouse_pricelist_version = VALUES(warehouse_pricelist_version),
|
||||
competitor_pricelist_version = VALUES(competitor_pricelist_version),
|
||||
last_sync_error_code = VALUES(last_sync_error_code),
|
||||
last_sync_error_text = VALUES(last_sync_error_text),
|
||||
last_checked_at = VALUES(last_checked_at),
|
||||
updated_at = VALUES(updated_at)
|
||||
`, username, lastMigrationID, appmeta.Version(), checkedAt, checkedAt).Error
|
||||
`, username, hostname, appmeta.Version(),
|
||||
lastSyncAt, lastSyncStatus, pendingChangesCount, pendingErrorsCount,
|
||||
configurationsCount, projectsCount,
|
||||
estimateVersion, warehouseVersion, competitorVersion,
|
||||
lastSyncErrorCode, lastSyncErrorText,
|
||||
checkedAt, checkedAt).Error
|
||||
}
|
||||
|
||||
func normalizeVersionParts(v string) []int {
|
||||
trimmed := strings.TrimSpace(v)
|
||||
trimmed = strings.TrimPrefix(trimmed, "v")
|
||||
chunks := strings.Split(trimmed, ".")
|
||||
parts := make([]int, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
clean := strings.TrimSpace(chunk)
|
||||
if clean == "" {
|
||||
parts = append(parts, 0)
|
||||
continue
|
||||
}
|
||||
n := 0
|
||||
for i := 0; i < len(clean); i++ {
|
||||
if clean[i] < '0' || clean[i] > '9' {
|
||||
clean = clean[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if clean != "" {
|
||||
if parsed, err := strconv.Atoi(clean); err == nil {
|
||||
n = parsed
|
||||
}
|
||||
}
|
||||
parts = append(parts, n)
|
||||
func isDuplicatePrimaryKeyDefinition(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return parts
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "multiple primary key defined") ||
|
||||
strings.Contains(msg, "duplicate key name 'primary'") ||
|
||||
strings.Contains(msg, "duplicate entry")
|
||||
}
|
||||
|
||||
func latestPricelistVersion(local *localdb.LocalDB, source string) *string {
|
||||
if local == nil {
|
||||
return nil
|
||||
}
|
||||
pl, err := local.GetLatestLocalPricelistBySource(source)
|
||||
if err != nil || pl == nil {
|
||||
return nil
|
||||
}
|
||||
version := strings.TrimSpace(pl.Version)
|
||||
if version == "" {
|
||||
return nil
|
||||
}
|
||||
return &version
|
||||
}
|
||||
|
||||
func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
|
||||
if local == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if guard, err := local.GetSyncGuardState(); err == nil && guard != nil && strings.EqualFold(guard.Status, ReadinessBlocked) {
|
||||
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
|
||||
}
|
||||
|
||||
var pending localdb.PendingChange
|
||||
if err := local.DB().
|
||||
Where("TRIM(COALESCE(last_error, '')) <> ''").
|
||||
Order("id DESC").
|
||||
First(&pending).Error; err == nil {
|
||||
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func optionalString(value string) *string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
v := strings.TrimSpace(value)
|
||||
return &v
|
||||
}
|
||||
|
||||
func toReadinessFromState(state *localdb.LocalSyncGuardState) *SyncReadiness {
|
||||
|
||||
@@ -148,9 +148,6 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||
if localCfg.Line <= 0 && existing.Line > 0 {
|
||||
localCfg.Line = existing.Line
|
||||
}
|
||||
// vendor_spec is local-only for BOM tab and is not stored on server.
|
||||
// Preserve it during server pull updates.
|
||||
localCfg.VendorSpec = existing.VendorSpec
|
||||
result.Updated++
|
||||
} else {
|
||||
result.Imported++
|
||||
@@ -693,6 +690,9 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
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 {
|
||||
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
||||
@@ -711,6 +711,111 @@ func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, err
|
||||
return s.SyncPricelistItems(localPL.ID)
|
||||
}
|
||||
|
||||
func (s *Service) enrichLocalPricelistItemsWithStock(mariaDB *gorm.DB, items []localdb.LocalPricelistItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
|
||||
book, err := bookRepo.GetActiveBook()
|
||||
if err != nil || book == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
bookItems, err := bookRepo.GetBookItems(book.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(bookItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
partnumberToLots := make(map[string][]string, len(bookItems))
|
||||
for _, item := range bookItems {
|
||||
pn := strings.TrimSpace(item.Partnumber)
|
||||
if pn == "" {
|
||||
continue
|
||||
}
|
||||
seenLots := make(map[string]struct{}, len(item.LotsJSON))
|
||||
for _, lot := range item.LotsJSON {
|
||||
lotName := strings.TrimSpace(lot.LotName)
|
||||
if lotName == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(lotName)
|
||||
if _, exists := seenLots[key]; exists {
|
||||
continue
|
||||
}
|
||||
seenLots[key] = struct{}{}
|
||||
partnumberToLots[pn] = append(partnumberToLots[pn], lotName)
|
||||
}
|
||||
}
|
||||
if len(partnumberToLots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type stockRow struct {
|
||||
Partnumber string `gorm:"column:partnumber"`
|
||||
Qty *float64 `gorm:"column:qty"`
|
||||
}
|
||||
rows := make([]stockRow, 0)
|
||||
if err := mariaDB.Raw(`
|
||||
SELECT s.partnumber, s.qty
|
||||
FROM stock_log s
|
||||
INNER JOIN (
|
||||
SELECT partnumber, MAX(date) AS max_date
|
||||
FROM stock_log
|
||||
GROUP BY partnumber
|
||||
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
|
||||
WHERE s.qty IS NOT NULL
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lotTotals := make(map[string]float64, len(items))
|
||||
lotPartnumbers := make(map[string][]string, len(items))
|
||||
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
|
||||
|
||||
for _, row := range rows {
|
||||
pn := strings.TrimSpace(row.Partnumber)
|
||||
if pn == "" || row.Qty == nil {
|
||||
continue
|
||||
}
|
||||
lots := partnumberToLots[pn]
|
||||
if len(lots) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, lotName := range lots {
|
||||
lotTotals[lotName] += *row.Qty
|
||||
if _, ok := seenPartnumbers[lotName]; !ok {
|
||||
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
|
||||
}
|
||||
key := strings.ToLower(pn)
|
||||
if _, exists := seenPartnumbers[lotName][key]; exists {
|
||||
continue
|
||||
}
|
||||
seenPartnumbers[lotName][key] = struct{}{}
|
||||
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
lotName := strings.TrimSpace(items[i].LotName)
|
||||
if qty, ok := lotTotals[lotName]; ok {
|
||||
qtyCopy := qty
|
||||
items[i].AvailableQty = &qtyCopy
|
||||
}
|
||||
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
|
||||
sort.Slice(partnumbers, func(a, b int) bool {
|
||||
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
|
||||
})
|
||||
items[i].Partnumbers = append(localdb.LocalStringList{}, partnumbers...)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
||||
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
||||
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
||||
|
||||
@@ -17,7 +17,6 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
|
||||
&models.Pricelist{},
|
||||
&models.PricelistItem{},
|
||||
&models.Lot{},
|
||||
&models.LotPartnumber{},
|
||||
&models.StockLog{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate server tables: %v", err)
|
||||
@@ -105,3 +104,102 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,10 +315,21 @@ func TestImportConfigurationsToLocalPullsLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
||||
func TestImportConfigurationsToLocalLoadsVendorSpecFromServer(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
|
||||
serverSpec := models.VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "GPU-NVHGX-H200-8141",
|
||||
Quantity: 1,
|
||||
Description: "NVIDIA HGX Delta-Next GPU Baseboard",
|
||||
LotMappings: []models.VendorSpecLotMapping{
|
||||
{LotName: "GPU_NV_H200_141GB_SXM_(HGX)", QuantityPerPN: 8},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := models.Configuration{
|
||||
UUID: "server-vendorspec-config",
|
||||
OwnerUsername: "tester",
|
||||
@@ -326,6 +337,7 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
||||
Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
|
||||
ServerCount: 1,
|
||||
Line: 50,
|
||||
VendorSpec: serverSpec,
|
||||
}
|
||||
total := cfg.Items.Total()
|
||||
cfg.TotalPrice = &total
|
||||
@@ -333,32 +345,6 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
||||
t.Fatalf("seed server config: %v", err)
|
||||
}
|
||||
|
||||
localSpec := localdb.VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "GPU-NVHGX-H200-8141",
|
||||
Quantity: 1,
|
||||
Description: "NVIDIA HGX Delta-Next GPU Baseboard",
|
||||
LotMappings: []localdb.VendorSpecLotMapping{
|
||||
{LotName: "GPU_NV_H200_141GB_SXM_(HGX)", QuantityPerPN: 8},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
|
||||
UUID: cfg.UUID,
|
||||
OriginalUsername: "tester",
|
||||
Name: "Local cfg",
|
||||
Items: localdb.LocalConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
|
||||
IsActive: true,
|
||||
SyncStatus: "synced",
|
||||
Line: 50,
|
||||
VendorSpec: localSpec,
|
||||
CreatedAt: time.Now().Add(-30 * time.Minute),
|
||||
UpdatedAt: time.Now().Add(-30 * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed local configuration: %v", err)
|
||||
}
|
||||
|
||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
if _, err := svc.ImportConfigurationsToLocal(); err != nil {
|
||||
t.Fatalf("import configurations to local: %v", err)
|
||||
@@ -369,7 +355,7 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
||||
t.Fatalf("load local config: %v", err)
|
||||
}
|
||||
if len(localCfg.VendorSpec) != 1 {
|
||||
t.Fatalf("expected local vendor_spec preserved, got %d rows", len(localCfg.VendorSpec))
|
||||
t.Fatalf("expected server vendor_spec imported, got %d rows", len(localCfg.VendorSpec))
|
||||
}
|
||||
if localCfg.VendorSpec[0].VendorPartnumber != "GPU-NVHGX-H200-8141" {
|
||||
t.Fatalf("unexpected vendor_partnumber after import: %q", localCfg.VendorSpec[0].VendorPartnumber)
|
||||
@@ -492,6 +478,7 @@ CREATE TABLE qt_configurations (
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"math"
|
||||
)
|
||||
|
||||
// ResolvedBOMRow is the result of resolving a single vendor BOM row.
|
||||
@@ -47,7 +48,19 @@ func (r *VendorSpecResolver) Resolve(items []localdb.VendorSpecItem) ([]localdb.
|
||||
// Step 1: Look up in active book
|
||||
matches, err := r.bookRepo.FindLotByPartnumber(book.ID, pn)
|
||||
if err == nil && len(matches) > 0 {
|
||||
items[i].ResolvedLotName = matches[0].LotName
|
||||
items[i].LotMappings = make([]localdb.VendorSpecLotMapping, 0, len(matches[0].LotsJSON))
|
||||
for _, lot := range matches[0].LotsJSON {
|
||||
if lot.LotName == "" {
|
||||
continue
|
||||
}
|
||||
items[i].LotMappings = append(items[i].LotMappings, localdb.VendorSpecLotMapping{
|
||||
LotName: lot.LotName,
|
||||
QuantityPerPN: lotQtyToInt(lot.Qty),
|
||||
})
|
||||
}
|
||||
if len(items[i].LotMappings) > 0 {
|
||||
items[i].ResolvedLotName = items[i].LotMappings[0].LotName
|
||||
}
|
||||
items[i].ResolutionSource = "book"
|
||||
continue
|
||||
}
|
||||
@@ -67,13 +80,9 @@ func (r *VendorSpecResolver) Resolve(items []localdb.VendorSpecItem) ([]localdb.
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// AggregateLOTs applies the qty-logic to compute per-LOT quantities from the resolved BOM.
|
||||
// qty(lot) = SUM(qty of primary PN rows for this lot) if any primary PN exists, else 1.
|
||||
// AggregateLOTs applies qty from the resolved PN composition stored in lots_json.
|
||||
func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumberBook, bookRepo *repository.PartnumberBookRepository) ([]AggregatedLOT, error) {
|
||||
// Gather all unique lot names that resolved
|
||||
lotPrimary := make(map[string]int) // lot_name → sum of primary PN quantities
|
||||
lotAny := make(map[string]bool) // lot_name → seen at least once (non-primary)
|
||||
lotHasPrimary := make(map[string]bool) // lot_name → has at least one primary PN in spec
|
||||
lotTotals := make(map[string]int)
|
||||
|
||||
if book != nil {
|
||||
for _, item := range items {
|
||||
@@ -83,21 +92,17 @@ func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumber
|
||||
lot := item.ResolvedLotName
|
||||
pn := item.VendorPartnumber
|
||||
|
||||
// Find if this pn is primary for its lot
|
||||
matches, err := bookRepo.FindLotByPartnumber(book.ID, pn)
|
||||
if err != nil || len(matches) == 0 {
|
||||
// manual/unresolved — treat as non-primary
|
||||
lotAny[lot] = true
|
||||
lotTotals[lot] += item.Quantity
|
||||
continue
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.LotName == lot {
|
||||
if m.IsPrimaryPN {
|
||||
lotPrimary[lot] += item.Quantity
|
||||
lotHasPrimary[lot] = true
|
||||
} else {
|
||||
lotAny[lot] = true
|
||||
for _, mappedLot := range m.LotsJSON {
|
||||
if mappedLot.LotName != lot {
|
||||
continue
|
||||
}
|
||||
lotTotals[lot] += item.Quantity * lotQtyToInt(mappedLot.Qty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +110,7 @@ func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumber
|
||||
// No book: all resolved rows contribute qty=1 per lot
|
||||
for _, item := range items {
|
||||
if item.ResolvedLotName != "" {
|
||||
lotAny[item.ResolvedLotName] = true
|
||||
lotTotals[item.ResolvedLotName] += item.Quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,11 +124,18 @@ func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumber
|
||||
continue
|
||||
}
|
||||
seen[lot] = true
|
||||
qty := 1
|
||||
if lotHasPrimary[lot] {
|
||||
qty = lotPrimary[lot]
|
||||
qty := lotTotals[lot]
|
||||
if qty < 1 {
|
||||
qty = 1
|
||||
}
|
||||
result = append(result, AggregatedLOT{LotName: lot, Quantity: qty})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func lotQtyToInt(qty float64) int {
|
||||
if qty < 1 {
|
||||
return 1
|
||||
}
|
||||
return int(math.Round(qty))
|
||||
}
|
||||
|
||||
560
internal/services/vendor_workspace_import.go
Normal file
560
internal/services/vendor_workspace_import.go
Normal file
@@ -0,0 +1,560 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type VendorWorkspaceImportResult struct {
|
||||
Imported int `json:"imported"`
|
||||
Project *models.Project `json:"project,omitempty"`
|
||||
Configs []VendorWorkspaceImportedConfig `json:"configs"`
|
||||
}
|
||||
|
||||
type VendorWorkspaceImportedConfig struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
ServerCount int `json:"server_count"`
|
||||
ServerModel string `json:"server_model,omitempty"`
|
||||
Rows int `json:"rows"`
|
||||
}
|
||||
|
||||
type importedWorkspace struct {
|
||||
SourceFormat string
|
||||
SourceDocID string
|
||||
SourceFileName string
|
||||
CurrencyCode string
|
||||
Configurations []importedConfiguration
|
||||
}
|
||||
|
||||
type importedConfiguration struct {
|
||||
GroupID string
|
||||
Name string
|
||||
Line int
|
||||
ServerCount int
|
||||
ServerModel string
|
||||
Article string
|
||||
CurrencyCode string
|
||||
Rows []localdb.VendorSpecItem
|
||||
TotalPrice *float64
|
||||
}
|
||||
|
||||
type groupedItem struct {
|
||||
order int
|
||||
row cfxmlProductLineItem
|
||||
}
|
||||
|
||||
type cfxmlDocument struct {
|
||||
XMLName xml.Name `xml:"CFXML"`
|
||||
ThisDocumentIdentifier cfxmlDocumentIdentifier `xml:"thisDocumentIdentifier"`
|
||||
CFData cfxmlData `xml:"CFData"`
|
||||
}
|
||||
|
||||
type cfxmlDocumentIdentifier struct {
|
||||
ProprietaryDocumentIdentifier string `xml:"ProprietaryDocumentIdentifier"`
|
||||
}
|
||||
|
||||
type cfxmlData struct {
|
||||
ProprietaryInformation []cfxmlProprietaryInformation `xml:"ProprietaryInformation"`
|
||||
ProductLineItems []cfxmlProductLineItem `xml:"ProductLineItem"`
|
||||
}
|
||||
|
||||
type cfxmlProprietaryInformation struct {
|
||||
Name string `xml:"Name"`
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
type cfxmlProductLineItem struct {
|
||||
ProductLineNumber string `xml:"ProductLineNumber"`
|
||||
ItemNo string `xml:"ItemNo"`
|
||||
TransactionType string `xml:"TransactionType"`
|
||||
ProprietaryGroupIdentifier string `xml:"ProprietaryGroupIdentifier"`
|
||||
ConfigurationGroupLineNumberReference string `xml:"ConfigurationGroupLineNumberReference"`
|
||||
Quantity string `xml:"Quantity"`
|
||||
ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"`
|
||||
UnitListPrice cfxmlUnitListPrice `xml:"UnitListPrice"`
|
||||
ProductSubLineItems []cfxmlProductSubLineItem `xml:"ProductSubLineItem"`
|
||||
}
|
||||
|
||||
type cfxmlProductSubLineItem struct {
|
||||
LineNumber string `xml:"LineNumber"`
|
||||
TransactionType string `xml:"TransactionType"`
|
||||
Quantity string `xml:"Quantity"`
|
||||
ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"`
|
||||
UnitListPrice cfxmlUnitListPrice `xml:"UnitListPrice"`
|
||||
}
|
||||
|
||||
type cfxmlProductIdentification struct {
|
||||
PartnerProductIdentification cfxmlPartnerProductIdentification `xml:"PartnerProductIdentification"`
|
||||
}
|
||||
|
||||
type cfxmlPartnerProductIdentification struct {
|
||||
ProprietaryProductIdentifier string `xml:"ProprietaryProductIdentifier"`
|
||||
ProprietaryProductChar string `xml:"ProprietaryProductChar"`
|
||||
ProductCharacter string `xml:"ProductCharacter"`
|
||||
ProductDescription string `xml:"ProductDescription"`
|
||||
ProductName string `xml:"ProductName"`
|
||||
ProductTypeCode string `xml:"ProductTypeCode"`
|
||||
}
|
||||
|
||||
type cfxmlUnitListPrice struct {
|
||||
FinancialAmount cfxmlFinancialAmount `xml:"FinancialAmount"`
|
||||
}
|
||||
|
||||
type cfxmlFinancialAmount struct {
|
||||
GlobalCurrencyCode string `xml:"GlobalCurrencyCode"`
|
||||
MonetaryAmount string `xml:"MonetaryAmount"`
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID string, sourceFileName string, data []byte, ownerUsername string) (*VendorWorkspaceImportResult, error) {
|
||||
project, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
|
||||
workspace, err := parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &VendorWorkspaceImportResult{
|
||||
Imported: 0,
|
||||
Project: localdb.LocalToProject(project),
|
||||
Configs: make([]VendorWorkspaceImportedConfig, 0, len(workspace.Configurations)),
|
||||
}
|
||||
|
||||
err = s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
bookRepo := repository.NewPartnumberBookRepository(tx)
|
||||
for _, imported := range workspace.Configurations {
|
||||
now := time.Now()
|
||||
cfgUUID := uuid.NewString()
|
||||
groupRows, items, totalPrice, estimatePricelistID, err := s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, err)
|
||||
}
|
||||
localCfg := &localdb.LocalConfiguration{
|
||||
UUID: cfgUUID,
|
||||
ProjectUUID: &projectUUID,
|
||||
IsActive: true,
|
||||
Name: imported.Name,
|
||||
Items: items,
|
||||
TotalPrice: totalPrice,
|
||||
ServerCount: imported.ServerCount,
|
||||
ServerModel: imported.ServerModel,
|
||||
Article: imported.Article,
|
||||
PricelistID: estimatePricelistID,
|
||||
VendorSpec: groupRows,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: ownerUsername,
|
||||
}
|
||||
|
||||
if err := s.createWithVersionTx(tx, localCfg, ownerUsername); err != nil {
|
||||
return fmt.Errorf("import configuration group %s: %w", imported.GroupID, err)
|
||||
}
|
||||
|
||||
result.Imported++
|
||||
result.Configs = append(result.Configs, VendorWorkspaceImportedConfig{
|
||||
UUID: localCfg.UUID,
|
||||
Name: localCfg.Name,
|
||||
ServerCount: localCfg.ServerCount,
|
||||
ServerModel: localCfg.ServerModel,
|
||||
Rows: len(localCfg.VendorSpec),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) prepareImportedConfiguration(rows []localdb.VendorSpecItem, serverCount int, bookRepo *repository.PartnumberBookRepository) (localdb.VendorSpec, localdb.LocalConfigItems, *float64, *uint, error) {
|
||||
resolver := NewVendorSpecResolver(bookRepo)
|
||||
resolved, err := resolver.Resolve(append([]localdb.VendorSpecItem(nil), rows...))
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
canonical := make(localdb.VendorSpec, 0, len(resolved))
|
||||
for _, row := range resolved {
|
||||
if len(row.LotMappings) == 0 && strings.TrimSpace(row.ResolvedLotName) != "" {
|
||||
row.LotMappings = []localdb.VendorSpecLotMapping{
|
||||
{LotName: strings.TrimSpace(row.ResolvedLotName), QuantityPerPN: 1},
|
||||
}
|
||||
}
|
||||
row.LotMappings = normalizeImportedLotMappings(row.LotMappings)
|
||||
row.ResolvedLotName = ""
|
||||
row.ResolutionSource = ""
|
||||
row.ManualLotSuggestion = ""
|
||||
row.LotQtyPerPN = 0
|
||||
row.LotAllocations = nil
|
||||
canonical = append(canonical, row)
|
||||
}
|
||||
|
||||
estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate")
|
||||
var serverPricelistID *uint
|
||||
if estimatePricelist != nil {
|
||||
serverPricelistID = &estimatePricelist.ServerID
|
||||
}
|
||||
|
||||
items := aggregateVendorSpecToItems(canonical, estimatePricelist, s.localDB)
|
||||
totalValue := items.Total()
|
||||
if serverCount > 1 {
|
||||
totalValue *= float64(serverCount)
|
||||
}
|
||||
totalPrice := &totalValue
|
||||
return canonical, items, totalPrice, serverPricelistID, nil
|
||||
}
|
||||
|
||||
func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *localdb.LocalPricelist, local *localdb.LocalDB) localdb.LocalConfigItems {
|
||||
if len(spec) == 0 {
|
||||
return localdb.LocalConfigItems{}
|
||||
}
|
||||
|
||||
lotMap := make(map[string]int)
|
||||
order := make([]string, 0)
|
||||
for _, row := range spec {
|
||||
for _, mapping := range normalizeImportedLotMappings(row.LotMappings) {
|
||||
if _, exists := lotMap[mapping.LotName]; !exists {
|
||||
order = append(order, mapping.LotName)
|
||||
}
|
||||
lotMap[mapping.LotName] += row.Quantity * mapping.QuantityPerPN
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(order)
|
||||
items := make(localdb.LocalConfigItems, 0, len(order))
|
||||
for _, lotName := range order {
|
||||
unitPrice := 0.0
|
||||
if estimatePricelist != nil && local != nil {
|
||||
if price, err := local.GetLocalPriceForLot(estimatePricelist.ID, lotName); err == nil && price > 0 {
|
||||
unitPrice = price
|
||||
}
|
||||
}
|
||||
items = append(items, localdb.LocalConfigItem{
|
||||
LotName: lotName,
|
||||
Quantity: lotMap[lotName],
|
||||
UnitPrice: unitPrice,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func normalizeImportedLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
merged := make(map[string]int, len(in))
|
||||
order := make([]string, 0, len(in))
|
||||
for _, mapping := range in {
|
||||
lot := strings.TrimSpace(mapping.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
qty := mapping.QuantityPerPN
|
||||
if qty < 1 {
|
||||
qty = 1
|
||||
}
|
||||
if _, exists := merged[lot]; !exists {
|
||||
order = append(order, lot)
|
||||
}
|
||||
merged[lot] += qty
|
||||
}
|
||||
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
|
||||
for _, lot := range order {
|
||||
out = append(out, localdb.VendorSpecLotMapping{
|
||||
LotName: lot,
|
||||
QuantityPerPN: merged[lot],
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseCFXMLWorkspace(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||||
var doc cfxmlDocument
|
||||
if err := xml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse CFXML workspace: %w", err)
|
||||
}
|
||||
if doc.XMLName.Local != "CFXML" {
|
||||
return nil, fmt.Errorf("unsupported workspace root: %s", doc.XMLName.Local)
|
||||
}
|
||||
if len(doc.CFData.ProductLineItems) == 0 {
|
||||
return nil, fmt.Errorf("CFXML workspace has no ProductLineItem rows")
|
||||
}
|
||||
|
||||
workspace := &importedWorkspace{
|
||||
SourceFormat: "CFXML",
|
||||
SourceDocID: strings.TrimSpace(doc.ThisDocumentIdentifier.ProprietaryDocumentIdentifier),
|
||||
SourceFileName: sourceFileName,
|
||||
CurrencyCode: detectWorkspaceCurrency(doc.CFData.ProprietaryInformation, doc.CFData.ProductLineItems),
|
||||
}
|
||||
|
||||
type groupBucket struct {
|
||||
order int
|
||||
items []groupedItem
|
||||
}
|
||||
|
||||
groupOrder := make([]string, 0)
|
||||
groups := make(map[string]*groupBucket)
|
||||
for idx, item := range doc.CFData.ProductLineItems {
|
||||
groupID := strings.TrimSpace(item.ProprietaryGroupIdentifier)
|
||||
if groupID == "" {
|
||||
groupID = firstNonEmpty(strings.TrimSpace(item.ProductLineNumber), strings.TrimSpace(item.ItemNo), fmt.Sprintf("group-%d", idx+1))
|
||||
}
|
||||
bucket := groups[groupID]
|
||||
if bucket == nil {
|
||||
bucket = &groupBucket{order: idx}
|
||||
groups[groupID] = bucket
|
||||
groupOrder = append(groupOrder, groupID)
|
||||
}
|
||||
bucket.items = append(bucket.items, groupedItem{order: idx, row: item})
|
||||
}
|
||||
|
||||
for lineIdx, groupID := range groupOrder {
|
||||
bucket := groups[groupID]
|
||||
if bucket == nil || len(bucket.items) == 0 {
|
||||
continue
|
||||
}
|
||||
primary := pickPrimaryTopLevelRow(bucket.items)
|
||||
serverCount := maxInt(parseInt(primary.row.Quantity), 1)
|
||||
rows := make([]localdb.VendorSpecItem, 0, len(bucket.items)*4)
|
||||
sortOrder := 10
|
||||
|
||||
for _, item := range bucket.items {
|
||||
topRow := vendorSpecItemFromTopLevel(item.row, serverCount, sortOrder)
|
||||
if topRow != nil {
|
||||
rows = append(rows, *topRow)
|
||||
sortOrder += 10
|
||||
}
|
||||
|
||||
for _, sub := range item.row.ProductSubLineItems {
|
||||
subRow := vendorSpecItemFromSubLine(sub, sortOrder)
|
||||
if subRow == nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, *subRow)
|
||||
sortOrder += 10
|
||||
}
|
||||
}
|
||||
|
||||
total := sumVendorSpecRows(rows, serverCount)
|
||||
name := strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription)
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("Imported config %d", lineIdx+1)
|
||||
}
|
||||
|
||||
workspace.Configurations = append(workspace.Configurations, importedConfiguration{
|
||||
GroupID: groupID,
|
||||
Name: name,
|
||||
Line: (lineIdx + 1) * 10,
|
||||
ServerCount: serverCount,
|
||||
ServerModel: strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription),
|
||||
Article: strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier),
|
||||
CurrencyCode: workspace.CurrencyCode,
|
||||
Rows: rows,
|
||||
TotalPrice: total,
|
||||
})
|
||||
}
|
||||
|
||||
if len(workspace.Configurations) == 0 {
|
||||
return nil, fmt.Errorf("CFXML workspace has no importable configuration groups")
|
||||
}
|
||||
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
func detectWorkspaceCurrency(meta []cfxmlProprietaryInformation, rows []cfxmlProductLineItem) string {
|
||||
for _, item := range meta {
|
||||
if strings.EqualFold(strings.TrimSpace(item.Name), "Currencies") {
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
code := strings.TrimSpace(row.UnitListPrice.FinancialAmount.GlobalCurrencyCode)
|
||||
if code != "" {
|
||||
return code
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pickPrimaryTopLevelRow(items []groupedItem) groupedItem {
|
||||
best := items[0]
|
||||
bestScore := primaryScore(best.row)
|
||||
for _, item := range items[1:] {
|
||||
score := primaryScore(item.row)
|
||||
if score > bestScore {
|
||||
best = item
|
||||
bestScore = score
|
||||
continue
|
||||
}
|
||||
if score == bestScore && compareLineNumbers(item.row.ProductLineNumber, best.row.ProductLineNumber) < 0 {
|
||||
best = item
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func primaryScore(row cfxmlProductLineItem) int {
|
||||
score := len(row.ProductSubLineItems)
|
||||
if strings.EqualFold(strings.TrimSpace(row.ProductIdentification.PartnerProductIdentification.ProductTypeCode), "Hardware") {
|
||||
score += 100000
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func compareLineNumbers(left, right string) int {
|
||||
li := parseInt(left)
|
||||
ri := parseInt(right)
|
||||
switch {
|
||||
case li < ri:
|
||||
return -1
|
||||
case li > ri:
|
||||
return 1
|
||||
default:
|
||||
return strings.Compare(left, right)
|
||||
}
|
||||
}
|
||||
|
||||
func vendorSpecItemFromTopLevel(item cfxmlProductLineItem, serverCount int, sortOrder int) *localdb.VendorSpecItem {
|
||||
code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier)
|
||||
desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription)
|
||||
if code == "" && desc == "" {
|
||||
return nil
|
||||
}
|
||||
qty := normalizeTopLevelQuantity(item.Quantity, serverCount)
|
||||
unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount)
|
||||
return &localdb.VendorSpecItem{
|
||||
SortOrder: sortOrder,
|
||||
VendorPartnumber: code,
|
||||
Quantity: qty,
|
||||
Description: desc,
|
||||
UnitPrice: unitPrice,
|
||||
TotalPrice: totalPrice(unitPrice, qty),
|
||||
}
|
||||
}
|
||||
|
||||
func vendorSpecItemFromSubLine(item cfxmlProductSubLineItem, sortOrder int) *localdb.VendorSpecItem {
|
||||
code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier)
|
||||
desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription)
|
||||
if code == "" && desc == "" {
|
||||
return nil
|
||||
}
|
||||
qty := maxInt(parseInt(item.Quantity), 1)
|
||||
unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount)
|
||||
return &localdb.VendorSpecItem{
|
||||
SortOrder: sortOrder,
|
||||
VendorPartnumber: code,
|
||||
Quantity: qty,
|
||||
Description: desc,
|
||||
UnitPrice: unitPrice,
|
||||
TotalPrice: totalPrice(unitPrice, qty),
|
||||
}
|
||||
}
|
||||
|
||||
func sumVendorSpecRows(rows []localdb.VendorSpecItem, serverCount int) *float64 {
|
||||
total := 0.0
|
||||
hasTotal := false
|
||||
for _, row := range rows {
|
||||
if row.TotalPrice == nil {
|
||||
continue
|
||||
}
|
||||
total += *row.TotalPrice
|
||||
hasTotal = true
|
||||
}
|
||||
if !hasTotal {
|
||||
return nil
|
||||
}
|
||||
if serverCount > 1 {
|
||||
total *= float64(serverCount)
|
||||
}
|
||||
return &total
|
||||
}
|
||||
|
||||
func totalPrice(unitPrice *float64, qty int) *float64 {
|
||||
if unitPrice == nil {
|
||||
return nil
|
||||
}
|
||||
total := *unitPrice * float64(qty)
|
||||
return &total
|
||||
}
|
||||
|
||||
func parseOptionalFloat(raw string) *float64 {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
value, err := strconv.ParseFloat(trimmed, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func parseInt(raw string) int {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func maxInt(value, floor int) int {
|
||||
if value < floor {
|
||||
return floor
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeTopLevelQuantity(raw string, serverCount int) int {
|
||||
qty := maxInt(parseInt(raw), 1)
|
||||
if serverCount <= 1 {
|
||||
return qty
|
||||
}
|
||||
if qty%serverCount == 0 {
|
||||
return maxInt(qty/serverCount, 1)
|
||||
}
|
||||
return qty
|
||||
}
|
||||
|
||||
func IsCFXMLWorkspace(data []byte) bool {
|
||||
return bytes.Contains(data, []byte("<CFXML>")) || bytes.Contains(data, []byte("<CFXML "))
|
||||
}
|
||||
360
internal/services/vendor_workspace_import_test.go
Normal file
360
internal/services/vendor_workspace_import_test.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
)
|
||||
|
||||
func TestParseCFXMLWorkspaceGroupsSoftwareIntoConfiguration(t *testing.T) {
|
||||
const sample = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CFXML>
|
||||
<thisDocumentIdentifier>
|
||||
<ProprietaryDocumentIdentifier>CFXML.workspace-test</ProprietaryDocumentIdentifier>
|
||||
</thisDocumentIdentifier>
|
||||
<CFData>
|
||||
<ProprietaryInformation>
|
||||
<Name>Currencies</Name>
|
||||
<Value>USD</Value>
|
||||
</ProprietaryInformation>
|
||||
<ProductLineItem>
|
||||
<ProductLineNumber>1000</ProductLineNumber>
|
||||
<ItemNo>1000</ItemNo>
|
||||
<TransactionType>NEW</TransactionType>
|
||||
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
|
||||
<ConfigurationGroupLineNumberReference>100</ConfigurationGroupLineNumberReference>
|
||||
<Quantity>6</Quantity>
|
||||
<ProductIdentification>
|
||||
<PartnerProductIdentification>
|
||||
<ProprietaryProductIdentifier>7DG9-CTO1WW</ProprietaryProductIdentifier>
|
||||
<ProductDescription>ThinkSystem SR630 V4</ProductDescription>
|
||||
<ProductName>#1</ProductName>
|
||||
<ProductTypeCode>Hardware</ProductTypeCode>
|
||||
</PartnerProductIdentification>
|
||||
</ProductIdentification>
|
||||
<UnitListPrice>
|
||||
<FinancialAmount>
|
||||
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
|
||||
<MonetaryAmount>100.00</MonetaryAmount>
|
||||
</FinancialAmount>
|
||||
</UnitListPrice>
|
||||
<ProductSubLineItem>
|
||||
<LineNumber>1001</LineNumber>
|
||||
<TransactionType>ADD</TransactionType>
|
||||
<Quantity>2</Quantity>
|
||||
<ProductIdentification>
|
||||
<PartnerProductIdentification>
|
||||
<ProprietaryProductIdentifier>CPU-1</ProprietaryProductIdentifier>
|
||||
<ProductDescription>CPU</ProductDescription>
|
||||
<ProductCharacter>PROCESSOR</ProductCharacter>
|
||||
</PartnerProductIdentification>
|
||||
</ProductIdentification>
|
||||
<UnitListPrice>
|
||||
<FinancialAmount>
|
||||
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
|
||||
<MonetaryAmount>0</MonetaryAmount>
|
||||
</FinancialAmount>
|
||||
</UnitListPrice>
|
||||
</ProductSubLineItem>
|
||||
</ProductLineItem>
|
||||
<ProductLineItem>
|
||||
<ProductLineNumber>2000</ProductLineNumber>
|
||||
<ItemNo>2000</ItemNo>
|
||||
<TransactionType>NEW</TransactionType>
|
||||
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
|
||||
<ConfigurationGroupLineNumberReference>100</ConfigurationGroupLineNumberReference>
|
||||
<Quantity>6</Quantity>
|
||||
<ProductIdentification>
|
||||
<PartnerProductIdentification>
|
||||
<ProprietaryProductIdentifier>7S0X-CTO8WW</ProprietaryProductIdentifier>
|
||||
<ProductDescription>XClarity Controller Prem-FOD</ProductDescription>
|
||||
<ProductName>software1</ProductName>
|
||||
<ProductTypeCode>Software</ProductTypeCode>
|
||||
</PartnerProductIdentification>
|
||||
</ProductIdentification>
|
||||
<UnitListPrice>
|
||||
<FinancialAmount>
|
||||
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
|
||||
<MonetaryAmount>25.00</MonetaryAmount>
|
||||
</FinancialAmount>
|
||||
</UnitListPrice>
|
||||
<ProductSubLineItem>
|
||||
<LineNumber>2001</LineNumber>
|
||||
<TransactionType>ADD</TransactionType>
|
||||
<Quantity>1</Quantity>
|
||||
<ProductIdentification>
|
||||
<PartnerProductIdentification>
|
||||
<ProprietaryProductIdentifier>LIC-1</ProprietaryProductIdentifier>
|
||||
<ProductDescription>License</ProductDescription>
|
||||
<ProductCharacter>SOFTWARE</ProductCharacter>
|
||||
</PartnerProductIdentification>
|
||||
</ProductIdentification>
|
||||
<UnitListPrice>
|
||||
<FinancialAmount>
|
||||
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
|
||||
<MonetaryAmount>0</MonetaryAmount>
|
||||
</FinancialAmount>
|
||||
</UnitListPrice>
|
||||
</ProductSubLineItem>
|
||||
</ProductLineItem>
|
||||
<ProductLineItem>
|
||||
<ProductLineNumber>3000</ProductLineNumber>
|
||||
<ItemNo>3000</ItemNo>
|
||||
<TransactionType>NEW</TransactionType>
|
||||
<ProprietaryGroupIdentifier>3000</ProprietaryGroupIdentifier>
|
||||
<ConfigurationGroupLineNumberReference>100</ConfigurationGroupLineNumberReference>
|
||||
<Quantity>2</Quantity>
|
||||
<ProductIdentification>
|
||||
<PartnerProductIdentification>
|
||||
<ProprietaryProductIdentifier>7DG9-CTO1WW</ProprietaryProductIdentifier>
|
||||
<ProductDescription>ThinkSystem SR630 V4</ProductDescription>
|
||||
<ProductName>#2</ProductName>
|
||||
<ProductTypeCode>Hardware</ProductTypeCode>
|
||||
</PartnerProductIdentification>
|
||||
</ProductIdentification>
|
||||
<UnitListPrice>
|
||||
<FinancialAmount>
|
||||
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
|
||||
<MonetaryAmount>90.00</MonetaryAmount>
|
||||
</FinancialAmount>
|
||||
</UnitListPrice>
|
||||
</ProductLineItem>
|
||||
</CFData>
|
||||
</CFXML>`
|
||||
|
||||
workspace, err := parseCFXMLWorkspace([]byte(sample), "sample.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("parseCFXMLWorkspace: %v", err)
|
||||
}
|
||||
|
||||
if workspace.SourceFormat != "CFXML" {
|
||||
t.Fatalf("unexpected source format: %q", workspace.SourceFormat)
|
||||
}
|
||||
if len(workspace.Configurations) != 2 {
|
||||
t.Fatalf("expected 2 configurations, got %d", len(workspace.Configurations))
|
||||
}
|
||||
|
||||
first := workspace.Configurations[0]
|
||||
if first.GroupID != "1000" {
|
||||
t.Fatalf("expected first group 1000, got %q", first.GroupID)
|
||||
}
|
||||
if first.Name != "#1" {
|
||||
t.Fatalf("expected first config name #1, got %q", first.Name)
|
||||
}
|
||||
if first.ServerCount != 6 {
|
||||
t.Fatalf("expected first server count 6, got %d", first.ServerCount)
|
||||
}
|
||||
if len(first.Rows) != 4 {
|
||||
t.Fatalf("expected 4 vendor rows in first config, got %d", len(first.Rows))
|
||||
}
|
||||
|
||||
foundSoftwareTopLevel := false
|
||||
foundSoftwareSubRow := false
|
||||
foundPrimaryTopLevelQty := 0
|
||||
for _, row := range first.Rows {
|
||||
if row.VendorPartnumber == "7DG9-CTO1WW" {
|
||||
foundPrimaryTopLevelQty = row.Quantity
|
||||
}
|
||||
if row.VendorPartnumber == "7S0X-CTO8WW" {
|
||||
foundSoftwareTopLevel = true
|
||||
}
|
||||
if row.VendorPartnumber == "LIC-1" {
|
||||
foundSoftwareSubRow = true
|
||||
}
|
||||
}
|
||||
if !foundSoftwareTopLevel {
|
||||
t.Fatalf("expected software top-level row to stay inside configuration")
|
||||
}
|
||||
if !foundSoftwareSubRow {
|
||||
t.Fatalf("expected software sub-row to stay inside configuration")
|
||||
}
|
||||
if foundPrimaryTopLevelQty != 1 {
|
||||
t.Fatalf("expected primary top-level qty normalized to 1, got %d", foundPrimaryTopLevelQty)
|
||||
}
|
||||
if first.TotalPrice == nil || *first.TotalPrice != 750 {
|
||||
t.Fatalf("expected first total price 750, got %+v", first.TotalPrice)
|
||||
}
|
||||
|
||||
second := workspace.Configurations[1]
|
||||
if second.Name != "#2" {
|
||||
t.Fatalf("expected second config name #2, got %q", second.Name)
|
||||
}
|
||||
if len(second.Rows) != 1 {
|
||||
t.Fatalf("expected second config to contain single top-level row, got %d", len(second.Rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportVendorWorkspaceToProject_AutoResolvesAndAppliesEstimate(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() })
|
||||
|
||||
projectName := "OPS-2079"
|
||||
if err := local.SaveProject(&localdb.LocalProject{
|
||||
UUID: "project-1",
|
||||
OwnerUsername: "tester",
|
||||
Code: "OPS-2079",
|
||||
Variant: "",
|
||||
Name: &projectName,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save project: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 101,
|
||||
Source: "estimate",
|
||||
Version: "E-1",
|
||||
Name: "Estimate",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save estimate pricelist: %v", err)
|
||||
}
|
||||
estimatePL, err := local.GetLocalPricelistByServerID(101)
|
||||
if err != nil {
|
||||
t.Fatalf("get estimate pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: estimatePL.ID, LotName: "CPU_INTEL_6747P", Price: 1000},
|
||||
{PricelistID: estimatePL.ID, LotName: "LICENSE_XCC", Price: 50},
|
||||
}); err != nil {
|
||||
t.Fatalf("save estimate items: %v", err)
|
||||
}
|
||||
|
||||
bookRepo := local.DB()
|
||||
if err := bookRepo.Create(&localdb.LocalPartnumberBook{
|
||||
ServerID: 501,
|
||||
Version: "B-1",
|
||||
CreatedAt: time.Now(),
|
||||
IsActive: true,
|
||||
PartnumbersJSON: localdb.LocalStringList{"CPU-1", "LIC-1"},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("save active book: %v", err)
|
||||
}
|
||||
if err := bookRepo.Create([]localdb.LocalPartnumberBookItem{
|
||||
{Partnumber: "CPU-1", LotsJSON: localdb.LocalPartnumberBookLots{{LotName: "CPU_INTEL_6747P", Qty: 1}}},
|
||||
{Partnumber: "LIC-1", LotsJSON: localdb.LocalPartnumberBookLots{{LotName: "LICENSE_XCC", Qty: 1}}},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("save book items: %v", err)
|
||||
}
|
||||
|
||||
service := NewLocalConfigurationService(local, syncsvc.NewService(nil, local), &QuoteService{}, func() bool { return false })
|
||||
|
||||
const sample = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CFXML>
|
||||
<thisDocumentIdentifier>
|
||||
<ProprietaryDocumentIdentifier>CFXML.workspace-test</ProprietaryDocumentIdentifier>
|
||||
</thisDocumentIdentifier>
|
||||
<CFData>
|
||||
<ProductLineItem>
|
||||
<ProductLineNumber>1000</ProductLineNumber>
|
||||
<ItemNo>1000</ItemNo>
|
||||
<TransactionType>NEW</TransactionType>
|
||||
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
|
||||
<Quantity>2</Quantity>
|
||||
<ProductIdentification>
|
||||
<PartnerProductIdentification>
|
||||
<ProprietaryProductIdentifier>7DG9-CTO1WW</ProprietaryProductIdentifier>
|
||||
<ProductDescription>ThinkSystem SR630 V4</ProductDescription>
|
||||
<ProductName>#1</ProductName>
|
||||
<ProductTypeCode>Hardware</ProductTypeCode>
|
||||
</PartnerProductIdentification>
|
||||
</ProductIdentification>
|
||||
<ProductSubLineItem>
|
||||
<LineNumber>1001</LineNumber>
|
||||
<Quantity>2</Quantity>
|
||||
<ProductIdentification>
|
||||
<PartnerProductIdentification>
|
||||
<ProprietaryProductIdentifier>CPU-1</ProprietaryProductIdentifier>
|
||||
<ProductDescription>CPU</ProductDescription>
|
||||
</PartnerProductIdentification>
|
||||
</ProductIdentification>
|
||||
</ProductSubLineItem>
|
||||
</ProductLineItem>
|
||||
<ProductLineItem>
|
||||
<ProductLineNumber>2000</ProductLineNumber>
|
||||
<ItemNo>2000</ItemNo>
|
||||
<TransactionType>NEW</TransactionType>
|
||||
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
|
||||
<Quantity>2</Quantity>
|
||||
<ProductIdentification>
|
||||
<PartnerProductIdentification>
|
||||
<ProprietaryProductIdentifier>7S0X-CTO8WW</ProprietaryProductIdentifier>
|
||||
<ProductDescription>XClarity Controller</ProductDescription>
|
||||
<ProductName>software1</ProductName>
|
||||
<ProductTypeCode>Software</ProductTypeCode>
|
||||
</PartnerProductIdentification>
|
||||
</ProductIdentification>
|
||||
<ProductSubLineItem>
|
||||
<LineNumber>2001</LineNumber>
|
||||
<Quantity>1</Quantity>
|
||||
<ProductIdentification>
|
||||
<PartnerProductIdentification>
|
||||
<ProprietaryProductIdentifier>LIC-1</ProprietaryProductIdentifier>
|
||||
<ProductDescription>License</ProductDescription>
|
||||
</PartnerProductIdentification>
|
||||
</ProductIdentification>
|
||||
</ProductSubLineItem>
|
||||
</ProductLineItem>
|
||||
</CFData>
|
||||
</CFXML>`
|
||||
|
||||
result, err := service.ImportVendorWorkspaceToProject("project-1", "sample.xml", []byte(sample), "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("ImportVendorWorkspaceToProject: %v", err)
|
||||
}
|
||||
if result.Imported != 1 || len(result.Configs) != 1 {
|
||||
t.Fatalf("unexpected import result: %+v", result)
|
||||
}
|
||||
|
||||
cfg, err := local.GetConfigurationByUUID(result.Configs[0].UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("load imported config: %v", err)
|
||||
}
|
||||
if cfg.PricelistID == nil || *cfg.PricelistID != 101 {
|
||||
t.Fatalf("expected estimate pricelist id 101, got %+v", cfg.PricelistID)
|
||||
}
|
||||
if len(cfg.VendorSpec) != 4 {
|
||||
t.Fatalf("expected 4 vendor spec rows, got %d", len(cfg.VendorSpec))
|
||||
}
|
||||
if len(cfg.Items) != 2 {
|
||||
t.Fatalf("expected 2 cart items, got %d", len(cfg.Items))
|
||||
}
|
||||
if cfg.Items[0].LotName != "CPU_INTEL_6747P" || cfg.Items[0].Quantity != 2 || cfg.Items[0].UnitPrice != 1000 {
|
||||
t.Fatalf("unexpected first item: %+v", cfg.Items[0])
|
||||
}
|
||||
if cfg.Items[1].LotName != "LICENSE_XCC" || cfg.Items[1].Quantity != 1 || cfg.Items[1].UnitPrice != 50 {
|
||||
t.Fatalf("unexpected second item: %+v", cfg.Items[1])
|
||||
}
|
||||
if cfg.TotalPrice == nil || *cfg.TotalPrice != 4100 {
|
||||
t.Fatalf("expected total price 4100 for 2 servers, got %+v", cfg.TotalPrice)
|
||||
}
|
||||
|
||||
foundCPU := false
|
||||
foundLIC := false
|
||||
for _, row := range cfg.VendorSpec {
|
||||
if row.VendorPartnumber == "CPU-1" {
|
||||
foundCPU = true
|
||||
if len(row.LotMappings) != 1 || row.LotMappings[0].LotName != "CPU_INTEL_6747P" || row.LotMappings[0].QuantityPerPN != 1 {
|
||||
t.Fatalf("unexpected CPU mappings: %+v", row.LotMappings)
|
||||
}
|
||||
}
|
||||
if row.VendorPartnumber == "LIC-1" {
|
||||
foundLIC = true
|
||||
if len(row.LotMappings) != 1 || row.LotMappings[0].LotName != "LICENSE_XCC" || row.LotMappings[0].QuantityPerPN != 1 {
|
||||
t.Fatalf("unexpected LIC mappings: %+v", row.LotMappings)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundCPU || !foundLIC {
|
||||
t.Fatalf("expected resolved rows for CPU and LIC in vendor spec")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS lot_partnumbers (
|
||||
partnumber VARCHAR(255) NOT NULL,
|
||||
lot_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
description VARCHAR(10000) NULL,
|
||||
PRIMARY KEY (partnumber, lot_name),
|
||||
INDEX idx_lot_partnumbers_lot_name (lot_name)
|
||||
);
|
||||
@@ -1,25 +0,0 @@
|
||||
-- Allow placeholder mappings (partnumber without bound lot) and store import description.
|
||||
ALTER TABLE lot_partnumbers
|
||||
ADD COLUMN IF NOT EXISTS description VARCHAR(10000) NULL AFTER lot_name;
|
||||
|
||||
ALTER TABLE lot_partnumbers
|
||||
MODIFY COLUMN lot_name VARCHAR(255) NOT NULL DEFAULT '';
|
||||
|
||||
-- Drop FK on lot_name if it exists to allow unresolved placeholders.
|
||||
SET @lp_fk_name := (
|
||||
SELECT kcu.CONSTRAINT_NAME
|
||||
FROM information_schema.KEY_COLUMN_USAGE kcu
|
||||
WHERE kcu.TABLE_SCHEMA = DATABASE()
|
||||
AND kcu.TABLE_NAME = 'lot_partnumbers'
|
||||
AND kcu.COLUMN_NAME = 'lot_name'
|
||||
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
|
||||
LIMIT 1
|
||||
);
|
||||
SET @lp_drop_fk_sql := IF(
|
||||
@lp_fk_name IS NULL,
|
||||
'SELECT 1',
|
||||
CONCAT('ALTER TABLE lot_partnumbers DROP FOREIGN KEY `', @lp_fk_name, '`')
|
||||
);
|
||||
PREPARE lp_stmt FROM @lp_drop_fk_sql;
|
||||
EXECUTE lp_stmt;
|
||||
DEALLOCATE PREPARE lp_stmt;
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
|
||||
<div class="max-w-md">
|
||||
<input id="configs-search" type="text" placeholder="Поиск квоты по названию"
|
||||
<input id="configs-search" type="text" placeholder="Поиск конфигурации по названию"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
Квота: <span id="move-project-config-name" class="font-medium text-gray-900"></span>
|
||||
Конфигурация: <span id="move-project-config-name" class="font-medium text-gray-900"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||
@@ -174,7 +174,7 @@
|
||||
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
|
||||
<p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать конфигурацию?</span></p>
|
||||
<div class="mb-4">
|
||||
<label for="create-project-on-move-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
|
||||
<input id="create-project-on-move-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
|
||||
@@ -601,7 +601,7 @@ function openCreateProjectOnMoveModal(projectName) {
|
||||
document.getElementById('create-project-on-move-code').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-name').value = projectName;
|
||||
document.getElementById('create-project-on-move-variant').value = '';
|
||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
|
||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать конфигурацию?';
|
||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||
document.getElementById('create-project-on-move-modal').classList.add('flex');
|
||||
@@ -719,7 +719,7 @@ async function moveConfigToProject(uuid, projectUUID) {
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
alert('Не удалось перенести квоту: ' + (err.error || 'ошибка'));
|
||||
alert('Не удалось перенести конфигурацию: ' + (err.error || 'ошибка'));
|
||||
return false;
|
||||
}
|
||||
closeMoveProjectModal();
|
||||
@@ -727,7 +727,7 @@ async function moveConfigToProject(uuid, projectUUID) {
|
||||
await loadConfigs();
|
||||
return true;
|
||||
} catch (e) {
|
||||
alert('Ошибка переноса квоты');
|
||||
alert('Ошибка переноса конфигурации');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-estimate">—</td>
|
||||
<td class="px-3 py-2 text-right font-bold" id="pricing-total-vendor">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-warehouse">—</td>
|
||||
<td class="px-3 py-2 text-right">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-competitor">—</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
@@ -755,6 +755,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
document.getElementById('server-count').value = serverCount;
|
||||
document.getElementById('total-server-count').textContent = serverCount;
|
||||
selectedPricelistIds.estimate = config.pricelist_id || null;
|
||||
selectedPricelistIds.warehouse = config.warehouse_pricelist_id || null;
|
||||
selectedPricelistIds.competitor = config.competitor_pricelist_id || null;
|
||||
disablePriceRefresh = Boolean(config.disable_price_refresh);
|
||||
onlyInStock = Boolean(config.only_in_stock);
|
||||
|
||||
if (config.items && config.items.length > 0) {
|
||||
@@ -1983,6 +1986,9 @@ function buildSavePayload() {
|
||||
support_code: supportCode,
|
||||
article: getCurrentArticle(),
|
||||
pricelist_id: selectedPricelistIds.estimate,
|
||||
warehouse_pricelist_id: selectedPricelistIds.warehouse,
|
||||
competitor_pricelist_id: selectedPricelistIds.competitor,
|
||||
disable_price_refresh: disablePriceRefresh,
|
||||
only_in_stock: onlyInStock
|
||||
};
|
||||
}
|
||||
@@ -3540,8 +3546,8 @@ async function renderPricingTab() {
|
||||
} catch(e) { /* silent — pricing tab renders with available data */ }
|
||||
}
|
||||
|
||||
let totalVendor = 0, totalEstimate = 0, totalWarehouse = 0;
|
||||
let hasVendor = false, hasEstimate = false, hasWarehouse = false;
|
||||
let totalVendor = 0, totalEstimate = 0, totalWarehouse = 0, totalCompetitor = 0;
|
||||
let hasVendor = false, hasEstimate = false, hasWarehouse = false, hasCompetitor = false;
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
@@ -3557,10 +3563,13 @@ async function renderPricingTab() {
|
||||
const pl = priceMap[item.lot_name];
|
||||
const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0);
|
||||
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
|
||||
const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null;
|
||||
const estimateTotal = estUnit * item.quantity;
|
||||
const warehouseTotal = warehouseUnit != null ? warehouseUnit * item.quantity : null;
|
||||
const competitorTotal = competitorUnit != null ? competitorUnit * item.quantity : null;
|
||||
if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; }
|
||||
if (warehouseTotal != null && warehouseTotal > 0) { totalWarehouse += warehouseTotal; hasWarehouse = true; }
|
||||
if (competitorTotal != null && competitorTotal > 0) { totalCompetitor += competitorTotal; hasCompetitor = true; }
|
||||
tr.dataset.est = estimateTotal;
|
||||
const desc = (compMap[item.lot_name] || {}).description || '';
|
||||
tr.dataset.vendorOrig = '';
|
||||
@@ -3572,7 +3581,7 @@ async function renderPricingTab() {
|
||||
<td class="px-3 py-1.5 text-right text-xs">${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs text-gray-400 pricing-vendor-price">—</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${competitorTotal != null && competitorTotal > 0 ? formatCurrency(competitorTotal) : '—'}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
@@ -3592,10 +3601,13 @@ async function renderPricingTab() {
|
||||
let hasEstimateForRow = false;
|
||||
let rowWarehouse = 0;
|
||||
let hasWarehouseForRow = false;
|
||||
let rowCompetitor = 0;
|
||||
let hasCompetitorForRow = false;
|
||||
if (baseLot) {
|
||||
const pl = priceMap[baseLot];
|
||||
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
|
||||
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
|
||||
const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null;
|
||||
if (estimateUnit != null) {
|
||||
rowEst += estimateUnit * row.quantity * _getRowLotQtyPerPN(row);
|
||||
hasEstimateForRow = true;
|
||||
@@ -3604,11 +3616,16 @@ async function renderPricingTab() {
|
||||
rowWarehouse += warehouseUnit * row.quantity * _getRowLotQtyPerPN(row);
|
||||
hasWarehouseForRow = true;
|
||||
}
|
||||
if (competitorUnit != null) {
|
||||
rowCompetitor += competitorUnit * row.quantity * _getRowLotQtyPerPN(row);
|
||||
hasCompetitorForRow = true;
|
||||
}
|
||||
}
|
||||
allocs.forEach(a => {
|
||||
const pl = priceMap[a.lot_name];
|
||||
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
|
||||
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
|
||||
const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null;
|
||||
if (estimateUnit != null) {
|
||||
rowEst += estimateUnit * row.quantity * a.quantity;
|
||||
hasEstimateForRow = true;
|
||||
@@ -3617,12 +3634,17 @@ async function renderPricingTab() {
|
||||
rowWarehouse += warehouseUnit * row.quantity * a.quantity;
|
||||
hasWarehouseForRow = true;
|
||||
}
|
||||
if (competitorUnit != null) {
|
||||
rowCompetitor += competitorUnit * row.quantity * a.quantity;
|
||||
hasCompetitorForRow = true;
|
||||
}
|
||||
});
|
||||
|
||||
const vendorTotal = row.total_price != null ? row.total_price : (row.unit_price != null ? row.unit_price * row.quantity : null);
|
||||
if (vendorTotal != null) { totalVendor += vendorTotal; hasVendor = true; }
|
||||
if (hasEstimateForRow) { totalEstimate += rowEst; hasEstimate = true; }
|
||||
if (hasWarehouseForRow) { totalWarehouse += rowWarehouse; hasWarehouse = true; }
|
||||
if (hasCompetitorForRow) { totalCompetitor += rowCompetitor; hasCompetitor = true; }
|
||||
|
||||
tr.dataset.est = rowEst;
|
||||
tr.dataset.vendorOrig = vendorTotal != null ? vendorTotal : '';
|
||||
@@ -3643,7 +3665,7 @@ async function renderPricingTab() {
|
||||
<td class="px-3 py-1.5 text-right text-xs">${hasEstimateForRow ? formatCurrency(rowEst) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price">${vendorTotal != null ? formatCurrency(vendorTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${hasWarehouseForRow ? formatCurrency(rowWarehouse) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${hasCompetitorForRow ? formatCurrency(rowCompetitor) : '—'}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
@@ -3657,10 +3679,13 @@ async function renderPricingTab() {
|
||||
const pl = priceMap[item.lot_name];
|
||||
const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0);
|
||||
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
|
||||
const competitorUnit = (pl && pl.competitor_price > 0) ? pl.competitor_price : null;
|
||||
const estimateTotal = estUnit * item.quantity;
|
||||
const warehouseTotal = warehouseUnit != null ? warehouseUnit * item.quantity : null;
|
||||
const competitorTotal = competitorUnit != null ? competitorUnit * item.quantity : null;
|
||||
if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; }
|
||||
if (warehouseTotal != null && warehouseTotal > 0) { totalWarehouse += warehouseTotal; hasWarehouse = true; }
|
||||
if (competitorTotal != null && competitorTotal > 0) { totalCompetitor += competitorTotal; hasCompetitor = true; }
|
||||
tr.dataset.est = estimateTotal;
|
||||
tr.dataset.vendorOrig = '';
|
||||
const desc = (compMap[item.lot_name] || {}).description || '';
|
||||
@@ -3672,7 +3697,7 @@ async function renderPricingTab() {
|
||||
<td class="px-3 py-1.5 text-right text-xs">${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs text-gray-400 pricing-vendor-price">—</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${competitorTotal != null && competitorTotal > 0 ? formatCurrency(competitorTotal) : '—'}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
@@ -3682,6 +3707,7 @@ async function renderPricingTab() {
|
||||
document.getElementById('pricing-total-vendor').textContent = hasVendor ? formatCurrency(totalVendor) : '—';
|
||||
document.getElementById('pricing-total-estimate').textContent = hasEstimate ? formatCurrency(totalEstimate) : '—';
|
||||
document.getElementById('pricing-total-warehouse').textContent = hasWarehouse ? formatCurrency(totalWarehouse) : '—';
|
||||
document.getElementById('pricing-total-competitor').textContent = hasCompetitor ? formatCurrency(totalCompetitor) : '—';
|
||||
tfoot.classList.remove('hidden');
|
||||
|
||||
// Update custom price proportional breakdown
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
{{define "title"}}Вход - QuoteForge{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-sm mx-auto mt-16">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h1 class="text-xl font-bold text-center mb-6">Вход в систему</h1>
|
||||
|
||||
<form id="login-form">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Логин</label>
|
||||
<input type="text" name="username" id="username" required
|
||||
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value="admin">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||
<input type="password" name="password" id="password" required
|
||||
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value="admin123">
|
||||
</div>
|
||||
<div id="error" class="text-red-600 text-sm mb-4 hidden"></div>
|
||||
<button type="submit" id="submit-btn"
|
||||
class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700">
|
||||
Войти
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-gray-500 mt-4">
|
||||
<a href="/" class="text-blue-600">← На главную</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('login-form');
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const errorEl = document.getElementById('error');
|
||||
const btn = document.getElementById('submit-btn');
|
||||
|
||||
errorEl.classList.add('hidden');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Вход...';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username, password})
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
if (resp.ok && data.access_token) {
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
window.location.href = '/configs';
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Неверный логин или пароль';
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Войти';
|
||||
}
|
||||
} catch(err) {
|
||||
errorEl.textContent = 'Ошибка соединения с сервером';
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Войти';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 class="text-2xl font-bold text-gray-900">Партномера</h1>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div id="summary-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 hidden">
|
||||
<div id="summary-cards" class="grid grid-cols-2 md:grid-cols-3 gap-4 hidden">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="text-xs text-gray-500 mb-1">Активный лист</div>
|
||||
<div id="card-version" class="font-mono font-semibold text-gray-800 text-sm truncate">—</div>
|
||||
@@ -19,40 +19,12 @@
|
||||
<div class="text-xs text-gray-500 mb-1">Всего PN</div>
|
||||
<div id="card-pn-total" class="text-2xl font-bold text-gray-800">—</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="text-xs text-gray-500 mb-1">Primary PN</div>
|
||||
<div id="card-pn-primary" class="text-2xl font-bold text-green-600">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера.
|
||||
</div>
|
||||
|
||||
<!-- Active book items with search -->
|
||||
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
|
||||
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
|
||||
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
|
||||
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
oninput="filterItems(this.value)">
|
||||
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left">Partnumber</th>
|
||||
<th class="px-4 py-2 text-left">LOT</th>
|
||||
<th class="px-4 py-2 text-center w-24">Primary</th>
|
||||
<th class="px-4 py-2 text-left">Описание</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="active-items-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All books list (collapsed by default) -->
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<!-- Header row — always visible -->
|
||||
@@ -92,14 +64,50 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active book items with search -->
|
||||
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
|
||||
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
|
||||
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
|
||||
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
oninput="onItemsSearchInput(this.value)">
|
||||
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left">Partnumber</th>
|
||||
<th class="px-4 py-2 text-left">LOT</th>
|
||||
<th class="px-4 py-2 text-left">Описание</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="active-items-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="items-pagination" class="hidden px-4 py-3 border-t flex items-center justify-between text-sm text-gray-600">
|
||||
<span id="items-page-info"></span>
|
||||
<div class="flex gap-2">
|
||||
<button id="items-prev" onclick="changeItemsPage(-1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">← Назад</button>
|
||||
<button id="items-next" onclick="changeItemsPage(1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">Вперёд →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
let allItems = [];
|
||||
let allBooks = [];
|
||||
let booksPage = 1;
|
||||
const BOOKS_PER_PAGE = 10;
|
||||
const ITEMS_PER_PAGE = 100;
|
||||
let activeBookServerID = null;
|
||||
let activeItems = [];
|
||||
let itemsPage = 1;
|
||||
let itemsTotal = 0;
|
||||
let itemsSearch = '';
|
||||
let _itemsSearchTimer = null;
|
||||
|
||||
function toggleBooksSection() {
|
||||
const body = document.getElementById('books-section-body');
|
||||
@@ -179,29 +187,45 @@ function changeBooksPage(delta) {
|
||||
}
|
||||
|
||||
async function loadActiveBookItems(book) {
|
||||
activeBookServerID = book.server_id;
|
||||
return loadActiveBookItemsPage(1, itemsSearch, book);
|
||||
}
|
||||
|
||||
async function loadActiveBookItemsPage(page = 1, search = '', book = null) {
|
||||
const targetBook = book || allBooks.find(b => b.server_id === activeBookServerID);
|
||||
if (!targetBook) return;
|
||||
|
||||
let resp, data;
|
||||
try {
|
||||
resp = await fetch(`/api/partnumber-books/${book.server_id}`);
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(ITEMS_PER_PAGE),
|
||||
});
|
||||
if (search.trim()) {
|
||||
params.set('search', search.trim());
|
||||
}
|
||||
resp = await fetch(`/api/partnumber-books/${targetBook.server_id}?` + params.toString());
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) return;
|
||||
|
||||
allItems = data.items || [];
|
||||
activeItems = data.items || [];
|
||||
itemsPage = data.page || page;
|
||||
itemsTotal = Number(data.total || 0);
|
||||
itemsSearch = data.search || search || '';
|
||||
|
||||
const lots = new Set(allItems.map(i => i.lot_name));
|
||||
const primaryCount = allItems.filter(i => i.is_primary_pn).length;
|
||||
|
||||
document.getElementById('card-version').textContent = book.version;
|
||||
document.getElementById('card-date').textContent = book.created_at;
|
||||
document.getElementById('card-lots').textContent = lots.size;
|
||||
document.getElementById('card-pn-total').textContent = allItems.length;
|
||||
document.getElementById('card-pn-primary').textContent = primaryCount;
|
||||
document.getElementById('card-version').textContent = targetBook.version;
|
||||
document.getElementById('card-date').textContent = targetBook.created_at;
|
||||
document.getElementById('card-lots').textContent = Number(data.lot_count || 0);
|
||||
document.getElementById('card-pn-total').textContent = Number(data.book_total || 0);
|
||||
document.getElementById('summary-cards').classList.remove('hidden');
|
||||
document.getElementById('active-book-section').classList.remove('hidden');
|
||||
document.getElementById('pn-search').value = itemsSearch;
|
||||
|
||||
renderItems(allItems);
|
||||
renderItems(activeItems);
|
||||
renderItemsPagination();
|
||||
}
|
||||
|
||||
function renderItems(items) {
|
||||
@@ -210,23 +234,47 @@ function renderItems(items) {
|
||||
items.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50';
|
||||
const lots = Array.isArray(item.lots_json) ? item.lots_json : [];
|
||||
const lotsText = lots.map(l => `${l.lot_name} x${l.qty}`).join(', ');
|
||||
tr.innerHTML = `
|
||||
<td class="px-4 py-1.5 font-mono text-xs">${item.partnumber}</td>
|
||||
<td class="px-4 py-1.5 text-xs font-medium text-blue-700">${item.lot_name}</td>
|
||||
<td class="px-4 py-1.5 text-center text-green-600 text-xs">${item.is_primary_pn ? '✓' : ''}</td>
|
||||
<td class="px-4 py-1.5 text-xs font-medium text-blue-700">${lotsText}</td>
|
||||
<td class="px-4 py-1.5 text-xs text-gray-500">${item.description || ''}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
document.getElementById('pn-count').textContent = `${items.length} из ${allItems.length}`;
|
||||
const totalLabel = itemsSearch ? `${items.length} из ${itemsTotal} по фильтру` : `${items.length} из ${itemsTotal}`;
|
||||
document.getElementById('pn-count').textContent = totalLabel;
|
||||
}
|
||||
|
||||
function filterItems(query) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) { renderItems(allItems); return; }
|
||||
renderItems(allItems.filter(i =>
|
||||
i.partnumber.toLowerCase().includes(q) || i.lot_name.toLowerCase().includes(q)
|
||||
));
|
||||
function renderItemsPagination() {
|
||||
const totalPages = Math.max(1, Math.ceil(itemsTotal / ITEMS_PER_PAGE));
|
||||
const start = itemsTotal === 0 ? 0 : ((itemsPage - 1) * ITEMS_PER_PAGE) + 1;
|
||||
const end = Math.min(itemsPage * ITEMS_PER_PAGE, itemsTotal);
|
||||
const box = document.getElementById('items-pagination');
|
||||
if (itemsTotal > ITEMS_PER_PAGE) {
|
||||
box.classList.remove('hidden');
|
||||
document.getElementById('items-page-info').textContent = `Сопоставления ${start}–${end} из ${itemsTotal}`;
|
||||
document.getElementById('items-prev').disabled = itemsPage === 1;
|
||||
document.getElementById('items-next').disabled = itemsPage >= totalPages;
|
||||
} else {
|
||||
box.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function changeItemsPage(delta) {
|
||||
const totalPages = Math.max(1, Math.ceil(itemsTotal / ITEMS_PER_PAGE));
|
||||
const nextPage = Math.max(1, Math.min(totalPages, itemsPage + delta));
|
||||
if (nextPage === itemsPage) return;
|
||||
loadActiveBookItemsPage(nextPage, itemsSearch);
|
||||
}
|
||||
|
||||
function onItemsSearchInput(value) {
|
||||
clearTimeout(_itemsSearchTimer);
|
||||
_itemsSearchTimer = setTimeout(() => {
|
||||
itemsSearch = value.trim();
|
||||
loadActiveBookItemsPage(1, itemsSearch);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function syncPartnumberBooks() {
|
||||
@@ -253,4 +301,3 @@ document.addEventListener('DOMContentLoaded', loadBooks);
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3 min-w-0">
|
||||
<a href="/projects" class="shrink-0 text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="text-2xl font-bold flex items-center gap-2">
|
||||
<a id="project-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xl sm:text-2xl font-bold min-w-0">
|
||||
<a id="project-code-link" href="/projects" class="min-w-0 truncate text-blue-700 hover:underline">
|
||||
<span id="project-code">—</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<div class="relative">
|
||||
<button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-base font-medium px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200">
|
||||
<span class="text-gray-400 shrink-0">-</span>
|
||||
<div class="relative shrink-0">
|
||||
<button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-sm sm:text-base font-medium px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200">
|
||||
<span id="project-variant-label">main</span>
|
||||
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
@@ -26,24 +26,24 @@
|
||||
<div id="project-variant-list" class="py-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="openNewVariantModal()" class="inline-flex w-full sm:w-auto justify-center items-center px-3 py-1.5 text-sm font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Вариант
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-6 gap-3">
|
||||
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
||||
+ Новый вариант
|
||||
</button>
|
||||
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
+ Создать новую квоту
|
||||
Новая конфигурация
|
||||
</button>
|
||||
<button onclick="openImportModal()" class="py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
|
||||
Импорт квоты
|
||||
<button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium">
|
||||
Импорт выгрузки вендора
|
||||
</button>
|
||||
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
|
||||
Параметры
|
||||
</button>
|
||||
<button onclick="exportProject()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
|
||||
<button onclick="openExportModal()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
|
||||
Экспорт CSV
|
||||
</button>
|
||||
<button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden">
|
||||
@@ -72,10 +72,10 @@
|
||||
|
||||
<div id="create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Новая квота в проекте</h2>
|
||||
<h2 class="text-xl font-semibold mb-4">Новая конфигурация в проекте</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название квоты</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
|
||||
<input type="text" id="create-name" placeholder="Например: OPP-2026-001"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
@@ -87,6 +87,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vendor-import-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Импорт выгрузки вендора</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
Загружает `CFXML`-выгрузку в текущий проект и создаёт несколько конфигураций, если они есть в файле.
|
||||
</div>
|
||||
<div>
|
||||
<label for="vendor-import-file" class="block text-sm font-medium text-gray-700 mb-1">Файл выгрузки</label>
|
||||
<input id="vendor-import-file" type="file" accept=".xml,text/xml,application/xml"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-amber-500 focus:border-amber-500">
|
||||
</div>
|
||||
<div id="vendor-import-status" class="hidden text-sm rounded border px-3 py-2"></div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeVendorImportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button id="vendor-import-submit" onclick="importVendorWorkspace()" class="px-4 py-2 bg-amber-600 text-white rounded hover:bg-amber-700">Импортировать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="project-export-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Экспорт CSV</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
Экспортирует проект в формате вкладки ценообразования. Если включён `BOM`, строки строятся по BOM; иначе по текущему Estimate.
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<input id="export-col-lot" type="checkbox" class="rounded border-gray-300" checked>
|
||||
<span>LOT</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<input id="export-col-bom" type="checkbox" class="rounded border-gray-300">
|
||||
<span>BOM</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<input id="export-col-estimate" type="checkbox" class="rounded border-gray-300" checked>
|
||||
<span>Estimate</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<input id="export-col-stock" type="checkbox" class="rounded border-gray-300">
|
||||
<span>Stock</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<input id="export-col-competitor" type="checkbox" class="rounded border-gray-300">
|
||||
<span>Конкуренты</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="project-export-status" class="hidden text-sm rounded border px-3 py-2"></div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeExportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button id="project-export-submit" onclick="exportProject()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Скачать CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="new-variant-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Новый вариант</h2>
|
||||
@@ -116,7 +175,7 @@
|
||||
|
||||
<div id="config-action-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Действия с квотой</h2>
|
||||
<h2 class="text-xl font-semibold mb-4">Действия с конфигурацией</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
||||
@@ -154,25 +213,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="import-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Импорт квоты в проект</h2>
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700">Квота</label>
|
||||
<input id="import-config-input"
|
||||
list="import-config-options"
|
||||
placeholder="Начните вводить название квоты"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<datalist id="import-config-options"></datalist>
|
||||
<div class="text-xs text-gray-500">Квота будет перемещена в текущий проект.</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
<button onclick="closeImportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="importConfigToProject()" class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Импортировать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="project-settings-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Параметры проекта</h2>
|
||||
@@ -347,7 +387,7 @@ function applyStatusModeUI() {
|
||||
}
|
||||
|
||||
function renderConfigs(configs) {
|
||||
const emptyText = configStatusMode === 'archived' ? 'Архив пуст' : 'Нет квот в проекте';
|
||||
const emptyText = configStatusMode === 'archived' ? 'Архив пуст' : 'Нет конфигураций в проекте';
|
||||
if (configs.length === 0) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">' + emptyText + '</div>';
|
||||
@@ -540,6 +580,86 @@ function closeCreateModal() {
|
||||
document.getElementById('create-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function setVendorImportStatus(message, type) {
|
||||
const box = document.getElementById('vendor-import-status');
|
||||
if (!box) return;
|
||||
if (!message) {
|
||||
box.textContent = '';
|
||||
box.className = 'hidden text-sm rounded border px-3 py-2';
|
||||
return;
|
||||
}
|
||||
let classes = 'text-sm rounded border px-3 py-2 ';
|
||||
if (type === 'error') {
|
||||
classes += 'border-red-200 bg-red-50 text-red-700';
|
||||
} else if (type === 'success') {
|
||||
classes += 'border-emerald-200 bg-emerald-50 text-emerald-700';
|
||||
} else {
|
||||
classes += 'border-amber-200 bg-amber-50 text-amber-700';
|
||||
}
|
||||
box.className = classes;
|
||||
box.textContent = message;
|
||||
}
|
||||
|
||||
function openVendorImportModal() {
|
||||
document.getElementById('vendor-import-file').value = '';
|
||||
setVendorImportStatus('', '');
|
||||
document.getElementById('vendor-import-submit').disabled = false;
|
||||
document.getElementById('vendor-import-submit').textContent = 'Импортировать';
|
||||
document.getElementById('vendor-import-modal').classList.remove('hidden');
|
||||
document.getElementById('vendor-import-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeVendorImportModal() {
|
||||
document.getElementById('vendor-import-modal').classList.add('hidden');
|
||||
document.getElementById('vendor-import-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function importVendorWorkspace() {
|
||||
const input = document.getElementById('vendor-import-file');
|
||||
const submit = document.getElementById('vendor-import-submit');
|
||||
if (!input || !input.files || !input.files[0]) {
|
||||
setVendorImportStatus('Выберите XML-файл выгрузки', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', input.files[0]);
|
||||
submit.disabled = true;
|
||||
submit.textContent = 'Импорт...';
|
||||
setVendorImportStatus('Импортирую файл в проект...', 'info');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/vendor-import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.error || 'Не удалось импортировать выгрузку');
|
||||
}
|
||||
|
||||
const imported = Array.isArray(data.configs) ? data.configs : [];
|
||||
const importedNames = imported.map(c => c.name).filter(Boolean);
|
||||
const message = importedNames.length
|
||||
? 'Импортировано ' + imported.length + ': ' + importedNames.join(', ')
|
||||
: 'Импортировано конфигураций: ' + (data.imported || 0);
|
||||
setVendorImportStatus(message, 'success');
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('Импорт завершён', 'success');
|
||||
}
|
||||
await loadConfigs();
|
||||
setTimeout(closeVendorImportModal, 900);
|
||||
} catch (e) {
|
||||
setVendorImportStatus(e.message || 'Не удалось импортировать выгрузку', 'error');
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(e.message || 'Не удалось импортировать выгрузку', 'error');
|
||||
}
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
submit.textContent = 'Импортировать';
|
||||
}
|
||||
}
|
||||
|
||||
async function createConfig() {
|
||||
const name = document.getElementById('create-name').value.trim();
|
||||
if (!name) {
|
||||
@@ -552,7 +672,7 @@ async function createConfig() {
|
||||
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось создать квоту');
|
||||
alert('Не удалось создать конфигурацию');
|
||||
return;
|
||||
}
|
||||
closeCreateModal();
|
||||
@@ -560,7 +680,7 @@ async function createConfig() {
|
||||
}
|
||||
|
||||
async function deleteConfig(uuid) {
|
||||
if (!confirm('Переместить квоту в архив?')) return;
|
||||
if (!confirm('Переместить конфигурацию в архив?')) return;
|
||||
await fetch('/api/configs/' + uuid, {method: 'DELETE'});
|
||||
loadConfigs();
|
||||
}
|
||||
@@ -568,7 +688,7 @@ async function deleteConfig(uuid) {
|
||||
async function reactivateConfig(uuid) {
|
||||
const resp = await fetch('/api/configs/' + uuid + '/reactivate', {method: 'POST'});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось восстановить квоту');
|
||||
alert('Не удалось восстановить конфигурацию');
|
||||
return;
|
||||
}
|
||||
const moved = await fetch('/api/configs/' + uuid + '/project', {
|
||||
@@ -577,7 +697,7 @@ async function reactivateConfig(uuid) {
|
||||
body: JSON.stringify({project_uuid: projectUUID})
|
||||
});
|
||||
if (!moved.ok) {
|
||||
alert('Квота восстановлена, но не удалось вернуть в проект');
|
||||
alert('Конфигурация восстановлена, но не удалось вернуть в проект');
|
||||
}
|
||||
loadConfigs();
|
||||
}
|
||||
@@ -752,7 +872,7 @@ async function saveConfigAction() {
|
||||
body: JSON.stringify({name: name})
|
||||
});
|
||||
if (!cloneResp.ok) {
|
||||
notify('Не удалось скопировать квоту', 'error');
|
||||
notify('Не удалось скопировать конфигурацию', 'error');
|
||||
return;
|
||||
}
|
||||
closeConfigActionModal();
|
||||
@@ -773,7 +893,7 @@ async function saveConfigAction() {
|
||||
body: JSON.stringify({name: name})
|
||||
});
|
||||
if (!renameResp.ok) {
|
||||
notify('Не удалось переименовать квоту', 'error');
|
||||
notify('Не удалось переименовать конфигурацию', 'error');
|
||||
return;
|
||||
}
|
||||
changed = true;
|
||||
@@ -786,7 +906,7 @@ async function saveConfigAction() {
|
||||
body: JSON.stringify({project_uuid: targetProjectUUID})
|
||||
});
|
||||
if (!moveResp.ok) {
|
||||
notify('Не удалось перенести квоту', 'error');
|
||||
notify('Не удалось перенести конфигурацию', 'error');
|
||||
return;
|
||||
}
|
||||
changed = true;
|
||||
@@ -805,21 +925,6 @@ async function saveConfigAction() {
|
||||
}
|
||||
}
|
||||
|
||||
function openImportModal() {
|
||||
const activeOther = allConfigs.length ? null : null; // no-op placeholder
|
||||
void activeOther;
|
||||
document.getElementById('import-config-input').value = '';
|
||||
document.getElementById('import-config-options').innerHTML = '';
|
||||
loadImportOptions();
|
||||
document.getElementById('import-modal').classList.remove('hidden');
|
||||
document.getElementById('import-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeImportModal() {
|
||||
document.getElementById('import-modal').classList.add('hidden');
|
||||
document.getElementById('import-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function openProjectSettingsModal() {
|
||||
if (!project) return;
|
||||
if (project.is_system) {
|
||||
@@ -879,89 +984,10 @@ async function saveProjectSettings() {
|
||||
closeProjectSettingsModal();
|
||||
}
|
||||
|
||||
async function loadImportOptions() {
|
||||
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const options = document.getElementById('import-config-options');
|
||||
options.innerHTML = '';
|
||||
(data.configurations || [])
|
||||
.filter(c => c.project_uuid !== projectUUID)
|
||||
.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.name;
|
||||
options.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
async function importConfigToProject() {
|
||||
const query = document.getElementById('import-config-input').value.trim();
|
||||
if (!query) {
|
||||
alert('Выберите квоту');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось загрузить список квот');
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const sourceConfigs = (data.configurations || []).filter(c => c.project_uuid !== projectUUID);
|
||||
|
||||
let targets = [];
|
||||
if (query.includes('*')) {
|
||||
targets = sourceConfigs.filter(c => wildcardMatch(c.name || '', query));
|
||||
} else {
|
||||
const found = sourceConfigs.find(c => (c.name || '').toLowerCase() === query.toLowerCase());
|
||||
if (found) {
|
||||
targets = [found];
|
||||
}
|
||||
}
|
||||
|
||||
if (!targets.length) {
|
||||
alert('Подходящие квоты не найдены');
|
||||
return;
|
||||
}
|
||||
|
||||
let moved = 0;
|
||||
let failed = 0;
|
||||
for (const cfg of targets) {
|
||||
const move = await fetch('/api/configs/' + cfg.uuid + '/project', {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({project_uuid: projectUUID})
|
||||
});
|
||||
if (move.ok) {
|
||||
moved++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!moved) {
|
||||
alert('Не удалось импортировать квоты');
|
||||
return;
|
||||
}
|
||||
|
||||
closeImportModal();
|
||||
await loadConfigs();
|
||||
if (targets.length > 1 || failed > 0) {
|
||||
alert('Импорт завершен: перенесено ' + moved + ', ошибок ' + failed);
|
||||
}
|
||||
}
|
||||
|
||||
function wildcardMatch(value, pattern) {
|
||||
const escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*');
|
||||
const regex = new RegExp('^' + escaped + '$', 'i');
|
||||
return regex.test(value);
|
||||
}
|
||||
|
||||
async function deleteVariant() {
|
||||
if (!project) return;
|
||||
const variantLabel = normalizeVariantLabel(project.variant);
|
||||
if (!confirm('Удалить вариант «' + variantLabel + '»? Все квоты будут архивированы.')) return;
|
||||
if (!confirm('Удалить вариант «' + variantLabel + '»? Все конфигурации будут архивированы.')) return;
|
||||
const resp = await fetch('/api/projects/' + projectUUID, {method: 'DELETE'});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
@@ -988,9 +1014,9 @@ function updateDeleteVariantButton() {
|
||||
}
|
||||
|
||||
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
||||
document.getElementById('vendor-import-modal').addEventListener('click', function(e) { if (e.target === this) closeVendorImportModal(); });
|
||||
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
|
||||
document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); });
|
||||
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
|
||||
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
|
||||
document.getElementById('config-action-project-input').addEventListener('input', function(e) {
|
||||
const code = resolveProjectCodeFromInput(e.target.value);
|
||||
@@ -1007,8 +1033,8 @@ document.getElementById('config-action-copy').addEventListener('change', functio
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeCreateModal();
|
||||
closeVendorImportModal();
|
||||
closeConfigActionModal();
|
||||
closeImportModal();
|
||||
closeProjectSettingsModal();
|
||||
}
|
||||
});
|
||||
@@ -1162,8 +1188,82 @@ function updateFooterTotal() {
|
||||
}
|
||||
}
|
||||
|
||||
function exportProject() {
|
||||
window.location.href = '/api/projects/' + projectUUID + '/export';
|
||||
function openExportModal() {
|
||||
const modal = document.getElementById('project-export-modal');
|
||||
if (!modal) return;
|
||||
setProjectExportStatus('', '');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
}
|
||||
|
||||
function closeExportModal() {
|
||||
const modal = document.getElementById('project-export-modal');
|
||||
if (!modal) return;
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
|
||||
function setProjectExportStatus(message, tone) {
|
||||
const status = document.getElementById('project-export-status');
|
||||
if (!status) return;
|
||||
if (!message) {
|
||||
status.className = 'hidden text-sm rounded border px-3 py-2';
|
||||
status.textContent = '';
|
||||
return;
|
||||
}
|
||||
const palette = tone === 'error'
|
||||
? 'text-red-700 bg-red-50 border-red-200'
|
||||
: 'text-gray-700 bg-gray-50 border-gray-200';
|
||||
status.className = 'text-sm rounded border px-3 py-2 ' + palette;
|
||||
status.textContent = message;
|
||||
}
|
||||
|
||||
async function exportProject() {
|
||||
const submitBtn = document.getElementById('project-export-submit');
|
||||
const payload = {
|
||||
include_lot: !!document.getElementById('export-col-lot')?.checked,
|
||||
include_bom: !!document.getElementById('export-col-bom')?.checked,
|
||||
include_estimate: !!document.getElementById('export-col-estimate')?.checked,
|
||||
include_stock: !!document.getElementById('export-col-stock')?.checked,
|
||||
include_competitor: !!document.getElementById('export-col-competitor')?.checked
|
||||
};
|
||||
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
setProjectExportStatus('', '');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/export', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let message = 'Не удалось экспортировать CSV';
|
||||
try {
|
||||
const data = await resp.json();
|
||||
if (data && data.error) message = data.error;
|
||||
} catch (_) {}
|
||||
setProjectExportStatus(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await resp.blob();
|
||||
const link = document.createElement('a');
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const disposition = resp.headers.get('Content-Disposition') || '';
|
||||
const match = disposition.match(/filename="([^"]+)"/);
|
||||
link.href = url;
|
||||
link.download = match ? match[1] : 'project-export.csv';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
closeExportModal();
|
||||
} catch (e) {
|
||||
setProjectExportStatus('Ошибка сети при экспорте CSV', 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
|
||||
@@ -315,7 +315,7 @@ async function loadProjects() {
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>';
|
||||
html += '</button>';
|
||||
|
||||
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-900" title="Добавить квоту">';
|
||||
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-900" title="Добавить конфигурацию">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>';
|
||||
html += '</button>';
|
||||
} else {
|
||||
@@ -472,7 +472,7 @@ async function reactivateProject(projectUUID) {
|
||||
}
|
||||
|
||||
async function addConfigToProject(projectUUID) {
|
||||
const name = prompt('Название новой квоты');
|
||||
const name = prompt('Название новой конфигурации');
|
||||
if (!name || !name.trim()) return;
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
|
||||
method: 'POST',
|
||||
@@ -480,7 +480,7 @@ async function addConfigToProject(projectUUID) {
|
||||
body: JSON.stringify({name: name.trim(), items: [], notes: '', server_count: 1})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось создать квоту');
|
||||
alert('Не удалось создать конфигурацию');
|
||||
return;
|
||||
}
|
||||
loadProjects();
|
||||
@@ -510,7 +510,7 @@ async function copyProject(projectUUID, projectName) {
|
||||
|
||||
const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
|
||||
if (!listResp.ok) {
|
||||
alert('Проект скопирован без квот (не удалось загрузить исходные квоты)');
|
||||
alert('Проект скопирован без конфигураций (не удалось загрузить исходные конфигурации)');
|
||||
loadProjects();
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user