Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83a3202bdf | ||
|
|
4bc7979a70 | ||
|
|
1137c6d4db | ||
|
|
7e1e2ac18d | ||
|
|
aea6bf91ab | ||
|
|
d58d52c5e7 | ||
|
|
7a628deb8a | ||
|
|
7f6be786a8 | ||
|
|
a360992a01 | ||
|
|
1ea21ece33 | ||
|
|
7ae804d2d3 | ||
|
|
da5414c708 | ||
|
|
7a69c1513d | ||
|
|
f448111e77 | ||
|
|
a5dafd37d3 | ||
|
|
3661e345b1 | ||
|
|
f915866f83 | ||
|
|
c34a42aaf5 | ||
|
|
7de0f359b6 | ||
|
|
a8d8d7dfa9 | ||
|
|
20ce0124be | ||
|
|
b0a106415f | ||
|
|
a054fc7564 | ||
|
|
68cd087356 |
2
bible
2
bible
Submodule bible updated: 5a69e0bba8...52444350c1
@@ -35,6 +35,8 @@ Readiness guard:
|
|||||||
- blocked sync returns `423 Locked` with a machine-readable reason;
|
- blocked sync returns `423 Locked` with a machine-readable reason;
|
||||||
- local work continues even when sync is blocked.
|
- local work continues even when sync is blocked.
|
||||||
- sync metadata updates must preserve project `updated_at`; sync time belongs in `synced_at`, not in the user-facing last-modified timestamp.
|
- sync metadata updates must preserve project `updated_at`; sync time belongs in `synced_at`, not in the user-facing last-modified timestamp.
|
||||||
|
- pricelist pull must persist a new local snapshot atomically: header and items appear together, and `last_pricelist_sync` advances only after item download succeeds.
|
||||||
|
- UI sync status must distinguish "last sync failed" from "up to date"; if the app can prove newer server pricelist data exists, the indicator must say local cache is incomplete.
|
||||||
|
|
||||||
## Pricing contract
|
## Pricing contract
|
||||||
|
|
||||||
@@ -46,16 +48,55 @@ Rules:
|
|||||||
- latest pricelist selection ignores snapshots without items;
|
- latest pricelist selection ignores snapshots without items;
|
||||||
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
|
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
|
||||||
|
|
||||||
|
## Pricing tab layout
|
||||||
|
|
||||||
|
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
|
||||||
|
|
||||||
|
Column order (both tables):
|
||||||
|
|
||||||
|
```
|
||||||
|
PN вендора | Описание | LOT | Кол-во | Estimate | Склад | Конкуренты | Ручная цена
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-LOT row expansion rules:
|
||||||
|
- each `lot_mappings` entry in a BOM row becomes its own table row with its own quantity and prices;
|
||||||
|
- `baseLot` (resolved LOT without an explicit mapping) is treated as the first sub-row with `quantity_per_pn` from `_getRowLotQtyPerPN`;
|
||||||
|
- when one vendor PN expands into N LOT sub-rows, PN вендора and Описание cells use `rowspan="N"` and appear only on the first sub-row;
|
||||||
|
- a visual top border (`border-t border-gray-200`) separates each vendor PN group.
|
||||||
|
|
||||||
|
Vendor price attachment:
|
||||||
|
- `vendorOrig` and `vendorOrigUnit` (BOM unit/total price) are attached to the first LOT sub-row only;
|
||||||
|
- subsequent sub-rows carry empty `data-vendor-orig` so `setPricingCustomPriceFromVendor` counts each vendor PN exactly once.
|
||||||
|
|
||||||
|
Controls terminology:
|
||||||
|
- custom price input is labeled **Ручная цена** (not "Своя цена");
|
||||||
|
- the button that fills custom price from BOM totals is labeled **BOM Цена** (not "Проставить цены BOM").
|
||||||
|
|
||||||
|
CSV export reads PN вендора, Описание, and LOT from `data-vendor-pn`, `data-desc`, `data-lot` row attributes to bypass the rowspan cell offset problem.
|
||||||
|
|
||||||
## Configuration versioning
|
## Configuration versioning
|
||||||
|
|
||||||
Configuration revisions are append-only snapshots stored in `local_configuration_versions`.
|
Configuration revisions are append-only snapshots stored in `local_configuration_versions`.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- create a new revision only when spec or price content changes;
|
- the editable working configuration is always the implicit head named `main`; UI must not switch the user to a numbered revision after save;
|
||||||
|
- create a new revision when spec, BOM, or pricing content changes;
|
||||||
|
- revision history is retrospective: the revisions page shows past snapshots, not the current `main` state;
|
||||||
- rollback creates a new head revision from an old snapshot;
|
- rollback creates a new head revision from an old snapshot;
|
||||||
- rename, reorder, project move, and similar operational edits do not create a new revision snapshot;
|
- rename, reorder, project move, and similar operational edits do not create a new revision snapshot;
|
||||||
|
- revision deduplication includes `items`, `server_count`, `total_price`, `custom_price`, `vendor_spec`, pricelist selectors, `disable_price_refresh`, and `only_in_stock`;
|
||||||
|
- BOM updates must use version-aware save flow, not a direct SQL field update;
|
||||||
- current revision pointer must be recoverable if legacy or damaged rows are found locally.
|
- current revision pointer must be recoverable if legacy or damaged rows are found locally.
|
||||||
|
|
||||||
|
## Sync UX
|
||||||
|
|
||||||
|
UI-facing sync status must never block on live MariaDB calls.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- navbar sync indicator and sync info modal read only local cached state from SQLite/app settings;
|
||||||
|
- background/manual sync may talk to MariaDB, but polling endpoints must stay fast even on slow or broken connections;
|
||||||
|
- any MariaDB timeout/invalid-connection during sync must invalidate the cached remote handle immediately so UI stops treating the connection as healthy.
|
||||||
|
|
||||||
## Naming collisions
|
## Naming collisions
|
||||||
|
|
||||||
UI-driven rename and copy flows use one suffix convention for conflicts.
|
UI-driven rename and copy flows use one suffix convention for conflicts.
|
||||||
@@ -65,6 +106,17 @@ Rules:
|
|||||||
- copy checkboxes and copy modals must prefill `_копия`, not ` (копия)`;
|
- copy checkboxes and copy modals must prefill `_копия`, not ` (копия)`;
|
||||||
- the literal variant name `main` is reserved and must not be allowed for non-main variants.
|
- the literal variant name `main` is reserved and must not be allowed for non-main variants.
|
||||||
|
|
||||||
|
## Configuration types
|
||||||
|
|
||||||
|
Configurations have a `config_type` field: `"server"` (default) or `"storage"`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- `config_type` defaults to `"server"` for all existing and new configurations unless explicitly set;
|
||||||
|
- the configurator page is shared for both types; the SW tab is always visible regardless of type;
|
||||||
|
- storage configurations use the same vendor_spec + PN→LOT + pricing flow as server configurations;
|
||||||
|
- storage component categories map to existing tabs: `ENC`/`DKC`/`CTL` → Base, `HIC` → PCI (HIC-карты СХД; `HBA`/`NIC` — серверные, не смешивать), `SSD`/`HDD` → Storage (используют существующие серверные LOT), `ACC` → Accessories (используют существующие серверные LOT), `SW` → SW.
|
||||||
|
- `DKC` = контроллерная полка (модель СХД + тип дисков + кол-во слотов + кол-во контроллеров); `CTL` = контроллер (кэш + встроенные порты); `ENC` = дисковая полка без контроллера.
|
||||||
|
|
||||||
## Vendor BOM contract
|
## Vendor BOM contract
|
||||||
|
|
||||||
Vendor BOM is stored in `vendor_spec` on the configuration row.
|
Vendor BOM is stored in `vendor_spec` on the configuration row.
|
||||||
|
|||||||
@@ -29,31 +29,369 @@ Rules:
|
|||||||
|
|
||||||
## MariaDB
|
## MariaDB
|
||||||
|
|
||||||
MariaDB is the central sync database.
|
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
|
||||||
|
|
||||||
Runtime read permissions:
|
### QuoteForge tables (qt_*)
|
||||||
- `lot`
|
|
||||||
- `qt_lot_metadata`
|
|
||||||
- `qt_categories`
|
|
||||||
- `qt_pricelists`
|
|
||||||
- `qt_pricelist_items`
|
|
||||||
- `stock_log`
|
|
||||||
- `qt_partnumber_books`
|
|
||||||
- `qt_partnumber_book_items`
|
|
||||||
|
|
||||||
Runtime read/write permissions:
|
Runtime read:
|
||||||
- `qt_projects`
|
- `qt_categories` — pricelist categories
|
||||||
- `qt_configurations`
|
- `qt_lot_metadata` — component metadata, price settings
|
||||||
- `qt_client_schema_state`
|
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
||||||
- `qt_pricelist_sync_status`
|
- `qt_pricelist_items` — pricelist rows
|
||||||
|
- `qt_partnumber_books` — partnumber book headers
|
||||||
|
- `qt_partnumber_book_items` — PN→LOT catalog payload
|
||||||
|
|
||||||
|
Runtime read/write:
|
||||||
|
- `qt_projects` — projects
|
||||||
|
- `qt_configurations` — configurations
|
||||||
|
- `qt_client_schema_state` — per-client sync status and version tracking
|
||||||
|
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
|
||||||
|
|
||||||
Insert-only tracking:
|
Insert-only tracking:
|
||||||
- `qt_vendor_partnumber_seen`
|
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync
|
||||||
|
|
||||||
|
Server-side only (not queried by client runtime):
|
||||||
|
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
||||||
|
- `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime)
|
||||||
|
- `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`)
|
||||||
|
- `qt_scheduler_runs` — server background job tracking (no Go code references it in this repo)
|
||||||
|
|
||||||
|
### Competitor subsystem (server-side only, not used by QuoteForge Go code)
|
||||||
|
|
||||||
|
- `qt_competitors` — competitor registry
|
||||||
|
- `partnumber_log_competitors` — competitor price log (FK → qt_competitors)
|
||||||
|
|
||||||
|
These tables exist in the schema and are maintained by another tool or workflow.
|
||||||
|
QuoteForge references competitor pricelists only via `qt_pricelists` (source='competitor').
|
||||||
|
|
||||||
|
### Legacy RFQ tables (pre-QuoteForge, no Go code references)
|
||||||
|
|
||||||
|
- `lot` — original component registry (data preserved; superseded by `qt_lot_metadata`)
|
||||||
|
- `lot_log` — original supplier price log
|
||||||
|
- `supplier` — supplier registry (FK target for lot_log and machine_log)
|
||||||
|
- `machine` — device model registry
|
||||||
|
- `machine_log` — device price/quote log
|
||||||
|
- `parts_log` — supplier partnumber log used by server-side import/pricing workflows, not by QuoteForge runtime
|
||||||
|
|
||||||
|
These tables are retained for historical data. QuoteForge does not read or write them at runtime.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- QuoteForge runtime must not depend on any removed legacy BOM tables;
|
- QuoteForge runtime must not depend on any legacy RFQ tables;
|
||||||
- stock enrichment happens during sync and is persisted into SQLite;
|
- QuoteForge sync reads prices and categories from `qt_pricelists` / `qt_pricelist_items` only;
|
||||||
- normal UI requests must not query MariaDB tables directly.
|
- QuoteForge does not enrich local pricelist rows from `parts_log` or any other raw supplier log table;
|
||||||
|
- normal UI requests must not query MariaDB tables directly;
|
||||||
|
- `qt_client_local_migrations` exists in the 2026-04-15 schema dump, but runtime sync does not depend on it.
|
||||||
|
|
||||||
|
## MariaDB Table Structures
|
||||||
|
|
||||||
|
Full column reference as of 2026-03-21 (`RFQ_LOG` final schema).
|
||||||
|
|
||||||
|
### qt_categories
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| code | varchar(20) UNIQUE NOT NULL | |
|
||||||
|
| name | varchar(100) NOT NULL | |
|
||||||
|
| name_ru | varchar(100) | |
|
||||||
|
| display_order | bigint DEFAULT 0 | |
|
||||||
|
| is_required | tinyint(1) DEFAULT 0 | |
|
||||||
|
|
||||||
|
### qt_client_schema_state
|
||||||
|
PK: (username, hostname)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| username | varchar(100) | |
|
||||||
|
| hostname | varchar(255) DEFAULT '' | |
|
||||||
|
| last_applied_migration_id | varchar(128) | |
|
||||||
|
| app_version | varchar(64) | |
|
||||||
|
| last_sync_at | datetime | |
|
||||||
|
| last_sync_status | varchar(32) | |
|
||||||
|
| pending_changes_count | int DEFAULT 0 | |
|
||||||
|
| pending_errors_count | int DEFAULT 0 | |
|
||||||
|
| configurations_count | int DEFAULT 0 | |
|
||||||
|
| projects_count | int DEFAULT 0 | |
|
||||||
|
| estimate_pricelist_version | varchar(128) | |
|
||||||
|
| warehouse_pricelist_version | varchar(128) | |
|
||||||
|
| competitor_pricelist_version | varchar(128) | |
|
||||||
|
| last_sync_error_code | varchar(128) | |
|
||||||
|
| last_sync_error_text | text | |
|
||||||
|
| last_checked_at | datetime NOT NULL | |
|
||||||
|
| updated_at | datetime NOT NULL | |
|
||||||
|
|
||||||
|
### qt_component_usage_stats
|
||||||
|
PK: lot_name
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| lot_name | varchar(255) | |
|
||||||
|
| quotes_total | bigint DEFAULT 0 | |
|
||||||
|
| quotes_last30d | bigint DEFAULT 0 | |
|
||||||
|
| quotes_last7d | bigint DEFAULT 0 | |
|
||||||
|
| total_quantity | bigint DEFAULT 0 | |
|
||||||
|
| total_revenue | decimal(14,2) DEFAULT 0 | |
|
||||||
|
| trend_direction | enum('up','stable','down') DEFAULT 'stable' | |
|
||||||
|
| trend_percent | decimal(5,2) DEFAULT 0 | |
|
||||||
|
| last_used_at | datetime(3) | |
|
||||||
|
|
||||||
|
### qt_competitors
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| name | varchar(255) NOT NULL | |
|
||||||
|
| code | varchar(100) UNIQUE NOT NULL | |
|
||||||
|
| delivery_basis | varchar(50) DEFAULT 'DDP' | |
|
||||||
|
| currency | varchar(10) DEFAULT 'USD' | |
|
||||||
|
| column_mapping | longtext JSON | |
|
||||||
|
| is_active | tinyint(1) DEFAULT 1 | |
|
||||||
|
| created_at | timestamp | |
|
||||||
|
| updated_at | timestamp ON UPDATE | |
|
||||||
|
| price_uplift | decimal(8,4) DEFAULT 1.3 | effective_price = price / price_uplift |
|
||||||
|
|
||||||
|
### qt_configurations
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| uuid | varchar(36) UNIQUE NOT NULL | |
|
||||||
|
| user_id | bigint UNSIGNED | |
|
||||||
|
| owner_username | varchar(100) NOT NULL | |
|
||||||
|
| app_version | varchar(64) | |
|
||||||
|
| project_uuid | char(36) | FK → qt_projects.uuid ON DELETE SET NULL |
|
||||||
|
| name | varchar(200) NOT NULL | |
|
||||||
|
| items | longtext JSON NOT NULL | component list |
|
||||||
|
| total_price | decimal(12,2) | |
|
||||||
|
| notes | text | |
|
||||||
|
| is_template | tinyint(1) DEFAULT 0 | |
|
||||||
|
| created_at | datetime(3) | |
|
||||||
|
| custom_price | decimal(12,2) | |
|
||||||
|
| server_count | bigint DEFAULT 1 | |
|
||||||
|
| server_model | varchar(100) | |
|
||||||
|
| support_code | varchar(20) | |
|
||||||
|
| article | varchar(80) | |
|
||||||
|
| pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
|
||||||
|
| warehouse_pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
|
||||||
|
| competitor_pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
|
||||||
|
| disable_price_refresh | tinyint(1) DEFAULT 0 | |
|
||||||
|
| only_in_stock | tinyint(1) DEFAULT 0 | |
|
||||||
|
| line_no | int | position within project |
|
||||||
|
| price_updated_at | timestamp | |
|
||||||
|
| vendor_spec | longtext JSON | |
|
||||||
|
|
||||||
|
### qt_lot_metadata
|
||||||
|
PK: lot_name
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| lot_name | varchar(255) | |
|
||||||
|
| category_id | bigint UNSIGNED | FK → qt_categories.id |
|
||||||
|
| vendor | varchar(50) | |
|
||||||
|
| model | varchar(100) | |
|
||||||
|
| specs | longtext JSON | |
|
||||||
|
| current_price | decimal(12,2) | cached computed price |
|
||||||
|
| price_method | enum('manual','median','average','weighted_median') DEFAULT 'median' | |
|
||||||
|
| price_period_days | bigint DEFAULT 90 | |
|
||||||
|
| price_updated_at | datetime(3) | |
|
||||||
|
| request_count | bigint DEFAULT 0 | |
|
||||||
|
| last_request_date | date | |
|
||||||
|
| popularity_score | decimal(10,4) DEFAULT 0 | |
|
||||||
|
| price_coefficient | decimal(5,2) DEFAULT 0 | markup % |
|
||||||
|
| manual_price | decimal(12,2) | |
|
||||||
|
| meta_prices | varchar(1000) | raw price samples JSON |
|
||||||
|
| meta_method | varchar(20) | method used for last compute |
|
||||||
|
| meta_period_days | bigint DEFAULT 90 | |
|
||||||
|
| is_hidden | tinyint(1) DEFAULT 0 | |
|
||||||
|
|
||||||
|
### qt_partnumber_books
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| version | varchar(30) UNIQUE NOT NULL | |
|
||||||
|
| created_at | timestamp | |
|
||||||
|
| created_by | varchar(100) | |
|
||||||
|
| is_active | tinyint(1) DEFAULT 0 | only one active at a time |
|
||||||
|
| partnumbers_json | longtext DEFAULT '[]' | flat list of partnumbers |
|
||||||
|
|
||||||
|
### qt_partnumber_book_items
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| partnumber | varchar(255) UNIQUE NOT NULL | |
|
||||||
|
| lots_json | longtext NOT NULL | JSON array of lot_names |
|
||||||
|
| description | varchar(10000) | |
|
||||||
|
|
||||||
|
### qt_pricelists
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| source | varchar(20) DEFAULT 'estimate' | 'estimate' / 'warehouse' / 'competitor' |
|
||||||
|
| version | varchar(20) NOT NULL | UNIQUE with source |
|
||||||
|
| created_at | datetime(3) | |
|
||||||
|
| created_by | varchar(100) | |
|
||||||
|
| is_active | tinyint(1) DEFAULT 1 | |
|
||||||
|
| usage_count | bigint DEFAULT 0 | |
|
||||||
|
| expires_at | datetime(3) | |
|
||||||
|
| notification | varchar(500) | shown to clients on sync |
|
||||||
|
|
||||||
|
### qt_pricelist_items
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| pricelist_id | bigint UNSIGNED NOT NULL | FK → qt_pricelists.id |
|
||||||
|
| lot_name | varchar(255) NOT NULL | INDEX with pricelist_id |
|
||||||
|
| lot_category | varchar(50) | |
|
||||||
|
| price | decimal(12,2) NOT NULL | |
|
||||||
|
| price_method | varchar(20) | |
|
||||||
|
| price_period_days | bigint DEFAULT 90 | |
|
||||||
|
| price_coefficient | decimal(5,2) DEFAULT 0 | |
|
||||||
|
| manual_price | decimal(12,2) | |
|
||||||
|
| meta_prices | varchar(1000) | |
|
||||||
|
|
||||||
|
### qt_pricelist_sync_status
|
||||||
|
PK: username
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| username | varchar(100) | |
|
||||||
|
| last_sync_at | datetime NOT NULL | |
|
||||||
|
| updated_at | datetime NOT NULL | |
|
||||||
|
| app_version | varchar(64) | |
|
||||||
|
|
||||||
|
### qt_pricing_alerts
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| lot_name | varchar(255) NOT NULL | |
|
||||||
|
| alert_type | enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price') | |
|
||||||
|
| severity | enum('low','medium','high','critical') DEFAULT 'medium' | |
|
||||||
|
| message | text NOT NULL | |
|
||||||
|
| details | longtext JSON | |
|
||||||
|
| status | enum('new','acknowledged','resolved','ignored') DEFAULT 'new' | |
|
||||||
|
| created_at | datetime(3) | |
|
||||||
|
|
||||||
|
### qt_projects
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| uuid | char(36) UNIQUE NOT NULL | |
|
||||||
|
| owner_username | varchar(100) NOT NULL | |
|
||||||
|
| code | varchar(100) NOT NULL | UNIQUE with variant |
|
||||||
|
| variant | varchar(100) DEFAULT '' | UNIQUE with code |
|
||||||
|
| name | varchar(200) | |
|
||||||
|
| tracker_url | varchar(500) | |
|
||||||
|
| is_active | tinyint(1) DEFAULT 1 | |
|
||||||
|
| is_system | tinyint(1) DEFAULT 0 | |
|
||||||
|
| created_at | timestamp | |
|
||||||
|
| updated_at | timestamp ON UPDATE | |
|
||||||
|
|
||||||
|
### qt_schema_migrations
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| filename | varchar(255) UNIQUE NOT NULL | |
|
||||||
|
| applied_at | datetime(3) | |
|
||||||
|
|
||||||
|
### qt_scheduler_runs
|
||||||
|
PK: job_name
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| job_name | varchar(100) | |
|
||||||
|
| last_started_at | datetime | |
|
||||||
|
| last_finished_at | datetime | |
|
||||||
|
| last_status | varchar(20) DEFAULT 'idle' | |
|
||||||
|
| last_error | text | |
|
||||||
|
| updated_at | timestamp ON UPDATE | |
|
||||||
|
|
||||||
|
### qt_vendor_partnumber_seen
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| source_type | varchar(32) NOT NULL | |
|
||||||
|
| vendor | varchar(255) DEFAULT '' | |
|
||||||
|
| partnumber | varchar(255) UNIQUE NOT NULL | |
|
||||||
|
| description | varchar(10000) | |
|
||||||
|
| last_seen_at | datetime(3) NOT NULL | |
|
||||||
|
| is_ignored | tinyint(1) DEFAULT 0 | |
|
||||||
|
| is_pattern | tinyint(1) DEFAULT 0 | |
|
||||||
|
| ignored_at | datetime(3) | |
|
||||||
|
| ignored_by | varchar(100) | |
|
||||||
|
| created_at | datetime(3) | |
|
||||||
|
| updated_at | datetime(3) | |
|
||||||
|
|
||||||
|
### stock_ignore_rules
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| target | varchar(20) NOT NULL | UNIQUE with match_type+pattern |
|
||||||
|
| match_type | varchar(20) NOT NULL | |
|
||||||
|
| pattern | varchar(500) NOT NULL | |
|
||||||
|
| created_at | timestamp | |
|
||||||
|
|
||||||
|
### stock_log
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| stock_log_id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| partnumber | varchar(255) NOT NULL | INDEX with date |
|
||||||
|
| supplier | varchar(255) | |
|
||||||
|
| date | date NOT NULL | |
|
||||||
|
| price | decimal(12,2) NOT NULL | |
|
||||||
|
| quality | varchar(255) | |
|
||||||
|
| comments | text | |
|
||||||
|
| vendor | varchar(255) | INDEX |
|
||||||
|
| qty | decimal(14,3) | |
|
||||||
|
|
||||||
|
### partnumber_log_competitors
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||||
|
| competitor_id | bigint UNSIGNED NOT NULL | FK → qt_competitors.id |
|
||||||
|
| partnumber | varchar(255) NOT NULL | |
|
||||||
|
| description | varchar(500) | |
|
||||||
|
| vendor | varchar(255) | |
|
||||||
|
| price | decimal(12,2) NOT NULL | |
|
||||||
|
| price_loccur | decimal(12,2) | local currency price |
|
||||||
|
| currency | varchar(10) | |
|
||||||
|
| qty | decimal(12,4) DEFAULT 1 | |
|
||||||
|
| date | date NOT NULL | |
|
||||||
|
| created_at | timestamp | |
|
||||||
|
|
||||||
|
### Legacy tables (lot / lot_log / machine / machine_log / supplier)
|
||||||
|
|
||||||
|
Retained for historical data only. Not queried by QuoteForge.
|
||||||
|
|
||||||
|
**lot**: lot_name (PK, char 255), lot_category, lot_description
|
||||||
|
**lot_log**: lot_log_id AUTO_INCREMENT, lot (FK→lot), supplier (FK→supplier), date, price double, quality, comments
|
||||||
|
**supplier**: supplier_name (PK, char 255), supplier_comment
|
||||||
|
**machine**: machine_name (PK, char 255), machine_description
|
||||||
|
**machine_log**: machine_log_id AUTO_INCREMENT, date, supplier (FK→supplier), country, opty, type, machine (FK→machine), customer_requirement, variant, price_gpl, price_estimate, qty, quality, carepack, lead_time_weeks, prepayment_percent, price_got, Comment
|
||||||
|
|
||||||
|
## MariaDB User Permissions
|
||||||
|
|
||||||
|
The application user needs read-only access to reference tables and read/write access to runtime tables.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Read-only: reference and pricing data
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_categories TO 'qfs_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'qfs_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'qfs_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'qfs_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.stock_log TO 'qfs_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.stock_ignore_rules TO 'qfs_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'qfs_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'qfs_user'@'%';
|
||||||
|
GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
|
||||||
|
|
||||||
|
-- Read/write: runtime sync and user data
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_projects TO 'qfs_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'qfs_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'qfs_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'qfs_user'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
|
||||||
|
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- `qt_client_schema_state` requires INSERT + UPDATE for sync status tracking (uses `ON DUPLICATE KEY UPDATE`);
|
||||||
|
- `qt_vendor_partnumber_seen` requires INSERT + UPDATE (vendor PN discovery during sync);
|
||||||
|
- no DELETE is needed on sync/tracking tables — rows are never removed by the client;
|
||||||
|
- `lot` SELECT is required for the connection validation probe in `/setup`;
|
||||||
|
- the setup page shows `can_write: true` only when `qt_client_schema_state` INSERT succeeds.
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
|
|||||||
@@ -1126,7 +1126,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
|
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
config, err := configService.RefreshPricesNoAuth(uuid)
|
var req struct {
|
||||||
|
PricelistID *uint `json:"pricelist_id"`
|
||||||
|
}
|
||||||
|
// Ignore bind error — pricelist_id is optional
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
config, err := configService.RefreshPricesNoAuth(uuid, req.PricelistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
@@ -1539,7 +1544,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrReservedMainVariant):
|
case errors.Is(err, services.ErrReservedMainVariant),
|
||||||
|
errors.Is(err, services.ErrCannotRenameMainVariant):
|
||||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
case errors.Is(err, services.ErrProjectCodeExists):
|
case errors.Is(err, services.ErrProjectCodeExists):
|
||||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||||
|
|||||||
213
docs/storage-components-guide.md
Normal file
213
docs/storage-components-guide.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# Руководство по составлению каталога лотов СХД
|
||||||
|
|
||||||
|
## Что такое LOT и зачем он нужен
|
||||||
|
|
||||||
|
LOT — это внутренний идентификатор типа компонента в системе QuoteForge.
|
||||||
|
|
||||||
|
Каждый LOT представляет одну рыночную позицию и хранит **средневзвешенную рыночную цену**, рассчитанную по историческим данным от поставщиков. Это позволяет получать актуальную оценку стоимости независимо от конкретного поставщика или прайс-листа.
|
||||||
|
|
||||||
|
Партномера вендора (Part Number, Feature Code) сами по себе не имеют цены в системе — они **переводятся в LOT** через книгу партномеров. Именно через LOT происходит расценка конфигурации.
|
||||||
|
|
||||||
|
**Пример:** Feature Code `B4B9` и Part Number `4C57A14368` — это два разных обозначения одной и той же HIC-карты от Lenovo. Оба маппируются на один LOT `HIC_4pFC32`, у которого есть рыночная цена.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Категории и вкладки конфигуратора
|
||||||
|
|
||||||
|
Категория LOT определяет, в какой вкладке конфигуратора он появится.
|
||||||
|
|
||||||
|
| Код категории | Название | Вкладка | Что сюда относится |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ENC` | Storage Enclosure | **Base** | Дисковая полка без контроллера |
|
||||||
|
| `DKC` | Disk/Controller Enclosure | **Base** | Контроллерная полка: модель СХД + тип дисков + кол-во слотов + кол-во контроллеров |
|
||||||
|
| `CTL` | Storage Controller | **Base** | Контроллер СХД: объём кэша + встроенные хост-порты |
|
||||||
|
| `HIC` | Host Interface Card | **PCI** | HIC-карты СХД: интерфейсы подключения (FC, iSCSI, SAS) |
|
||||||
|
| `HDD` | HDD | **Storage** | Жёсткие диски (HDD) |
|
||||||
|
| `SSD` | SSD | **Storage** | Твердотельные диски (SSD, NVMe) |
|
||||||
|
| `ACC` | Accessories | **Accessories** | Кабели подключения, кабели питания |
|
||||||
|
| `SW` | Software | **SW** | Программные лицензии |
|
||||||
|
| *(прочее)* | — | **Other** | Гарантийные опции, инсталляция |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правила именования LOT
|
||||||
|
|
||||||
|
Формат: `КАТЕГОРИЯ_МОДЕЛЬСХД_СПЕЦИФИКА`
|
||||||
|
|
||||||
|
- только латиница, цифры и знак `_`
|
||||||
|
- регистр — ВЕРХНИЙ
|
||||||
|
- без пробелов, дефисов, точек
|
||||||
|
- каждый LOT уникален — два разных компонента не могут иметь одинаковое имя
|
||||||
|
|
||||||
|
### DKC — контроллерная полка
|
||||||
|
|
||||||
|
Специфика: `ТИПДИСКА_СЛОТЫ_NCTRL`
|
||||||
|
|
||||||
|
| Пример | Расшифровка |
|
||||||
|
|---|---|
|
||||||
|
| `DKC_DE4000H_SFF_24_2CTRL` | DE4000H, 24 слота SFF (2.5"), 2 контроллера |
|
||||||
|
| `DKC_DE4000H_LFF_12_2CTRL` | DE4000H, 12 слотов LFF (3.5"), 2 контроллера |
|
||||||
|
| `DKC_DE4000H_SFF_24_1CTRL` | DE4000H, 24 слота SFF, 1 контроллер (симплекс) |
|
||||||
|
|
||||||
|
Обозначения типа диска: `SFF` — 2.5", `LFF` — 3.5", `NVMe` — U.2/U.3.
|
||||||
|
|
||||||
|
### CTL — контроллер
|
||||||
|
|
||||||
|
Специфика: `КЭШГБ_ПОРТЫТИП` (если встроенные порты есть) или `КЭШГБ_BASE` (если без портов, добавляются через HIC)
|
||||||
|
|
||||||
|
| Пример | Расшифровка |
|
||||||
|
|---|---|
|
||||||
|
| `CTL_DE4000H_32GB_BASE` | 32GB кэш, без встроенных хост-портов |
|
||||||
|
| `CTL_DE4000H_8GB_BASE` | 8GB кэш, без встроенных хост-портов |
|
||||||
|
| `CTL_MSA2060_8GB_ISCSI10G_4P` | 8GB кэш, встроенные 4× iSCSI 10GbE |
|
||||||
|
|
||||||
|
### HIC — HIC-карты (интерфейс подключения)
|
||||||
|
|
||||||
|
Специфика: `NpПРОТОКОЛ` — без привязки к модели СХД, по аналогии с серверными `HBA_2pFC16`, `HBA_4pFC32_Gen6`.
|
||||||
|
|
||||||
|
| Пример | Расшифровка |
|
||||||
|
|---|---|
|
||||||
|
| `HIC_4pFC32` | 4 порта FC 32Gb |
|
||||||
|
| `HIC_4pFC16` | 4 порта FC 16G/10GbE |
|
||||||
|
| `HIC_4p25G_iSCSI` | 4 порта 25G iSCSI |
|
||||||
|
| `HIC_4p12G_SAS` | 4 порта SAS 12Gb |
|
||||||
|
| `HIC_2p10G_BaseT` | 2 порта 10G Base-T |
|
||||||
|
|
||||||
|
### HDD / SSD / NVMe — диски
|
||||||
|
|
||||||
|
Диски **не привязываются к модели СХД** — используются существующие LOT из серверного каталога (`HDD_...`, `SSD_...`, `NVME_...`). Новые LOT для дисков СХД не создаются; партномера дисков маппируются на уже существующие серверные LOT.
|
||||||
|
|
||||||
|
### ACC — кабели
|
||||||
|
|
||||||
|
Кабели **не привязываются к модели СХД**. Формат: `ACC_CABLE_{ТИП}_{ДЛИНА}` — универсальные LOT, одинаковые для серверов и СХД.
|
||||||
|
|
||||||
|
| Пример | Расшифровка |
|
||||||
|
|---|---|
|
||||||
|
| `ACC_CABLE_CAT6_10M` | Кабель CAT6 10м |
|
||||||
|
| `ACC_CABLE_FC_OM4_3M` | Кабель FC LC-LC OM4 до 3м |
|
||||||
|
| `ACC_CABLE_PWR_C13C14_15M` | Кабель питания C13–C14 1.5м |
|
||||||
|
|
||||||
|
### SW — программные лицензии
|
||||||
|
|
||||||
|
Специфика: краткое название функции.
|
||||||
|
|
||||||
|
| Пример | Расшифровка |
|
||||||
|
|---|---|
|
||||||
|
| `SW_DE4000H_ASYNC_MIRROR` | Async Mirroring |
|
||||||
|
| `SW_DE4000H_SNAPSHOT_512` | Snapshot 512 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Таблица лотов: DE4000H (пример заполнения)
|
||||||
|
|
||||||
|
### DKC — контроллерная полка
|
||||||
|
|
||||||
|
| lot_name | vendor | model | description | disk_slots | disk_type | controllers |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| `DKC_DE4000H_SFF_24_2CTRL` | Lenovo | DE4000H 2U24 | DE4000H, 24× SFF, 2 контроллера | 24 | SFF | 2 |
|
||||||
|
| `DKC_DE4000H_LFF_12_2CTRL` | Lenovo | DE4000H 2U12 | DE4000H, 12× LFF, 2 контроллера | 12 | LFF | 2 |
|
||||||
|
|
||||||
|
### CTL — контроллер
|
||||||
|
|
||||||
|
| lot_name | vendor | model | description | cache_gb | host_ports |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `CTL_DE4000H_32GB_BASE` | Lenovo | DE4000 Controller 32GB Gen2 | Контроллер DE4000, 32GB кэш, без встроенных портов | 32 | — |
|
||||||
|
| `CTL_DE4000H_8GB_BASE` | Lenovo | DE4000 Controller 8GB Gen2 | Контроллер DE4000, 8GB кэш, без встроенных портов | 8 | — |
|
||||||
|
|
||||||
|
### HIC — HIC-карты
|
||||||
|
|
||||||
|
| lot_name | vendor | model | description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `HIC_2p10G_BaseT` | Lenovo | HIC 10GBASE-T 2-Ports | HIC 10GBASE-T, 2 порта |
|
||||||
|
| `HIC_4p25G_iSCSI` | Lenovo | HIC 10/25GbE iSCSI 4-ports | HIC iSCSI 10/25GbE, 4 порта |
|
||||||
|
| `HIC_4p12G_SAS` | Lenovo | HIC 12Gb SAS 4-ports | HIC SAS 12Gb, 4 порта |
|
||||||
|
| `HIC_4pFC32` | Lenovo | HIC 32Gb FC 4-ports | HIC FC 32Gb, 4 порта |
|
||||||
|
| `HIC_4pFC16` | Lenovo | HIC 16G FC/10GbE 4-ports | HIC FC 16G/10GbE, 4 порта |
|
||||||
|
|
||||||
|
### HDD / SSD / NVMe / ACC — диски и кабели
|
||||||
|
|
||||||
|
Для дисков и кабелей новые LOT не создаются. Партномера маппируются на существующие серверные LOT из каталога.
|
||||||
|
|
||||||
|
### SW — программные лицензии
|
||||||
|
|
||||||
|
| lot_name | vendor | model | description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SW_DE4000H_ASYNC_MIRROR` | Lenovo | DE4000H Asynchronous Mirroring | Лицензия Async Mirroring |
|
||||||
|
| `SW_DE4000H_SNAPSHOT_512` | Lenovo | DE4000H Snapshot Upgrade 512 | Лицензия Snapshot 512 |
|
||||||
|
| `SW_DE4000H_SYNC_MIRROR` | Lenovo | DE4000 Synchronous Mirroring | Лицензия Sync Mirroring |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Таблица партномеров: DE4000H (пример заполнения)
|
||||||
|
|
||||||
|
Каждый Feature Code и Part Number должен быть привязан к своему LOT.
|
||||||
|
Если у компонента есть оба — добавить две строки.
|
||||||
|
|
||||||
|
| partnumber | lot_name | описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `BEY7` | `ENC_2U24_CHASSIS` | Lenovo ThinkSystem Storage 2U24 Chassis |
|
||||||
|
| `BQA0` | `CTL_DE4000H_32GB_BASE` | DE4000 Controller 32GB Gen2 |
|
||||||
|
| `BQ9Z` | `CTL_DE4000H_8GB_BASE` | DE4000 Controller 8GB Gen2 |
|
||||||
|
| `B4B1` | `HIC_2p10G_BaseT` | HIC 10GBASE-T 2-Ports |
|
||||||
|
| `4C57A14376` | `HIC_2p10G_BaseT` | HIC 10GBASE-T 2-Ports |
|
||||||
|
| `B4BA` | `HIC_4p25G_iSCSI` | HIC 10/25GbE iSCSI 4-ports |
|
||||||
|
| `4C57A14369` | `HIC_4p25G_iSCSI` | HIC 10/25GbE iSCSI 4-ports |
|
||||||
|
| `B4B8` | `HIC_4p12G_SAS` | HIC 12Gb SAS 4-ports |
|
||||||
|
| `4C57A14367` | `HIC_4p12G_SAS` | HIC 12Gb SAS 4-ports |
|
||||||
|
| `B4B9` | `HIC_4pFC32` | HIC 32Gb FC 4-ports |
|
||||||
|
| `4C57A14368` | `HIC_4pFC32` | HIC 32Gb FC 4-ports |
|
||||||
|
| `B4B7` | `HIC_4pFC16` | HIC 16G FC/10GbE 4-ports |
|
||||||
|
| `4C57A14366` | `HIC_4pFC16` | HIC 16G FC/10GbE 4-ports |
|
||||||
|
| `BW12` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD 2U24 |
|
||||||
|
| `4XB7A88046` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD 2U24 |
|
||||||
|
| `B4C0` | `HDD_SAS_01.8TB` | 1.8TB 10K 2.5" HDD SED FIPS |
|
||||||
|
| `4XB7A14114` | `HDD_SAS_01.8TB` | 1.8TB 10K 2.5" HDD SED FIPS |
|
||||||
|
| `BW13` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD FIPS |
|
||||||
|
| `4XB7A88048` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD FIPS |
|
||||||
|
| `BKUQ` | `SSD_SAS_0.960T` | 960GB 1DWD 2.5" SSD |
|
||||||
|
| `4XB7A74948` | `SSD_SAS_0.960T` | 960GB 1DWD 2.5" SSD |
|
||||||
|
| `BKUT` | `SSD_SAS_01.92T` | 1.92TB 1DWD 2.5" SSD |
|
||||||
|
| `4XB7A74951` | `SSD_SAS_01.92T` | 1.92TB 1DWD 2.5" SSD |
|
||||||
|
| `BKUK` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD |
|
||||||
|
| `4XB7A74955` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD |
|
||||||
|
| `B4RY` | `SSD_SAS_07.68T` | 7.68TB 1DWD 2.5" SSD |
|
||||||
|
| `4XB7A14176` | `SSD_SAS_07.68T` | 7.68TB 1DWD 2.5" SSD |
|
||||||
|
| `B4CD` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD |
|
||||||
|
| `4XB7A14110` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD |
|
||||||
|
| `BWCJ` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD FIPS |
|
||||||
|
| `4XB7A88469` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD FIPS |
|
||||||
|
| `BW2B` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD SED |
|
||||||
|
| `4XB7A88466` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD SED |
|
||||||
|
| `AVFW` | `ACC_CABLE_CAT6_1M` | CAT6 0.75-1.5m |
|
||||||
|
| `A1MT` | `ACC_CABLE_CAT6_10M` | CAT6 10m |
|
||||||
|
| `90Y3718` | `ACC_CABLE_CAT6_10M` | CAT6 10m |
|
||||||
|
| `A1MW` | `ACC_CABLE_CAT6_25M` | CAT6 25m |
|
||||||
|
| `90Y3727` | `ACC_CABLE_CAT6_25M` | CAT6 25m |
|
||||||
|
| `39Y7937` | `ACC_CABLE_PWR_C13C14_15M` | C13–C14 1.5m |
|
||||||
|
| `39Y7938` | `ACC_CABLE_PWR_C13C20_28M` | C13–C20 2.8m |
|
||||||
|
| `4L67A08371` | `ACC_CABLE_PWR_C13C14_43M` | C13–C14 4.3m |
|
||||||
|
| `C932` | `SW_DE4000H_ASYNC_MIRROR` | DE4000H Asynchronous Mirroring |
|
||||||
|
| `00WE123` | `SW_DE4000H_ASYNC_MIRROR` | DE4000H Asynchronous Mirroring |
|
||||||
|
| `C930` | `SW_DE4000H_SNAPSHOT_512` | DE4000H Snapshot Upgrade 512 |
|
||||||
|
| `C931` | `SW_DE4000H_SYNC_MIRROR` | DE4000 Synchronous Mirroring |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаблон для новых моделей СХД
|
||||||
|
|
||||||
|
```
|
||||||
|
DKC_МОДЕЛЬ_ТИПДИСКА_СЛОТЫ_NCTRL — контроллерная полка
|
||||||
|
CTL_МОДЕЛЬ_КЭШГБ_ПОРТЫ — контроллер
|
||||||
|
HIC_МОДЕЛЬ_ПРОТОКОЛ_СКОРОСТЬ_ПОРТЫ — HIC-карта (интерфейс подключения)
|
||||||
|
SW_МОДЕЛЬ_ФУНКЦИЯ — лицензия
|
||||||
|
```
|
||||||
|
|
||||||
|
Диски (HDD/SSD/NVMe) и кабели (ACC) — маппируются на существующие серверные LOT, новые не создаются.
|
||||||
|
|
||||||
|
Пример для HPE MSA 2060:
|
||||||
|
```
|
||||||
|
DKC_MSA2060_SFF_24_2CTRL
|
||||||
|
CTL_MSA2060_8GB_ISCSI10G_4P
|
||||||
|
HIC_MSA2060_FC32G_2P
|
||||||
|
SW_MSA2060_REMOTE_SNAP
|
||||||
|
```
|
||||||
@@ -329,33 +329,60 @@ func parseGPUModel(lotName string) string {
|
|||||||
}
|
}
|
||||||
parts := strings.Split(upper, "_")
|
parts := strings.Split(upper, "_")
|
||||||
model := ""
|
model := ""
|
||||||
|
numSuffix := ""
|
||||||
mem := ""
|
mem := ""
|
||||||
for i, p := range parts {
|
for i, p := range parts {
|
||||||
if p == "" {
|
if p == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch p {
|
switch p {
|
||||||
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX", "SFF", "LOVELACE":
|
||||||
|
continue
|
||||||
|
case "ADA", "AMPERE", "HOPPER", "BLACKWELL":
|
||||||
|
if model != "" {
|
||||||
|
archAbbr := map[string]string{
|
||||||
|
"ADA": "ADA", "AMPERE": "AMP", "HOPPER": "HOP", "BLACKWELL": "BWL",
|
||||||
|
}
|
||||||
|
numSuffix += archAbbr[p]
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
default:
|
default:
|
||||||
if strings.Contains(p, "GB") {
|
if strings.Contains(p, "GB") {
|
||||||
mem = p
|
mem = p
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if model == "" && (i > 0) {
|
if model == "" && i > 0 {
|
||||||
model = p
|
model = p
|
||||||
|
} else if model != "" && numSuffix == "" && isNumeric(p) {
|
||||||
|
numSuffix = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if model != "" && mem != "" {
|
full := model
|
||||||
return model + "_" + mem
|
if numSuffix != "" {
|
||||||
|
full = model + numSuffix
|
||||||
}
|
}
|
||||||
if model != "" {
|
if full != "" && mem != "" {
|
||||||
return model
|
return full + "_" + mem
|
||||||
|
}
|
||||||
|
if full != "" {
|
||||||
|
return full
|
||||||
}
|
}
|
||||||
return normalizeModelToken(lotName)
|
return normalizeModelToken(lotName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isNumeric(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func parseMemGiB(lotName string) int {
|
func parseMemGiB(lotName string) int {
|
||||||
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
|
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
|
||||||
return atoi(m[1]) * 1024
|
return atoi(m[1]) * 1024
|
||||||
|
|||||||
@@ -238,6 +238,22 @@ func (cm *ConnectionManager) Disconnect() {
|
|||||||
cm.lastError = nil
|
cm.lastError = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkOffline closes the current connection and preserves the last observed error.
|
||||||
|
func (cm *ConnectionManager) MarkOffline(err error) {
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
if cm.db != nil {
|
||||||
|
sqlDB, dbErr := cm.db.DB()
|
||||||
|
if dbErr == nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cm.db = nil
|
||||||
|
cm.lastError = err
|
||||||
|
cm.lastCheck = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
// GetLastError returns the last connection error (thread-safe)
|
// GetLastError returns the last connection error (thread-safe)
|
||||||
func (cm *ConnectionManager) GetLastError() error {
|
func (cm *ConnectionManager) GetLastError() error {
|
||||||
cm.mu.RLock()
|
cm.mu.RLock()
|
||||||
|
|||||||
@@ -48,11 +48,13 @@ type ExportRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectExportOptionsRequest struct {
|
type ProjectExportOptionsRequest struct {
|
||||||
IncludeLOT bool `json:"include_lot"`
|
IncludeLOT bool `json:"include_lot"`
|
||||||
IncludeBOM bool `json:"include_bom"`
|
IncludeBOM bool `json:"include_bom"`
|
||||||
IncludeEstimate bool `json:"include_estimate"`
|
IncludeEstimate bool `json:"include_estimate"`
|
||||||
IncludeStock bool `json:"include_stock"`
|
IncludeStock bool `json:"include_stock"`
|
||||||
IncludeCompetitor bool `json:"include_competitor"`
|
IncludeCompetitor bool `json:"include_competitor"`
|
||||||
|
Basis string `json:"basis"` // "fob" or "ddp"
|
||||||
|
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||||
@@ -252,6 +254,8 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
|||||||
IncludeEstimate: req.IncludeEstimate,
|
IncludeEstimate: req.IncludeEstimate,
|
||||||
IncludeStock: req.IncludeStock,
|
IncludeStock: req.IncludeStock,
|
||||||
IncludeCompetitor: req.IncludeCompetitor,
|
IncludeCompetitor: req.IncludeCompetitor,
|
||||||
|
Basis: req.Basis,
|
||||||
|
SaleMarkup: req.SaleMarkup,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
|
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
|
||||||
@@ -260,7 +264,15 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%s (%s) pricing.csv", time.Now().Format("2006-01-02"), project.Code)
|
basisLabel := "FOB"
|
||||||
|
if strings.EqualFold(strings.TrimSpace(req.Basis), "ddp") {
|
||||||
|
basisLabel = "DDP"
|
||||||
|
}
|
||||||
|
variantLabel := strings.TrimSpace(project.Variant)
|
||||||
|
if variantLabel == "" {
|
||||||
|
variantLabel = "main"
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("%s (%s) %s %s.csv", time.Now().Format("2006-01-02"), project.Code, basisLabel, variantLabel)
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
stdsync "sync"
|
stdsync "sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -49,15 +50,20 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
|||||||
|
|
||||||
// SyncStatusResponse represents the sync status
|
// SyncStatusResponse represents the sync status
|
||||||
type SyncStatusResponse struct {
|
type SyncStatusResponse struct {
|
||||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||||
IsOnline bool `json:"is_online"`
|
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||||||
ComponentsCount int64 `json:"components_count"`
|
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||||||
PricelistsCount int64 `json:"pricelists_count"`
|
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
|
||||||
ServerPricelists int `json:"server_pricelists"`
|
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
|
||||||
NeedComponentSync bool `json:"need_component_sync"`
|
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
|
||||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
IsOnline bool `json:"is_online"`
|
||||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
ComponentsCount int64 `json:"components_count"`
|
||||||
|
PricelistsCount int64 `json:"pricelists_count"`
|
||||||
|
ServerPricelists int `json:"server_pricelists"`
|
||||||
|
NeedComponentSync bool `json:"need_component_sync"`
|
||||||
|
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||||
|
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncReadinessResponse struct {
|
type SyncReadinessResponse struct {
|
||||||
@@ -72,42 +78,34 @@ type SyncReadinessResponse struct {
|
|||||||
// GetStatus returns current sync status
|
// GetStatus returns current sync status
|
||||||
// GET /api/sync/status
|
// GET /api/sync/status
|
||||||
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||||
// Check online status by pinging MariaDB
|
connStatus := h.connMgr.GetStatus()
|
||||||
isOnline := h.checkOnline()
|
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||||||
|
|
||||||
// Get sync times
|
|
||||||
lastComponentSync := h.localDB.GetComponentSyncTime()
|
lastComponentSync := h.localDB.GetComponentSyncTime()
|
||||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||||
|
|
||||||
// Get counts
|
|
||||||
componentsCount := h.localDB.CountLocalComponents()
|
componentsCount := h.localDB.CountLocalComponents()
|
||||||
pricelistsCount := h.localDB.CountLocalPricelists()
|
pricelistsCount := h.localDB.CountLocalPricelists()
|
||||||
|
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||||
// Get server pricelist count if online
|
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||||
serverPricelists := 0
|
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||||
needPricelistSync := false
|
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||||
if isOnline {
|
|
||||||
status, err := h.syncService.GetStatus()
|
|
||||||
if err == nil {
|
|
||||||
serverPricelists = status.ServerPricelists
|
|
||||||
needPricelistSync = status.NeedsSync
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if component sync is needed (older than 24 hours)
|
|
||||||
needComponentSync := h.localDB.NeedComponentSync(24)
|
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||||
readiness := h.getReadinessCached(10 * time.Second)
|
readiness := h.getReadinessLocal()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||||
LastComponentSync: lastComponentSync,
|
LastComponentSync: lastComponentSync,
|
||||||
LastPricelistSync: lastPricelistSync,
|
LastPricelistSync: lastPricelistSync,
|
||||||
IsOnline: isOnline,
|
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||||
ComponentsCount: componentsCount,
|
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||||
PricelistsCount: pricelistsCount,
|
LastPricelistSyncError: lastPricelistSyncError,
|
||||||
ServerPricelists: serverPricelists,
|
HasIncompleteServerSync: hasFailedSync,
|
||||||
NeedComponentSync: needComponentSync,
|
KnownServerChangesMiss: hasFailedSync,
|
||||||
NeedPricelistSync: needPricelistSync,
|
IsOnline: isOnline,
|
||||||
Readiness: readiness,
|
ComponentsCount: componentsCount,
|
||||||
|
PricelistsCount: pricelistsCount,
|
||||||
|
ServerPricelists: 0,
|
||||||
|
NeedComponentSync: needComponentSync,
|
||||||
|
NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
|
||||||
|
Readiness: readiness,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,8 +472,13 @@ type SyncInfoResponse struct {
|
|||||||
DBName string `json:"db_name"`
|
DBName string `json:"db_name"`
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
IsOnline bool `json:"is_online"`
|
IsOnline bool `json:"is_online"`
|
||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
|
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||||||
|
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||||||
|
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
|
||||||
|
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||||
|
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
LotCount int64 `json:"lot_count"`
|
LotCount int64 `json:"lot_count"`
|
||||||
@@ -511,8 +514,8 @@ type SyncError struct {
|
|||||||
// GetInfo returns sync information for modal
|
// GetInfo returns sync information for modal
|
||||||
// GET /api/sync/info
|
// GET /api/sync/info
|
||||||
func (h *SyncHandler) GetInfo(c *gin.Context) {
|
func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||||
// Check online status by pinging MariaDB
|
connStatus := h.connMgr.GetStatus()
|
||||||
isOnline := h.checkOnline()
|
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||||||
|
|
||||||
// Get DB connection info
|
// Get DB connection info
|
||||||
var dbHost, dbUser, dbName string
|
var dbHost, dbUser, dbName string
|
||||||
@@ -524,6 +527,12 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
|||||||
|
|
||||||
// Get sync times
|
// Get sync times
|
||||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||||
|
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||||
|
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||||
|
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||||
|
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||||
|
needPricelistSync := lastPricelistSync == nil || hasFailedSync
|
||||||
|
hasIncompleteServerSync := hasFailedSync
|
||||||
|
|
||||||
// Get local counts
|
// Get local counts
|
||||||
configCount := h.localDB.CountConfigurations()
|
configCount := h.localDB.CountConfigurations()
|
||||||
@@ -556,22 +565,27 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
|||||||
syncErrors = syncErrors[:10]
|
syncErrors = syncErrors[:10]
|
||||||
}
|
}
|
||||||
|
|
||||||
readiness := h.getReadinessCached(10 * time.Second)
|
readiness := h.getReadinessLocal()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||||
DBHost: dbHost,
|
DBHost: dbHost,
|
||||||
DBUser: dbUser,
|
DBUser: dbUser,
|
||||||
DBName: dbName,
|
DBName: dbName,
|
||||||
IsOnline: isOnline,
|
IsOnline: isOnline,
|
||||||
LastSyncAt: lastPricelistSync,
|
LastSyncAt: lastPricelistSync,
|
||||||
LotCount: componentCount,
|
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||||
LotLogCount: pricelistCount,
|
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||||
ConfigCount: configCount,
|
LastPricelistSyncError: lastPricelistSyncError,
|
||||||
ProjectCount: projectCount,
|
NeedPricelistSync: needPricelistSync,
|
||||||
PendingChanges: changes,
|
HasIncompleteServerSync: hasIncompleteServerSync,
|
||||||
ErrorCount: errorCount,
|
LotCount: componentCount,
|
||||||
Errors: syncErrors,
|
LotLogCount: pricelistCount,
|
||||||
Readiness: readiness,
|
ConfigCount: configCount,
|
||||||
|
ProjectCount: projectCount,
|
||||||
|
PendingChanges: changes,
|
||||||
|
ErrorCount: errorCount,
|
||||||
|
Errors: syncErrors,
|
||||||
|
Readiness: readiness,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,15 +640,33 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
|||||||
|
|
||||||
// Get pending count
|
// Get pending count
|
||||||
pendingCount := h.localDB.GetPendingCount()
|
pendingCount := h.localDB.GetPendingCount()
|
||||||
readiness := h.getReadinessCached(10 * time.Second)
|
readiness := h.getReadinessLocal()
|
||||||
isBlocked := readiness != nil && readiness.Blocked
|
isBlocked := readiness != nil && readiness.Blocked
|
||||||
|
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||||
|
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||||
|
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||||
|
hasIncompleteServerSync := hasFailedSync
|
||||||
|
|
||||||
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
|
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
|
||||||
|
|
||||||
data := gin.H{
|
data := gin.H{
|
||||||
"IsOffline": isOffline,
|
"IsOffline": isOffline,
|
||||||
"PendingCount": pendingCount,
|
"PendingCount": pendingCount,
|
||||||
"IsBlocked": isBlocked,
|
"IsBlocked": isBlocked,
|
||||||
|
"HasFailedSync": hasFailedSync,
|
||||||
|
"HasIncompleteServerSync": hasIncompleteServerSync,
|
||||||
|
"SyncIssueTitle": func() string {
|
||||||
|
if hasIncompleteServerSync {
|
||||||
|
return "Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые не загружены локально."
|
||||||
|
}
|
||||||
|
if hasFailedSync {
|
||||||
|
if lastPricelistSyncError != "" {
|
||||||
|
return lastPricelistSyncError
|
||||||
|
}
|
||||||
|
return "Последняя синхронизация прайслистов завершилась ошибкой."
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(),
|
||||||
"BlockedReason": func() string {
|
"BlockedReason": func() string {
|
||||||
if readiness == nil {
|
if readiness == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -651,20 +683,29 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness {
|
func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness {
|
||||||
h.readinessMu.Lock()
|
h.readinessMu.Lock()
|
||||||
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
|
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < 10*time.Second {
|
||||||
cached := *h.readinessCached
|
cached := *h.readinessCached
|
||||||
h.readinessMu.Unlock()
|
h.readinessMu.Unlock()
|
||||||
return &cached
|
return &cached
|
||||||
}
|
}
|
||||||
h.readinessMu.Unlock()
|
h.readinessMu.Unlock()
|
||||||
|
|
||||||
readiness, err := h.syncService.GetReadiness()
|
state, err := h.localDB.GetSyncGuardState()
|
||||||
if err != nil && readiness == nil {
|
if err != nil || state == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readiness := &sync.SyncReadiness{
|
||||||
|
Status: state.Status,
|
||||||
|
Blocked: state.Status == sync.ReadinessBlocked,
|
||||||
|
ReasonCode: state.ReasonCode,
|
||||||
|
ReasonText: state.ReasonText,
|
||||||
|
RequiredMinAppVersion: state.RequiredMinAppVersion,
|
||||||
|
LastCheckedAt: state.LastCheckedAt,
|
||||||
|
}
|
||||||
|
|
||||||
h.readinessMu.Lock()
|
h.readinessMu.Lock()
|
||||||
h.readinessCached = readiness
|
h.readinessCached = readiness
|
||||||
h.readinessCachedAt = time.Now()
|
h.readinessCachedAt = time.Now()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,11 +13,15 @@ import (
|
|||||||
|
|
||||||
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
|
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
|
||||||
type VendorSpecHandler struct {
|
type VendorSpecHandler struct {
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
|
configService *services.LocalConfigurationService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
||||||
return &VendorSpecHandler{localDB: localDB}
|
return &VendorSpecHandler{
|
||||||
|
localDB: localDB,
|
||||||
|
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookupConfig finds an active configuration by UUID using the standard localDB method.
|
// lookupConfig finds an active configuration by UUID using the standard localDB method.
|
||||||
@@ -80,12 +83,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
spec := localdb.VendorSpec(body.VendorSpec)
|
spec := localdb.VendorSpec(body.VendorSpec)
|
||||||
specJSON, err := json.Marshal(spec)
|
if _, err := h.configService.UpdateVendorSpecNoAuth(cfg.UUID, spec); err != nil {
|
||||||
if err != nil {
|
|
||||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil {
|
|
||||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -194,13 +192,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsJSON, err := json.Marshal(newItems)
|
if _, err := h.configService.ApplyVendorSpecItemsNoAuth(cfg.UUID, newItems); err != nil {
|
||||||
if err != nil {
|
|
||||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
|
|
||||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -112,6 +113,7 @@ func NewWebHandler(_ string, localDB *localdb.LocalDB) (*WebHandler, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
||||||
|
data["AppVersion"] = appmeta.Version()
|
||||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
tmpl, ok := h.templates[name]
|
tmpl, ok := h.templates[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ type ComponentSyncResult struct {
|
|||||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// Query to join lot with qt_lot_metadata (metadata only, no pricing)
|
// Build the component catalog from every runtime source of LOT names.
|
||||||
// Use LEFT JOIN to include lots without metadata
|
// Storage lots may exist in qt_lot_metadata / qt_pricelist_items before they appear in lot,
|
||||||
|
// so the sync cannot start from lot alone.
|
||||||
type componentRow struct {
|
type componentRow struct {
|
||||||
LotName string
|
LotName string
|
||||||
LotDescription string
|
LotDescription string
|
||||||
@@ -40,15 +41,29 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
|||||||
var rows []componentRow
|
var rows []componentRow
|
||||||
err := mariaDB.Raw(`
|
err := mariaDB.Raw(`
|
||||||
SELECT
|
SELECT
|
||||||
l.lot_name,
|
src.lot_name,
|
||||||
l.lot_description,
|
COALESCE(MAX(NULLIF(TRIM(l.lot_description), '')), '') AS lot_description,
|
||||||
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
COALESCE(
|
||||||
m.model
|
MAX(NULLIF(TRIM(c.code), '')),
|
||||||
FROM lot l
|
MAX(NULLIF(TRIM(l.lot_category), '')),
|
||||||
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
SUBSTRING_INDEX(src.lot_name, '_', 1)
|
||||||
|
) AS category,
|
||||||
|
MAX(NULLIF(TRIM(m.model), '')) AS model
|
||||||
|
FROM (
|
||||||
|
SELECT lot_name FROM lot
|
||||||
|
UNION
|
||||||
|
SELECT lot_name FROM qt_lot_metadata
|
||||||
|
WHERE is_hidden = FALSE OR is_hidden IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT lot_name FROM qt_pricelist_items
|
||||||
|
) src
|
||||||
|
LEFT JOIN lot l ON l.lot_name = src.lot_name
|
||||||
|
LEFT JOIN qt_lot_metadata m
|
||||||
|
ON m.lot_name = src.lot_name
|
||||||
|
AND (m.is_hidden = FALSE OR m.is_hidden IS NULL)
|
||||||
LEFT JOIN qt_categories c ON m.category_id = c.id
|
LEFT JOIN qt_categories c ON m.category_id = c.id
|
||||||
WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL
|
GROUP BY src.lot_name
|
||||||
ORDER BY l.lot_name
|
ORDER BY src.lot_name
|
||||||
`).Scan(&rows).Error
|
`).Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
|
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
|
||||||
@@ -71,18 +86,25 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
|||||||
existingMap[c.LotName] = true
|
existingMap[c.LotName] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare components for batch insert/update
|
// Prepare components for batch insert/update.
|
||||||
|
// Source joins may duplicate the same lot_name, so collapse them before insert.
|
||||||
syncTime := time.Now()
|
syncTime := time.Now()
|
||||||
components := make([]LocalComponent, 0, len(rows))
|
components := make([]LocalComponent, 0, len(rows))
|
||||||
|
componentIndex := make(map[string]int, len(rows))
|
||||||
newCount := 0
|
newCount := 0
|
||||||
|
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
|
lotName := strings.TrimSpace(row.LotName)
|
||||||
|
if lotName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
category := ""
|
category := ""
|
||||||
if row.Category != nil {
|
if row.Category != nil {
|
||||||
category = *row.Category
|
category = strings.TrimSpace(*row.Category)
|
||||||
} else {
|
} else {
|
||||||
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||||
parts := strings.SplitN(row.LotName, "_", 2)
|
parts := strings.SplitN(lotName, "_", 2)
|
||||||
if len(parts) >= 1 {
|
if len(parts) >= 1 {
|
||||||
category = parts[0]
|
category = parts[0]
|
||||||
}
|
}
|
||||||
@@ -90,18 +112,34 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
|||||||
|
|
||||||
model := ""
|
model := ""
|
||||||
if row.Model != nil {
|
if row.Model != nil {
|
||||||
model = *row.Model
|
model = strings.TrimSpace(*row.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
comp := LocalComponent{
|
comp := LocalComponent{
|
||||||
LotName: row.LotName,
|
LotName: lotName,
|
||||||
LotDescription: row.LotDescription,
|
LotDescription: strings.TrimSpace(row.LotDescription),
|
||||||
Category: category,
|
Category: category,
|
||||||
Model: model,
|
Model: model,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if idx, exists := componentIndex[lotName]; exists {
|
||||||
|
// Keep the first row, but fill any missing metadata from duplicates.
|
||||||
|
if components[idx].LotDescription == "" && comp.LotDescription != "" {
|
||||||
|
components[idx].LotDescription = comp.LotDescription
|
||||||
|
}
|
||||||
|
if components[idx].Category == "" && comp.Category != "" {
|
||||||
|
components[idx].Category = comp.Category
|
||||||
|
}
|
||||||
|
if components[idx].Model == "" && comp.Model != "" {
|
||||||
|
components[idx].Model = comp.Model
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
componentIndex[lotName] = len(components)
|
||||||
components = append(components, comp)
|
components = append(components, comp)
|
||||||
|
|
||||||
if !existingMap[row.LotName] {
|
if !existingMap[lotName] {
|
||||||
newCount++
|
newCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,3 +95,60 @@ func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
|
|||||||
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
|
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigurationFingerprintIncludesPricingSelectorsAndVendorSpec(t *testing.T) {
|
||||||
|
estimateID := uint(11)
|
||||||
|
warehouseID := uint(22)
|
||||||
|
competitorID := uint(33)
|
||||||
|
|
||||||
|
base := &LocalConfiguration{
|
||||||
|
UUID: "cfg-1",
|
||||||
|
Name: "Config",
|
||||||
|
ServerCount: 1,
|
||||||
|
Items: LocalConfigItems{{LotName: "LOT_A", Quantity: 1, UnitPrice: 100}},
|
||||||
|
PricelistID: &estimateID,
|
||||||
|
WarehousePricelistID: &warehouseID,
|
||||||
|
CompetitorPricelistID: &competitorID,
|
||||||
|
DisablePriceRefresh: true,
|
||||||
|
OnlyInStock: true,
|
||||||
|
VendorSpec: VendorSpec{
|
||||||
|
{
|
||||||
|
SortOrder: 10,
|
||||||
|
VendorPartnumber: "PN-1",
|
||||||
|
Quantity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
baseFingerprint, err := BuildConfigurationSpecPriceFingerprint(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("base fingerprint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
changedPricelist := *base
|
||||||
|
newEstimateID := uint(44)
|
||||||
|
changedPricelist.PricelistID = &newEstimateID
|
||||||
|
pricelistFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedPricelist)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pricelist fingerprint: %v", err)
|
||||||
|
}
|
||||||
|
if pricelistFingerprint == baseFingerprint {
|
||||||
|
t.Fatalf("expected pricelist selector to affect fingerprint")
|
||||||
|
}
|
||||||
|
|
||||||
|
changedVendorSpec := *base
|
||||||
|
changedVendorSpec.VendorSpec = VendorSpec{
|
||||||
|
{
|
||||||
|
SortOrder: 10,
|
||||||
|
VendorPartnumber: "PN-2",
|
||||||
|
Quantity: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vendorFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedVendorSpec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("vendor fingerprint: %v", err)
|
||||||
|
}
|
||||||
|
if vendorFingerprint == baseFingerprint {
|
||||||
|
t.Fatalf("expected vendor spec to affect fingerprint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
PricelistID: cfg.PricelistID,
|
PricelistID: cfg.PricelistID,
|
||||||
WarehousePricelistID: cfg.WarehousePricelistID,
|
WarehousePricelistID: cfg.WarehousePricelistID,
|
||||||
CompetitorPricelistID: cfg.CompetitorPricelistID,
|
CompetitorPricelistID: cfg.CompetitorPricelistID,
|
||||||
|
ConfigType: cfg.ConfigType,
|
||||||
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
|
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
|
||||||
DisablePriceRefresh: cfg.DisablePriceRefresh,
|
DisablePriceRefresh: cfg.DisablePriceRefresh,
|
||||||
OnlyInStock: cfg.OnlyInStock,
|
OnlyInStock: cfg.OnlyInStock,
|
||||||
@@ -82,6 +83,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
PricelistID: local.PricelistID,
|
PricelistID: local.PricelistID,
|
||||||
WarehousePricelistID: local.WarehousePricelistID,
|
WarehousePricelistID: local.WarehousePricelistID,
|
||||||
CompetitorPricelistID: local.CompetitorPricelistID,
|
CompetitorPricelistID: local.CompetitorPricelistID,
|
||||||
|
ConfigType: local.ConfigType,
|
||||||
VendorSpec: localVendorSpecToModel(local.VendorSpec),
|
VendorSpec: localVendorSpecToModel(local.VendorSpec),
|
||||||
DisablePriceRefresh: local.DisablePriceRefresh,
|
DisablePriceRefresh: local.DisablePriceRefresh,
|
||||||
OnlyInStock: local.OnlyInStock,
|
OnlyInStock: local.OnlyInStock,
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable WAL mode so background sync writes never block UI reads.
|
||||||
|
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
|
||||||
|
slog.Warn("failed to enable WAL mode", "error", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec("PRAGMA synchronous=NORMAL").Error; err != nil {
|
||||||
|
slog.Warn("failed to set synchronous=NORMAL", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := ensureLocalProjectsTable(db); err != nil {
|
if err := ensureLocalProjectsTable(db); err != nil {
|
||||||
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1066,6 +1074,26 @@ func (l *LocalDB) GetLastSyncTime() *time.Time {
|
|||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *LocalDB) getAppSettingValue(key string) (string, bool) {
|
||||||
|
var setting struct {
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
if err := l.db.Table("app_settings").
|
||||||
|
Where("key = ?", key).
|
||||||
|
First(&setting).Error; err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return setting.Value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LocalDB) upsertAppSetting(tx *gorm.DB, key, value string, updatedAt time.Time) error {
|
||||||
|
return tx.Exec(`
|
||||||
|
INSERT INTO app_settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||||
|
`, key, value, updatedAt.Format(time.RFC3339)).Error
|
||||||
|
}
|
||||||
|
|
||||||
// SetLastSyncTime sets the last sync timestamp
|
// SetLastSyncTime sets the last sync timestamp
|
||||||
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
||||||
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
|
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
|
||||||
@@ -1076,6 +1104,55 @@ func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
|||||||
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *LocalDB) GetLastPricelistSyncAttemptAt() *time.Time {
|
||||||
|
value, ok := l.getAppSettingValue("last_pricelist_sync_attempt_at")
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LocalDB) GetLastPricelistSyncStatus() string {
|
||||||
|
value, ok := l.getAppSettingValue("last_pricelist_sync_status")
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LocalDB) GetLastPricelistSyncError() string {
|
||||||
|
value, ok := l.getAppSettingValue("last_pricelist_sync_error")
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LocalDB) SetPricelistSyncResult(status, errorText string, attemptedAt time.Time) error {
|
||||||
|
status = strings.TrimSpace(status)
|
||||||
|
errorText = strings.TrimSpace(errorText)
|
||||||
|
if status == "" {
|
||||||
|
status = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := l.upsertAppSetting(tx, "last_pricelist_sync_status", status, attemptedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := l.upsertAppSetting(tx, "last_pricelist_sync_error", errorText, attemptedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := l.upsertAppSetting(tx, "last_pricelist_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// CountLocalPricelists returns the number of local pricelists
|
// CountLocalPricelists returns the number of local pricelists
|
||||||
func (l *LocalDB) CountLocalPricelists() int64 {
|
func (l *LocalDB) CountLocalPricelists() int64 {
|
||||||
var count int64
|
var count int64
|
||||||
@@ -1083,6 +1160,29 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountAllPricelistItems returns total rows across all local_pricelist_items.
|
||||||
|
func (l *LocalDB) CountAllPricelistItems() int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&LocalPricelistItem{}).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountComponents returns the number of rows in local_components.
|
||||||
|
func (l *LocalDB) CountComponents() int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&LocalComponent{}).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBFileSizeBytes returns the size of the SQLite database file in bytes.
|
||||||
|
func (l *LocalDB) DBFileSizeBytes() int64 {
|
||||||
|
info, err := os.Stat(l.path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||||
var pricelist LocalPricelist
|
var pricelist LocalPricelist
|
||||||
@@ -1250,6 +1350,32 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
|
|||||||
return item.Price, nil
|
return item.Price, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query.
|
||||||
|
// Uses the composite index (pricelist_id, lot_name). Missing lots are omitted from the result.
|
||||||
|
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
|
||||||
|
result := make(map[string]float64, len(lotNames))
|
||||||
|
if len(lotNames) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
LotName string `gorm:"column:lot_name"`
|
||||||
|
Price float64 `gorm:"column:price"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.db.Model(&LocalPricelistItem{}).
|
||||||
|
Select("lot_name, price").
|
||||||
|
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Price > 0 {
|
||||||
|
result[r.LotName] = r.Price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
||||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||||
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ type LocalConfiguration struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
SyncedAt *time.Time `json:"synced_at"`
|
SyncedAt *time.Time `json:"synced_at"`
|
||||||
|
ConfigType string `gorm:"default:server" json:"config_type"` // "server" | "storage"
|
||||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||||
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
||||||
|
|||||||
@@ -112,10 +112,16 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type configurationSpecPriceFingerprint struct {
|
type configurationSpecPriceFingerprint struct {
|
||||||
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
||||||
ServerCount int `json:"server_count"`
|
ServerCount int `json:"server_count"`
|
||||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||||
CustomPrice *float64 `json:"custom_price,omitempty"`
|
CustomPrice *float64 `json:"custom_price,omitempty"`
|
||||||
|
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||||
|
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
|
||||||
|
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
|
||||||
|
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||||
|
OnlyInStock bool `json:"only_in_stock"`
|
||||||
|
VendorSpec VendorSpec `json:"vendor_spec,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type configurationSpecPriceFingerprintItem struct {
|
type configurationSpecPriceFingerprintItem struct {
|
||||||
@@ -146,10 +152,16 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin
|
|||||||
})
|
})
|
||||||
|
|
||||||
payload := configurationSpecPriceFingerprint{
|
payload := configurationSpecPriceFingerprint{
|
||||||
Items: items,
|
Items: items,
|
||||||
ServerCount: localCfg.ServerCount,
|
ServerCount: localCfg.ServerCount,
|
||||||
TotalPrice: localCfg.TotalPrice,
|
TotalPrice: localCfg.TotalPrice,
|
||||||
CustomPrice: localCfg.CustomPrice,
|
CustomPrice: localCfg.CustomPrice,
|
||||||
|
PricelistID: localCfg.PricelistID,
|
||||||
|
WarehousePricelistID: localCfg.WarehousePricelistID,
|
||||||
|
CompetitorPricelistID: localCfg.CompetitorPricelistID,
|
||||||
|
DisablePriceRefresh: localCfg.DisablePriceRefresh,
|
||||||
|
OnlyInStock: localCfg.OnlyInStock,
|
||||||
|
VendorSpec: localCfg.VendorSpec,
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := json.Marshal(payload)
|
raw, err := json.Marshal(payload)
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ type Configuration struct {
|
|||||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,omitempty"`
|
VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,omitempty"`
|
||||||
|
ConfigType string `gorm:"size:20;default:server" json:"config_type"` // "server" | "storage"
|
||||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||||
Line int `gorm:"column:line_no;index" json:"line"`
|
Line int `gorm:"column:line_no;index" json:"line"`
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ func Migrate(db *gorm.DB) error {
|
|||||||
errStr := err.Error()
|
errStr := err.Error()
|
||||||
if strings.Contains(errStr, "Can't DROP") ||
|
if strings.Contains(errStr, "Can't DROP") ||
|
||||||
strings.Contains(errStr, "Duplicate key name") ||
|
strings.Contains(errStr, "Duplicate key name") ||
|
||||||
strings.Contains(errStr, "check that it exists") {
|
strings.Contains(errStr, "check that it exists") ||
|
||||||
|
strings.Contains(errStr, "Cannot change column") ||
|
||||||
|
strings.Contains(errStr, "used in a foreign key constraint") {
|
||||||
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
|
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type CreateConfigRequest struct {
|
|||||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||||
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
|
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
|
||||||
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
|
||||||
|
ConfigType string `json:"config_type,omitempty"` // "server" | "storage"
|
||||||
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||||
OnlyInStock bool `json:"only_in_stock"`
|
OnlyInStock bool `json:"only_in_stock"`
|
||||||
}
|
}
|
||||||
@@ -103,9 +104,13 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
|||||||
PricelistID: pricelistID,
|
PricelistID: pricelistID,
|
||||||
WarehousePricelistID: req.WarehousePricelistID,
|
WarehousePricelistID: req.WarehousePricelistID,
|
||||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||||
|
ConfigType: req.ConfigType,
|
||||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||||
OnlyInStock: req.OnlyInStock,
|
OnlyInStock: req.OnlyInStock,
|
||||||
}
|
}
|
||||||
|
if config.ConfigType == "" {
|
||||||
|
config.ConfigType = "server"
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(config); err != nil {
|
if err := s.configRepo.Create(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -56,11 +56,24 @@ type ProjectExportData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectPricingExportOptions struct {
|
type ProjectPricingExportOptions struct {
|
||||||
IncludeLOT bool `json:"include_lot"`
|
IncludeLOT bool `json:"include_lot"`
|
||||||
IncludeBOM bool `json:"include_bom"`
|
IncludeBOM bool `json:"include_bom"`
|
||||||
IncludeEstimate bool `json:"include_estimate"`
|
IncludeEstimate bool `json:"include_estimate"`
|
||||||
IncludeStock bool `json:"include_stock"`
|
IncludeStock bool `json:"include_stock"`
|
||||||
IncludeCompetitor bool `json:"include_competitor"`
|
IncludeCompetitor bool `json:"include_competitor"`
|
||||||
|
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
|
||||||
|
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
|
||||||
|
if o.SaleMarkup > 0 {
|
||||||
|
return o.SaleMarkup
|
||||||
|
}
|
||||||
|
return 1.3
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o ProjectPricingExportOptions) isDDP() bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(o.Basis), "ddp")
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectPricingExportData struct {
|
type ProjectPricingExportData struct {
|
||||||
@@ -251,18 +264,16 @@ func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData
|
|||||||
return fmt.Errorf("failed to write pricing header: %w", err)
|
return fmt.Errorf("failed to write pricing header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for idx, cfg := range data.Configs {
|
writeRows := opts.IncludeLOT || opts.IncludeBOM
|
||||||
|
for _, cfg := range data.Configs {
|
||||||
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
|
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
|
||||||
return fmt.Errorf("failed to write config summary row: %w", err)
|
return fmt.Errorf("failed to write config summary row: %w", err)
|
||||||
}
|
}
|
||||||
for _, row := range cfg.Rows {
|
if writeRows {
|
||||||
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
for _, row := range cfg.Rows {
|
||||||
return fmt.Errorf("failed to write pricing row: %w", err)
|
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
||||||
}
|
return fmt.Errorf("failed to write pricing row: %w", err)
|
||||||
}
|
}
|
||||||
if idx < len(data.Configs)-1 {
|
|
||||||
if err := csvWriter.Write([]string{}); err != nil {
|
|
||||||
return fmt.Errorf("failed to write separator row: %w", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,6 +435,9 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if opts.isDDP() {
|
||||||
|
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||||
|
}
|
||||||
return block, nil
|
return block, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,9 +457,29 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.isDDP() {
|
||||||
|
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||||
|
}
|
||||||
|
|
||||||
return block, nil
|
return block, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
|
||||||
|
for i := range rows {
|
||||||
|
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
|
||||||
|
rows[i].Stock = scaleFloatPtr(rows[i].Stock, factor)
|
||||||
|
rows[i].Competitor = scaleFloatPtr(rows[i].Competitor, factor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scaleFloatPtr(v *float64, factor float64) *float64 {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := *v * factor
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
// resolveCategories returns lot_name → category map.
|
// resolveCategories returns lot_name → category map.
|
||||||
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
||||||
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
||||||
@@ -735,7 +769,7 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
|
|||||||
record = append(record, "")
|
record = append(record, "")
|
||||||
}
|
}
|
||||||
record = append(record,
|
record = append(record,
|
||||||
"",
|
emptyDash(cfg.Article),
|
||||||
emptyDash(cfg.Name),
|
emptyDash(cfg.Name),
|
||||||
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
|
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,11 +49,13 @@ func NewLocalConfigurationService(
|
|||||||
|
|
||||||
// Create creates a new configuration in local SQLite and queues it for sync
|
// Create creates a new configuration in local SQLite and queues it for sync
|
||||||
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
// If online, check for new pricelists first
|
// If online, trigger pricelist sync in the background — do not block config creation
|
||||||
if s.isOnline() {
|
if s.isOnline() {
|
||||||
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
go func() {
|
||||||
// Log but don't fail - we can still use local pricelists
|
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||||
}
|
// Log but don't fail - we can still use local pricelists
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||||
@@ -99,10 +101,14 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
PricelistID: pricelistID,
|
PricelistID: pricelistID,
|
||||||
WarehousePricelistID: req.WarehousePricelistID,
|
WarehousePricelistID: req.WarehousePricelistID,
|
||||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||||
|
ConfigType: req.ConfigType,
|
||||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||||
OnlyInStock: req.OnlyInStock,
|
OnlyInStock: req.OnlyInStock,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
if cfg.ConfigType == "" {
|
||||||
|
cfg.ConfigType = "server"
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to local model
|
// Convert to local model
|
||||||
localCfg := localdb.ConfigurationToLocal(cfg)
|
localCfg := localdb.ConfigurationToLocal(cfg)
|
||||||
@@ -399,17 +405,29 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
return nil, ErrConfigForbidden
|
return nil, ErrConfigForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh local pricelists when online and use latest active/local pricelist for recalculation.
|
// Refresh local pricelists when online.
|
||||||
if s.isOnline() {
|
if s.isOnline() {
|
||||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
_ = s.syncService.SyncPricelistsIfNeeded()
|
||||||
}
|
}
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
|
||||||
|
// Use the pricelist stored in the config; fall back to latest if unavailable.
|
||||||
|
var pricelist *localdb.LocalPricelist
|
||||||
|
if localCfg.PricelistID != nil && *localCfg.PricelistID > 0 {
|
||||||
|
if pl, err := s.localDB.GetLocalPricelistByServerID(*localCfg.PricelistID); err == nil {
|
||||||
|
pricelist = pl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pricelist == nil {
|
||||||
|
if pl, err := s.localDB.GetLatestLocalPricelist(); err == nil {
|
||||||
|
pricelist = pl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update prices for all items from pricelist
|
// Update prices for all items from pricelist
|
||||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
for i, item := range localCfg.Items {
|
for i, item := range localCfg.Items {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if pricelist != nil {
|
||||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName)
|
||||||
if err == nil && price > 0 {
|
if err == nil && price > 0 {
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
updatedItems[i] = localdb.LocalConfigItem{
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
@@ -434,8 +452,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCfg.TotalPrice = &total
|
localCfg.TotalPrice = &total
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if pricelist != nil {
|
||||||
localCfg.PricelistID = &latestPricelist.ServerID
|
localCfg.PricelistID = &pricelist.ServerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set price update timestamp and mark for sync
|
// Set price update timestamp and mark for sync
|
||||||
@@ -762,8 +780,10 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
|
|||||||
return templates[start:end], total, nil
|
return templates[start:end], total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check
|
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check.
|
||||||
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
// pricelistServerID optionally specifies which pricelist to use; if nil, the config's stored
|
||||||
|
// pricelist is used; if that is also absent, the latest local pricelist is used as a fallback.
|
||||||
|
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistServerID *uint) (*models.Configuration, error) {
|
||||||
// Get configuration from local SQLite
|
// Get configuration from local SQLite
|
||||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -773,13 +793,36 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
if s.isOnline() {
|
if s.isOnline() {
|
||||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
_ = s.syncService.SyncPricelistsIfNeeded()
|
||||||
}
|
}
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
|
||||||
|
// Resolve which pricelist to use:
|
||||||
|
// 1. Explicitly requested pricelist (from UI selection)
|
||||||
|
// 2. Pricelist stored in the configuration
|
||||||
|
// 3. Latest local pricelist as last-resort fallback
|
||||||
|
var targetServerID *uint
|
||||||
|
if pricelistServerID != nil && *pricelistServerID > 0 {
|
||||||
|
targetServerID = pricelistServerID
|
||||||
|
} else if localCfg.PricelistID != nil && *localCfg.PricelistID > 0 {
|
||||||
|
targetServerID = localCfg.PricelistID
|
||||||
|
}
|
||||||
|
|
||||||
|
var pricelist *localdb.LocalPricelist
|
||||||
|
if targetServerID != nil {
|
||||||
|
if pl, err := s.localDB.GetLocalPricelistByServerID(*targetServerID); err == nil {
|
||||||
|
pricelist = pl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pricelist == nil {
|
||||||
|
// Fallback: use latest local pricelist
|
||||||
|
if pl, err := s.localDB.GetLatestLocalPricelist(); err == nil {
|
||||||
|
pricelist = pl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update prices for all items from pricelist
|
// Update prices for all items from pricelist
|
||||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
for i, item := range localCfg.Items {
|
for i, item := range localCfg.Items {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if pricelist != nil {
|
||||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName)
|
||||||
if err == nil && price > 0 {
|
if err == nil && price > 0 {
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
updatedItems[i] = localdb.LocalConfigItem{
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
@@ -804,8 +847,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCfg.TotalPrice = &total
|
localCfg.TotalPrice = &total
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if pricelist != nil {
|
||||||
localCfg.PricelistID = &latestPricelist.ServerID
|
localCfg.PricelistID = &pricelist.ServerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set price update timestamp and mark for sync
|
// Set price update timestamp and mark for sync
|
||||||
@@ -1205,21 +1248,55 @@ func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, nex
|
|||||||
current.ServerModel != next.ServerModel ||
|
current.ServerModel != next.ServerModel ||
|
||||||
current.SupportCode != next.SupportCode ||
|
current.SupportCode != next.SupportCode ||
|
||||||
current.Article != next.Article ||
|
current.Article != next.Article ||
|
||||||
current.DisablePriceRefresh != next.DisablePriceRefresh ||
|
|
||||||
current.OnlyInStock != next.OnlyInStock ||
|
|
||||||
current.IsActive != next.IsActive ||
|
current.IsActive != next.IsActive ||
|
||||||
current.Line != next.Line {
|
current.Line != next.Line {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if !equalUintPtr(current.PricelistID, next.PricelistID) ||
|
if !equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
|
||||||
!equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) ||
|
|
||||||
!equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) ||
|
|
||||||
!equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) UpdateVendorSpecNoAuth(uuid string, spec localdb.VendorSpec) (*models.Configuration, error) {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg.VendorSpec = spec
|
||||||
|
localCfg.UpdatedAt = time.Now()
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update vendor spec without auth with version: %w", err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) ApplyVendorSpecItemsNoAuth(uuid string, items localdb.LocalConfigItems) (*models.Configuration, error) {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg.Items = items
|
||||||
|
total := items.Total()
|
||||||
|
if localCfg.ServerCount > 1 {
|
||||||
|
total *= float64(localCfg.ServerCount)
|
||||||
|
}
|
||||||
|
localCfg.TotalPrice = &total
|
||||||
|
localCfg.UpdatedAt = time.Now()
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("apply vendor spec items without auth with version: %w", err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
func equalStringPtr(a, b *string) bool {
|
func equalStringPtr(a, b *string) bool {
|
||||||
if a == nil && b == nil {
|
if a == nil && b == nil {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -137,6 +137,77 @@ func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoAuthCreatesRevisionWhenPricingSettingsChanged(t *testing.T) {
|
||||||
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
created, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "pricing",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "pricing",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
DisablePriceRefresh: true,
|
||||||
|
OnlyInStock: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update pricing settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions := loadVersions(t, local, created.UUID)
|
||||||
|
if len(versions) != 2 {
|
||||||
|
t.Fatalf("expected 2 versions after pricing settings change, got %d", len(versions))
|
||||||
|
}
|
||||||
|
if versions[1].VersionNo != 2 {
|
||||||
|
t.Fatalf("expected latest version_no=2, got %d", versions[1].VersionNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateVendorSpecNoAuthCreatesRevision(t *testing.T) {
|
||||||
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
created, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "bom",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := localdb.VendorSpec{
|
||||||
|
{
|
||||||
|
VendorPartnumber: "PN-001",
|
||||||
|
Quantity: 2,
|
||||||
|
SortOrder: 10,
|
||||||
|
LotMappings: []localdb.VendorSpecLotMapping{
|
||||||
|
{LotName: "CPU_A", QuantityPerPN: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := service.UpdateVendorSpecNoAuth(created.UUID, spec); err != nil {
|
||||||
|
t.Fatalf("update vendor spec: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions := loadVersions(t, local, created.UUID)
|
||||||
|
if len(versions) != 2 {
|
||||||
|
t.Fatalf("expected 2 versions after vendor spec change, got %d", len(versions))
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load config after vendor spec update: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.VendorSpec) != 1 || cfg.VendorSpec[0].VendorPartnumber != "PN-001" {
|
||||||
|
t.Fatalf("expected saved vendor spec, got %+v", cfg.VendorSpec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) {
|
func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) {
|
||||||
service, local := newLocalConfigServiceForTest(t)
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ var (
|
|||||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||||
|
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectService struct {
|
type ProjectService struct {
|
||||||
@@ -108,7 +109,12 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
|||||||
localProject.Code = code
|
localProject.Code = code
|
||||||
}
|
}
|
||||||
if req.Variant != nil {
|
if req.Variant != nil {
|
||||||
localProject.Variant = strings.TrimSpace(*req.Variant)
|
newVariant := strings.TrimSpace(*req.Variant)
|
||||||
|
// Block renaming of the main variant (empty Variant) — there must always be a main.
|
||||||
|
if strings.TrimSpace(localProject.Variant) == "" && newVariant != "" {
|
||||||
|
return nil, ErrCannotRenameMainVariant
|
||||||
|
}
|
||||||
|
localProject.Variant = newVariant
|
||||||
if err := validateProjectVariantName(localProject.Variant); err != nil {
|
if err := validateProjectVariantName(localProject.Variant); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,13 +388,14 @@ func (s *QuoteService) lookupPricesByPricelistID(pricelistID uint, lotNames []st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback path (usually offline): local per-lot lookup.
|
// Fallback path (usually offline): batch local lookup (single query via index).
|
||||||
if s.localDB != nil {
|
if s.localDB != nil {
|
||||||
for _, lotName := range missing {
|
if localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID); err == nil {
|
||||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
if batchPrices, err := s.localDB.GetLocalPricesForLots(localPL.ID, missing); err == nil {
|
||||||
if found && price > 0 {
|
for lotName, price := range batchPrices {
|
||||||
result[lotName] = price
|
result[lotName] = price
|
||||||
loaded[lotName] = price
|
loaded[lotName] = price
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.updateCache(pricelistID, missing, loaded)
|
s.updateCache(pricelistID, missing, loaded)
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ func ensureClientSchemaStateTable(db *gorm.DB) error {
|
|||||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version",
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version",
|
||||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version",
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version",
|
||||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code",
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS local_pricelist_count INT NOT NULL DEFAULT 0 AFTER last_sync_error_text",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pricelist_items_count INT NOT NULL DEFAULT 0 AFTER local_pricelist_count",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS components_count INT NOT NULL DEFAULT 0 AFTER pricelist_items_count",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS db_size_bytes BIGINT NOT NULL DEFAULT 0 AFTER components_count",
|
||||||
} {
|
} {
|
||||||
if err := db.Exec(stmt).Error; err != nil {
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
||||||
@@ -215,6 +219,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
|
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
|
||||||
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
|
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
|
||||||
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
||||||
|
localPricelistCount := s.localDB.CountLocalPricelists()
|
||||||
|
pricelistItemsCount := s.localDB.CountAllPricelistItems()
|
||||||
|
componentsCount := s.localDB.CountComponents()
|
||||||
|
dbSizeBytes := s.localDB.DBFileSizeBytes()
|
||||||
return mariaDB.Exec(`
|
return mariaDB.Exec(`
|
||||||
INSERT INTO qt_client_schema_state (
|
INSERT INTO qt_client_schema_state (
|
||||||
username, hostname, app_version,
|
username, hostname, app_version,
|
||||||
@@ -222,9 +230,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
configurations_count, projects_count,
|
configurations_count, projects_count,
|
||||||
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
|
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
|
||||||
last_sync_error_code, last_sync_error_text,
|
last_sync_error_code, last_sync_error_text,
|
||||||
|
local_pricelist_count, pricelist_items_count, components_count, db_size_bytes,
|
||||||
last_checked_at, updated_at
|
last_checked_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
app_version = VALUES(app_version),
|
app_version = VALUES(app_version),
|
||||||
last_sync_at = VALUES(last_sync_at),
|
last_sync_at = VALUES(last_sync_at),
|
||||||
@@ -238,6 +247,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
competitor_pricelist_version = VALUES(competitor_pricelist_version),
|
competitor_pricelist_version = VALUES(competitor_pricelist_version),
|
||||||
last_sync_error_code = VALUES(last_sync_error_code),
|
last_sync_error_code = VALUES(last_sync_error_code),
|
||||||
last_sync_error_text = VALUES(last_sync_error_text),
|
last_sync_error_text = VALUES(last_sync_error_text),
|
||||||
|
local_pricelist_count = VALUES(local_pricelist_count),
|
||||||
|
pricelist_items_count = VALUES(pricelist_items_count),
|
||||||
|
components_count = VALUES(components_count),
|
||||||
|
db_size_bytes = VALUES(db_size_bytes),
|
||||||
last_checked_at = VALUES(last_checked_at),
|
last_checked_at = VALUES(last_checked_at),
|
||||||
updated_at = VALUES(updated_at)
|
updated_at = VALUES(updated_at)
|
||||||
`, username, hostname, appmeta.Version(),
|
`, username, hostname, appmeta.Version(),
|
||||||
@@ -245,6 +258,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
configurationsCount, projectsCount,
|
configurationsCount, projectsCount,
|
||||||
estimateVersion, warehouseVersion, competitorVersion,
|
estimateVersion, warehouseVersion, competitorVersion,
|
||||||
lastSyncErrorCode, lastSyncErrorText,
|
lastSyncErrorCode, lastSyncErrorText,
|
||||||
|
localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes,
|
||||||
checkedAt, checkedAt).Error
|
checkedAt, checkedAt).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
@@ -22,9 +23,10 @@ var ErrOffline = errors.New("database is offline")
|
|||||||
|
|
||||||
// Service handles synchronization between MariaDB and local SQLite
|
// Service handles synchronization between MariaDB and local SQLite
|
||||||
type Service struct {
|
type Service struct {
|
||||||
connMgr *db.ConnectionManager
|
connMgr *db.ConnectionManager
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
directDB *gorm.DB
|
directDB *gorm.DB
|
||||||
|
pricelistMu sync.Mutex // prevents concurrent pricelist syncs
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new sync service
|
// NewService creates a new sync service
|
||||||
@@ -45,10 +47,15 @@ func NewServiceWithDB(mariaDB *gorm.DB, localDB *localdb.LocalDB) *Service {
|
|||||||
|
|
||||||
// SyncStatus represents the current sync status
|
// SyncStatus represents the current sync status
|
||||||
type SyncStatus struct {
|
type SyncStatus struct {
|
||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
ServerPricelists int `json:"server_pricelists"`
|
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
|
||||||
LocalPricelists int `json:"local_pricelists"`
|
LastSyncStatus string `json:"last_sync_status,omitempty"`
|
||||||
NeedsSync bool `json:"needs_sync"`
|
LastSyncError string `json:"last_sync_error,omitempty"`
|
||||||
|
ServerPricelists int `json:"server_pricelists"`
|
||||||
|
LocalPricelists int `json:"local_pricelists"`
|
||||||
|
NeedsSync bool `json:"needs_sync"`
|
||||||
|
IncompleteServerSync bool `json:"incomplete_server_sync"`
|
||||||
|
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSyncStatus struct {
|
type UserSyncStatus struct {
|
||||||
@@ -240,30 +247,23 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
|||||||
// GetStatus returns the current sync status
|
// GetStatus returns the current sync status
|
||||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||||
lastSync := s.localDB.GetLastSyncTime()
|
lastSync := s.localDB.GetLastSyncTime()
|
||||||
|
lastAttempt := s.localDB.GetLastPricelistSyncAttemptAt()
|
||||||
// Count server pricelists (only if already connected, don't reconnect)
|
lastSyncStatus := s.localDB.GetLastPricelistSyncStatus()
|
||||||
serverCount := 0
|
lastSyncError := s.localDB.GetLastPricelistSyncError()
|
||||||
connStatus := s.getConnectionStatus()
|
|
||||||
if connStatus.IsConnected {
|
|
||||||
if mariaDB, err := s.getDB(); err == nil && mariaDB != nil {
|
|
||||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
|
||||||
activeCount, err := pricelistRepo.CountActive()
|
|
||||||
if err == nil {
|
|
||||||
serverCount = int(activeCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count local pricelists
|
|
||||||
localCount := s.localDB.CountLocalPricelists()
|
localCount := s.localDB.CountLocalPricelists()
|
||||||
|
hasFailedSync := strings.EqualFold(lastSyncStatus, "failed")
|
||||||
needsSync, _ := s.NeedSync()
|
needsSync := lastSync == nil || hasFailedSync
|
||||||
|
|
||||||
return &SyncStatus{
|
return &SyncStatus{
|
||||||
LastSyncAt: lastSync,
|
LastSyncAt: lastSync,
|
||||||
ServerPricelists: serverCount,
|
LastAttemptAt: lastAttempt,
|
||||||
LocalPricelists: int(localCount),
|
LastSyncStatus: lastSyncStatus,
|
||||||
NeedsSync: needsSync,
|
LastSyncError: lastSyncError,
|
||||||
|
ServerPricelists: 0,
|
||||||
|
LocalPricelists: int(localCount),
|
||||||
|
NeedsSync: needsSync,
|
||||||
|
IncompleteServerSync: hasFailedSync,
|
||||||
|
KnownServerChangesMiss: hasFailedSync,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +333,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
// Get database connection
|
// Get database connection
|
||||||
mariaDB, err := s.getDB()
|
mariaDB, err := s.getDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.recordPricelistSyncFailure(err)
|
||||||
return 0, fmt.Errorf("database not available: %w", err)
|
return 0, fmt.Errorf("database not available: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +343,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
// Get active pricelists from server (up to 100)
|
// Get active pricelists from server (up to 100)
|
||||||
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
|
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.recordPricelistSyncFailure(err)
|
||||||
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
||||||
}
|
}
|
||||||
serverPricelistIDs := make([]uint, 0, len(serverPricelists))
|
serverPricelistIDs := make([]uint, 0, len(serverPricelists))
|
||||||
@@ -350,6 +352,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
synced := 0
|
synced := 0
|
||||||
|
var syncErr error
|
||||||
for _, pl := range serverPricelists {
|
for _, pl := range serverPricelists {
|
||||||
// Check if pricelist already exists locally
|
// Check if pricelist already exists locally
|
||||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||||
@@ -358,6 +361,9 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
|
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
|
||||||
itemCount, err := s.SyncPricelistItems(existing.ID)
|
itemCount, err := s.SyncPricelistItems(existing.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if syncErr == nil {
|
||||||
|
syncErr = fmt.Errorf("sync items for existing pricelist %s: %w", pl.Version, err)
|
||||||
|
}
|
||||||
slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err)
|
slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount)
|
slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount)
|
||||||
@@ -377,19 +383,15 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
IsUsed: false,
|
IsUsed: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
|
itemCount, err := s.syncNewPricelistSnapshot(localPL)
|
||||||
slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
|
if err != nil {
|
||||||
|
if syncErr == nil {
|
||||||
|
syncErr = fmt.Errorf("sync new pricelist %s: %w", pl.Version, err)
|
||||||
|
}
|
||||||
|
slog.Warn("failed to sync pricelist snapshot", "version", pl.Version, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||||
// Sync items for the newly created pricelist
|
|
||||||
itemCount, err := s.SyncPricelistItems(localPL.ID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("failed to sync pricelist items", "version", pl.Version, "error", err)
|
|
||||||
// Continue even if items sync fails - we have the pricelist metadata
|
|
||||||
} else {
|
|
||||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
synced++
|
synced++
|
||||||
}
|
}
|
||||||
@@ -404,14 +406,96 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
// Backfill lot_category for used pricelists (older local caches may miss the column values).
|
// Backfill lot_category for used pricelists (older local caches may miss the column values).
|
||||||
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
||||||
|
|
||||||
|
if syncErr != nil {
|
||||||
|
s.recordPricelistSyncFailure(syncErr)
|
||||||
|
return synced, syncErr
|
||||||
|
}
|
||||||
|
|
||||||
// Update last sync time
|
// Update last sync time
|
||||||
s.localDB.SetLastSyncTime(time.Now())
|
now := time.Now()
|
||||||
|
s.localDB.SetLastSyncTime(now)
|
||||||
|
s.recordPricelistSyncSuccess(now)
|
||||||
s.RecordSyncHeartbeat()
|
s.RecordSyncHeartbeat()
|
||||||
|
|
||||||
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
||||||
return synced, nil
|
return synced, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) recordPricelistSyncSuccess(at time.Time) {
|
||||||
|
if s.localDB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.localDB.SetPricelistSyncResult("success", "", at); err != nil {
|
||||||
|
slog.Warn("failed to persist pricelist sync success state", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) recordPricelistSyncFailure(syncErr error) {
|
||||||
|
if s.localDB == nil || syncErr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.markConnectionBroken(syncErr)
|
||||||
|
if err := s.localDB.SetPricelistSyncResult("failed", syncErr.Error(), time.Now()); err != nil {
|
||||||
|
slog.Warn("failed to persist pricelist sync failure state", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) markConnectionBroken(err error) {
|
||||||
|
if err == nil || s.connMgr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
switch {
|
||||||
|
case strings.Contains(msg, "i/o timeout"),
|
||||||
|
strings.Contains(msg, "invalid connection"),
|
||||||
|
strings.Contains(msg, "bad connection"),
|
||||||
|
strings.Contains(msg, "connection reset"),
|
||||||
|
strings.Contains(msg, "broken pipe"),
|
||||||
|
strings.Contains(msg, "unexpected eof"):
|
||||||
|
s.connMgr.MarkOffline(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int, error) {
|
||||||
|
if localPL == nil {
|
||||||
|
return 0, fmt.Errorf("local pricelist is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
localItems, err := s.fetchServerPricelistItems(localPL.ServerID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(localPL).Error; err != nil {
|
||||||
|
return fmt.Errorf("save local pricelist: %w", err)
|
||||||
|
}
|
||||||
|
if len(localItems) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range localItems {
|
||||||
|
localItems[i].PricelistID = localPL.ID
|
||||||
|
}
|
||||||
|
batchSize := 500
|
||||||
|
for i := 0; i < len(localItems); i += batchSize {
|
||||||
|
end := i + batchSize
|
||||||
|
if end > len(localItems) {
|
||||||
|
end = len(localItems)
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(localItems[i:end], batchSize).Error; err != nil {
|
||||||
|
return fmt.Errorf("save local pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("synced pricelist items", "pricelist_id", localPL.ID, "items", len(localItems))
|
||||||
|
return len(localItems), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
|
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
|
||||||
if s.localDB == nil || pricelistRepo == nil {
|
if s.localDB == nil || pricelistRepo == nil {
|
||||||
return
|
return
|
||||||
@@ -670,30 +754,13 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
|||||||
return int(existingCount), nil
|
return int(existingCount), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get database connection
|
localItems, err := s.fetchServerPricelistItems(localPL.ServerID)
|
||||||
mariaDB, err := s.getDB()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("database not available: %w", err)
|
return 0, err
|
||||||
}
|
}
|
||||||
|
for i := range localItems {
|
||||||
// Create repository
|
localItems[i].PricelistID = localPricelistID
|
||||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
|
||||||
|
|
||||||
// Get items from server
|
|
||||||
serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("getting server pricelist items: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert and save locally
|
|
||||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
|
||||||
for i, item := range serverItems {
|
|
||||||
localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
|
|
||||||
}
|
|
||||||
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
|
|
||||||
slog.Warn("pricelist stock enrichment skipped", "pricelist_id", localPricelistID, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||||
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
||||||
}
|
}
|
||||||
@@ -702,6 +769,30 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
|||||||
return len(localItems), nil
|
return len(localItems), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) fetchServerPricelistItems(serverPricelistID uint) ([]localdb.LocalPricelistItem, error) {
|
||||||
|
// Get database connection
|
||||||
|
mariaDB, err := s.getDB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("database not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create repository
|
||||||
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||||
|
|
||||||
|
// Get items from server
|
||||||
|
serverItems, _, err := pricelistRepo.GetItems(serverPricelistID, 0, 10000, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting server pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||||
|
for i, item := range serverItems {
|
||||||
|
localItems[i] = *localdb.PricelistItemToLocal(&item, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return localItems, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
|
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
|
||||||
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
|
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
|
||||||
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||||
@@ -711,111 +802,6 @@ func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, err
|
|||||||
return s.SyncPricelistItems(localPL.ID)
|
return s.SyncPricelistItems(localPL.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) enrichLocalPricelistItemsWithStock(mariaDB *gorm.DB, items []localdb.LocalPricelistItem) error {
|
|
||||||
if len(items) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
bookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
|
|
||||||
book, err := bookRepo.GetActiveBook()
|
|
||||||
if err != nil || book == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
bookItems, err := bookRepo.GetBookItems(book.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(bookItems) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
partnumberToLots := make(map[string][]string, len(bookItems))
|
|
||||||
for _, item := range bookItems {
|
|
||||||
pn := strings.TrimSpace(item.Partnumber)
|
|
||||||
if pn == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenLots := make(map[string]struct{}, len(item.LotsJSON))
|
|
||||||
for _, lot := range item.LotsJSON {
|
|
||||||
lotName := strings.TrimSpace(lot.LotName)
|
|
||||||
if lotName == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.ToLower(lotName)
|
|
||||||
if _, exists := seenLots[key]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenLots[key] = struct{}{}
|
|
||||||
partnumberToLots[pn] = append(partnumberToLots[pn], lotName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(partnumberToLots) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type stockRow struct {
|
|
||||||
Partnumber string `gorm:"column:partnumber"`
|
|
||||||
Qty *float64 `gorm:"column:qty"`
|
|
||||||
}
|
|
||||||
rows := make([]stockRow, 0)
|
|
||||||
if err := mariaDB.Raw(`
|
|
||||||
SELECT s.partnumber, s.qty
|
|
||||||
FROM stock_log s
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT partnumber, MAX(date) AS max_date
|
|
||||||
FROM stock_log
|
|
||||||
GROUP BY partnumber
|
|
||||||
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
|
|
||||||
WHERE s.qty IS NOT NULL
|
|
||||||
`).Scan(&rows).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
lotTotals := make(map[string]float64, len(items))
|
|
||||||
lotPartnumbers := make(map[string][]string, len(items))
|
|
||||||
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
pn := strings.TrimSpace(row.Partnumber)
|
|
||||||
if pn == "" || row.Qty == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lots := partnumberToLots[pn]
|
|
||||||
if len(lots) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, lotName := range lots {
|
|
||||||
lotTotals[lotName] += *row.Qty
|
|
||||||
if _, ok := seenPartnumbers[lotName]; !ok {
|
|
||||||
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
|
|
||||||
}
|
|
||||||
key := strings.ToLower(pn)
|
|
||||||
if _, exists := seenPartnumbers[lotName][key]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenPartnumbers[lotName][key] = struct{}{}
|
|
||||||
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range items {
|
|
||||||
lotName := strings.TrimSpace(items[i].LotName)
|
|
||||||
if qty, ok := lotTotals[lotName]; ok {
|
|
||||||
qtyCopy := qty
|
|
||||||
items[i].AvailableQty = &qtyCopy
|
|
||||||
}
|
|
||||||
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
|
|
||||||
sort.Slice(partnumbers, func(a, b int) bool {
|
|
||||||
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
|
|
||||||
})
|
|
||||||
items[i].Partnumbers = append(localdb.LocalStringList{}, partnumbers...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
||||||
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
||||||
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
||||||
@@ -847,9 +833,15 @@ func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.Local
|
|||||||
return localPL, nil
|
return localPL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed
|
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed.
|
||||||
// This should be called before creating a new configuration when online
|
// If a sync is already in progress, returns immediately without blocking.
|
||||||
func (s *Service) SyncPricelistsIfNeeded() error {
|
func (s *Service) SyncPricelistsIfNeeded() error {
|
||||||
|
if !s.pricelistMu.TryLock() {
|
||||||
|
slog.Debug("pricelist sync already in progress, skipping")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer s.pricelistMu.Unlock()
|
||||||
|
|
||||||
needSync, err := s.NeedSync()
|
needSync, err := s.NeedSync()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to check if sync needed", "error", err)
|
slog.Warn("failed to check if sync needed", "error", err)
|
||||||
@@ -901,6 +893,7 @@ func (s *Service) PushPendingChanges() (int, error) {
|
|||||||
for _, change := range sortedChanges {
|
for _, change := range sortedChanges {
|
||||||
err := s.pushSingleChange(&change)
|
err := s.pushSingleChange(&change)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.markConnectionBroken(err)
|
||||||
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
||||||
// Increment attempts
|
// Increment attempts
|
||||||
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
|
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
|
|||||||
&models.Pricelist{},
|
&models.Pricelist{},
|
||||||
&models.PricelistItem{},
|
&models.PricelistItem{},
|
||||||
&models.Lot{},
|
&models.Lot{},
|
||||||
&models.StockLog{},
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("migrate server tables: %v", err)
|
t.Fatalf("migrate server tables: %v", err)
|
||||||
}
|
}
|
||||||
@@ -103,103 +102,3 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
|
|||||||
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
|
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSyncPricelistItems_EnrichesStockFromLocalPartnumberBook(t *testing.T) {
|
|
||||||
local := newLocalDBForSyncTest(t)
|
|
||||||
serverDB := newServerDBForSyncTest(t)
|
|
||||||
|
|
||||||
if err := serverDB.AutoMigrate(
|
|
||||||
&models.Pricelist{},
|
|
||||||
&models.PricelistItem{},
|
|
||||||
&models.Lot{},
|
|
||||||
&models.StockLog{},
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("migrate server tables: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
serverPL := models.Pricelist{
|
|
||||||
Source: "warehouse",
|
|
||||||
Version: "2026-03-07-001",
|
|
||||||
Notification: "server",
|
|
||||||
CreatedBy: "tester",
|
|
||||||
IsActive: true,
|
|
||||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
|
||||||
}
|
|
||||||
if err := serverDB.Create(&serverPL).Error; err != nil {
|
|
||||||
t.Fatalf("create server pricelist: %v", err)
|
|
||||||
}
|
|
||||||
if err := serverDB.Create(&models.PricelistItem{
|
|
||||||
PricelistID: serverPL.ID,
|
|
||||||
LotName: "CPU_A",
|
|
||||||
LotCategory: "CPU",
|
|
||||||
Price: 10,
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("create server pricelist item: %v", err)
|
|
||||||
}
|
|
||||||
qty := 7.0
|
|
||||||
if err := serverDB.Create(&models.StockLog{
|
|
||||||
Partnumber: "CPU-PN-1",
|
|
||||||
Date: time.Now(),
|
|
||||||
Price: 100,
|
|
||||||
Qty: &qty,
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("create stock log: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
|
||||||
ServerID: serverPL.ID,
|
|
||||||
Source: serverPL.Source,
|
|
||||||
Version: serverPL.Version,
|
|
||||||
Name: serverPL.Notification,
|
|
||||||
CreatedAt: serverPL.CreatedAt,
|
|
||||||
SyncedAt: time.Now(),
|
|
||||||
IsUsed: false,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("seed local pricelist: %v", err)
|
|
||||||
}
|
|
||||||
localPL, err := local.GetLocalPricelistByServerID(serverPL.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get local pricelist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := local.DB().Create(&localdb.LocalPartnumberBook{
|
|
||||||
ServerID: 1,
|
|
||||||
Version: "2026-03-07-001",
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
IsActive: true,
|
|
||||||
PartnumbersJSON: localdb.LocalStringList{"CPU-PN-1"},
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("create local partnumber book: %v", err)
|
|
||||||
}
|
|
||||||
if err := local.DB().Create(&localdb.LocalPartnumberBookItem{
|
|
||||||
Partnumber: "CPU-PN-1",
|
|
||||||
LotsJSON: localdb.LocalPartnumberBookLots{
|
|
||||||
{LotName: "CPU_A", Qty: 1},
|
|
||||||
},
|
|
||||||
Description: "CPU PN",
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatalf("create local partnumber book item: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
|
||||||
if _, err := svc.SyncPricelistItems(localPL.ID); err != nil {
|
|
||||||
t.Fatalf("sync pricelist items: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
items, err := local.GetLocalPricelistItems(localPL.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load local items: %v", err)
|
|
||||||
}
|
|
||||||
if len(items) != 1 {
|
|
||||||
t.Fatalf("expected 1 local item, got %d", len(items))
|
|
||||||
}
|
|
||||||
if items[0].AvailableQty == nil {
|
|
||||||
t.Fatalf("expected available_qty to be set")
|
|
||||||
}
|
|
||||||
if *items[0].AvailableQty != 7 {
|
|
||||||
t.Fatalf("expected available_qty=7, got %v", *items[0].AvailableQty)
|
|
||||||
}
|
|
||||||
if len(items[0].Partnumbers) != 1 || items[0].Partnumbers[0] != "CPU-PN-1" {
|
|
||||||
t.Fatalf("expected partnumbers [CPU-PN-1], got %v", items[0].Partnumbers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package sync_test
|
package sync_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
||||||
@@ -83,3 +86,58 @@ func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
|||||||
t.Fatalf("expected server pricelist to be synced locally: %v", err)
|
t.Fatalf("expected server pricelist to be synced locally: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncPricelistsDoesNotPersistHeaderWithoutItems(t *testing.T) {
|
||||||
|
local := newLocalDBForSyncTest(t)
|
||||||
|
serverDB := newServerDBForSyncTest(t)
|
||||||
|
if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
|
||||||
|
t.Fatalf("migrate server pricelist tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverPL := models.Pricelist{
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "2026-03-17-001",
|
||||||
|
Notification: "server",
|
||||||
|
CreatedBy: "tester",
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := serverDB.Create(&serverPL).Error; err != nil {
|
||||||
|
t.Fatalf("create server pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil {
|
||||||
|
t.Fatalf("create server pricelist item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackName = "test:fail_qt_pricelist_items_query"
|
||||||
|
if err := serverDB.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
|
||||||
|
if db.Statement != nil && db.Statement.Table == "qt_pricelist_items" {
|
||||||
|
_ = db.AddError(errors.New("forced pricelist item fetch failure"))
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("register query callback: %v", err)
|
||||||
|
}
|
||||||
|
defer serverDB.Callback().Query().Remove(callbackName)
|
||||||
|
|
||||||
|
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||||
|
synced, err := svc.SyncPricelists()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected sync error when item fetch fails")
|
||||||
|
}
|
||||||
|
if synced != 0 {
|
||||||
|
t.Fatalf("expected synced=0 on incomplete sync, got %d", synced)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "forced pricelist item fetch failure") {
|
||||||
|
t.Fatalf("expected item fetch error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := local.GetLocalPricelistByServerID(serverPL.ID); err == nil {
|
||||||
|
t.Fatalf("expected pricelist header not to be persisted without items")
|
||||||
|
}
|
||||||
|
if got := local.CountLocalPricelists(); got != 0 {
|
||||||
|
t.Fatalf("expected no local pricelists after failed sync, got %d", got)
|
||||||
|
}
|
||||||
|
if ts := local.GetLastSyncTime(); ts != nil {
|
||||||
|
t.Fatalf("expected last_pricelist_sync to stay unset on incomplete sync, got %v", ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
2
migrations/029_add_config_type.sql
Normal file
2
migrations/029_add_config_type.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN config_type VARCHAR(20) NOT NULL DEFAULT 'server';
|
||||||
@@ -3,16 +3,48 @@
|
|||||||
Дата релиза: 2026-03-16
|
Дата релиза: 2026-03-16
|
||||||
Тег: `v1.5.4`
|
Тег: `v1.5.4`
|
||||||
|
|
||||||
|
Предыдущий релиз: `v1.5.0`
|
||||||
|
|
||||||
## Ключевые изменения
|
## Ключевые изменения
|
||||||
|
|
||||||
- runtime автоматически нормализует `server.host` к `127.0.0.1` и переписывает некорректный локальный конфиг;
|
- pricing tab переработан: закупка и продажа разделены на отдельные таблицы с ценами за 1 шт.;
|
||||||
|
- экран прайслиста переработан под разные типы источников; удалены misleading-колонки `Поставщик` и `partnumbers`;
|
||||||
|
- runtime и startup ужесточены: локальный клиент принудительно работает только на loopback, конфиг автоматически нормализуется;
|
||||||
- добавлены действия с вариантом и унифицированы правила именования `_копия` для вариантов и конфигураций;
|
- добавлены действия с вариантом и унифицированы правила именования `_копия` для вариантов и конфигураций;
|
||||||
- исправлен CSV-экспорт прайсинговых таблиц в конфигураторе под Excel-совместимый формат;
|
- исправлен CSV-экспорт прайсинговых таблиц в конфигураторе под Excel-совместимый формат Excel-friendly;
|
||||||
- таблица проектов переработана: новая колонка даты, tooltip с деталями, отдельный автор, компактные действия и ссылка на трекер;
|
- таблица проектов переработана: дата последней правки, tooltip с деталями, отдельный автор, компактные действия и ссылка на трекер;
|
||||||
- sync больше не подменяет `updated_at` проектов временем синхронизации;
|
- sync больше не подменяет `updated_at` проектов временем синхронизации;
|
||||||
- добавлена одноразовая утилита `cmd/migrate_project_updated_at` для пересинхронизации `updated_at` проектов из MariaDB в локальную SQLite.
|
- добавлена одноразовая утилита `cmd/migrate_project_updated_at` для пересинхронизации `updated_at` проектов из MariaDB в локальную SQLite;
|
||||||
|
- runtime config, release notes и `bible-local/` очищены и приведены к актуальной архитектуре;
|
||||||
- `scripts/release.sh` больше не затирает существующий `RELEASE_NOTES.md`.
|
- `scripts/release.sh` больше не затирает существующий `RELEASE_NOTES.md`.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
### UI и UX
|
||||||
|
|
||||||
|
- вкладка ценообразования теперь разделена на отдельные таблицы закупки и продажи;
|
||||||
|
- список проектов переработан: новая колонка даты, отдельный автор, tooltip с деталями, компактные действия, ссылка на трекер;
|
||||||
|
- для вариантов добавлены действия переименования, переноса и копирования;
|
||||||
|
- копии вариантов и конфигураций теперь именуются единообразно: `_копия`, `_копия2`, `_копия3`.
|
||||||
|
|
||||||
|
### Прайслисты и экспорт
|
||||||
|
|
||||||
|
- экран прайслиста переработан под разные типы источников;
|
||||||
|
- из прайслистов убраны misleading-колонки `Поставщик` и `partnumbers`;
|
||||||
|
- CSV-экспорт прайсинговых таблиц в конфигураторе приведён к Excel-совместимому формату.
|
||||||
|
|
||||||
|
### Runtime и sync
|
||||||
|
|
||||||
|
- локальный runtime нормализует `server.host` к `127.0.0.1` и переписывает некорректный runtime config;
|
||||||
|
- sync перестал подменять `updated_at` проектов временем локальной синхронизации;
|
||||||
|
- добавлена утилита `cmd/migrate_project_updated_at` для восстановления локальных дат проектов с сервера.
|
||||||
|
|
||||||
|
### Документация и release tooling
|
||||||
|
|
||||||
|
- `bible-local/` сокращён до актуальных архитектурных контрактов;
|
||||||
|
- release notes и release-структура приведены к одному формату;
|
||||||
|
- `scripts/release.sh` теперь сохраняет существующий `RELEASE_NOTES.md` и не затирает его шаблоном.
|
||||||
|
|
||||||
## Затронутые области
|
## Затронутые области
|
||||||
|
|
||||||
- `cmd/qfs/`;
|
- `cmd/qfs/`;
|
||||||
@@ -20,6 +52,8 @@
|
|||||||
- `internal/localdb/`;
|
- `internal/localdb/`;
|
||||||
- `internal/services/project.go`;
|
- `internal/services/project.go`;
|
||||||
- `internal/services/sync/service.go`;
|
- `internal/services/sync/service.go`;
|
||||||
|
- `internal/handlers/pricelist.go`;
|
||||||
|
- `web/templates/pricelist_detail.html`;
|
||||||
- `web/templates/index.html`;
|
- `web/templates/index.html`;
|
||||||
- `web/templates/project_detail.html`;
|
- `web/templates/project_detail.html`;
|
||||||
- `web/templates/projects.html`;
|
- `web/templates/projects.html`;
|
||||||
|
|||||||
1
web/static/vendor/htmx-1.9.10.min.js
vendored
Normal file
1
web/static/vendor/htmx-1.9.10.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
83
web/static/vendor/tailwindcss.browser.js
vendored
Normal file
83
web/static/vendor/tailwindcss.browser.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -5,8 +5,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{template "title" .}}</title>
|
<title>{{template "title" .}}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<script src="/static/vendor/tailwindcss.browser.js"></script>
|
||||||
|
<script src="/static/vendor/htmx-1.9.10.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
.htmx-request { opacity: 0.5; }
|
.htmx-request { opacity: 0.5; }
|
||||||
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
@@ -43,6 +44,10 @@
|
|||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 text-right">
|
||||||
|
<span class="text-xs text-gray-400">v{{.AppVersion}}</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
||||||
|
|
||||||
<!-- Sync Info Modal -->
|
<!-- Sync Info Modal -->
|
||||||
@@ -92,6 +97,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-pricelist-sync-issue" class="hidden">
|
||||||
|
<h4 class="font-medium text-red-700 mb-2">Состояние прайслистов</h4>
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm space-y-1">
|
||||||
|
<div id="modal-pricelist-sync-summary" class="text-red-700">—</div>
|
||||||
|
<div id="modal-pricelist-sync-attempt" class="text-red-600 text-xs hidden"></div>
|
||||||
|
<div id="modal-pricelist-sync-error" class="text-red-600 text-xs hidden whitespace-pre-wrap"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Section 2: Statistics -->
|
<!-- Section 2: Statistics -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
|
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
|
||||||
@@ -229,6 +243,43 @@
|
|||||||
readinessMinVersion.textContent = '';
|
readinessMinVersion.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncIssueSection = document.getElementById('modal-pricelist-sync-issue');
|
||||||
|
const syncIssueSummary = document.getElementById('modal-pricelist-sync-summary');
|
||||||
|
const syncIssueAttempt = document.getElementById('modal-pricelist-sync-attempt');
|
||||||
|
const syncIssueError = document.getElementById('modal-pricelist-sync-error');
|
||||||
|
const hasSyncFailure = data.last_pricelist_sync_status === 'failed';
|
||||||
|
if (data.has_incomplete_server_sync) {
|
||||||
|
syncIssueSection.classList.remove('hidden');
|
||||||
|
syncIssueSummary.textContent = 'Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые еще не загружены локально.';
|
||||||
|
} else if (hasSyncFailure) {
|
||||||
|
syncIssueSection.classList.remove('hidden');
|
||||||
|
syncIssueSummary.textContent = 'Последняя синхронизация прайслистов завершилась ошибкой.';
|
||||||
|
} else {
|
||||||
|
syncIssueSection.classList.add('hidden');
|
||||||
|
syncIssueSummary.textContent = '';
|
||||||
|
}
|
||||||
|
if (syncIssueSection.classList.contains('hidden')) {
|
||||||
|
syncIssueAttempt.classList.add('hidden');
|
||||||
|
syncIssueAttempt.textContent = '';
|
||||||
|
syncIssueError.classList.add('hidden');
|
||||||
|
syncIssueError.textContent = '';
|
||||||
|
} else {
|
||||||
|
if (data.last_pricelist_attempt_at) {
|
||||||
|
syncIssueAttempt.classList.remove('hidden');
|
||||||
|
syncIssueAttempt.textContent = 'Последняя попытка: ' + new Date(data.last_pricelist_attempt_at).toLocaleString('ru-RU');
|
||||||
|
} else {
|
||||||
|
syncIssueAttempt.classList.add('hidden');
|
||||||
|
syncIssueAttempt.textContent = '';
|
||||||
|
}
|
||||||
|
if (data.last_pricelist_sync_error) {
|
||||||
|
syncIssueError.classList.remove('hidden');
|
||||||
|
syncIssueError.textContent = data.last_pricelist_sync_error;
|
||||||
|
} else {
|
||||||
|
syncIssueError.classList.add('hidden');
|
||||||
|
syncIssueError.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Section 2: Statistics
|
// Section 2: Statistics
|
||||||
document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—';
|
document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—';
|
||||||
document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—';
|
document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Ревизии - QuoteForge{{end}}
|
{{define "title"}}Ревизии - OFS{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -135,15 +135,18 @@ async function loadVersions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderVersions(versions) {
|
function renderVersions(versions) {
|
||||||
if (versions.length === 0) {
|
const currentVersionNo = configData && configData.current_version_no ? Number(configData.current_version_no) : null;
|
||||||
|
const snapshots = versions.filter(v => Number(v.version_no) !== currentVersionNo);
|
||||||
|
|
||||||
|
if (snapshots.length === 0) {
|
||||||
document.getElementById('revisions-list').innerHTML =
|
document.getElementById('revisions-list').innerHTML =
|
||||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет ревизий</div>';
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет прошлых снимков. Рабочая версия остается main.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||||||
html += '<thead class="bg-gray-50"><tr>';
|
html += '<thead class="bg-gray-50"><tr>';
|
||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Снимок</th>';
|
||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>';
|
||||||
@@ -152,16 +155,14 @@ function renderVersions(versions) {
|
|||||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||||
html += '</tr></thead><tbody class="divide-y">';
|
html += '</tr></thead><tbody class="divide-y">';
|
||||||
|
|
||||||
versions.forEach((v, idx) => {
|
snapshots.forEach((v) => {
|
||||||
const date = new Date(v.created_at).toLocaleString('ru-RU');
|
const date = new Date(v.created_at).toLocaleString('ru-RU');
|
||||||
const author = v.created_by || '—';
|
const author = v.created_by || '—';
|
||||||
const snapshot = parseVersionSnapshot(v);
|
const snapshot = parseVersionSnapshot(v);
|
||||||
const isCurrent = idx === 0;
|
|
||||||
|
|
||||||
html += '<tr class="hover:bg-gray-50' + (isCurrent ? ' bg-blue-50' : '') + '">';
|
html += '<tr class="hover:bg-gray-50">';
|
||||||
html += '<td class="px-4 py-3 text-sm font-medium">';
|
html += '<td class="px-4 py-3 text-sm font-medium">';
|
||||||
html += 'v' + v.version_no;
|
html += 'v' + v.version_no;
|
||||||
if (isCurrent) html += ' <span class="text-xs text-blue-600 font-normal">(текущая)</span>';
|
|
||||||
html += '</td>';
|
html += '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(date) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(date) + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||||
@@ -178,11 +179,8 @@ function renderVersions(versions) {
|
|||||||
html += '<button onclick="cloneFromVersion(' + v.version_no + ')" class="text-green-600 hover:text-green-800" title="Скопировать из этой ревизии">';
|
html += '<button onclick="cloneFromVersion(' + v.version_no + ')" class="text-green-600 hover:text-green-800" 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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
|
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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
|
||||||
|
|
||||||
// Rollback (not for current version)
|
html += '<button onclick="rollbackToVersion(' + v.version_no + ')" class="text-orange-600 hover:text-orange-800" title="Восстановить этот снимок как новую main-версию">';
|
||||||
if (!isCurrent) {
|
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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path></svg></button>';
|
||||||
html += '<button onclick="rollbackToVersion(' + v.version_no + ')" class="text-orange-600 hover:text-orange-800" 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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path></svg></button>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</td></tr>';
|
html += '</td></tr>';
|
||||||
});
|
});
|
||||||
@@ -212,7 +210,7 @@ async function cloneFromVersion(versionNo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function rollbackToVersion(versionNo) {
|
async function rollbackToVersion(versionNo) {
|
||||||
if (!confirm('Восстановить конфигурацию до ревизии v' + versionNo + '?')) return;
|
if (!confirm('Восстановить снимок v' + versionNo + ' как новую рабочую версию main?')) return;
|
||||||
const resp = await fetch('/api/configs/' + configUUID + '/rollback', {
|
const resp = await fetch('/api/configs/' + configUUID + '/rollback', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Мои конфигурации - QuoteForge{{end}}
|
{{define "title"}}Мои конфигурации - OFS{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -53,6 +53,19 @@
|
|||||||
<h2 class="text-xl font-semibold mb-4">Новая конфигурация</h2>
|
<h2 class="text-xl font-semibold mb-4">Новая конфигурация</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label>
|
||||||
|
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
|
||||||
|
<button type="button" id="type-server-btn" onclick="setCreateType('server')"
|
||||||
|
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||||
|
Сервер
|
||||||
|
</button>
|
||||||
|
<button type="button" id="type-storage-btn" onclick="setCreateType('storage')"
|
||||||
|
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
|
||||||
|
СХД
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<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="opportunity-number" placeholder="Например: Сервер для проекта X"
|
<input type="text" id="opportunity-number" placeholder="Например: Сервер для проекта X"
|
||||||
@@ -518,7 +531,19 @@ async function cloneConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let createConfigType = 'server';
|
||||||
|
|
||||||
|
function setCreateType(type) {
|
||||||
|
createConfigType = type;
|
||||||
|
document.getElementById('type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' +
|
||||||
|
(type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200');
|
||||||
|
document.getElementById('type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' +
|
||||||
|
(type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
|
createConfigType = 'server';
|
||||||
|
setCreateType('server');
|
||||||
document.getElementById('opportunity-number').value = '';
|
document.getElementById('opportunity-number').value = '';
|
||||||
document.getElementById('create-project-input').value = '';
|
document.getElementById('create-project-input').value = '';
|
||||||
document.getElementById('create-modal').classList.remove('hidden');
|
document.getElementById('create-modal').classList.remove('hidden');
|
||||||
@@ -573,7 +598,8 @@ async function createConfigWithProject(name, projectUUID) {
|
|||||||
items: [],
|
items: [],
|
||||||
notes: '',
|
notes: '',
|
||||||
server_count: 1,
|
server_count: 1,
|
||||||
project_uuid: projectUUID || null
|
project_uuid: projectUUID || null,
|
||||||
|
config_type: createConfigType
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}QuoteForge - Конфигуратор{{end}}
|
{{define "title"}}OFS - Конфигуратор{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<span id="breadcrumb-config-name">Конфигуратор</span>
|
<span id="breadcrumb-config-name">Конфигуратор</span>
|
||||||
</a>
|
</a>
|
||||||
<span class="text-gray-400">-</span>
|
<span class="text-gray-400">-</span>
|
||||||
<span id="breadcrumb-config-version">v1</span>
|
<span id="breadcrumb-config-version">main</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
||||||
@@ -108,6 +108,10 @@
|
|||||||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||||||
Accessories
|
Accessories
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="switchTab('sw')" data-tab="sw"
|
||||||
|
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||||||
|
SW
|
||||||
|
</button>
|
||||||
<button onclick="switchTab('other')" data-tab="other"
|
<button onclick="switchTab('other')" data-tab="other"
|
||||||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||||||
Other
|
Other
|
||||||
@@ -211,9 +215,9 @@
|
|||||||
<table class="w-full text-sm border-collapse">
|
<table class="w-full text-sm border-collapse">
|
||||||
<thead class="bg-gray-50 text-gray-700">
|
<thead class="bg-gray-50 text-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-3 py-2 text-left border-b">LOT</th>
|
|
||||||
<th class="px-3 py-2 text-left border-b">PN вендора</th>
|
<th class="px-3 py-2 text-left border-b">PN вендора</th>
|
||||||
<th class="px-3 py-2 text-left border-b">Описание</th>
|
<th class="px-3 py-2 text-left border-b">Описание</th>
|
||||||
|
<th class="px-3 py-2 text-left border-b">LOT</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Кол-во</th>
|
<th class="px-3 py-2 text-right border-b">Кол-во</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Estimate</th>
|
<th class="px-3 py-2 text-right border-b">Estimate</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Склад</th>
|
<th class="px-3 py-2 text-right border-b">Склад</th>
|
||||||
@@ -236,12 +240,12 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-4">
|
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||||||
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
|
<label class="text-sm font-medium text-gray-700">Ручная цена:</label>
|
||||||
<input type="number" id="pricing-custom-price-buy" step="0.01" min="0" placeholder="0.00"
|
<input type="number" id="pricing-custom-price-buy" step="0.01" min="0" placeholder="0.00"
|
||||||
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
oninput="onBuyCustomPriceInput()">
|
oninput="onBuyCustomPriceInput()">
|
||||||
<button onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm">
|
<button onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm">
|
||||||
Проставить цены BOM
|
BOM Цена
|
||||||
</button>
|
</button>
|
||||||
<button onclick="exportPricingCSV('buy')" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
|
<button onclick="exportPricingCSV('buy')" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
|
||||||
Экспорт CSV
|
Экспорт CSV
|
||||||
@@ -260,9 +264,9 @@
|
|||||||
<table class="w-full text-sm border-collapse">
|
<table class="w-full text-sm border-collapse">
|
||||||
<thead class="bg-gray-50 text-gray-700">
|
<thead class="bg-gray-50 text-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-3 py-2 text-left border-b">LOT</th>
|
|
||||||
<th class="px-3 py-2 text-left border-b">PN вендора</th>
|
<th class="px-3 py-2 text-left border-b">PN вендора</th>
|
||||||
<th class="px-3 py-2 text-left border-b">Описание</th>
|
<th class="px-3 py-2 text-left border-b">Описание</th>
|
||||||
|
<th class="px-3 py-2 text-left border-b">LOT</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Кол-во</th>
|
<th class="px-3 py-2 text-right border-b">Кол-во</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Estimate</th>
|
<th class="px-3 py-2 text-right border-b">Estimate</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Склад</th>
|
<th class="px-3 py-2 text-right border-b">Склад</th>
|
||||||
@@ -289,7 +293,7 @@
|
|||||||
<input type="text" id="pricing-uplift-sale" inputmode="decimal" placeholder="1,3000"
|
<input type="text" id="pricing-uplift-sale" inputmode="decimal" placeholder="1,3000"
|
||||||
class="w-28 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
class="w-28 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
oninput="onSaleMarkupInput()">
|
oninput="onSaleMarkupInput()">
|
||||||
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
|
<label class="text-sm font-medium text-gray-700">Ручная цена:</label>
|
||||||
<input type="number" id="pricing-custom-price-sale" step="0.01" min="0" placeholder="0.00"
|
<input type="number" id="pricing-custom-price-sale" step="0.01" min="0" placeholder="0.00"
|
||||||
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
oninput="onSaleCustomPriceInput()">
|
oninput="onSaleCustomPriceInput()">
|
||||||
@@ -365,7 +369,7 @@
|
|||||||
// Tab configuration - will be populated dynamically
|
// Tab configuration - will be populated dynamically
|
||||||
let TAB_CONFIG = {
|
let TAB_CONFIG = {
|
||||||
base: {
|
base: {
|
||||||
categories: ['MB', 'CPU', 'MEM'],
|
categories: ['MB', 'CPU', 'MEM', 'ENC', 'DKC', 'CTL'],
|
||||||
singleSelect: true,
|
singleSelect: true,
|
||||||
label: 'Base'
|
label: 'Base'
|
||||||
},
|
},
|
||||||
@@ -379,13 +383,14 @@ let TAB_CONFIG = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
pci: {
|
pci: {
|
||||||
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA'],
|
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'],
|
||||||
singleSelect: false,
|
singleSelect: false,
|
||||||
label: 'PCI',
|
label: 'PCI',
|
||||||
sections: [
|
sections: [
|
||||||
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
||||||
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
||||||
{ title: 'HBA', categories: ['HBA'] }
|
{ title: 'HBA', categories: ['HBA'] },
|
||||||
|
{ title: 'HIC', categories: ['HIC'] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
power: {
|
power: {
|
||||||
@@ -398,6 +403,11 @@ let TAB_CONFIG = {
|
|||||||
singleSelect: false,
|
singleSelect: false,
|
||||||
label: 'Accessories'
|
label: 'Accessories'
|
||||||
},
|
},
|
||||||
|
sw: {
|
||||||
|
categories: ['SW'],
|
||||||
|
singleSelect: false,
|
||||||
|
label: 'SW'
|
||||||
|
},
|
||||||
other: {
|
other: {
|
||||||
categories: [],
|
categories: [],
|
||||||
singleSelect: false,
|
singleSelect: false,
|
||||||
@@ -411,6 +421,7 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
|||||||
|
|
||||||
// State
|
// State
|
||||||
let configUUID = '{{.ConfigUUID}}';
|
let configUUID = '{{.ConfigUUID}}';
|
||||||
|
let configType = 'server';
|
||||||
let configName = '';
|
let configName = '';
|
||||||
let currentVersionNo = 1;
|
let currentVersionNo = 1;
|
||||||
let projectUUID = '';
|
let projectUUID = '';
|
||||||
@@ -476,7 +487,8 @@ function updateConfigBreadcrumbs() {
|
|||||||
const fullConfigName = configName || 'Конфигурация';
|
const fullConfigName = configName || 'Конфигурация';
|
||||||
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||||
configEl.title = fullConfigName;
|
configEl.title = fullConfigName;
|
||||||
versionEl.textContent = 'v' + (currentVersionNo || 1);
|
versionEl.textContent = 'main';
|
||||||
|
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — OFS';
|
||||||
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||||
if (configNameLinkEl && configUUID) {
|
if (configNameLinkEl && configUUID) {
|
||||||
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||||
@@ -792,6 +804,8 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
|
|
||||||
const config = await resp.json();
|
const config = await resp.json();
|
||||||
configName = config.name;
|
configName = config.name;
|
||||||
|
configType = config.config_type || 'server';
|
||||||
|
applyConfigTypeToTabs();
|
||||||
currentVersionNo = config.current_version_no || 1;
|
currentVersionNo = config.current_version_no || 1;
|
||||||
projectUUID = config.project_uuid || '';
|
projectUUID = config.project_uuid || '';
|
||||||
await loadProjectIndex();
|
await loadProjectIndex();
|
||||||
@@ -823,6 +837,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
serverModelForQuote = config.server_model || '';
|
serverModelForQuote = config.server_model || '';
|
||||||
supportCode = config.support_code || '';
|
supportCode = config.support_code || '';
|
||||||
currentArticle = config.article || '';
|
currentArticle = config.article || '';
|
||||||
|
restorePricingStateFromNotes(config.notes || '');
|
||||||
|
|
||||||
// Restore custom price if saved
|
// Restore custom price if saved
|
||||||
if (config.custom_price) {
|
if (config.custom_price) {
|
||||||
@@ -925,14 +940,9 @@ async function loadActivePricelists(force = false) {
|
|||||||
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
activePricelistsBySource[source] = data.pricelists || [];
|
activePricelistsBySource[source] = data.pricelists || [];
|
||||||
const existing = selectedPricelistIds[source];
|
// Do not reset the stored pricelist — it may be inactive but must be preserved
|
||||||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectedPricelistIds[source] = null;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
activePricelistsBySource[source] = [];
|
activePricelistsBySource[source] = [];
|
||||||
selectedPricelistIds[source] = null;
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
activePricelistsLoadedAt = Date.now();
|
activePricelistsLoadedAt = Date.now();
|
||||||
@@ -954,11 +964,25 @@ function renderPricelistSelectOptions(selectId, source) {
|
|||||||
select.value = '';
|
select.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
select.innerHTML = `<option value="">Авто (последний активный)</option>` + pricelists.map(pl => {
|
select.innerHTML = pricelists.map(pl => {
|
||||||
return `<option value="${pl.id}">${escapeHtml(pl.version)}</option>`;
|
return `<option value="${pl.id}">${escapeHtml(pl.version)}</option>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
const current = selectedPricelistIds[source];
|
const current = selectedPricelistIds[source];
|
||||||
select.value = current ? String(current) : '';
|
if (current) {
|
||||||
|
select.value = String(current);
|
||||||
|
// Stored pricelist may be inactive — add it as a virtual option if not found
|
||||||
|
if (!select.value) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = String(current);
|
||||||
|
opt.textContent = `ID ${current} (неактивный)`;
|
||||||
|
select.prepend(opt);
|
||||||
|
select.value = String(current);
|
||||||
|
}
|
||||||
|
} else if (pricelists.length > 0) {
|
||||||
|
// New config: pre-select the first (latest) pricelist
|
||||||
|
selectedPricelistIds[source] = Number(pricelists[0].id);
|
||||||
|
select.value = String(pricelists[0].id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPriceSettingsControls() {
|
function syncPriceSettingsControls() {
|
||||||
@@ -984,9 +1008,9 @@ function getPricelistVersionById(source, id) {
|
|||||||
function renderPricelistSettingsSummary() {
|
function renderPricelistSettingsSummary() {
|
||||||
const summary = document.getElementById('pricelist-settings-summary');
|
const summary = document.getElementById('pricelist-settings-summary');
|
||||||
if (!summary) return;
|
if (!summary) return;
|
||||||
const estimate = selectedPricelistIds.estimate ? getPricelistVersionById('estimate', selectedPricelistIds.estimate) || `ID ${selectedPricelistIds.estimate}` : 'авто';
|
const estimate = selectedPricelistIds.estimate ? getPricelistVersionById('estimate', selectedPricelistIds.estimate) || `ID ${selectedPricelistIds.estimate}` : '—';
|
||||||
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
|
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : '—';
|
||||||
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
|
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : '—';
|
||||||
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
|
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
|
||||||
const stockFilterState = onlyInStock ? ' | Только наличие: вкл' : '';
|
const stockFilterState = onlyInStock ? ' | Только наличие: вкл' : '';
|
||||||
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}${stockFilterState}`;
|
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}${stockFilterState}`;
|
||||||
@@ -1062,16 +1086,16 @@ function applyPriceSettings() {
|
|||||||
const inStockVal = Boolean(document.getElementById('settings-only-in-stock')?.checked);
|
const inStockVal = Boolean(document.getElementById('settings-only-in-stock')?.checked);
|
||||||
|
|
||||||
const prevWarehouseID = currentWarehousePricelistID();
|
const prevWarehouseID = currentWarehousePricelistID();
|
||||||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
if (Number.isFinite(estimateVal) && estimateVal > 0) {
|
||||||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
selectedPricelistIds.estimate = estimateVal;
|
||||||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
|
||||||
if (selectedPricelistIds.estimate) {
|
|
||||||
resolvedAutoPricelistIds.estimate = null;
|
resolvedAutoPricelistIds.estimate = null;
|
||||||
}
|
}
|
||||||
if (selectedPricelistIds.warehouse) {
|
if (Number.isFinite(warehouseVal) && warehouseVal > 0) {
|
||||||
|
selectedPricelistIds.warehouse = warehouseVal;
|
||||||
resolvedAutoPricelistIds.warehouse = null;
|
resolvedAutoPricelistIds.warehouse = null;
|
||||||
}
|
}
|
||||||
if (selectedPricelistIds.competitor) {
|
if (Number.isFinite(competitorVal) && competitorVal > 0) {
|
||||||
|
selectedPricelistIds.competitor = competitorVal;
|
||||||
resolvedAutoPricelistIds.competitor = null;
|
resolvedAutoPricelistIds.competitor = null;
|
||||||
}
|
}
|
||||||
disablePriceRefresh = disableVal;
|
disablePriceRefresh = disableVal;
|
||||||
@@ -1129,6 +1153,87 @@ function switchTab(tab) {
|
|||||||
renderTab();
|
renderTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
|
||||||
|
|
||||||
|
// Storage-only categories — hidden for server configs
|
||||||
|
const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC'];
|
||||||
|
// Server-only categories — hidden for storage configs
|
||||||
|
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
|
||||||
|
const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID'];
|
||||||
|
const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU'];
|
||||||
|
const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU'];
|
||||||
|
|
||||||
|
function applyConfigTypeToTabs() {
|
||||||
|
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
|
||||||
|
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
|
||||||
|
const storageSections = [
|
||||||
|
{ title: 'RAID Контроллеры', categories: ['RAID'] },
|
||||||
|
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
|
||||||
|
];
|
||||||
|
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
|
||||||
|
const pciSections = [
|
||||||
|
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
||||||
|
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
||||||
|
{ title: 'HBA', categories: ['HBA'] },
|
||||||
|
{ title: 'HIC', categories: ['HIC'] }
|
||||||
|
];
|
||||||
|
const powerCategories = ['PS', 'PSU'];
|
||||||
|
|
||||||
|
TAB_CONFIG.base.categories = baseCategories.filter(c => {
|
||||||
|
if (configType === 'storage') {
|
||||||
|
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
|
||||||
|
}
|
||||||
|
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
TAB_CONFIG.storage.categories = storageCategories.filter(c => {
|
||||||
|
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
|
||||||
|
});
|
||||||
|
TAB_CONFIG.storage.sections = storageSections.filter(section => {
|
||||||
|
if (configType === 'storage') {
|
||||||
|
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
|
||||||
|
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC';
|
||||||
|
});
|
||||||
|
TAB_CONFIG.pci.sections = pciSections.filter(section => {
|
||||||
|
if (configType === 'storage') {
|
||||||
|
return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat));
|
||||||
|
}
|
||||||
|
return section.title !== 'HIC';
|
||||||
|
});
|
||||||
|
TAB_CONFIG.power.categories = powerCategories.filter(c => {
|
||||||
|
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rebuild assigned categories index
|
||||||
|
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||||
|
.flatMap(t => t.categories)
|
||||||
|
.map(c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTabVisibility() {
|
||||||
|
for (const tabId of Object.keys(TAB_CONFIG)) {
|
||||||
|
if (ALWAYS_VISIBLE_TABS.has(tabId)) continue;
|
||||||
|
const btn = document.querySelector(`[data-tab="${tabId}"]`);
|
||||||
|
if (!btn) continue;
|
||||||
|
const hasComponents = getComponentsForTab(tabId).length > 0;
|
||||||
|
const hasCartItems = cart.some(item => {
|
||||||
|
const cat = (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase();
|
||||||
|
return getTabForCategory(cat) === tabId;
|
||||||
|
});
|
||||||
|
const visible = hasComponents || hasCartItems;
|
||||||
|
btn.classList.toggle('hidden', !visible);
|
||||||
|
// If the current tab just got hidden, fall back to base
|
||||||
|
if (!visible && currentTab === tabId) {
|
||||||
|
switchTab('base');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getComponentsForTab(tab) {
|
function getComponentsForTab(tab) {
|
||||||
const config = TAB_CONFIG[tab];
|
const config = TAB_CONFIG[tab];
|
||||||
return allComponents.filter(comp => {
|
return allComponents.filter(comp => {
|
||||||
@@ -1164,7 +1269,7 @@ function renderSingleSelectTab(categories) {
|
|||||||
if (currentTab === 'base') {
|
if (currentTab === 'base') {
|
||||||
html += `
|
html += `
|
||||||
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||||
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label>
|
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель системы для партномера:</label>
|
||||||
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
|
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||||
@@ -1860,12 +1965,14 @@ function updateMultiQuantity(lotName, value) {
|
|||||||
|
|
||||||
function removeFromCart(lotName) {
|
function removeFromCart(lotName) {
|
||||||
cart = cart.filter(i => i.lot_name !== lotName);
|
cart = cart.filter(i => i.lot_name !== lotName);
|
||||||
|
updateTabVisibility();
|
||||||
renderTab();
|
renderTab();
|
||||||
updateCartUI();
|
updateCartUI();
|
||||||
triggerAutoSave();
|
triggerAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCartUI() {
|
function updateCartUI() {
|
||||||
|
updateTabVisibility();
|
||||||
window._currentCart = cart; // expose for BOM/Pricing tabs
|
window._currentCart = cart; // expose for BOM/Pricing tabs
|
||||||
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||||
document.getElementById('cart-total').textContent = formatMoney(total);
|
document.getElementById('cart-total').textContent = formatMoney(total);
|
||||||
@@ -2015,6 +2122,58 @@ function getCurrentArticle() {
|
|||||||
return currentArticle || '';
|
return currentArticle || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPricingState() {
|
||||||
|
const buyCustom = parseDecimalInput(document.getElementById('pricing-custom-price-buy')?.value || '');
|
||||||
|
const saleUplift = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
|
||||||
|
const saleCustom = parseDecimalInput(document.getElementById('pricing-custom-price-sale')?.value || '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
buy_custom_price: buyCustom > 0 ? buyCustom : null,
|
||||||
|
sale_uplift: saleUplift > 0 ? saleUplift : null,
|
||||||
|
sale_custom_price: saleCustom > 0 ? saleCustom : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeConfigNotes() {
|
||||||
|
return JSON.stringify({
|
||||||
|
pricing_ui: buildPricingState()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restorePricingStateFromNotes(notesRaw) {
|
||||||
|
if (!notesRaw) return;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(notesRaw);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricing = parsed?.pricing_ui;
|
||||||
|
if (!pricing || typeof pricing !== 'object') return;
|
||||||
|
|
||||||
|
const buyInput = document.getElementById('pricing-custom-price-buy');
|
||||||
|
if (buyInput) {
|
||||||
|
buyInput.value = typeof pricing.buy_custom_price === 'number' && pricing.buy_custom_price > 0
|
||||||
|
? pricing.buy_custom_price.toFixed(2)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const upliftInput = document.getElementById('pricing-uplift-sale');
|
||||||
|
if (upliftInput) {
|
||||||
|
upliftInput.value = typeof pricing.sale_uplift === 'number' && pricing.sale_uplift > 0
|
||||||
|
? formatUpliftInput(pricing.sale_uplift)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const saleInput = document.getElementById('pricing-custom-price-sale');
|
||||||
|
if (saleInput) {
|
||||||
|
saleInput.value = typeof pricing.sale_custom_price === 'number' && pricing.sale_custom_price > 0
|
||||||
|
? pricing.sale_custom_price.toFixed(2)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getAutosaveStorageKey() {
|
function getAutosaveStorageKey() {
|
||||||
return `qf_config_autosave_${configUUID || 'default'}`;
|
return `qf_config_autosave_${configUUID || 'default'}`;
|
||||||
}
|
}
|
||||||
@@ -2028,7 +2187,7 @@ function buildSavePayload() {
|
|||||||
name: configName,
|
name: configName,
|
||||||
items: cart,
|
items: cart,
|
||||||
custom_price: customPrice,
|
custom_price: customPrice,
|
||||||
notes: '',
|
notes: serializeConfigNotes(),
|
||||||
server_count: serverCount,
|
server_count: serverCount,
|
||||||
server_model: serverModelForQuote,
|
server_model: serverModelForQuote,
|
||||||
support_code: supportCode,
|
support_code: supportCode,
|
||||||
@@ -2172,9 +2331,8 @@ async function saveConfig(showNotification = true) {
|
|||||||
const saved = await resp.json();
|
const saved = await resp.json();
|
||||||
if (saved && saved.current_version_no) {
|
if (saved && saved.current_version_no) {
|
||||||
currentVersionNo = saved.current_version_no;
|
currentVersionNo = saved.current_version_no;
|
||||||
const versionEl = document.getElementById('breadcrumb-config-version');
|
|
||||||
if (versionEl) versionEl.textContent = 'v' + currentVersionNo;
|
|
||||||
}
|
}
|
||||||
|
updateConfigBreadcrumbs();
|
||||||
hasUnsavedChanges = false;
|
hasUnsavedChanges = false;
|
||||||
clearAutosaveDraft();
|
clearAutosaveDraft();
|
||||||
exitSaveStarted = false;
|
exitSaveStarted = false;
|
||||||
@@ -2508,63 +2666,67 @@ async function refreshPrices() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshBtn = document.getElementById('refresh-prices-btn');
|
||||||
|
const previousLabel = refreshBtn ? refreshBtn.textContent : '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
if (refreshBtn) {
|
||||||
method: 'POST',
|
refreshBtn.disabled = true;
|
||||||
headers: {
|
refreshBtn.textContent = 'Обновление...';
|
||||||
'Content-Type': 'application/json'
|
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentSyncResp = await fetch('/api/sync/components', { method: 'POST' });
|
||||||
|
if (!componentSyncResp.ok) {
|
||||||
|
throw new Error('component sync failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricelistSyncResp = await fetch('/api/sync/pricelists', { method: 'POST' });
|
||||||
|
if (!pricelistSyncResp.ok) {
|
||||||
|
throw new Error('pricelist sync failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
loadActivePricelists(true),
|
||||||
|
loadAllComponents()
|
||||||
|
]);
|
||||||
|
|
||||||
|
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||||||
|
const latest = activePricelistsBySource[source]?.[0];
|
||||||
|
if (latest && latest.id) {
|
||||||
|
selectedPricelistIds[source] = Number(latest.id);
|
||||||
|
resolvedAutoPricelistIds[source] = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
syncPriceSettingsControls();
|
||||||
showToast('Ошибка обновления цен', 'error');
|
renderPricelistSettingsSummary();
|
||||||
return;
|
persistLocalPriceSettings();
|
||||||
}
|
|
||||||
|
|
||||||
const config = await resp.json();
|
await saveConfig(false);
|
||||||
|
await refreshPriceLevels({ force: true, noCache: true });
|
||||||
// Update cart with new prices
|
|
||||||
if (config.items && config.items.length > 0) {
|
|
||||||
cart = config.items.map(item => ({
|
|
||||||
lot_name: item.lot_name,
|
|
||||||
quantity: item.quantity,
|
|
||||||
unit_price: item.unit_price,
|
|
||||||
estimate_price: item.unit_price,
|
|
||||||
warehouse_price: null,
|
|
||||||
competitor_price: null,
|
|
||||||
description: item.description || '',
|
|
||||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update price update date
|
|
||||||
if (config.price_updated_at) {
|
|
||||||
updatePriceUpdateDate(config.price_updated_at);
|
|
||||||
}
|
|
||||||
if (config.pricelist_id) {
|
|
||||||
if (selectedPricelistIds.estimate) {
|
|
||||||
selectedPricelistIds.estimate = config.pricelist_id;
|
|
||||||
} else {
|
|
||||||
resolvedAutoPricelistIds.estimate = Number(config.pricelist_id);
|
|
||||||
}
|
|
||||||
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
|
||||||
await loadActivePricelists();
|
|
||||||
}
|
|
||||||
syncPriceSettingsControls();
|
|
||||||
renderPricelistSettingsSummary();
|
|
||||||
if (selectedPricelistIds.estimate) {
|
|
||||||
persistLocalPriceSettings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-render UI
|
|
||||||
await refreshPriceLevels();
|
|
||||||
renderTab();
|
renderTab();
|
||||||
updateCartUI();
|
updateCartUI();
|
||||||
|
|
||||||
|
if (configUUID) {
|
||||||
|
const configResp = await fetch('/api/configs/' + configUUID);
|
||||||
|
if (configResp.ok) {
|
||||||
|
const config = await configResp.json();
|
||||||
|
if (config.price_updated_at) {
|
||||||
|
updatePriceUpdateDate(config.price_updated_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
showToast('Цены обновлены', 'success');
|
showToast('Цены обновлены', 'success');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
showToast('Ошибка обновления цен', 'error');
|
showToast('Ошибка обновления цен', 'error');
|
||||||
|
} finally {
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
refreshBtn.textContent = previousLabel || 'Обновить цены';
|
||||||
|
updateRefreshPricesButtonState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3610,6 +3772,7 @@ async function renderPricingTab() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Build shared row data (unit prices for display, totals for math) ────
|
// ─── Build shared row data (unit prices for display, totals for math) ────
|
||||||
|
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
|
||||||
const _buildRows = () => {
|
const _buildRows = () => {
|
||||||
const result = [];
|
const result = [];
|
||||||
const coveredLots = new Set();
|
const coveredLots = new Set();
|
||||||
@@ -3619,7 +3782,8 @@ async function renderPricingTab() {
|
|||||||
const u = _getUnitPrices(pl);
|
const u = _getUnitPrices(pl);
|
||||||
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
|
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
|
||||||
result.push({
|
result.push({
|
||||||
lotCell: escapeHtml(item.lot_name), vendorPN: null,
|
lotCell: escapeHtml(item.lot_name), lotText: item.lot_name,
|
||||||
|
vendorPN: null,
|
||||||
desc: (compMap[item.lot_name] || {}).description || '',
|
desc: (compMap[item.lot_name] || {}).description || '',
|
||||||
qty: item.quantity,
|
qty: item.quantity,
|
||||||
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
||||||
@@ -3627,6 +3791,7 @@ async function renderPricingTab() {
|
|||||||
warehouse: u.warehouseUnit != null ? u.warehouseUnit * item.quantity : null,
|
warehouse: u.warehouseUnit != null ? u.warehouseUnit * item.quantity : null,
|
||||||
competitor: u.competitorUnit != null ? u.competitorUnit * item.quantity : null,
|
competitor: u.competitorUnit != null ? u.competitorUnit * item.quantity : null,
|
||||||
vendorOrig: null, vendorOrigUnit: null, isEstOnly,
|
vendorOrig: null, vendorOrigUnit: null, isEstOnly,
|
||||||
|
groupStart: true, groupSize: 1,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3641,42 +3806,66 @@ async function renderPricingTab() {
|
|||||||
if (baseLot) coveredLots.add(baseLot);
|
if (baseLot) coveredLots.add(baseLot);
|
||||||
allocs.forEach(a => coveredLots.add(a.lot_name));
|
allocs.forEach(a => coveredLots.add(a.lot_name));
|
||||||
|
|
||||||
// Accumulate unit prices per 1 vendor PN (base + allocs)
|
|
||||||
let rowEstUnit = 0, rowWhUnit = 0, rowCompUnit = 0;
|
|
||||||
let hasEst = false, hasWh = false, hasComp = false;
|
|
||||||
if (baseLot) {
|
|
||||||
const u = _getUnitPrices(priceMap[baseLot]);
|
|
||||||
const lotQty = _getRowLotQtyPerPN(row);
|
|
||||||
if (u.estUnit > 0) { rowEstUnit += u.estUnit * lotQty; hasEst = true; }
|
|
||||||
if (u.warehouseUnit != null) { rowWhUnit += u.warehouseUnit * lotQty; hasWh = true; }
|
|
||||||
if (u.competitorUnit != null) { rowCompUnit += u.competitorUnit * lotQty; hasComp = true; }
|
|
||||||
}
|
|
||||||
allocs.forEach(a => {
|
|
||||||
const u = _getUnitPrices(priceMap[a.lot_name]);
|
|
||||||
if (u.estUnit > 0) { rowEstUnit += u.estUnit * a.quantity; hasEst = true; }
|
|
||||||
if (u.warehouseUnit != null) { rowWhUnit += u.warehouseUnit * a.quantity; hasWh = true; }
|
|
||||||
if (u.competitorUnit != null) { rowCompUnit += u.competitorUnit * a.quantity; hasComp = true; }
|
|
||||||
});
|
|
||||||
|
|
||||||
let lotCell = '<span class="text-red-500">н/д</span>';
|
|
||||||
if (baseLot && allocs.length) lotCell = `${escapeHtml(baseLot)} <span class="text-gray-400">+${allocs.length}</span>`;
|
|
||||||
else if (baseLot) lotCell = escapeHtml(baseLot);
|
|
||||||
else if (allocs.length) lotCell = `${escapeHtml(allocs[0].lot_name)}${allocs.length > 1 ? ` <span class="text-gray-400">+${allocs.length - 1}</span>` : ''}`;
|
|
||||||
|
|
||||||
const vendorOrigUnit = row.unit_price != null ? row.unit_price
|
const vendorOrigUnit = row.unit_price != null ? row.unit_price
|
||||||
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
|
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
|
||||||
const vendorOrig = row.total_price != null ? row.total_price
|
const vendorOrig = row.total_price != null ? row.total_price
|
||||||
: (row.unit_price != null ? row.unit_price * row.quantity : null);
|
: (row.unit_price != null ? row.unit_price * row.quantity : null);
|
||||||
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : '');
|
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : '');
|
||||||
result.push({
|
|
||||||
lotCell, vendorPN: row.vendor_pn, desc, qty: row.quantity,
|
// Build per-LOT sub-rows
|
||||||
estUnit: hasEst ? rowEstUnit : 0,
|
const subRows = [];
|
||||||
warehouseUnit: hasWh ? rowWhUnit : null,
|
if (baseLot) {
|
||||||
competitorUnit: hasComp ? rowCompUnit : null,
|
const u = _getUnitPrices(priceMap[baseLot]);
|
||||||
est: hasEst ? rowEstUnit * row.quantity : 0,
|
const lotQty = _getRowLotQtyPerPN(row);
|
||||||
warehouse: hasWh ? rowWhUnit * row.quantity : null,
|
const qty = row.quantity * lotQty;
|
||||||
competitor: hasComp ? rowCompUnit * row.quantity : null,
|
subRows.push({
|
||||||
vendorOrig, vendorOrigUnit, isEstOnly: false,
|
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
|
||||||
|
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||||
|
warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
||||||
|
est: u.estUnit > 0 ? u.estUnit * qty : 0,
|
||||||
|
warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null,
|
||||||
|
competitor: u.competitorUnit != null ? u.competitorUnit * qty : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
allocs.forEach(a => {
|
||||||
|
const u = _getUnitPrices(priceMap[a.lot_name]);
|
||||||
|
const qty = row.quantity * a.quantity;
|
||||||
|
subRows.push({
|
||||||
|
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
|
||||||
|
estUnit: u.estUnit > 0 ? u.estUnit : 0,
|
||||||
|
warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
||||||
|
est: u.estUnit > 0 ? u.estUnit * qty : 0,
|
||||||
|
warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null,
|
||||||
|
competitor: u.competitorUnit != null ? u.competitorUnit * qty : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subRows.length) {
|
||||||
|
result.push({
|
||||||
|
lotCell: '<span class="text-red-500">н/д</span>', lotText: '',
|
||||||
|
vendorPN: row.vendor_pn, desc, qty: row.quantity,
|
||||||
|
estUnit: 0, warehouseUnit: null, competitorUnit: null,
|
||||||
|
est: 0, warehouse: null, competitor: null,
|
||||||
|
vendorOrig, vendorOrigUnit, isEstOnly: false,
|
||||||
|
groupStart: true, groupSize: 1,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupSize = subRows.length;
|
||||||
|
subRows.forEach((sub, idx) => {
|
||||||
|
result.push({
|
||||||
|
lotCell: sub.lotCell, lotText: sub.lotText,
|
||||||
|
vendorPN: row.vendor_pn, desc,
|
||||||
|
qty: sub.qty,
|
||||||
|
estUnit: sub.estUnit, warehouseUnit: sub.warehouseUnit, competitorUnit: sub.competitorUnit,
|
||||||
|
est: sub.est, warehouse: sub.warehouse, competitor: sub.competitor,
|
||||||
|
vendorOrig: idx === 0 ? vendorOrig : null,
|
||||||
|
vendorOrigUnit: idx === 0 ? vendorOrigUnit : null,
|
||||||
|
isEstOnly: false,
|
||||||
|
groupStart: idx === 0,
|
||||||
|
groupSize: idx === 0 ? groupSize : 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3709,19 +3898,28 @@ async function renderPricingTab() {
|
|||||||
tr.dataset.qty = r.qty;
|
tr.dataset.qty = r.qty;
|
||||||
tr.dataset.vendorOrig = r.vendorOrig != null ? r.vendorOrig : '';
|
tr.dataset.vendorOrig = r.vendorOrig != null ? r.vendorOrig : '';
|
||||||
tr.dataset.vendorOrigUnit = r.vendorOrigUnit != null ? r.vendorOrigUnit : '';
|
tr.dataset.vendorOrigUnit = r.vendorOrigUnit != null ? r.vendorOrigUnit : '';
|
||||||
|
tr.dataset.groupStart = r.groupStart ? 'true' : 'false';
|
||||||
|
tr.dataset.vendorPn = r.vendorPN || '';
|
||||||
|
tr.dataset.desc = r.desc;
|
||||||
|
tr.dataset.lot = r.lotText;
|
||||||
if (r.est > 0) { totEst += r.est; hasEst = true; }
|
if (r.est > 0) { totEst += r.est; hasEst = true; }
|
||||||
if (r.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; }
|
if (r.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; }
|
||||||
if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; }
|
if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; }
|
||||||
if (r.vendorOrig != null) { totVendor += r.vendorOrig; hasVendor = true; }
|
if (r.vendorOrig != null) { totVendor += r.vendorOrig; hasVendor = true; }
|
||||||
|
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
|
||||||
|
const pnDescHtml = r.groupStart ? (() => {
|
||||||
|
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
|
||||||
|
return `<td${rs} class="px-3 py-1.5 font-mono text-xs border-t border-gray-200 align-top ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
||||||
|
<td${rs} class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
|
||||||
|
})() : '';
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td class="px-3 py-1.5 text-xs">${r.lotCell}</td>
|
${pnDescHtml}
|
||||||
<td class="px-3 py-1.5 font-mono text-xs ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
|
||||||
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(r.desc)}</td>
|
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
|
||||||
<td class="px-3 py-1.5 text-right text-xs">${r.qty}</td>
|
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td>
|
||||||
<td class="px-3 py-1.5 text-right text-xs">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td>
|
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td>
|
||||||
<td class="px-3 py-1.5 text-right text-xs">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td>
|
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td>
|
||||||
<td class="px-3 py-1.5 text-right text-xs">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td>
|
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-buy ${borderTop} ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td>
|
||||||
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-buy ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td>
|
|
||||||
`;
|
`;
|
||||||
tbodyBuy.appendChild(tr);
|
tbodyBuy.appendChild(tr);
|
||||||
});
|
});
|
||||||
@@ -3753,18 +3951,27 @@ async function renderPricingTab() {
|
|||||||
const saleCompTotal = saleCompUnit != null ? saleCompUnit * r.qty : null;
|
const saleCompTotal = saleCompUnit != null ? saleCompUnit * r.qty : null;
|
||||||
tr.dataset.estSale = saleEstTotal;
|
tr.dataset.estSale = saleEstTotal;
|
||||||
tr.dataset.qty = r.qty;
|
tr.dataset.qty = r.qty;
|
||||||
|
tr.dataset.groupStart = r.groupStart ? 'true' : 'false';
|
||||||
|
tr.dataset.vendorPn = r.vendorPN || '';
|
||||||
|
tr.dataset.desc = r.desc;
|
||||||
|
tr.dataset.lot = r.lotText;
|
||||||
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; }
|
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; }
|
||||||
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; }
|
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; }
|
||||||
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; }
|
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; }
|
||||||
|
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
|
||||||
|
const pnDescHtml = r.groupStart ? (() => {
|
||||||
|
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
|
||||||
|
return `<td${rs} class="px-3 py-1.5 font-mono text-xs border-t border-gray-200 align-top ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
||||||
|
<td${rs} class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
|
||||||
|
})() : '';
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td class="px-3 py-1.5 text-xs">${r.lotCell}</td>
|
${pnDescHtml}
|
||||||
<td class="px-3 py-1.5 font-mono text-xs ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
|
||||||
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(r.desc)}</td>
|
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
|
||||||
<td class="px-3 py-1.5 text-right text-xs">${r.qty}</td>
|
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td>
|
||||||
<td class="px-3 py-1.5 text-right text-xs">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td>
|
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
|
||||||
<td class="px-3 py-1.5 text-right text-xs">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
|
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
|
||||||
<td class="px-3 py-1.5 text-right text-xs">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
|
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td>
|
||||||
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-sale text-gray-400">—</td>
|
|
||||||
`;
|
`;
|
||||||
tbodySale.appendChild(tr);
|
tbodySale.appendChild(tr);
|
||||||
});
|
});
|
||||||
@@ -3891,14 +4098,17 @@ function applyCustomPrice(table) {
|
|||||||
|
|
||||||
function onBuyCustomPriceInput() {
|
function onBuyCustomPriceInput() {
|
||||||
applyCustomPrice('buy');
|
applyCustomPrice('buy');
|
||||||
|
triggerAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSaleCustomPriceInput() {
|
function onSaleCustomPriceInput() {
|
||||||
applyCustomPrice('sale');
|
applyCustomPrice('sale');
|
||||||
|
triggerAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSaleMarkupInput() {
|
function onSaleMarkupInput() {
|
||||||
renderPricingTab();
|
renderPricingTab();
|
||||||
|
triggerAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPricingCustomPriceFromVendor() {
|
function setPricingCustomPriceFromVendor() {
|
||||||
@@ -3962,12 +4172,25 @@ function exportPricingCSV(table) {
|
|||||||
return /[;"\n\r]/.test(s) ? `"${s}"` : s;
|
return /[;"\n\r]/.test(s) ? `"${s}"` : s;
|
||||||
};
|
};
|
||||||
|
|
||||||
const headers = ['Lot', 'PN вендора', 'Описание', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
|
const headers = ['PN вендора', 'Описание', 'LOT', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
|
||||||
const lines = [headers.map(csvEscape).join(csvDelimiter)];
|
const lines = [headers.map(csvEscape).join(csvDelimiter)];
|
||||||
|
|
||||||
rows.forEach(tr => {
|
rows.forEach(tr => {
|
||||||
|
// PN вендора, Описание, LOT are stored in dataset to handle rowspan correctly
|
||||||
|
const pn = cleanExportCell(tr.dataset.vendorPn || '');
|
||||||
|
const desc = cleanExportCell(tr.dataset.desc || '');
|
||||||
|
const lot = cleanExportCell(tr.dataset.lot || '');
|
||||||
|
// Qty..Ручная цена: cells at offset 2 for group-start rows, offset 0 for sub-rows
|
||||||
|
const isGroupStart = tr.dataset.groupStart === 'true';
|
||||||
const cells = tr.querySelectorAll('td');
|
const cells = tr.querySelectorAll('td');
|
||||||
const cols = [0,1,2,3,4,5,6,7].map(i => cells[i] ? cleanExportCell(cells[i].textContent) : '');
|
const o = isGroupStart ? 2 : 0;
|
||||||
|
const cols = [pn, desc, lot,
|
||||||
|
cleanExportCell(cells[o]?.textContent),
|
||||||
|
cleanExportCell(cells[o+1]?.textContent),
|
||||||
|
cleanExportCell(cells[o+2]?.textContent),
|
||||||
|
cleanExportCell(cells[o+3]?.textContent),
|
||||||
|
cleanExportCell(cells[o+4]?.textContent),
|
||||||
|
];
|
||||||
lines.push(cols.map(csvEscape).join(csvDelimiter));
|
lines.push(cols.map(csvEscape).join(csvDelimiter));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,21 @@
|
|||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .IsBlocked}}
|
{{if .HasIncompleteServerSync}}
|
||||||
|
<span class="bg-red-100 text-red-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.SyncIssueTitle}}" onclick="openSyncModal()">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-7.938 4h15.876c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L2.33 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
Не докачано
|
||||||
|
</span>
|
||||||
|
{{else if .HasFailedSync}}
|
||||||
|
<span class="bg-orange-100 text-orange-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.SyncIssueTitle}}" onclick="openSyncModal()">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M4.93 19h14.14c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.2 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
Sync error
|
||||||
|
</span>
|
||||||
|
{{else if .IsBlocked}}
|
||||||
<span class="bg-red-100 text-red-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.BlockedReason}}" onclick="openSyncModal()">
|
<span class="bg-red-100 text-red-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.BlockedReason}}" onclick="openSyncModal()">
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-7.938 4h15.876c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L2.33 16c-.77 1.333.192 3 1.732 3z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-7.938 4h15.876c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L2.33 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}QuoteForge - Партномера{{end}}
|
{{define "title"}}OFS - Партномера{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Прайслист - QuoteForge{{end}}
|
{{define "title"}}Прайслист - OFS{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Прайслисты - QuoteForge{{end}}
|
{{define "title"}}Прайслисты - OFS{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Проект - QuoteForge{{end}}
|
{{define "title"}}Проект - OFS{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -77,6 +77,19 @@
|
|||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Новая конфигурация в проекте</h2>
|
<h2 class="text-xl font-semibold mb-4">Новая конфигурация в проекте</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label>
|
||||||
|
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
|
||||||
|
<button type="button" id="pd-type-server-btn" onclick="pdSetCreateType('server')"
|
||||||
|
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||||
|
Сервер
|
||||||
|
</button>
|
||||||
|
<button type="button" id="pd-type-storage-btn" onclick="pdSetCreateType('storage')"
|
||||||
|
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
|
||||||
|
СХД
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<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"
|
<input type="text" id="create-name" placeholder="Например: OPP-2026-001"
|
||||||
@@ -113,33 +126,60 @@
|
|||||||
|
|
||||||
<div id="project-export-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<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">
|
<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>
|
<h2 class="text-xl font-semibold mb-5">Экспорт CSV</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-5">
|
||||||
<div class="text-sm text-gray-600">
|
|
||||||
Экспортирует проект в формате вкладки ценообразования. Если включён `BOM`, строки строятся по BOM; иначе по текущему Estimate.
|
<!-- Section 1: Артикул -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Артикул</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<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 class="text-gray-400 font-normal">(строки по BOM, иначе по Estimate)</span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
|
||||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
<!-- Section 2: Цены -->
|
||||||
<input id="export-col-lot" type="checkbox" class="rounded border-gray-300" checked>
|
<div>
|
||||||
<span>LOT</span>
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Цены</p>
|
||||||
</label>
|
<div class="space-y-2">
|
||||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||||
<input id="export-col-bom" type="checkbox" class="rounded border-gray-300">
|
<input id="export-col-estimate" type="checkbox" class="rounded border-gray-300" checked>
|
||||||
<span>BOM</span>
|
<span>Est</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
<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>
|
<input id="export-col-stock" type="checkbox" class="rounded border-gray-300">
|
||||||
<span>Estimate</span>
|
<span>Stock</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||||
<input id="export-col-stock" type="checkbox" class="rounded border-gray-300">
|
<input id="export-col-competitor" type="checkbox" class="rounded border-gray-300">
|
||||||
<span>Stock</span>
|
<span>Конкуренты</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
</div>
|
||||||
<input id="export-col-competitor" type="checkbox" class="rounded border-gray-300">
|
|
||||||
<span>Конкуренты</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 3: Базис поставки -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Базис поставки</p>
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||||
|
<input type="radio" name="export-basis" value="fob" class="border-gray-300" checked>
|
||||||
|
<span class="font-medium">FOB</span>
|
||||||
|
<span class="text-gray-400">— Цена покупки</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||||
|
<input type="radio" name="export-basis" value="ddp" class="border-gray-300">
|
||||||
|
<span class="font-medium">DDP</span>
|
||||||
|
<span class="text-gray-400">— Цена продажи ×1,3</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="project-export-status" class="hidden text-sm rounded border px-3 py-2"></div>
|
<div id="project-export-status" class="hidden text-sm rounded border px-3 py-2"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3 mt-6">
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
@@ -363,6 +403,7 @@ function renderVariantSelect() {
|
|||||||
if (item.uuid === projectUUID) {
|
if (item.uuid === projectUUID) {
|
||||||
option.className += ' font-semibold text-gray-900';
|
option.className += ' font-semibold text-gray-900';
|
||||||
label.textContent = variantLabel;
|
label.textContent = variantLabel;
|
||||||
|
document.title = (project && project.code ? project.code : '—') + ' / ' + variantLabel + ' — OFS';
|
||||||
}
|
}
|
||||||
option.textContent = variantLabel;
|
option.textContent = variantLabel;
|
||||||
option.onclick = function() {
|
option.onclick = function() {
|
||||||
@@ -472,8 +513,7 @@ function renderConfigs(configs) {
|
|||||||
html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>';
|
||||||
}
|
}
|
||||||
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">' + formatMoneyNoDecimals(total) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">' + formatMoneyNoDecimals(total) + '</td>';
|
||||||
const versionNo = c.current_version_no || 1;
|
html += '<td class="px-2 py-3 text-sm text-center text-gray-500 w-12">main</td>';
|
||||||
html += '<td class="px-2 py-3 text-sm text-center text-gray-500 w-12">v' + versionNo + '</td>';
|
|
||||||
html += '<td class="px-4 py-3 text-sm text-right whitespace-nowrap"><div class="inline-flex items-center justify-end gap-2">';
|
html += '<td class="px-4 py-3 text-sm text-right whitespace-nowrap"><div class="inline-flex items-center justify-end gap-2">';
|
||||||
if (configStatusMode === 'archived') {
|
if (configStatusMode === 'archived') {
|
||||||
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
||||||
@@ -549,7 +589,19 @@ async function loadConfigs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pdCreateConfigType = 'server';
|
||||||
|
|
||||||
|
function pdSetCreateType(type) {
|
||||||
|
pdCreateConfigType = type;
|
||||||
|
document.getElementById('pd-type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' +
|
||||||
|
(type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
|
||||||
|
document.getElementById('pd-type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' +
|
||||||
|
(type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
|
pdCreateConfigType = 'server';
|
||||||
|
pdSetCreateType('server');
|
||||||
document.getElementById('create-name').value = '';
|
document.getElementById('create-name').value = '';
|
||||||
document.getElementById('create-modal').classList.remove('hidden');
|
document.getElementById('create-modal').classList.remove('hidden');
|
||||||
document.getElementById('create-modal').classList.add('flex');
|
document.getElementById('create-modal').classList.add('flex');
|
||||||
@@ -907,7 +959,7 @@ async function createConfig() {
|
|||||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
|
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
|
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1, config_type: pdCreateConfigType})
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
alert('Не удалось создать конфигурацию');
|
alert('Не удалось создать конфигурацию');
|
||||||
@@ -1475,7 +1527,8 @@ async function exportProject() {
|
|||||||
include_bom: !!document.getElementById('export-col-bom')?.checked,
|
include_bom: !!document.getElementById('export-col-bom')?.checked,
|
||||||
include_estimate: !!document.getElementById('export-col-estimate')?.checked,
|
include_estimate: !!document.getElementById('export-col-estimate')?.checked,
|
||||||
include_stock: !!document.getElementById('export-col-stock')?.checked,
|
include_stock: !!document.getElementById('export-col-stock')?.checked,
|
||||||
include_competitor: !!document.getElementById('export-col-competitor')?.checked
|
include_competitor: !!document.getElementById('export-col-competitor')?.checked,
|
||||||
|
basis: document.querySelector('input[name="export-basis"]:checked')?.value || 'fob'
|
||||||
};
|
};
|
||||||
|
|
||||||
if (submitBtn) submitBtn.disabled = true;
|
if (submitBtn) submitBtn.disabled = true;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Мои проекты - QuoteForge{{end}}
|
{{define "title"}}Мои проекты - OFS{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>QuoteForge - Настройка подключения</title>
|
<title>OFS - Настройка подключения</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
<script src="/static/vendor/tailwindcss.browser.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
|
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||||
<div class="max-w-md w-full mx-4">
|
<div class="max-w-md w-full mx-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user