Compare commits
18 Commits
c599897142
...
v1.5.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a360992a01 | ||
|
|
1ea21ece33 | ||
|
|
7ae804d2d3 | ||
|
|
da5414c708 | ||
|
|
7a69c1513d | ||
|
|
f448111e77 | ||
|
|
a5dafd37d3 | ||
|
|
3661e345b1 | ||
|
|
f915866f83 | ||
|
|
c34a42aaf5 | ||
|
|
7de0f359b6 | ||
|
|
a8d8d7dfa9 | ||
|
|
20ce0124be | ||
|
|
b0a106415f | ||
|
|
a054fc7564 | ||
|
|
68cd087356 | ||
|
|
579ff46a7f | ||
|
|
35c5600b36 |
2
bible
2
bible
Submodule bible updated: 5a69e0bba8...52444350c1
@@ -34,6 +34,9 @@ Readiness guard:
|
||||
- every sync push/pull runs a preflight check;
|
||||
- blocked sync returns `423 Locked` with a machine-readable reason;
|
||||
- local work continues even when sync is blocked.
|
||||
- sync metadata updates must preserve project `updated_at`; sync time belongs in `synced_at`, not in the user-facing last-modified timestamp.
|
||||
- pricelist pull must persist a new local snapshot atomically: header and items appear together, and `last_pricelist_sync` advances only after item download succeeds.
|
||||
- UI sync status must distinguish "last sync failed" from "up to date"; if the app can prove newer server pricelist data exists, the indicator must say local cache is incomplete.
|
||||
|
||||
## Pricing contract
|
||||
|
||||
@@ -45,16 +48,64 @@ Rules:
|
||||
- latest pricelist selection ignores snapshots without items;
|
||||
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
|
||||
|
||||
## Pricing tab layout
|
||||
|
||||
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
|
||||
|
||||
Column order (both tables):
|
||||
|
||||
```
|
||||
PN вендора | Описание | LOT | Кол-во | Estimate | Склад | Конкуренты | Ручная цена
|
||||
```
|
||||
|
||||
Per-LOT row expansion rules:
|
||||
- each `lot_mappings` entry in a BOM row becomes its own table row with its own quantity and prices;
|
||||
- `baseLot` (resolved LOT without an explicit mapping) is treated as the first sub-row with `quantity_per_pn` from `_getRowLotQtyPerPN`;
|
||||
- when one vendor PN expands into N LOT sub-rows, PN вендора and Описание cells use `rowspan="N"` and appear only on the first sub-row;
|
||||
- a visual top border (`border-t border-gray-200`) separates each vendor PN group.
|
||||
|
||||
Vendor price attachment:
|
||||
- `vendorOrig` and `vendorOrigUnit` (BOM unit/total price) are attached to the first LOT sub-row only;
|
||||
- subsequent sub-rows carry empty `data-vendor-orig` so `setPricingCustomPriceFromVendor` counts each vendor PN exactly once.
|
||||
|
||||
Controls terminology:
|
||||
- custom price input is labeled **Ручная цена** (not "Своя цена");
|
||||
- the button that fills custom price from BOM totals is labeled **BOM Цена** (not "Проставить цены BOM").
|
||||
|
||||
CSV export reads PN вендора, Описание, and LOT from `data-vendor-pn`, `data-desc`, `data-lot` row attributes to bypass the rowspan cell offset problem.
|
||||
|
||||
## Configuration versioning
|
||||
|
||||
Configuration revisions are append-only snapshots stored in `local_configuration_versions`.
|
||||
|
||||
Rules:
|
||||
- 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;
|
||||
- rename, reorder, project move, and similar operational edits do not create a new revision snapshot;
|
||||
- revision deduplication includes `items`, `server_count`, `total_price`, `custom_price`, `vendor_spec`, pricelist selectors, `disable_price_refresh`, and `only_in_stock`;
|
||||
- BOM updates must use version-aware save flow, not a direct SQL field update;
|
||||
- current revision pointer must be recoverable if legacy or damaged rows are found locally.
|
||||
|
||||
## Sync UX
|
||||
|
||||
UI-facing sync status must never block on live MariaDB calls.
|
||||
|
||||
Rules:
|
||||
- navbar sync indicator and sync info modal read only local cached state from SQLite/app settings;
|
||||
- background/manual sync may talk to MariaDB, but polling endpoints must stay fast even on slow or broken connections;
|
||||
- any MariaDB timeout/invalid-connection during sync must invalidate the cached remote handle immediately so UI stops treating the connection as healthy.
|
||||
|
||||
## Naming collisions
|
||||
|
||||
UI-driven rename and copy flows use one suffix convention for conflicts.
|
||||
|
||||
Rules:
|
||||
- configuration and variant names must auto-resolve collisions with `_копия`, then `_копия2`, `_копия3`, and so on;
|
||||
- copy checkboxes and copy modals must prefill `_копия`, not ` (копия)`;
|
||||
- the literal variant name `main` is reserved and must not be allowed for non-main variants.
|
||||
|
||||
## Vendor BOM contract
|
||||
|
||||
Vendor BOM is stored in `vendor_spec` on the configuration row.
|
||||
|
||||
@@ -29,31 +29,369 @@ Rules:
|
||||
|
||||
## MariaDB
|
||||
|
||||
MariaDB is the central sync database.
|
||||
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-03-21.
|
||||
|
||||
Runtime read permissions:
|
||||
- `lot`
|
||||
- `qt_lot_metadata`
|
||||
- `qt_categories`
|
||||
- `qt_pricelists`
|
||||
- `qt_pricelist_items`
|
||||
- `stock_log`
|
||||
- `qt_partnumber_books`
|
||||
- `qt_partnumber_book_items`
|
||||
### QuoteForge tables (qt_* and stock_*)
|
||||
|
||||
Runtime read/write permissions:
|
||||
- `qt_projects`
|
||||
- `qt_configurations`
|
||||
- `qt_client_schema_state`
|
||||
- `qt_pricelist_sync_status`
|
||||
Runtime read:
|
||||
- `qt_categories` — pricelist categories
|
||||
- `qt_lot_metadata` — component metadata, price settings
|
||||
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
||||
- `qt_pricelist_items` — pricelist rows
|
||||
- `stock_log` — raw supplier price log, source for pricelist generation
|
||||
- `stock_ignore_rules` — patterns to skip during stock import
|
||||
- `qt_partnumber_books` — partnumber book headers
|
||||
- `qt_partnumber_book_items` — PN→LOT catalog payload
|
||||
|
||||
Runtime read/write:
|
||||
- `qt_projects` — projects
|
||||
- `qt_configurations` — configurations
|
||||
- `qt_client_schema_state` — per-client sync status and version tracking
|
||||
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
|
||||
|
||||
Insert-only tracking:
|
||||
- `qt_vendor_partnumber_seen`
|
||||
- `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 (superseded by `stock_log`)
|
||||
- `supplier` — supplier registry (FK target for lot_log and machine_log)
|
||||
- `machine` — device model registry
|
||||
- `machine_log` — device price/quote log
|
||||
|
||||
These tables are retained for historical data. QuoteForge does not read or write them at runtime.
|
||||
|
||||
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;
|
||||
- normal UI requests must not query MariaDB tables directly.
|
||||
- normal UI requests must not query MariaDB tables directly;
|
||||
- `qt_client_local_migrations` was removed from the schema on 2026-03-21 (was in earlier drafts).
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ logging:
|
||||
Rules:
|
||||
- QuoteForge creates this file automatically if it does not exist;
|
||||
- startup rewrites legacy config files into this minimal runtime shape;
|
||||
- startup normalizes any `server.host` value to `127.0.0.1` before saving the runtime config;
|
||||
- `server.host` must stay on loopback.
|
||||
|
||||
Saved MariaDB credentials do not live in `config.yaml`.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
```bash
|
||||
go run ./cmd/qfs
|
||||
go run ./cmd/qfs -migrate
|
||||
go run ./cmd/migrate_project_updated_at
|
||||
go test ./...
|
||||
go vet ./...
|
||||
make build-release
|
||||
|
||||
173
cmd/migrate_project_updated_at/main.go
Normal file
173
cmd/migrate_project_updated_at/main.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type projectTimestampRow struct {
|
||||
UUID string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type updatePlanRow struct {
|
||||
UUID string
|
||||
Code string
|
||||
Variant string
|
||||
LocalUpdatedAt time.Time
|
||||
ServerUpdatedAt time.Time
|
||||
}
|
||||
|
||||
func main() {
|
||||
defaultLocalDBPath, err := appstate.ResolveDBPath("")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to resolve default local SQLite path: %v", err)
|
||||
}
|
||||
|
||||
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||
apply := flag.Bool("apply", false, "apply updates to local SQLite (default is preview only)")
|
||||
flag.Parse()
|
||||
|
||||
local, err := localdb.New(*localDBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize local database: %v", err)
|
||||
}
|
||||
defer local.Close()
|
||||
|
||||
if !local.HasSettings() {
|
||||
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
|
||||
}
|
||||
|
||||
dsn, err := local.GetDSN()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to build DSN from SQLite settings: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to MariaDB: %v", err)
|
||||
}
|
||||
|
||||
serverRows, err := loadServerProjects(db)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load server projects: %v", err)
|
||||
}
|
||||
|
||||
localProjects, err := local.GetAllProjects(true)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load local projects: %v", err)
|
||||
}
|
||||
|
||||
plan := buildUpdatePlan(localProjects, serverRows)
|
||||
printPlan(plan, *apply)
|
||||
|
||||
if !*apply || len(plan) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
updated := 0
|
||||
for i := range plan {
|
||||
project, err := local.GetProjectByUUID(plan[i].UUID)
|
||||
if err != nil {
|
||||
log.Printf("skip %s: load local project: %v", plan[i].UUID, err)
|
||||
continue
|
||||
}
|
||||
project.UpdatedAt = plan[i].ServerUpdatedAt
|
||||
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
|
||||
log.Printf("skip %s: save local project: %v", plan[i].UUID, err)
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
}
|
||||
|
||||
log.Printf("updated %d local project timestamps", updated)
|
||||
}
|
||||
|
||||
func loadServerProjects(db *gorm.DB) (map[string]time.Time, error) {
|
||||
var rows []projectTimestampRow
|
||||
if err := db.Model(&models.Project{}).
|
||||
Select("uuid, updated_at").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]time.Time, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.UUID == "" {
|
||||
continue
|
||||
}
|
||||
out[row.UUID] = row.UpdatedAt
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildUpdatePlan(localProjects []localdb.LocalProject, serverRows map[string]time.Time) []updatePlanRow {
|
||||
plan := make([]updatePlanRow, 0)
|
||||
for i := range localProjects {
|
||||
project := localProjects[i]
|
||||
serverUpdatedAt, ok := serverRows[project.UUID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if project.UpdatedAt.Equal(serverUpdatedAt) {
|
||||
continue
|
||||
}
|
||||
plan = append(plan, updatePlanRow{
|
||||
UUID: project.UUID,
|
||||
Code: project.Code,
|
||||
Variant: project.Variant,
|
||||
LocalUpdatedAt: project.UpdatedAt,
|
||||
ServerUpdatedAt: serverUpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(plan, func(i, j int) bool {
|
||||
if plan[i].Code != plan[j].Code {
|
||||
return plan[i].Code < plan[j].Code
|
||||
}
|
||||
return plan[i].Variant < plan[j].Variant
|
||||
})
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
func printPlan(plan []updatePlanRow, apply bool) {
|
||||
mode := "preview"
|
||||
if apply {
|
||||
mode = "apply"
|
||||
}
|
||||
log.Printf("project updated_at resync mode=%s changes=%d", mode, len(plan))
|
||||
if len(plan) == 0 {
|
||||
log.Printf("no local project timestamps need resync")
|
||||
return
|
||||
}
|
||||
for _, row := range plan {
|
||||
variant := row.Variant
|
||||
if variant == "" {
|
||||
variant = "main"
|
||||
}
|
||||
log.Printf("%s [%s] local=%s server=%s", row.Code, variant, formatStamp(row.LocalUpdatedAt), formatStamp(row.ServerUpdatedAt))
|
||||
}
|
||||
if !apply {
|
||||
fmt.Println("Re-run with -apply to write server updated_at into local SQLite.")
|
||||
}
|
||||
}
|
||||
|
||||
func formatStamp(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return "zero"
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
@@ -39,6 +39,10 @@ logging:
|
||||
t.Fatalf("load legacy config: %v", err)
|
||||
}
|
||||
setConfigDefaults(cfg)
|
||||
cfg.Server.Host, _, err = normalizeLoopbackServerHost(cfg.Server.Host)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize server host: %v", err)
|
||||
}
|
||||
if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil {
|
||||
t.Fatalf("migrate config: %v", err)
|
||||
}
|
||||
@@ -60,32 +64,43 @@ logging:
|
||||
if !strings.Contains(text, "port: 9191") {
|
||||
t.Fatalf("migrated config did not preserve server port:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "host: 127.0.0.1") {
|
||||
t.Fatalf("migrated config did not normalize server host:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "level: debug") {
|
||||
t.Fatalf("migrated config did not preserve logging level:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLoopbackServerHost(t *testing.T) {
|
||||
func TestNormalizeLoopbackServerHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
host string
|
||||
wantErr bool
|
||||
host string
|
||||
want string
|
||||
wantChanged bool
|
||||
wantErr bool
|
||||
}{
|
||||
{host: "127.0.0.1", wantErr: false},
|
||||
{host: "localhost", wantErr: false},
|
||||
{host: "::1", wantErr: false},
|
||||
{host: "0.0.0.0", wantErr: true},
|
||||
{host: "192.168.1.10", wantErr: true},
|
||||
{host: "127.0.0.1", want: "127.0.0.1", wantChanged: false, wantErr: false},
|
||||
{host: "localhost", want: "127.0.0.1", wantChanged: true, wantErr: false},
|
||||
{host: "::1", want: "127.0.0.1", wantChanged: true, wantErr: false},
|
||||
{host: "0.0.0.0", want: "127.0.0.1", wantChanged: true, wantErr: false},
|
||||
{host: "192.168.1.10", want: "127.0.0.1", wantChanged: true, wantErr: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
err := ensureLoopbackServerHost(tc.host)
|
||||
got, changed, err := normalizeLoopbackServerHost(tc.host)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("expected error for host %q", tc.host)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error for host %q: %v", tc.host, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("unexpected normalized host for %q: got %q want %q", tc.host, got, tc.want)
|
||||
}
|
||||
if changed != tc.wantChanged {
|
||||
t.Fatalf("unexpected changed flag for %q: got %t want %t", tc.host, changed, tc.wantChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,10 +148,15 @@ func main() {
|
||||
}
|
||||
}
|
||||
setConfigDefaults(cfg)
|
||||
if err := ensureLoopbackServerHost(cfg.Server.Host); err != nil {
|
||||
normalizedHost, changed, err := normalizeLoopbackServerHost(cfg.Server.Host)
|
||||
if err != nil {
|
||||
slog.Error("invalid server host", "host", cfg.Server.Host, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if changed {
|
||||
slog.Warn("corrected server host to loopback", "from", cfg.Server.Host, "to", normalizedHost)
|
||||
}
|
||||
cfg.Server.Host = normalizedHost
|
||||
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
|
||||
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
|
||||
os.Exit(1)
|
||||
@@ -334,21 +339,28 @@ func setConfigDefaults(cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
func ensureLoopbackServerHost(host string) error {
|
||||
func normalizeLoopbackServerHost(host string) (string, bool, error) {
|
||||
trimmed := strings.TrimSpace(host)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("server.host must not be empty")
|
||||
return "", false, fmt.Errorf("server.host must not be empty")
|
||||
}
|
||||
const loopbackHost = "127.0.0.1"
|
||||
if trimmed == loopbackHost {
|
||||
return loopbackHost, false, nil
|
||||
}
|
||||
if strings.EqualFold(trimmed, "localhost") {
|
||||
return nil
|
||||
return loopbackHost, true, nil
|
||||
}
|
||||
|
||||
ip := net.ParseIP(strings.Trim(trimmed, "[]"))
|
||||
if ip != nil && ip.IsLoopback() {
|
||||
return nil
|
||||
if ip != nil {
|
||||
if ip.IsLoopback() || ip.IsUnspecified() {
|
||||
return loopbackHost, trimmed != loopbackHost, nil
|
||||
}
|
||||
return loopbackHost, true, nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("QuoteForge local client must bind to localhost only")
|
||||
return loopbackHost, true, nil
|
||||
}
|
||||
|
||||
func vendorImportBodyLimit() int64 {
|
||||
@@ -1114,7 +1126,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
|
||||
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
|
||||
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 {
|
||||
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
@@ -1490,6 +1507,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
project, err := projectService.Create(dbUsername, &req)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrReservedMainVariant):
|
||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||
default:
|
||||
@@ -1525,6 +1544,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrReservedMainVariant),
|
||||
errors.Is(err, services.ErrCannotRenameMainVariant):
|
||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
|
||||
@@ -238,6 +238,22 @@ func (cm *ConnectionManager) Disconnect() {
|
||||
cm.lastError = nil
|
||||
}
|
||||
|
||||
// MarkOffline closes the current connection and preserves the last observed error.
|
||||
func (cm *ConnectionManager) MarkOffline(err error) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
if cm.db != nil {
|
||||
sqlDB, dbErr := cm.db.DB()
|
||||
if dbErr == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
cm.db = nil
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
}
|
||||
|
||||
// GetLastError returns the last connection error (thread-safe)
|
||||
func (cm *ConnectionManager) GetLastError() error {
|
||||
cm.mu.RLock()
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
stdsync "sync"
|
||||
"time"
|
||||
|
||||
@@ -49,15 +50,20 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
||||
|
||||
// SyncStatusResponse represents the sync status
|
||||
type SyncStatusResponse struct {
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_sync"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||||
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||||
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
|
||||
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
|
||||
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_sync"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||||
}
|
||||
|
||||
type SyncReadinessResponse struct {
|
||||
@@ -72,42 +78,34 @@ type SyncReadinessResponse struct {
|
||||
// GetStatus returns current sync status
|
||||
// GET /api/sync/status
|
||||
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
// Check online status by pinging MariaDB
|
||||
isOnline := h.checkOnline()
|
||||
|
||||
// Get sync times
|
||||
connStatus := h.connMgr.GetStatus()
|
||||
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||||
lastComponentSync := h.localDB.GetComponentSyncTime()
|
||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||
|
||||
// Get counts
|
||||
componentsCount := h.localDB.CountLocalComponents()
|
||||
pricelistsCount := h.localDB.CountLocalPricelists()
|
||||
|
||||
// Get server pricelist count if online
|
||||
serverPricelists := 0
|
||||
needPricelistSync := false
|
||||
if isOnline {
|
||||
status, err := h.syncService.GetStatus()
|
||||
if err == nil {
|
||||
serverPricelists = status.ServerPricelists
|
||||
needPricelistSync = status.NeedsSync
|
||||
}
|
||||
}
|
||||
|
||||
// Check if component sync is needed (older than 24 hours)
|
||||
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
readiness := h.getReadinessLocal()
|
||||
|
||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||
LastComponentSync: lastComponentSync,
|
||||
LastPricelistSync: lastPricelistSync,
|
||||
IsOnline: isOnline,
|
||||
ComponentsCount: componentsCount,
|
||||
PricelistsCount: pricelistsCount,
|
||||
ServerPricelists: serverPricelists,
|
||||
NeedComponentSync: needComponentSync,
|
||||
NeedPricelistSync: needPricelistSync,
|
||||
Readiness: readiness,
|
||||
LastComponentSync: lastComponentSync,
|
||||
LastPricelistSync: lastPricelistSync,
|
||||
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||
LastPricelistSyncError: lastPricelistSyncError,
|
||||
HasIncompleteServerSync: hasFailedSync,
|
||||
KnownServerChangesMiss: hasFailedSync,
|
||||
IsOnline: isOnline,
|
||||
ComponentsCount: componentsCount,
|
||||
PricelistsCount: pricelistsCount,
|
||||
ServerPricelists: 0,
|
||||
NeedComponentSync: needComponentSync,
|
||||
NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
|
||||
Readiness: readiness,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -474,8 +472,13 @@ type SyncInfoResponse struct {
|
||||
DBName string `json:"db_name"`
|
||||
|
||||
// Status
|
||||
IsOnline bool `json:"is_online"`
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||||
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||||
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
|
||||
|
||||
// Statistics
|
||||
LotCount int64 `json:"lot_count"`
|
||||
@@ -511,8 +514,8 @@ type SyncError struct {
|
||||
// GetInfo returns sync information for modal
|
||||
// GET /api/sync/info
|
||||
func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
// Check online status by pinging MariaDB
|
||||
isOnline := h.checkOnline()
|
||||
connStatus := h.connMgr.GetStatus()
|
||||
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||||
|
||||
// Get DB connection info
|
||||
var dbHost, dbUser, dbName string
|
||||
@@ -524,6 +527,12 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
|
||||
// Get sync times
|
||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||
needPricelistSync := lastPricelistSync == nil || hasFailedSync
|
||||
hasIncompleteServerSync := hasFailedSync
|
||||
|
||||
// Get local counts
|
||||
configCount := h.localDB.CountConfigurations()
|
||||
@@ -556,22 +565,27 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
syncErrors = syncErrors[:10]
|
||||
}
|
||||
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
readiness := h.getReadinessLocal()
|
||||
|
||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||
DBHost: dbHost,
|
||||
DBUser: dbUser,
|
||||
DBName: dbName,
|
||||
IsOnline: isOnline,
|
||||
LastSyncAt: lastPricelistSync,
|
||||
LotCount: componentCount,
|
||||
LotLogCount: pricelistCount,
|
||||
ConfigCount: configCount,
|
||||
ProjectCount: projectCount,
|
||||
PendingChanges: changes,
|
||||
ErrorCount: errorCount,
|
||||
Errors: syncErrors,
|
||||
Readiness: readiness,
|
||||
DBHost: dbHost,
|
||||
DBUser: dbUser,
|
||||
DBName: dbName,
|
||||
IsOnline: isOnline,
|
||||
LastSyncAt: lastPricelistSync,
|
||||
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||
LastPricelistSyncError: lastPricelistSyncError,
|
||||
NeedPricelistSync: needPricelistSync,
|
||||
HasIncompleteServerSync: hasIncompleteServerSync,
|
||||
LotCount: componentCount,
|
||||
LotLogCount: pricelistCount,
|
||||
ConfigCount: configCount,
|
||||
ProjectCount: projectCount,
|
||||
PendingChanges: changes,
|
||||
ErrorCount: errorCount,
|
||||
Errors: syncErrors,
|
||||
Readiness: readiness,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -626,15 +640,33 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||
|
||||
// Get pending count
|
||||
pendingCount := h.localDB.GetPendingCount()
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
readiness := h.getReadinessLocal()
|
||||
isBlocked := readiness != nil && readiness.Blocked
|
||||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||
hasIncompleteServerSync := hasFailedSync
|
||||
|
||||
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
|
||||
|
||||
data := gin.H{
|
||||
"IsOffline": isOffline,
|
||||
"PendingCount": pendingCount,
|
||||
"IsBlocked": isBlocked,
|
||||
"IsOffline": isOffline,
|
||||
"PendingCount": pendingCount,
|
||||
"IsBlocked": isBlocked,
|
||||
"HasFailedSync": hasFailedSync,
|
||||
"HasIncompleteServerSync": hasIncompleteServerSync,
|
||||
"SyncIssueTitle": func() string {
|
||||
if hasIncompleteServerSync {
|
||||
return "Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые не загружены локально."
|
||||
}
|
||||
if hasFailedSync {
|
||||
if lastPricelistSyncError != "" {
|
||||
return lastPricelistSyncError
|
||||
}
|
||||
return "Последняя синхронизация прайслистов завершилась ошибкой."
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
"BlockedReason": func() string {
|
||||
if readiness == nil {
|
||||
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()
|
||||
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
|
||||
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < 10*time.Second {
|
||||
cached := *h.readinessCached
|
||||
h.readinessMu.Unlock()
|
||||
return &cached
|
||||
}
|
||||
h.readinessMu.Unlock()
|
||||
|
||||
readiness, err := h.syncService.GetReadiness()
|
||||
if err != nil && readiness == nil {
|
||||
state, err := h.localDB.GetSyncGuardState()
|
||||
if err != nil || state == 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.readinessCached = readiness
|
||||
h.readinessCachedAt = time.Now()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -14,11 +13,15 @@ import (
|
||||
|
||||
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
|
||||
type VendorSpecHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
localDB *localdb.LocalDB
|
||||
configService *services.LocalConfigurationService
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -80,12 +83,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||
}
|
||||
|
||||
spec := localdb.VendorSpec(body.VendorSpec)
|
||||
specJSON, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil {
|
||||
if _, err := h.configService.UpdateVendorSpecNoAuth(cfg.UUID, spec); err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
@@ -194,13 +192,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
itemsJSON, err := json.Marshal(newItems)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
|
||||
if _, err := h.configService.ApplyVendorSpecItemsNoAuth(cfg.UUID, newItems); err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -112,6 +113,7 @@ func NewWebHandler(_ string, localDB *localdb.LocalDB) (*WebHandler, error) {
|
||||
}
|
||||
|
||||
func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
||||
data["AppVersion"] = appmeta.Version()
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl, ok := h.templates[name]
|
||||
if !ok {
|
||||
|
||||
@@ -95,3 +95,60 @@ func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
|
||||
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationFingerprintIncludesPricingSelectorsAndVendorSpec(t *testing.T) {
|
||||
estimateID := uint(11)
|
||||
warehouseID := uint(22)
|
||||
competitorID := uint(33)
|
||||
|
||||
base := &LocalConfiguration{
|
||||
UUID: "cfg-1",
|
||||
Name: "Config",
|
||||
ServerCount: 1,
|
||||
Items: LocalConfigItems{{LotName: "LOT_A", Quantity: 1, UnitPrice: 100}},
|
||||
PricelistID: &estimateID,
|
||||
WarehousePricelistID: &warehouseID,
|
||||
CompetitorPricelistID: &competitorID,
|
||||
DisablePriceRefresh: true,
|
||||
OnlyInStock: true,
|
||||
VendorSpec: VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "PN-1",
|
||||
Quantity: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
baseFingerprint, err := BuildConfigurationSpecPriceFingerprint(base)
|
||||
if err != nil {
|
||||
t.Fatalf("base fingerprint: %v", err)
|
||||
}
|
||||
|
||||
changedPricelist := *base
|
||||
newEstimateID := uint(44)
|
||||
changedPricelist.PricelistID = &newEstimateID
|
||||
pricelistFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedPricelist)
|
||||
if err != nil {
|
||||
t.Fatalf("pricelist fingerprint: %v", err)
|
||||
}
|
||||
if pricelistFingerprint == baseFingerprint {
|
||||
t.Fatalf("expected pricelist selector to affect fingerprint")
|
||||
}
|
||||
|
||||
changedVendorSpec := *base
|
||||
changedVendorSpec.VendorSpec = VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "PN-2",
|
||||
Quantity: 1,
|
||||
},
|
||||
}
|
||||
vendorFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedVendorSpec)
|
||||
if err != nil {
|
||||
t.Fatalf("vendor fingerprint: %v", err)
|
||||
}
|
||||
if vendorFingerprint == baseFingerprint {
|
||||
t.Fatalf("expected vendor spec to affect fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,14 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
||||
}
|
||||
|
||||
// Enable WAL mode so background sync writes never block UI reads.
|
||||
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
|
||||
slog.Warn("failed to enable WAL mode", "error", err)
|
||||
}
|
||||
if err := db.Exec("PRAGMA synchronous=NORMAL").Error; err != nil {
|
||||
slog.Warn("failed to set synchronous=NORMAL", "error", err)
|
||||
}
|
||||
|
||||
if err := ensureLocalProjectsTable(db); err != nil {
|
||||
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
||||
}
|
||||
@@ -611,6 +619,46 @@ func (l *LocalDB) SaveProject(project *LocalProject) error {
|
||||
return l.db.Save(project).Error
|
||||
}
|
||||
|
||||
// SaveProjectPreservingUpdatedAt stores a project without replacing UpdatedAt
|
||||
// with the current local sync time.
|
||||
func (l *LocalDB) SaveProjectPreservingUpdatedAt(project *LocalProject) error {
|
||||
if project == nil {
|
||||
return fmt.Errorf("project is nil")
|
||||
}
|
||||
|
||||
if project.ID == 0 && strings.TrimSpace(project.UUID) != "" {
|
||||
var existing LocalProject
|
||||
err := l.db.Where("uuid = ?", project.UUID).First(&existing).Error
|
||||
if err == nil {
|
||||
project.ID = existing.ID
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if project.ID == 0 {
|
||||
return l.db.Create(project).Error
|
||||
}
|
||||
|
||||
return l.db.Model(&LocalProject{}).
|
||||
Where("id = ?", project.ID).
|
||||
UpdateColumns(map[string]interface{}{
|
||||
"uuid": project.UUID,
|
||||
"server_id": project.ServerID,
|
||||
"owner_username": project.OwnerUsername,
|
||||
"code": project.Code,
|
||||
"variant": project.Variant,
|
||||
"name": project.Name,
|
||||
"tracker_url": project.TrackerURL,
|
||||
"is_active": project.IsActive,
|
||||
"is_system": project.IsSystem,
|
||||
"created_at": project.CreatedAt,
|
||||
"updated_at": project.UpdatedAt,
|
||||
"synced_at": project.SyncedAt,
|
||||
"sync_status": project.SyncStatus,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) {
|
||||
var projects []LocalProject
|
||||
query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername)
|
||||
@@ -1026,6 +1074,26 @@ func (l *LocalDB) GetLastSyncTime() *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
func (l *LocalDB) getAppSettingValue(key string) (string, bool) {
|
||||
var setting struct {
|
||||
Value string
|
||||
}
|
||||
if err := l.db.Table("app_settings").
|
||||
Where("key = ?", key).
|
||||
First(&setting).Error; err != nil {
|
||||
return "", false
|
||||
}
|
||||
return setting.Value, true
|
||||
}
|
||||
|
||||
func (l *LocalDB) upsertAppSetting(tx *gorm.DB, key, value string, updatedAt time.Time) error {
|
||||
return tx.Exec(`
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
`, key, value, updatedAt.Format(time.RFC3339)).Error
|
||||
}
|
||||
|
||||
// SetLastSyncTime sets the last sync timestamp
|
||||
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
||||
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
|
||||
@@ -1036,6 +1104,55 @@ func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
||||
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetLastPricelistSyncAttemptAt() *time.Time {
|
||||
value, ok := l.getAppSettingValue("last_pricelist_sync_attempt_at")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetLastPricelistSyncStatus() string {
|
||||
value, ok := l.getAppSettingValue("last_pricelist_sync_status")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetLastPricelistSyncError() string {
|
||||
value, ok := l.getAppSettingValue("last_pricelist_sync_error")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func (l *LocalDB) SetPricelistSyncResult(status, errorText string, attemptedAt time.Time) error {
|
||||
status = strings.TrimSpace(status)
|
||||
errorText = strings.TrimSpace(errorText)
|
||||
if status == "" {
|
||||
status = "unknown"
|
||||
}
|
||||
|
||||
return l.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := l.upsertAppSetting(tx, "last_pricelist_sync_status", status, attemptedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := l.upsertAppSetting(tx, "last_pricelist_sync_error", errorText, attemptedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := l.upsertAppSetting(tx, "last_pricelist_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CountLocalPricelists returns the number of local pricelists
|
||||
func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
var count int64
|
||||
@@ -1043,6 +1160,29 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
return count
|
||||
}
|
||||
|
||||
// CountAllPricelistItems returns total rows across all local_pricelist_items.
|
||||
func (l *LocalDB) CountAllPricelistItems() int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalPricelistItem{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// CountComponents returns the number of rows in local_components.
|
||||
func (l *LocalDB) CountComponents() int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalComponent{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// DBFileSizeBytes returns the size of the SQLite database file in bytes.
|
||||
func (l *LocalDB) DBFileSizeBytes() int64 {
|
||||
info, err := os.Stat(l.path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
@@ -1210,6 +1350,32 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
|
||||
return item.Price, nil
|
||||
}
|
||||
|
||||
// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query.
|
||||
// Uses the composite index (pricelist_id, lot_name). Missing lots are omitted from the result.
|
||||
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
|
||||
result := make(map[string]float64, len(lotNames))
|
||||
if len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
type row struct {
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
var rows []row
|
||||
if err := l.db.Model(&LocalPricelistItem{}).
|
||||
Select("lot_name, price").
|
||||
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Price > 0 {
|
||||
result[r.LotName] = r.Price
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||
|
||||
53
internal/localdb/project_sync_timestamp_test.go
Normal file
53
internal/localdb/project_sync_timestamp_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSaveProjectPreservingUpdatedAtKeepsProvidedTimestamp(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "project_sync_timestamp.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
createdAt := time.Date(2026, 2, 1, 10, 0, 0, 0, time.UTC)
|
||||
updatedAt := time.Date(2026, 2, 3, 12, 30, 0, 0, time.UTC)
|
||||
project := &LocalProject{
|
||||
UUID: "project-1",
|
||||
OwnerUsername: "tester",
|
||||
Code: "OPS-1",
|
||||
Variant: "Lenovo",
|
||||
IsActive: true,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
SyncStatus: "synced",
|
||||
}
|
||||
|
||||
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
|
||||
t.Fatalf("save project: %v", err)
|
||||
}
|
||||
|
||||
syncedAt := time.Date(2026, 3, 16, 8, 45, 0, 0, time.UTC)
|
||||
project.SyncedAt = &syncedAt
|
||||
project.SyncStatus = "synced"
|
||||
|
||||
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
|
||||
t.Fatalf("save project second time: %v", err)
|
||||
}
|
||||
|
||||
stored, err := local.GetProjectByUUID(project.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("get project: %v", err)
|
||||
}
|
||||
if !stored.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("updated_at changed during sync save: got %s want %s", stored.UpdatedAt, updatedAt)
|
||||
}
|
||||
if stored.SyncedAt == nil || !stored.SyncedAt.Equal(syncedAt) {
|
||||
t.Fatalf("synced_at not updated correctly: got %+v want %s", stored.SyncedAt, syncedAt)
|
||||
}
|
||||
}
|
||||
@@ -112,10 +112,16 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
}
|
||||
|
||||
type configurationSpecPriceFingerprint struct {
|
||||
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
||||
ServerCount int `json:"server_count"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
CustomPrice *float64 `json:"custom_price,omitempty"`
|
||||
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
||||
ServerCount int `json:"server_count"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
CustomPrice *float64 `json:"custom_price,omitempty"`
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
|
||||
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
VendorSpec VendorSpec `json:"vendor_spec,omitempty"`
|
||||
}
|
||||
|
||||
type configurationSpecPriceFingerprintItem struct {
|
||||
@@ -146,10 +152,16 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin
|
||||
})
|
||||
|
||||
payload := configurationSpecPriceFingerprint{
|
||||
Items: items,
|
||||
ServerCount: localCfg.ServerCount,
|
||||
TotalPrice: localCfg.TotalPrice,
|
||||
CustomPrice: localCfg.CustomPrice,
|
||||
Items: items,
|
||||
ServerCount: localCfg.ServerCount,
|
||||
TotalPrice: localCfg.TotalPrice,
|
||||
CustomPrice: localCfg.CustomPrice,
|
||||
PricelistID: localCfg.PricelistID,
|
||||
WarehousePricelistID: localCfg.WarehousePricelistID,
|
||||
CompetitorPricelistID: localCfg.CompetitorPricelistID,
|
||||
DisablePriceRefresh: localCfg.DisablePriceRefresh,
|
||||
OnlyInStock: localCfg.OnlyInStock,
|
||||
VendorSpec: localCfg.VendorSpec,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(payload)
|
||||
|
||||
@@ -49,11 +49,13 @@ func NewLocalConfigurationService(
|
||||
|
||||
// Create creates a new configuration in local SQLite and queues it for sync
|
||||
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
// If online, check for new pricelists first
|
||||
// If online, trigger pricelist sync in the background — do not block config creation
|
||||
if s.isOnline() {
|
||||
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||
// Log but don't fail - we can still use local pricelists
|
||||
}
|
||||
go func() {
|
||||
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)
|
||||
@@ -399,17 +401,29 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
// Refresh local pricelists when online and use latest active/local pricelist for recalculation.
|
||||
// Refresh local pricelists when online.
|
||||
if s.isOnline() {
|
||||
_ = 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
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
||||
if pricelist != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName)
|
||||
if err == nil && price > 0 {
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
@@ -434,8 +448,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
}
|
||||
|
||||
localCfg.TotalPrice = &total
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
localCfg.PricelistID = &latestPricelist.ServerID
|
||||
if pricelist != nil {
|
||||
localCfg.PricelistID = &pricelist.ServerID
|
||||
}
|
||||
|
||||
// Set price update timestamp and mark for sync
|
||||
@@ -762,8 +776,10 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
|
||||
return templates[start:end], total, nil
|
||||
}
|
||||
|
||||
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check
|
||||
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check.
|
||||
// pricelistServerID optionally specifies which pricelist to use; if nil, the config's stored
|
||||
// pricelist is used; if that is also absent, the latest local pricelist is used as a fallback.
|
||||
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistServerID *uint) (*models.Configuration, error) {
|
||||
// Get configuration from local SQLite
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
@@ -773,13 +789,36 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
if s.isOnline() {
|
||||
_ = 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
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
||||
if pricelist != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName)
|
||||
if err == nil && price > 0 {
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
@@ -804,8 +843,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
}
|
||||
|
||||
localCfg.TotalPrice = &total
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
localCfg.PricelistID = &latestPricelist.ServerID
|
||||
if pricelist != nil {
|
||||
localCfg.PricelistID = &pricelist.ServerID
|
||||
}
|
||||
|
||||
// Set price update timestamp and mark for sync
|
||||
@@ -1205,21 +1244,55 @@ func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, nex
|
||||
current.ServerModel != next.ServerModel ||
|
||||
current.SupportCode != next.SupportCode ||
|
||||
current.Article != next.Article ||
|
||||
current.DisablePriceRefresh != next.DisablePriceRefresh ||
|
||||
current.OnlyInStock != next.OnlyInStock ||
|
||||
current.IsActive != next.IsActive ||
|
||||
current.Line != next.Line {
|
||||
return true
|
||||
}
|
||||
if !equalUintPtr(current.PricelistID, next.PricelistID) ||
|
||||
!equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) ||
|
||||
!equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) ||
|
||||
!equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
|
||||
if !equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) UpdateVendorSpecNoAuth(uuid string, spec localdb.VendorSpec) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
localCfg.VendorSpec = spec
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update vendor spec without auth with version: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) ApplyVendorSpecItemsNoAuth(uuid string, items localdb.LocalConfigItems) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
localCfg.Items = items
|
||||
total := items.Total()
|
||||
if localCfg.ServerCount > 1 {
|
||||
total *= float64(localCfg.ServerCount)
|
||||
}
|
||||
localCfg.TotalPrice = &total
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply vendor spec items without auth with version: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func equalStringPtr(a, b *string) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
|
||||
@@ -137,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) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ var (
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||
)
|
||||
|
||||
type ProjectService struct {
|
||||
@@ -63,6 +65,9 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
||||
return nil, fmt.Errorf("project code is required")
|
||||
}
|
||||
variant := strings.TrimSpace(req.Variant)
|
||||
if err := validateProjectVariantName(variant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -104,7 +109,15 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
localProject.Code = code
|
||||
}
|
||||
if req.Variant != nil {
|
||||
localProject.Variant = strings.TrimSpace(*req.Variant)
|
||||
newVariant := strings.TrimSpace(*req.Variant)
|
||||
// Block renaming of the main variant (empty Variant) — there must always be a main.
|
||||
if strings.TrimSpace(localProject.Variant) == "" && newVariant != "" {
|
||||
return nil, ErrCannotRenameMainVariant
|
||||
}
|
||||
localProject.Variant = newVariant
|
||||
if err := validateProjectVariantName(localProject.Variant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
|
||||
return nil, err
|
||||
@@ -166,6 +179,13 @@ func normalizeProjectVariant(variant string) string {
|
||||
return strings.ToLower(strings.TrimSpace(variant))
|
||||
}
|
||||
|
||||
func validateProjectVariantName(variant string) error {
|
||||
if normalizeProjectVariant(variant) == "main" {
|
||||
return ErrReservedMainVariant
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
||||
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||
}
|
||||
|
||||
60
internal/services/project_test.go
Normal file
60
internal/services/project_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
)
|
||||
|
||||
func TestProjectServiceCreateRejectsReservedMainVariant(t *testing.T) {
|
||||
local, err := newProjectTestLocalDB(t)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
service := NewProjectService(local)
|
||||
|
||||
_, err = service.Create("tester", &CreateProjectRequest{
|
||||
Code: "OPS-1",
|
||||
Variant: "main",
|
||||
})
|
||||
if !errors.Is(err, ErrReservedMainVariant) {
|
||||
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectServiceUpdateRejectsReservedMainVariant(t *testing.T) {
|
||||
local, err := newProjectTestLocalDB(t)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
service := NewProjectService(local)
|
||||
|
||||
created, err := service.Create("tester", &CreateProjectRequest{
|
||||
Code: "OPS-1",
|
||||
Variant: "Lenovo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
mainName := "main"
|
||||
_, err = service.Update(created.UUID, "tester", &UpdateProjectRequest{
|
||||
Variant: &mainName,
|
||||
})
|
||||
if !errors.Is(err, ErrReservedMainVariant) {
|
||||
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newProjectTestLocalDB(t *testing.T) (*localdb.LocalDB, error) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "project_test.db")
|
||||
local, err := localdb.New(dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
return local, nil
|
||||
}
|
||||
@@ -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 {
|
||||
for _, lotName := range missing {
|
||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
||||
if found && price > 0 {
|
||||
result[lotName] = price
|
||||
loaded[lotName] = price
|
||||
if localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID); err == nil {
|
||||
if batchPrices, err := s.localDB.GetLocalPricesForLots(localPL.ID, missing); err == nil {
|
||||
for lotName, price := range batchPrices {
|
||||
result[lotName] = price
|
||||
loaded[lotName] = price
|
||||
}
|
||||
}
|
||||
}
|
||||
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 last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS local_pricelist_count INT NOT NULL DEFAULT 0 AFTER last_sync_error_text",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pricelist_items_count INT NOT NULL DEFAULT 0 AFTER local_pricelist_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS components_count INT NOT NULL DEFAULT 0 AFTER pricelist_items_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS db_size_bytes BIGINT NOT NULL DEFAULT 0 AFTER components_count",
|
||||
} {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
||||
@@ -215,6 +219,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
|
||||
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
|
||||
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
||||
localPricelistCount := s.localDB.CountLocalPricelists()
|
||||
pricelistItemsCount := s.localDB.CountAllPricelistItems()
|
||||
componentsCount := s.localDB.CountComponents()
|
||||
dbSizeBytes := s.localDB.DBFileSizeBytes()
|
||||
return mariaDB.Exec(`
|
||||
INSERT INTO qt_client_schema_state (
|
||||
username, hostname, app_version,
|
||||
@@ -222,9 +230,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
configurations_count, projects_count,
|
||||
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
|
||||
last_sync_error_code, last_sync_error_text,
|
||||
local_pricelist_count, pricelist_items_count, components_count, db_size_bytes,
|
||||
last_checked_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
app_version = VALUES(app_version),
|
||||
last_sync_at = VALUES(last_sync_at),
|
||||
@@ -238,6 +247,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
competitor_pricelist_version = VALUES(competitor_pricelist_version),
|
||||
last_sync_error_code = VALUES(last_sync_error_code),
|
||||
last_sync_error_text = VALUES(last_sync_error_text),
|
||||
local_pricelist_count = VALUES(local_pricelist_count),
|
||||
pricelist_items_count = VALUES(pricelist_items_count),
|
||||
components_count = VALUES(components_count),
|
||||
db_size_bytes = VALUES(db_size_bytes),
|
||||
last_checked_at = VALUES(last_checked_at),
|
||||
updated_at = VALUES(updated_at)
|
||||
`, username, hostname, appmeta.Version(),
|
||||
@@ -245,6 +258,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
configurationsCount, projectsCount,
|
||||
estimateVersion, warehouseVersion, competitorVersion,
|
||||
lastSyncErrorCode, lastSyncErrorText,
|
||||
localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes,
|
||||
checkedAt, checkedAt).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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
|
||||
type Service struct {
|
||||
connMgr *db.ConnectionManager
|
||||
localDB *localdb.LocalDB
|
||||
directDB *gorm.DB
|
||||
connMgr *db.ConnectionManager
|
||||
localDB *localdb.LocalDB
|
||||
directDB *gorm.DB
|
||||
pricelistMu sync.Mutex // prevents concurrent pricelist syncs
|
||||
}
|
||||
|
||||
// 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
|
||||
type SyncStatus struct {
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
LocalPricelists int `json:"local_pricelists"`
|
||||
NeedsSync bool `json:"needs_sync"`
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
|
||||
LastSyncStatus string `json:"last_sync_status,omitempty"`
|
||||
LastSyncError string `json:"last_sync_error,omitempty"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
LocalPricelists int `json:"local_pricelists"`
|
||||
NeedsSync bool `json:"needs_sync"`
|
||||
IncompleteServerSync bool `json:"incomplete_server_sync"`
|
||||
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
|
||||
}
|
||||
|
||||
type UserSyncStatus struct {
|
||||
@@ -215,7 +222,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
||||
existing.SyncStatus = "synced"
|
||||
existing.SyncedAt = &now
|
||||
|
||||
if err := s.localDB.SaveProject(existing); err != nil {
|
||||
if err := s.localDB.SaveProjectPreservingUpdatedAt(existing); err != nil {
|
||||
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
||||
}
|
||||
result.Updated++
|
||||
@@ -225,7 +232,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
||||
localProject := localdb.ProjectToLocal(&project)
|
||||
localProject.SyncStatus = "synced"
|
||||
localProject.SyncedAt = &now
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
if err := s.localDB.SaveProjectPreservingUpdatedAt(localProject); err != nil {
|
||||
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
||||
}
|
||||
result.Imported++
|
||||
@@ -240,30 +247,23 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
||||
// GetStatus returns the current sync status
|
||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
|
||||
// Count server pricelists (only if already connected, don't reconnect)
|
||||
serverCount := 0
|
||||
connStatus := s.getConnectionStatus()
|
||||
if connStatus.IsConnected {
|
||||
if mariaDB, err := s.getDB(); err == nil && mariaDB != nil {
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
activeCount, err := pricelistRepo.CountActive()
|
||||
if err == nil {
|
||||
serverCount = int(activeCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count local pricelists
|
||||
lastAttempt := s.localDB.GetLastPricelistSyncAttemptAt()
|
||||
lastSyncStatus := s.localDB.GetLastPricelistSyncStatus()
|
||||
lastSyncError := s.localDB.GetLastPricelistSyncError()
|
||||
localCount := s.localDB.CountLocalPricelists()
|
||||
|
||||
needsSync, _ := s.NeedSync()
|
||||
hasFailedSync := strings.EqualFold(lastSyncStatus, "failed")
|
||||
needsSync := lastSync == nil || hasFailedSync
|
||||
|
||||
return &SyncStatus{
|
||||
LastSyncAt: lastSync,
|
||||
ServerPricelists: serverCount,
|
||||
LocalPricelists: int(localCount),
|
||||
NeedsSync: needsSync,
|
||||
LastSyncAt: lastSync,
|
||||
LastAttemptAt: lastAttempt,
|
||||
LastSyncStatus: lastSyncStatus,
|
||||
LastSyncError: lastSyncError,
|
||||
ServerPricelists: 0,
|
||||
LocalPricelists: int(localCount),
|
||||
NeedsSync: needsSync,
|
||||
IncompleteServerSync: hasFailedSync,
|
||||
KnownServerChangesMiss: hasFailedSync,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -333,6 +333,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
// Get database connection
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
s.recordPricelistSyncFailure(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)
|
||||
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
|
||||
if err != nil {
|
||||
s.recordPricelistSyncFailure(err)
|
||||
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
||||
}
|
||||
serverPricelistIDs := make([]uint, 0, len(serverPricelists))
|
||||
@@ -350,6 +352,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
}
|
||||
|
||||
synced := 0
|
||||
var syncErr error
|
||||
for _, pl := range serverPricelists {
|
||||
// Check if pricelist already exists locally
|
||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||
@@ -358,6 +361,9 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
|
||||
itemCount, err := s.SyncPricelistItems(existing.ID)
|
||||
if err != nil {
|
||||
if syncErr == nil {
|
||||
syncErr = fmt.Errorf("sync items for existing pricelist %s: %w", pl.Version, err)
|
||||
}
|
||||
slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err)
|
||||
} else {
|
||||
slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount)
|
||||
@@ -377,19 +383,15 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
IsUsed: false,
|
||||
}
|
||||
|
||||
if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
|
||||
slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
|
||||
itemCount, err := s.syncNewPricelistSnapshot(localPL)
|
||||
if err != nil {
|
||||
if syncErr == nil {
|
||||
syncErr = fmt.Errorf("sync new pricelist %s: %w", pl.Version, err)
|
||||
}
|
||||
slog.Warn("failed to sync pricelist snapshot", "version", pl.Version, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||
|
||||
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).
|
||||
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
||||
|
||||
if syncErr != nil {
|
||||
s.recordPricelistSyncFailure(syncErr)
|
||||
return synced, syncErr
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
s.localDB.SetLastSyncTime(time.Now())
|
||||
now := time.Now()
|
||||
s.localDB.SetLastSyncTime(now)
|
||||
s.recordPricelistSyncSuccess(now)
|
||||
s.RecordSyncHeartbeat()
|
||||
|
||||
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
||||
return synced, nil
|
||||
}
|
||||
|
||||
func (s *Service) recordPricelistSyncSuccess(at time.Time) {
|
||||
if s.localDB == nil {
|
||||
return
|
||||
}
|
||||
if err := s.localDB.SetPricelistSyncResult("success", "", at); err != nil {
|
||||
slog.Warn("failed to persist pricelist sync success state", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) recordPricelistSyncFailure(syncErr error) {
|
||||
if s.localDB == nil || syncErr == nil {
|
||||
return
|
||||
}
|
||||
s.markConnectionBroken(syncErr)
|
||||
if err := s.localDB.SetPricelistSyncResult("failed", syncErr.Error(), time.Now()); err != nil {
|
||||
slog.Warn("failed to persist pricelist sync failure state", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) markConnectionBroken(err error) {
|
||||
if err == nil || s.connMgr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
msg := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(msg, "i/o timeout"),
|
||||
strings.Contains(msg, "invalid connection"),
|
||||
strings.Contains(msg, "bad connection"),
|
||||
strings.Contains(msg, "connection reset"),
|
||||
strings.Contains(msg, "broken pipe"),
|
||||
strings.Contains(msg, "unexpected eof"):
|
||||
s.connMgr.MarkOffline(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int, error) {
|
||||
if localPL == nil {
|
||||
return 0, fmt.Errorf("local pricelist is nil")
|
||||
}
|
||||
|
||||
localItems, err := s.fetchServerPricelistItems(localPL.ServerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.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) {
|
||||
if s.localDB == nil || pricelistRepo == nil {
|
||||
return
|
||||
@@ -670,30 +754,13 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
return int(existingCount), nil
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.getDB()
|
||||
localItems, err := s.fetchServerPricelistItems(localPL.ServerID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("database not available: %w", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Create repository
|
||||
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)
|
||||
for i := range localItems {
|
||||
localItems[i].PricelistID = localPricelistID
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
||||
}
|
||||
@@ -702,6 +769,33 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
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)
|
||||
}
|
||||
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
|
||||
slog.Warn("pricelist stock enrichment skipped", "server_pricelist_id", serverPricelistID, "error", err)
|
||||
}
|
||||
|
||||
return localItems, nil
|
||||
}
|
||||
|
||||
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
|
||||
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||
@@ -847,9 +941,15 @@ func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.Local
|
||||
return localPL, nil
|
||||
}
|
||||
|
||||
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed
|
||||
// This should be called before creating a new configuration when online
|
||||
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed.
|
||||
// If a sync is already in progress, returns immediately without blocking.
|
||||
func (s *Service) SyncPricelistsIfNeeded() error {
|
||||
if !s.pricelistMu.TryLock() {
|
||||
slog.Debug("pricelist sync already in progress, skipping")
|
||||
return nil
|
||||
}
|
||||
defer s.pricelistMu.Unlock()
|
||||
|
||||
needSync, err := s.NeedSync()
|
||||
if err != nil {
|
||||
slog.Warn("failed to check if sync needed", "error", err)
|
||||
@@ -901,6 +1001,7 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
for _, change := range sortedChanges {
|
||||
err := s.pushSingleChange(&change)
|
||||
if err != nil {
|
||||
s.markConnectionBroken(err)
|
||||
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
||||
// Increment attempts
|
||||
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
|
||||
@@ -1008,7 +1109,7 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
||||
localProject.SyncStatus = "synced"
|
||||
now := time.Now()
|
||||
localProject.SyncedAt = &now
|
||||
_ = s.localDB.SaveProject(localProject)
|
||||
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1278,7 +1379,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
||||
localProject.SyncStatus = "synced"
|
||||
now := time.Now()
|
||||
localProject.SyncedAt = &now
|
||||
_ = s.localDB.SaveProject(localProject)
|
||||
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package sync_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
||||
@@ -83,3 +86,58 @@ func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
||||
t.Fatalf("expected server pricelist to be synced locally: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPricelistsDoesNotPersistHeaderWithoutItems(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
|
||||
t.Fatalf("migrate server pricelist tables: %v", err)
|
||||
}
|
||||
|
||||
serverPL := models.Pricelist{
|
||||
Source: "estimate",
|
||||
Version: "2026-03-17-001",
|
||||
Notification: "server",
|
||||
CreatedBy: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
if err := serverDB.Create(&serverPL).Error; err != nil {
|
||||
t.Fatalf("create server pricelist: %v", err)
|
||||
}
|
||||
if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil {
|
||||
t.Fatalf("create server pricelist item: %v", err)
|
||||
}
|
||||
|
||||
const callbackName = "test:fail_qt_pricelist_items_query"
|
||||
if err := serverDB.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
|
||||
if db.Statement != nil && db.Statement.Table == "qt_pricelist_items" {
|
||||
_ = db.AddError(errors.New("forced pricelist item fetch failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("register query callback: %v", err)
|
||||
}
|
||||
defer serverDB.Callback().Query().Remove(callbackName)
|
||||
|
||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
synced, err := svc.SyncPricelists()
|
||||
if err == nil {
|
||||
t.Fatalf("expected sync error when item fetch fails")
|
||||
}
|
||||
if synced != 0 {
|
||||
t.Fatalf("expected synced=0 on incomplete sync, got %d", synced)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "forced pricelist item fetch failure") {
|
||||
t.Fatalf("expected item fetch error, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := local.GetLocalPricelistByServerID(serverPL.ID); err == nil {
|
||||
t.Fatalf("expected pricelist header not to be persisted without items")
|
||||
}
|
||||
if got := local.CountLocalPricelists(); got != 0 {
|
||||
t.Fatalf("expected no local pricelists after failed sync, got %d", got)
|
||||
}
|
||||
if ts := local.GetLastSyncTime(); ts != nil {
|
||||
t.Fatalf("expected last_pricelist_sync to stay unset on incomplete sync, got %v", ts)
|
||||
}
|
||||
}
|
||||
|
||||
67
releases/v1.5.4/RELEASE_NOTES.md
Normal file
67
releases/v1.5.4/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# QuoteForge v1.5.4
|
||||
|
||||
Дата релиза: 2026-03-16
|
||||
Тег: `v1.5.4`
|
||||
|
||||
Предыдущий релиз: `v1.5.0`
|
||||
|
||||
## Ключевые изменения
|
||||
|
||||
- pricing tab переработан: закупка и продажа разделены на отдельные таблицы с ценами за 1 шт.;
|
||||
- экран прайслиста переработан под разные типы источников; удалены misleading-колонки `Поставщик` и `partnumbers`;
|
||||
- runtime и startup ужесточены: локальный клиент принудительно работает только на loopback, конфиг автоматически нормализуется;
|
||||
- добавлены действия с вариантом и унифицированы правила именования `_копия` для вариантов и конфигураций;
|
||||
- исправлен CSV-экспорт прайсинговых таблиц в конфигураторе под Excel-совместимый формат Excel-friendly;
|
||||
- таблица проектов переработана: дата последней правки, tooltip с деталями, отдельный автор, компактные действия и ссылка на трекер;
|
||||
- sync больше не подменяет `updated_at` проектов временем синхронизации;
|
||||
- добавлена одноразовая утилита `cmd/migrate_project_updated_at` для пересинхронизации `updated_at` проектов из MariaDB в локальную SQLite;
|
||||
- runtime config, release notes и `bible-local/` очищены и приведены к актуальной архитектуре;
|
||||
- `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/migrate_project_updated_at/`;
|
||||
- `internal/localdb/`;
|
||||
- `internal/services/project.go`;
|
||||
- `internal/services/sync/service.go`;
|
||||
- `internal/handlers/pricelist.go`;
|
||||
- `web/templates/pricelist_detail.html`;
|
||||
- `web/templates/index.html`;
|
||||
- `web/templates/project_detail.html`;
|
||||
- `web/templates/projects.html`;
|
||||
- `web/templates/configs.html`;
|
||||
- `bible-local/`.
|
||||
|
||||
## Совместимость
|
||||
|
||||
- схема данных не меняется;
|
||||
- серверные SQL-миграции не требуются;
|
||||
- для уже испорченных локальных дат проектов можно один раз запустить `go run ./cmd/migrate_project_updated_at -apply`.
|
||||
@@ -21,13 +21,14 @@ fi
|
||||
echo -e "${GREEN}Building QuoteForge version: ${VERSION}${NC}"
|
||||
echo ""
|
||||
|
||||
# Create release directory
|
||||
RELEASE_DIR="releases/${VERSION}"
|
||||
mkdir -p "${RELEASE_DIR}"
|
||||
ensure_release_notes() {
|
||||
local notes_path="$1"
|
||||
if [ -f "${notes_path}" ]; then
|
||||
echo -e "${GREEN} ✓ Preserving existing RELEASE_NOTES.md${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Create release notes template (always include macOS Gatekeeper note)
|
||||
if [ ! -f "${RELEASE_DIR}/RELEASE_NOTES.md" ]; then
|
||||
cat > "${RELEASE_DIR}/RELEASE_NOTES.md" <<EOF
|
||||
cat > "${notes_path}" <<EOF
|
||||
# QuoteForge ${VERSION}
|
||||
|
||||
Дата релиза: $(date +%Y-%m-%d)
|
||||
@@ -42,7 +43,15 @@ cat > "${RELEASE_DIR}/RELEASE_NOTES.md" <<EOF
|
||||
Снимите карантинный атрибут через терминал: \`xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64\`
|
||||
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||
EOF
|
||||
fi
|
||||
echo -e "${GREEN} ✓ Created RELEASE_NOTES.md template${NC}"
|
||||
}
|
||||
|
||||
# Create release directory
|
||||
RELEASE_DIR="releases/${VERSION}"
|
||||
mkdir -p "${RELEASE_DIR}"
|
||||
|
||||
# Create release notes template only when missing.
|
||||
ensure_release_notes "${RELEASE_DIR}/RELEASE_NOTES.md"
|
||||
|
||||
# Build for all platforms
|
||||
echo -e "${YELLOW}→ Building binaries...${NC}"
|
||||
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{template "title" .}}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/vendor/tailwindcss.browser.js"></script>
|
||||
<script src="/static/vendor/htmx-1.9.10.min.js"></script>
|
||||
<style>
|
||||
.htmx-request { opacity: 0.5; }
|
||||
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
@@ -43,6 +44,10 @@
|
||||
{{template "content" .}}
|
||||
</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>
|
||||
|
||||
<!-- Sync Info Modal -->
|
||||
@@ -92,6 +97,15 @@
|
||||
</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 -->
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
|
||||
@@ -229,6 +243,43 @@
|
||||
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
|
||||
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() : '—';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Ревизии - QuoteForge{{end}}
|
||||
{{define "title"}}Ревизии - OFS{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -135,15 +135,18 @@ async function loadVersions() {
|
||||
}
|
||||
|
||||
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 =
|
||||
'<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;
|
||||
}
|
||||
|
||||
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||||
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>';
|
||||
@@ -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 += '</tr></thead><tbody class="divide-y">';
|
||||
|
||||
versions.forEach((v, idx) => {
|
||||
snapshots.forEach((v) => {
|
||||
const date = new Date(v.created_at).toLocaleString('ru-RU');
|
||||
const author = v.created_by || '—';
|
||||
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 += 'v' + v.version_no;
|
||||
if (isCurrent) html += ' <span class="text-xs text-blue-600 font-normal">(текущая)</span>';
|
||||
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(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 += '<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)
|
||||
if (!isCurrent) {
|
||||
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 += '<button onclick="rollbackToVersion(' + v.version_no + ')" class="text-orange-600 hover:text-orange-800" title="Восстановить этот снимок как новую main-версию">';
|
||||
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>';
|
||||
});
|
||||
@@ -212,7 +210,7 @@ async function cloneFromVersion(versionNo) {
|
||||
}
|
||||
|
||||
async function rollbackToVersion(versionNo) {
|
||||
if (!confirm('Восстановить конфигурацию до ревизии v' + versionNo + '?')) return;
|
||||
if (!confirm('Восстановить снимок v' + versionNo + ' как новую рабочую версию main?')) return;
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/rollback', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Мои конфигурации - QuoteForge{{end}}
|
||||
{{define "title"}}Мои конфигурации - OFS{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -203,6 +203,8 @@ let projectsCache = [];
|
||||
let projectNameByUUID = {};
|
||||
let projectCodeByUUID = {};
|
||||
let projectVariantByUUID = {};
|
||||
let configProjectUUIDByUUID = {};
|
||||
let configNameByUUID = {};
|
||||
let pendingMoveConfigUUID = '';
|
||||
let pendingMoveProjectCode = '';
|
||||
let pendingCreateConfigName = '';
|
||||
@@ -343,6 +345,45 @@ function findProjectByInput(input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveUniqueConfigName(baseName, projectUUID, excludeUUID) {
|
||||
const cleanedBase = (baseName || '').trim();
|
||||
if (!cleanedBase) {
|
||||
return {error: 'Введите название'};
|
||||
}
|
||||
|
||||
let configs = [];
|
||||
if (projectUUID) {
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs?status=all');
|
||||
if (!resp.ok) {
|
||||
return {error: 'Не удалось проверить конфигурации проекта'};
|
||||
}
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
configs = Array.isArray(data.configurations) ? data.configurations : [];
|
||||
} else {
|
||||
configs = Object.keys(configProjectUUIDByUUID)
|
||||
.filter(uuid => !configProjectUUIDByUUID[uuid])
|
||||
.map(uuid => ({uuid: uuid, name: configNameByUUID[uuid] || ''}));
|
||||
}
|
||||
|
||||
const used = new Set(
|
||||
configs
|
||||
.filter(cfg => !excludeUUID || cfg.uuid !== excludeUUID)
|
||||
.map(cfg => (cfg.name || '').trim().toLowerCase())
|
||||
);
|
||||
|
||||
if (!used.has(cleanedBase.toLowerCase())) {
|
||||
return {name: cleanedBase, changed: false};
|
||||
}
|
||||
|
||||
let candidate = cleanedBase + '_копия';
|
||||
let suffix = 2;
|
||||
while (used.has(candidate.toLowerCase())) {
|
||||
candidate = cleanedBase + '_копия' + suffix;
|
||||
suffix++;
|
||||
}
|
||||
return {name: candidate, changed: true};
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
@@ -385,14 +426,23 @@ function closeRenameModal() {
|
||||
|
||||
async function renameConfig() {
|
||||
const uuid = document.getElementById('rename-uuid').value;
|
||||
const name = document.getElementById('rename-input').value.trim();
|
||||
const rawName = document.getElementById('rename-input').value.trim();
|
||||
|
||||
if (!name) {
|
||||
if (!rawName) {
|
||||
alert('Введите название');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await resolveUniqueConfigName(rawName, configProjectUUIDByUUID[uuid] || '', uuid);
|
||||
if (result.error) {
|
||||
alert(result.error);
|
||||
return;
|
||||
}
|
||||
const name = result.name;
|
||||
if (result.changed) {
|
||||
document.getElementById('rename-input').value = name;
|
||||
}
|
||||
const resp = await fetch('/api/configs/' + uuid + '/rename', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
@@ -416,7 +466,7 @@ async function renameConfig() {
|
||||
|
||||
function openCloneModal(uuid, currentName) {
|
||||
document.getElementById('clone-uuid').value = uuid;
|
||||
document.getElementById('clone-input').value = currentName + ' (копия)';
|
||||
document.getElementById('clone-input').value = currentName + '_копия';
|
||||
document.getElementById('clone-modal').classList.remove('hidden');
|
||||
document.getElementById('clone-modal').classList.add('flex');
|
||||
document.getElementById('clone-input').focus();
|
||||
@@ -430,14 +480,23 @@ function closeCloneModal() {
|
||||
|
||||
async function cloneConfig() {
|
||||
const uuid = document.getElementById('clone-uuid').value;
|
||||
const name = document.getElementById('clone-input').value.trim();
|
||||
const rawName = document.getElementById('clone-input').value.trim();
|
||||
|
||||
if (!name) {
|
||||
if (!rawName) {
|
||||
alert('Введите название');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await resolveUniqueConfigName(rawName, configProjectUUIDByUUID[uuid] || '', uuid);
|
||||
if (result.error) {
|
||||
alert(result.error);
|
||||
return;
|
||||
}
|
||||
const name = result.name;
|
||||
if (result.changed) {
|
||||
document.getElementById('clone-input').value = name;
|
||||
}
|
||||
const resp = await fetch('/api/configs/' + uuid + '/clone', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -851,6 +910,12 @@ async function loadConfigs() {
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
configProjectUUIDByUUID = {};
|
||||
configNameByUUID = {};
|
||||
(data.configurations || []).forEach(cfg => {
|
||||
configProjectUUIDByUUID[cfg.uuid] = cfg.project_uuid || '';
|
||||
configNameByUUID[cfg.uuid] = cfg.name || '';
|
||||
});
|
||||
renderConfigs(data.configurations || []);
|
||||
updatePagination(data.total);
|
||||
} catch(e) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}QuoteForge - Конфигуратор{{end}}
|
||||
{{define "title"}}OFS - Конфигуратор{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -24,7 +24,7 @@
|
||||
<span id="breadcrumb-config-name">Конфигуратор</span>
|
||||
</a>
|
||||
<span class="text-gray-400">-</span>
|
||||
<span id="breadcrumb-config-version">v1</span>
|
||||
<span id="breadcrumb-config-version">main</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
||||
@@ -211,9 +211,9 @@
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<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">Описание</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">Estimate</th>
|
||||
<th class="px-3 py-2 text-right border-b">Склад</th>
|
||||
@@ -236,12 +236,12 @@
|
||||
</table>
|
||||
</div>
|
||||
<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"
|
||||
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
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">
|
||||
Проставить цены BOM
|
||||
BOM Цена
|
||||
</button>
|
||||
<button onclick="exportPricingCSV('buy')" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
|
||||
Экспорт CSV
|
||||
@@ -260,9 +260,9 @@
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<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">Описание</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">Estimate</th>
|
||||
<th class="px-3 py-2 text-right border-b">Склад</th>
|
||||
@@ -289,7 +289,7 @@
|
||||
<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"
|
||||
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"
|
||||
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="onSaleCustomPriceInput()">
|
||||
@@ -476,7 +476,8 @@ function updateConfigBreadcrumbs() {
|
||||
const fullConfigName = configName || 'Конфигурация';
|
||||
configEl.textContent = truncateBreadcrumbSpecName(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');
|
||||
if (configNameLinkEl && configUUID) {
|
||||
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||
@@ -925,14 +926,9 @@ async function loadActivePricelists(force = false) {
|
||||
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
||||
const data = await resp.json();
|
||||
activePricelistsBySource[source] = data.pricelists || [];
|
||||
const existing = selectedPricelistIds[source];
|
||||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
||||
return;
|
||||
}
|
||||
selectedPricelistIds[source] = null;
|
||||
// Do not reset the stored pricelist — it may be inactive but must be preserved
|
||||
} catch (e) {
|
||||
activePricelistsBySource[source] = [];
|
||||
selectedPricelistIds[source] = null;
|
||||
}
|
||||
}));
|
||||
activePricelistsLoadedAt = Date.now();
|
||||
@@ -954,11 +950,25 @@ function renderPricelistSelectOptions(selectId, source) {
|
||||
select.value = '';
|
||||
return;
|
||||
}
|
||||
select.innerHTML = `<option value="">Авто (последний активный)</option>` + pricelists.map(pl => {
|
||||
select.innerHTML = pricelists.map(pl => {
|
||||
return `<option value="${pl.id}">${escapeHtml(pl.version)}</option>`;
|
||||
}).join('');
|
||||
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() {
|
||||
@@ -984,9 +994,9 @@ function getPricelistVersionById(source, id) {
|
||||
function renderPricelistSettingsSummary() {
|
||||
const summary = document.getElementById('pricelist-settings-summary');
|
||||
if (!summary) return;
|
||||
const estimate = selectedPricelistIds.estimate ? getPricelistVersionById('estimate', selectedPricelistIds.estimate) || `ID ${selectedPricelistIds.estimate}` : 'авто';
|
||||
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
|
||||
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
|
||||
const estimate = selectedPricelistIds.estimate ? getPricelistVersionById('estimate', selectedPricelistIds.estimate) || `ID ${selectedPricelistIds.estimate}` : '—';
|
||||
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : '—';
|
||||
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : '—';
|
||||
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
|
||||
const stockFilterState = onlyInStock ? ' | Только наличие: вкл' : '';
|
||||
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}${stockFilterState}`;
|
||||
@@ -1062,16 +1072,16 @@ function applyPriceSettings() {
|
||||
const inStockVal = Boolean(document.getElementById('settings-only-in-stock')?.checked);
|
||||
|
||||
const prevWarehouseID = currentWarehousePricelistID();
|
||||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
||||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
||||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
||||
if (selectedPricelistIds.estimate) {
|
||||
if (Number.isFinite(estimateVal) && estimateVal > 0) {
|
||||
selectedPricelistIds.estimate = estimateVal;
|
||||
resolvedAutoPricelistIds.estimate = null;
|
||||
}
|
||||
if (selectedPricelistIds.warehouse) {
|
||||
if (Number.isFinite(warehouseVal) && warehouseVal > 0) {
|
||||
selectedPricelistIds.warehouse = warehouseVal;
|
||||
resolvedAutoPricelistIds.warehouse = null;
|
||||
}
|
||||
if (selectedPricelistIds.competitor) {
|
||||
if (Number.isFinite(competitorVal) && competitorVal > 0) {
|
||||
selectedPricelistIds.competitor = competitorVal;
|
||||
resolvedAutoPricelistIds.competitor = null;
|
||||
}
|
||||
disablePriceRefresh = disableVal;
|
||||
@@ -2172,9 +2182,8 @@ async function saveConfig(showNotification = true) {
|
||||
const saved = await resp.json();
|
||||
if (saved && 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;
|
||||
clearAutosaveDraft();
|
||||
exitSaveStarted = false;
|
||||
@@ -2509,11 +2518,14 @@ async function refreshPrices() {
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshPayload = {};
|
||||
if (selectedPricelistIds.estimate) {
|
||||
refreshPayload.pricelist_id = selectedPricelistIds.estimate;
|
||||
}
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(refreshPayload)
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -2558,7 +2570,7 @@ async function refreshPrices() {
|
||||
}
|
||||
|
||||
// Re-render UI
|
||||
await refreshPriceLevels();
|
||||
await refreshPriceLevels({ force: true, noCache: true });
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
|
||||
@@ -3610,6 +3622,7 @@ async function renderPricingTab() {
|
||||
});
|
||||
|
||||
// ─── 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 result = [];
|
||||
const coveredLots = new Set();
|
||||
@@ -3619,7 +3632,8 @@ async function renderPricingTab() {
|
||||
const u = _getUnitPrices(pl);
|
||||
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
|
||||
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 || '',
|
||||
qty: item.quantity,
|
||||
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
||||
@@ -3627,6 +3641,7 @@ async function renderPricingTab() {
|
||||
warehouse: u.warehouseUnit != null ? u.warehouseUnit * item.quantity : null,
|
||||
competitor: u.competitorUnit != null ? u.competitorUnit * item.quantity : null,
|
||||
vendorOrig: null, vendorOrigUnit: null, isEstOnly,
|
||||
groupStart: true, groupSize: 1,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3641,42 +3656,66 @@ async function renderPricingTab() {
|
||||
if (baseLot) coveredLots.add(baseLot);
|
||||
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
|
||||
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
|
||||
const vendorOrig = row.total_price != null ? row.total_price
|
||||
: (row.unit_price != null ? row.unit_price * row.quantity : null);
|
||||
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : '');
|
||||
result.push({
|
||||
lotCell, vendorPN: row.vendor_pn, desc, qty: row.quantity,
|
||||
estUnit: hasEst ? rowEstUnit : 0,
|
||||
warehouseUnit: hasWh ? rowWhUnit : null,
|
||||
competitorUnit: hasComp ? rowCompUnit : null,
|
||||
est: hasEst ? rowEstUnit * row.quantity : 0,
|
||||
warehouse: hasWh ? rowWhUnit * row.quantity : null,
|
||||
competitor: hasComp ? rowCompUnit * row.quantity : null,
|
||||
vendorOrig, vendorOrigUnit, isEstOnly: false,
|
||||
|
||||
// Build per-LOT sub-rows
|
||||
const subRows = [];
|
||||
if (baseLot) {
|
||||
const u = _getUnitPrices(priceMap[baseLot]);
|
||||
const lotQty = _getRowLotQtyPerPN(row);
|
||||
const qty = row.quantity * lotQty;
|
||||
subRows.push({
|
||||
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 +3748,28 @@ async function renderPricingTab() {
|
||||
tr.dataset.qty = r.qty;
|
||||
tr.dataset.vendorOrig = r.vendorOrig != null ? r.vendorOrig : '';
|
||||
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.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; }
|
||||
if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; }
|
||||
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 = `
|
||||
<td class="px-3 py-1.5 text-xs">${r.lotCell}</td>
|
||||
<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 text-gray-500 truncate max-w-xs">${escapeHtml(r.desc)}</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">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</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">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</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>
|
||||
${pnDescHtml}
|
||||
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</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 ${borderTop}">${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 ${borderTop}">${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>
|
||||
`;
|
||||
tbodyBuy.appendChild(tr);
|
||||
});
|
||||
@@ -3753,18 +3801,27 @@ async function renderPricingTab() {
|
||||
const saleCompTotal = saleCompUnit != null ? saleCompUnit * r.qty : null;
|
||||
tr.dataset.estSale = saleEstTotal;
|
||||
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 (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; }
|
||||
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 = `
|
||||
<td class="px-3 py-1.5 text-xs">${r.lotCell}</td>
|
||||
<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 text-gray-500 truncate max-w-xs">${escapeHtml(r.desc)}</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">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</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">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-sale text-gray-400">—</td>
|
||||
${pnDescHtml}
|
||||
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</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 ${borderTop}">${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 ${borderTop}">${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>
|
||||
`;
|
||||
tbodySale.appendChild(tr);
|
||||
});
|
||||
@@ -3947,29 +4004,49 @@ function exportPricingCSV(table) {
|
||||
const rows = document.querySelectorAll(`#${bodyId} tr.${rowClass}`);
|
||||
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
|
||||
|
||||
const csvDelimiter = ';';
|
||||
const cleanExportCell = value => {
|
||||
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text || text === '—') return text || '';
|
||||
return text
|
||||
.replace(/\s*\(.*\)$/, '')
|
||||
.replace(/\s*\*+\s*$/, '')
|
||||
.trim();
|
||||
};
|
||||
const csvEscape = v => {
|
||||
if (v == null) return '';
|
||||
const s = String(v).replace(/"/g, '""');
|
||||
return /[,"\n]/.test(s) ? `"${s}"` : s;
|
||||
return /[;"\n\r]/.test(s) ? `"${s}"` : s;
|
||||
};
|
||||
|
||||
const headers = ['Lot', 'PN вендора', 'Описание', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
|
||||
const lines = [headers.map(csvEscape).join(',')];
|
||||
const headers = ['PN вендора', 'Описание', 'LOT', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
|
||||
const lines = [headers.map(csvEscape).join(csvDelimiter)];
|
||||
|
||||
rows.forEach(tr => {
|
||||
// PN вендора, Описание, LOT are stored in dataset to handle rowspan correctly
|
||||
const pn = cleanExportCell(tr.dataset.vendorPn || '');
|
||||
const desc = cleanExportCell(tr.dataset.desc || '');
|
||||
const lot = cleanExportCell(tr.dataset.lot || '');
|
||||
// Qty..Ручная цена: cells at offset 2 for group-start rows, offset 0 for sub-rows
|
||||
const isGroupStart = tr.dataset.groupStart === 'true';
|
||||
const cells = tr.querySelectorAll('td');
|
||||
const cols = [0,1,2,3,4,5,6,7].map(i => cells[i] ? cells[i].textContent.trim() : '');
|
||||
lines.push(cols.map(csvEscape).join(','));
|
||||
const o = isGroupStart ? 2 : 0;
|
||||
const cols = [pn, desc, lot,
|
||||
cleanExportCell(cells[o]?.textContent),
|
||||
cleanExportCell(cells[o+1]?.textContent),
|
||||
cleanExportCell(cells[o+2]?.textContent),
|
||||
cleanExportCell(cells[o+3]?.textContent),
|
||||
cleanExportCell(cells[o+4]?.textContent),
|
||||
];
|
||||
lines.push(cols.map(csvEscape).join(csvDelimiter));
|
||||
});
|
||||
|
||||
// Totals row
|
||||
const tEst = document.getElementById(totalIds.est)?.textContent.trim() || '';
|
||||
const tWh = document.getElementById(totalIds.wh)?.textContent.trim() || '';
|
||||
const tComp = document.getElementById(totalIds.comp)?.textContent.trim() || '';
|
||||
const tVendor = document.getElementById(totalIds.vendor)?.textContent.trim() || '';
|
||||
// Strip % annotation from vendor total for CSV
|
||||
const tVendorClean = tVendor.replace(/\s*\(.*\)$/, '').trim();
|
||||
lines.push(['', '', '', 'Итого:', tEst, tWh, tComp, tVendorClean].map(csvEscape).join(','));
|
||||
const tEst = cleanExportCell(document.getElementById(totalIds.est)?.textContent);
|
||||
const tWh = cleanExportCell(document.getElementById(totalIds.wh)?.textContent);
|
||||
const tComp = cleanExportCell(document.getElementById(totalIds.comp)?.textContent);
|
||||
const tVendor = cleanExportCell(document.getElementById(totalIds.vendor)?.textContent);
|
||||
lines.push(['', '', '', 'Итого:', tEst, tWh, tComp, tVendor].map(csvEscape).join(csvDelimiter));
|
||||
|
||||
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
@@ -14,7 +14,21 @@
|
||||
</span>
|
||||
{{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()">
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}QuoteForge - Партномера{{end}}
|
||||
{{define "title"}}OFS - Партномера{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Прайслист - QuoteForge{{end}}
|
||||
{{define "title"}}Прайслист - OFS{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Прайслисты - QuoteForge{{end}}
|
||||
{{define "title"}}Прайслисты - OFS{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Проект - QuoteForge{{end}}
|
||||
{{define "title"}}Проект - OFS{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -29,23 +29,26 @@
|
||||
<button onclick="openNewVariantModal()" class="inline-flex w-full sm:w-auto justify-center items-center px-3 py-1.5 text-sm font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Вариант
|
||||
</button>
|
||||
<button onclick="openVariantActionModal()" class="inline-flex w-full sm:w-auto justify-center items-center px-3 py-1.5 text-sm font-medium bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
|
||||
Действия с вариантом
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-6 gap-3">
|
||||
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
Новая конфигурация
|
||||
+ Конфигурация
|
||||
</button>
|
||||
<button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium">
|
||||
Импорт выгрузки вендора
|
||||
</button>
|
||||
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
|
||||
Параметры
|
||||
Импорт
|
||||
</button>
|
||||
<button onclick="openExportModal()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
|
||||
Экспорт CSV
|
||||
</button>
|
||||
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
|
||||
Параметры
|
||||
</button>
|
||||
<button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden">
|
||||
Удалить вариант
|
||||
</button>
|
||||
@@ -173,6 +176,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="variant-action-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Действия с вариантом</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
||||
<input type="text" id="variant-action-name"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" id="variant-action-copy" class="rounded border-gray-300">
|
||||
Создать копию
|
||||
</label>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||
<input type="text" id="variant-action-code"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<input type="hidden" id="variant-action-current-name">
|
||||
<input type="hidden" id="variant-action-current-code">
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeVariantActionModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="saveVariantAction()" class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="config-action-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Действия с конфигурацией</h2>
|
||||
@@ -332,6 +363,7 @@ function renderVariantSelect() {
|
||||
if (item.uuid === projectUUID) {
|
||||
option.className += ' font-semibold text-gray-900';
|
||||
label.textContent = variantLabel;
|
||||
document.title = (project && project.code ? project.code : '—') + ' / ' + variantLabel + ' — OFS';
|
||||
}
|
||||
option.textContent = variantLabel;
|
||||
option.onclick = function() {
|
||||
@@ -441,8 +473,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-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">v' + versionNo + '</td>';
|
||||
html += '<td class="px-2 py-3 text-sm text-center text-gray-500 w-12">main</td>';
|
||||
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') {
|
||||
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
||||
@@ -540,6 +571,213 @@ function closeNewVariantModal() {
|
||||
document.getElementById('new-variant-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function openVariantActionModal() {
|
||||
if (!project) return;
|
||||
const currentName = (project.variant || '').trim();
|
||||
const currentCode = (project.code || '').trim();
|
||||
document.getElementById('variant-action-current-name').value = currentName;
|
||||
document.getElementById('variant-action-current-code').value = currentCode;
|
||||
document.getElementById('variant-action-name').value = currentName;
|
||||
document.getElementById('variant-action-code').value = currentCode;
|
||||
document.getElementById('variant-action-copy').checked = false;
|
||||
document.getElementById('variant-action-modal').classList.remove('hidden');
|
||||
document.getElementById('variant-action-modal').classList.add('flex');
|
||||
const nameInput = document.getElementById('variant-action-name');
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}
|
||||
|
||||
function closeVariantActionModal() {
|
||||
document.getElementById('variant-action-modal').classList.add('hidden');
|
||||
document.getElementById('variant-action-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function findUniqueVariantActionName(baseName, targetCode, excludeProjectUUID) {
|
||||
const cleanedBase = (baseName || '').trim();
|
||||
if (!cleanedBase || normalizeVariantLabel(cleanedBase).toLowerCase() === 'main') {
|
||||
return {error: 'Имя варианта не должно быть пустым и не может быть main'};
|
||||
}
|
||||
|
||||
const code = (targetCode || '').trim();
|
||||
const used = new Set(
|
||||
projectsCatalog
|
||||
.filter(p => (p.code || '').trim().toLowerCase() === code.toLowerCase())
|
||||
.filter(p => !excludeProjectUUID || p.uuid !== excludeProjectUUID)
|
||||
.map(p => ((p.variant || '').trim()).toLowerCase())
|
||||
);
|
||||
|
||||
if (!used.has(cleanedBase.toLowerCase())) {
|
||||
return {name: cleanedBase, changed: false};
|
||||
}
|
||||
|
||||
let candidate = cleanedBase + '_копия';
|
||||
let suffix = 2;
|
||||
while (used.has(candidate.toLowerCase())) {
|
||||
candidate = cleanedBase + '_копия' + suffix;
|
||||
suffix++;
|
||||
}
|
||||
return {name: candidate, changed: true};
|
||||
}
|
||||
|
||||
async function resolveUniqueConfigActionName(baseName, targetProjectUUID, excludeConfigUUID) {
|
||||
const cleanedBase = (baseName || '').trim();
|
||||
if (!cleanedBase) {
|
||||
return {error: 'Введите название'};
|
||||
}
|
||||
|
||||
let configs = [];
|
||||
if (targetProjectUUID === projectUUID) {
|
||||
configs = Array.isArray(allConfigs) ? allConfigs : [];
|
||||
} else {
|
||||
const resp = await fetch('/api/projects/' + targetProjectUUID + '/configs?status=all');
|
||||
if (!resp.ok) {
|
||||
return {error: 'Не удалось проверить конфигурации целевого проекта'};
|
||||
}
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
configs = Array.isArray(data.configurations) ? data.configurations : [];
|
||||
}
|
||||
|
||||
const used = new Set(
|
||||
configs
|
||||
.filter(cfg => !excludeConfigUUID || cfg.uuid !== excludeConfigUUID)
|
||||
.map(cfg => (cfg.name || '').trim().toLowerCase())
|
||||
)
|
||||
|
||||
if (!used.has(cleanedBase.toLowerCase())) {
|
||||
return {name: cleanedBase, changed: false};
|
||||
}
|
||||
|
||||
let candidate = cleanedBase + '_копия';
|
||||
let suffix = 2;
|
||||
while (used.has(candidate.toLowerCase())) {
|
||||
candidate = cleanedBase + '_копия' + suffix;
|
||||
suffix++;
|
||||
}
|
||||
return {name: candidate, changed: true};
|
||||
}
|
||||
|
||||
async function cloneVariantConfigurations(targetProjectUUID) {
|
||||
const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
|
||||
if (!listResp.ok) {
|
||||
throw new Error('Не удалось загрузить конфигурации варианта');
|
||||
}
|
||||
const listData = await listResp.json().catch(() => ({}));
|
||||
const configs = Array.isArray(listData.configurations) ? listData.configurations : [];
|
||||
for (const cfg of configs) {
|
||||
const cloneResp = await fetch('/api/projects/' + targetProjectUUID + '/configs/' + cfg.uuid + '/clone', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: cfg.name})
|
||||
});
|
||||
if (!cloneResp.ok) {
|
||||
throw new Error('Не удалось скопировать конфигурацию «' + (cfg.name || 'без названия') + '»');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveVariantAction() {
|
||||
if (!project) return;
|
||||
const notify = (message, type) => {
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(message, type || 'success');
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
const currentName = document.getElementById('variant-action-current-name').value.trim();
|
||||
const currentCode = document.getElementById('variant-action-current-code').value.trim();
|
||||
const rawName = document.getElementById('variant-action-name').value.trim();
|
||||
const code = document.getElementById('variant-action-code').value.trim();
|
||||
const copy = document.getElementById('variant-action-copy').checked;
|
||||
|
||||
if (!code) {
|
||||
notify('Введите код проекта', 'error');
|
||||
return;
|
||||
}
|
||||
const uniqueNameResult = findUniqueVariantActionName(rawName, code, copy ? '' : projectUUID);
|
||||
if (uniqueNameResult.error) {
|
||||
notify(uniqueNameResult.error, 'error');
|
||||
return;
|
||||
}
|
||||
const name = uniqueNameResult.name;
|
||||
if (uniqueNameResult.changed) {
|
||||
document.getElementById('variant-action-name').value = name;
|
||||
notify('Имя варианта занято, использовано ' + name, 'success');
|
||||
}
|
||||
|
||||
if (copy) {
|
||||
const createResp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
code: code,
|
||||
variant: name,
|
||||
name: project.name || null,
|
||||
tracker_url: (project.tracker_url || '').trim()
|
||||
})
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
if (createResp.status === 400) {
|
||||
notify('Имя варианта не может быть main', 'error');
|
||||
return;
|
||||
}
|
||||
if (createResp.status === 409) {
|
||||
notify('Вариант с таким кодом и значением уже существует', 'error');
|
||||
return;
|
||||
}
|
||||
notify('Не удалось создать копию варианта', 'error');
|
||||
return;
|
||||
}
|
||||
const created = await createResp.json().catch(() => null);
|
||||
if (!created || !created.uuid) {
|
||||
notify('Не удалось создать копию варианта', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await cloneVariantConfigurations(created.uuid);
|
||||
} catch (err) {
|
||||
notify(err.message || 'Вариант создан, но конфигурации не скопированы полностью', 'error');
|
||||
window.location.href = '/projects/' + created.uuid;
|
||||
return;
|
||||
}
|
||||
closeVariantActionModal();
|
||||
notify('Копия варианта создана', 'success');
|
||||
window.location.href = '/projects/' + created.uuid;
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = name !== currentName || code !== currentCode;
|
||||
if (!changed) {
|
||||
closeVariantActionModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const updateResp = await fetch('/api/projects/' + projectUUID, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({code: code, variant: name})
|
||||
});
|
||||
if (!updateResp.ok) {
|
||||
if (updateResp.status === 400) {
|
||||
notify('Имя варианта не может быть main', 'error');
|
||||
return;
|
||||
}
|
||||
if (updateResp.status === 409) {
|
||||
notify('Вариант с таким кодом и значением уже существует', 'error');
|
||||
return;
|
||||
}
|
||||
notify('Не удалось сохранить вариант', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
closeVariantActionModal();
|
||||
await loadProject();
|
||||
await loadConfigs();
|
||||
updateDeleteVariantButton();
|
||||
notify('Вариант обновлён', 'success');
|
||||
}
|
||||
|
||||
async function createNewVariant() {
|
||||
if (!project) return;
|
||||
const code = (project.code || '').trim();
|
||||
@@ -864,12 +1102,22 @@ async function saveConfigAction() {
|
||||
notify('Введите название', 'error');
|
||||
return;
|
||||
}
|
||||
const uniqueNameResult = await resolveUniqueConfigActionName(name, targetProjectUUID, copy ? '' : uuid);
|
||||
if (uniqueNameResult.error) {
|
||||
notify(uniqueNameResult.error, 'error');
|
||||
return;
|
||||
}
|
||||
const resolvedName = uniqueNameResult.name;
|
||||
if (uniqueNameResult.changed) {
|
||||
document.getElementById('config-action-name').value = resolvedName;
|
||||
notify('Имя занято, использовано ' + resolvedName, 'success');
|
||||
}
|
||||
|
||||
if (copy) {
|
||||
const cloneResp = await fetch('/api/projects/' + targetProjectUUID + '/configs/' + uuid + '/clone', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name})
|
||||
body: JSON.stringify({name: resolvedName})
|
||||
});
|
||||
if (!cloneResp.ok) {
|
||||
notify('Не удалось скопировать конфигурацию', 'error');
|
||||
@@ -886,11 +1134,11 @@ async function saveConfigAction() {
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
if (name !== currentName) {
|
||||
if (resolvedName !== currentName) {
|
||||
const renameResp = await fetch('/api/configs/' + uuid + '/rename', {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name})
|
||||
body: JSON.stringify({name: resolvedName})
|
||||
});
|
||||
if (!renameResp.ok) {
|
||||
notify('Не удалось переименовать конфигурацию', 'error');
|
||||
@@ -1016,6 +1264,7 @@ function updateDeleteVariantButton() {
|
||||
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
||||
document.getElementById('vendor-import-modal').addEventListener('click', function(e) { if (e.target === this) closeVendorImportModal(); });
|
||||
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
|
||||
document.getElementById('variant-action-modal').addEventListener('click', function(e) { if (e.target === this) closeVariantActionModal(); });
|
||||
document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); });
|
||||
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
|
||||
document.getElementById('config-action-project-input').addEventListener('input', function(e) {
|
||||
@@ -1026,7 +1275,7 @@ document.getElementById('config-action-copy').addEventListener('change', functio
|
||||
const currentName = document.getElementById('config-action-current-name').value;
|
||||
const nameInput = document.getElementById('config-action-name');
|
||||
if (e.target.checked && nameInput.value.trim() === currentName.trim()) {
|
||||
nameInput.value = currentName + ' (копия)';
|
||||
nameInput.value = currentName + '_копия';
|
||||
}
|
||||
syncActionModalMode();
|
||||
});
|
||||
@@ -1034,6 +1283,7 @@ document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeCreateModal();
|
||||
closeVendorImportModal();
|
||||
closeVariantActionModal();
|
||||
closeConfigActionModal();
|
||||
closeProjectSettingsModal();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Мои проекты - QuoteForge{{end}}
|
||||
{{define "title"}}Мои проекты - OFS{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -64,7 +64,7 @@ let status = 'active';
|
||||
let projectsSearch = '';
|
||||
let authorSearch = '';
|
||||
let currentPage = 1;
|
||||
let perPage = 10;
|
||||
let perPage = 33;
|
||||
let sortField = 'created_at';
|
||||
let sortDir = 'desc';
|
||||
let createProjectTrackerManuallyEdited = false;
|
||||
@@ -114,21 +114,21 @@ function formatDateParts(value) {
|
||||
};
|
||||
}
|
||||
|
||||
function renderAuditCell(value, user) {
|
||||
const parts = formatDateParts(value);
|
||||
const safeUser = escapeHtml((user || '—').trim() || '—');
|
||||
if (!parts) {
|
||||
return '<div class="leading-tight">' +
|
||||
'<div class="text-gray-400">—</div>' +
|
||||
'<div class="text-gray-400">—</div>' +
|
||||
'<div class="text-gray-500">@ ' + safeUser + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
return '<div class="leading-tight whitespace-nowrap">' +
|
||||
'<div>' + escapeHtml(parts.date) + '</div>' +
|
||||
'<div class="text-gray-500">' + escapeHtml(parts.time) + '</div>' +
|
||||
'<div class="text-gray-600">@ ' + safeUser + '</div>' +
|
||||
'</div>';
|
||||
function formatISODate(value) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function renderProjectDateCell(project) {
|
||||
const updatedDate = formatISODate(project && project.updated_at);
|
||||
const tooltip = [
|
||||
'Создан: ' + formatDateTime(project && project.created_at),
|
||||
'Изменен: ' + formatDateTime(project && project.updated_at),
|
||||
'Автор: ' + ((project && project.owner_username) || '—')
|
||||
].join('\n');
|
||||
return '<div class="whitespace-nowrap text-gray-600 cursor-help" title="' + escapeHtml(tooltip) + '">' + escapeHtml(updatedDate) + '</div>';
|
||||
}
|
||||
|
||||
function normalizeVariant(variant) {
|
||||
@@ -141,11 +141,11 @@ function renderVariantChips(code, fallbackVariant, fallbackUUID) {
|
||||
if (!variants.length) {
|
||||
const single = normalizeVariant(fallbackVariant);
|
||||
const href = fallbackUUID ? ('/projects/' + fallbackUUID) : '/projects';
|
||||
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(single) + '</a>';
|
||||
return '<a href="' + href + '" class="inline-flex items-center px-1.5 py-px text-xs leading-5 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(single) + '</a>';
|
||||
}
|
||||
return variants.map(v => {
|
||||
const href = v.uuid ? ('/projects/' + v.uuid) : '/projects';
|
||||
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(v.label) + '</a>';
|
||||
return '<a href="' + href + '" class="inline-flex items-center px-1.5 py-px text-xs leading-5 rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(v.label) + '</a>';
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
@@ -262,25 +262,25 @@ async function loadProjects() {
|
||||
let html = '<div class="overflow-x-auto"><table class="w-full table-fixed min-w-[980px]">';
|
||||
html += '<thead class="bg-gray-50">';
|
||||
html += '<tr>';
|
||||
html += '<th class="w-28 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Код</th>';
|
||||
html += '<th class="w-28 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||
html += '<th class="w-32 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">';
|
||||
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название';
|
||||
if (sortField === 'name') {
|
||||
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
||||
}
|
||||
html += '</button></th>';
|
||||
html += '<th class="w-44 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Создан</th>';
|
||||
html += '<th class="w-44 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Изменен</th>';
|
||||
html += '<th class="w-36 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Варианты</th>';
|
||||
html += '<th class="w-36 px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '<th class="w-24 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||
html += '<th class="w-56 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Варианты</th>';
|
||||
html += '<th class="w-14 px-2 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>';
|
||||
html += '</tr>';
|
||||
html += '<tr>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
html += '<th class="px-2 py-2"></th>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
html += '<th class="px-2 py-2"></th>';
|
||||
html += '</tr>';
|
||||
html += '</thead><tbody class="divide-y">';
|
||||
|
||||
@@ -292,36 +292,21 @@ async function loadProjects() {
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
const displayName = p.name || '';
|
||||
const createdBy = p.owner_username || '—';
|
||||
const updatedBy = '—';
|
||||
const variantChips = renderVariantChips(p.code, p.variant, p.uuid);
|
||||
html += '<td class="px-4 py-3 text-sm font-medium align-top"><a class="inline-block max-w-full text-blue-600 hover:underline whitespace-nowrap" href="/projects/' + p.uuid + '">' + escapeHtml(p.code || '—') + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700 align-top"><div class="truncate" title="' + escapeHtml(displayName) + '">' + escapeHtml(displayName || '—') + '</div></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600 align-top">' + renderAuditCell(p.created_at, createdBy) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600 align-top">' + renderAuditCell(p.updated_at, updatedBy) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm align-top">' + renderProjectDateCell(p) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium align-top break-words"><a class="inline text-blue-600 hover:underline break-all whitespace-normal" href="/projects/' + p.uuid + '">' + escapeHtml(p.code || '—') + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700 align-top break-words"><div class="whitespace-normal break-words" title="' + escapeHtml(displayName) + '">' + escapeHtml(displayName || '—') + '</div></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600 align-top whitespace-nowrap">' + escapeHtml(createdBy) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm align-top"><div class="flex flex-wrap gap-1">' + variantChips + '</div></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
|
||||
html += '<td class="px-2 py-3 text-sm text-right"><div class="inline-flex items-center justify-end gap-2">';
|
||||
|
||||
if (p.is_active) {
|
||||
const safeName = escapeHtml(displayName).replace(/'/g, "\\'");
|
||||
html += '<button onclick="copyProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-green-700 hover:text-green-900" title="Копировать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>';
|
||||
html += '</button>';
|
||||
|
||||
html += '<button onclick="renameProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-blue-700 hover:text-blue-900" title="Переименовать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>';
|
||||
html += '</button>';
|
||||
|
||||
if ((p.tracker_url || '').trim() !== '') {
|
||||
html += '<a href="' + escapeHtml(p.tracker_url) + '" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center w-5 h-5 text-sky-700 hover:text-sky-900 font-semibold" title="Открыть в трекере">T</a>';
|
||||
}
|
||||
html += '<button onclick="archiveProject(\'' + p.uuid + '\')" class="text-red-700 hover:text-red-900" title="Удалить (в архив)">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>';
|
||||
html += '</button>';
|
||||
|
||||
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-900" title="Добавить конфигурацию">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>';
|
||||
html += '</button>';
|
||||
} else {
|
||||
html += '<button onclick="reactivateProject(\'' + p.uuid + '\')" class="text-emerald-700 hover:text-emerald-900" title="Восстановить">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>';
|
||||
html += '</button>';
|
||||
}
|
||||
html += '</div></td>';
|
||||
html += '</tr>';
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>QuoteForge - Настройка подключения</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<title>OFS - Настройка подключения</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/vendor/tailwindcss.browser.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="max-w-md w-full mx-4">
|
||||
|
||||
Reference in New Issue
Block a user