Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a360992a01 | ||
|
|
1ea21ece33 | ||
|
|
7ae804d2d3 | ||
|
|
da5414c708 | ||
|
|
7a69c1513d | ||
|
|
f448111e77 | ||
|
|
a5dafd37d3 | ||
|
|
3661e345b1 | ||
|
|
f915866f83 | ||
|
|
c34a42aaf5 |
2
bible
2
bible
Submodule bible updated: 5a69e0bba8...52444350c1
@@ -29,31 +29,369 @@ Rules:
|
|||||||
|
|
||||||
## MariaDB
|
## MariaDB
|
||||||
|
|
||||||
MariaDB is the central sync database.
|
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-03-21.
|
||||||
|
|
||||||
Runtime read permissions:
|
### QuoteForge tables (qt_* and stock_*)
|
||||||
- `lot`
|
|
||||||
- `qt_lot_metadata`
|
|
||||||
- `qt_categories`
|
|
||||||
- `qt_pricelists`
|
|
||||||
- `qt_pricelist_items`
|
|
||||||
- `stock_log`
|
|
||||||
- `qt_partnumber_books`
|
|
||||||
- `qt_partnumber_book_items`
|
|
||||||
|
|
||||||
Runtime read/write permissions:
|
Runtime read:
|
||||||
- `qt_projects`
|
- `qt_categories` — pricelist categories
|
||||||
- `qt_configurations`
|
- `qt_lot_metadata` — component metadata, price settings
|
||||||
- `qt_client_schema_state`
|
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
||||||
- `qt_pricelist_sync_status`
|
- `qt_pricelist_items` — pricelist rows
|
||||||
|
- `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:
|
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:
|
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;
|
- 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
|
## Migrations
|
||||||
|
|
||||||
|
|||||||
@@ -1126,7 +1126,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
|
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
config, err := configService.RefreshPricesNoAuth(uuid)
|
var req struct {
|
||||||
|
PricelistID *uint `json:"pricelist_id"`
|
||||||
|
}
|
||||||
|
// Ignore bind error — pricelist_id is optional
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
config, err := configService.RefreshPricesNoAuth(uuid, req.PricelistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||||
return
|
return
|
||||||
@@ -1539,7 +1544,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrReservedMainVariant):
|
case errors.Is(err, services.ErrReservedMainVariant),
|
||||||
|
errors.Is(err, services.ErrCannotRenameMainVariant):
|
||||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
case errors.Is(err, services.ErrProjectCodeExists):
|
case errors.Is(err, services.ErrProjectCodeExists):
|
||||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -112,6 +113,7 @@ func NewWebHandler(_ string, localDB *localdb.LocalDB) (*WebHandler, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
||||||
|
data["AppVersion"] = appmeta.Version()
|
||||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
tmpl, ok := h.templates[name]
|
tmpl, ok := h.templates[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable WAL mode so background sync writes never block UI reads.
|
||||||
|
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
|
||||||
|
slog.Warn("failed to enable WAL mode", "error", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec("PRAGMA synchronous=NORMAL").Error; err != nil {
|
||||||
|
slog.Warn("failed to set synchronous=NORMAL", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := ensureLocalProjectsTable(db); err != nil {
|
if err := ensureLocalProjectsTable(db); err != nil {
|
||||||
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1152,6 +1160,29 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountAllPricelistItems returns total rows across all local_pricelist_items.
|
||||||
|
func (l *LocalDB) CountAllPricelistItems() int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&LocalPricelistItem{}).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountComponents returns the number of rows in local_components.
|
||||||
|
func (l *LocalDB) CountComponents() int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&LocalComponent{}).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBFileSizeBytes returns the size of the SQLite database file in bytes.
|
||||||
|
func (l *LocalDB) DBFileSizeBytes() int64 {
|
||||||
|
info, err := os.Stat(l.path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||||
var pricelist LocalPricelist
|
var pricelist LocalPricelist
|
||||||
@@ -1319,6 +1350,32 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
|
|||||||
return item.Price, nil
|
return item.Price, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query.
|
||||||
|
// Uses the composite index (pricelist_id, lot_name). Missing lots are omitted from the result.
|
||||||
|
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
|
||||||
|
result := make(map[string]float64, len(lotNames))
|
||||||
|
if len(lotNames) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
LotName string `gorm:"column:lot_name"`
|
||||||
|
Price float64 `gorm:"column:price"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.db.Model(&LocalPricelistItem{}).
|
||||||
|
Select("lot_name, price").
|
||||||
|
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Price > 0 {
|
||||||
|
result[r.LotName] = r.Price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
||||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||||
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||||
|
|||||||
@@ -49,11 +49,13 @@ func NewLocalConfigurationService(
|
|||||||
|
|
||||||
// Create creates a new configuration in local SQLite and queues it for sync
|
// Create creates a new configuration in local SQLite and queues it for sync
|
||||||
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
// If online, check for new pricelists first
|
// If online, trigger pricelist sync in the background — do not block config creation
|
||||||
if s.isOnline() {
|
if s.isOnline() {
|
||||||
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
go func() {
|
||||||
// Log but don't fail - we can still use local pricelists
|
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||||
}
|
// Log but don't fail - we can still use local pricelists
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||||
@@ -399,17 +401,29 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
return nil, ErrConfigForbidden
|
return nil, ErrConfigForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh local pricelists when online and use latest active/local pricelist for recalculation.
|
// Refresh local pricelists when online.
|
||||||
if s.isOnline() {
|
if s.isOnline() {
|
||||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
_ = s.syncService.SyncPricelistsIfNeeded()
|
||||||
}
|
}
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
|
||||||
|
// Use the pricelist stored in the config; fall back to latest if unavailable.
|
||||||
|
var pricelist *localdb.LocalPricelist
|
||||||
|
if localCfg.PricelistID != nil && *localCfg.PricelistID > 0 {
|
||||||
|
if pl, err := s.localDB.GetLocalPricelistByServerID(*localCfg.PricelistID); err == nil {
|
||||||
|
pricelist = pl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pricelist == nil {
|
||||||
|
if pl, err := s.localDB.GetLatestLocalPricelist(); err == nil {
|
||||||
|
pricelist = pl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update prices for all items from pricelist
|
// Update prices for all items from pricelist
|
||||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
for i, item := range localCfg.Items {
|
for i, item := range localCfg.Items {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if pricelist != nil {
|
||||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName)
|
||||||
if err == nil && price > 0 {
|
if err == nil && price > 0 {
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
updatedItems[i] = localdb.LocalConfigItem{
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
@@ -434,8 +448,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCfg.TotalPrice = &total
|
localCfg.TotalPrice = &total
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if pricelist != nil {
|
||||||
localCfg.PricelistID = &latestPricelist.ServerID
|
localCfg.PricelistID = &pricelist.ServerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set price update timestamp and mark for sync
|
// Set price update timestamp and mark for sync
|
||||||
@@ -762,8 +776,10 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
|
|||||||
return templates[start:end], total, nil
|
return templates[start:end], total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check
|
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check.
|
||||||
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
// pricelistServerID optionally specifies which pricelist to use; if nil, the config's stored
|
||||||
|
// pricelist is used; if that is also absent, the latest local pricelist is used as a fallback.
|
||||||
|
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistServerID *uint) (*models.Configuration, error) {
|
||||||
// Get configuration from local SQLite
|
// Get configuration from local SQLite
|
||||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -773,13 +789,36 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
if s.isOnline() {
|
if s.isOnline() {
|
||||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
_ = s.syncService.SyncPricelistsIfNeeded()
|
||||||
}
|
}
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
|
||||||
|
// Resolve which pricelist to use:
|
||||||
|
// 1. Explicitly requested pricelist (from UI selection)
|
||||||
|
// 2. Pricelist stored in the configuration
|
||||||
|
// 3. Latest local pricelist as last-resort fallback
|
||||||
|
var targetServerID *uint
|
||||||
|
if pricelistServerID != nil && *pricelistServerID > 0 {
|
||||||
|
targetServerID = pricelistServerID
|
||||||
|
} else if localCfg.PricelistID != nil && *localCfg.PricelistID > 0 {
|
||||||
|
targetServerID = localCfg.PricelistID
|
||||||
|
}
|
||||||
|
|
||||||
|
var pricelist *localdb.LocalPricelist
|
||||||
|
if targetServerID != nil {
|
||||||
|
if pl, err := s.localDB.GetLocalPricelistByServerID(*targetServerID); err == nil {
|
||||||
|
pricelist = pl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pricelist == nil {
|
||||||
|
// Fallback: use latest local pricelist
|
||||||
|
if pl, err := s.localDB.GetLatestLocalPricelist(); err == nil {
|
||||||
|
pricelist = pl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update prices for all items from pricelist
|
// Update prices for all items from pricelist
|
||||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
for i, item := range localCfg.Items {
|
for i, item := range localCfg.Items {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if pricelist != nil {
|
||||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName)
|
||||||
if err == nil && price > 0 {
|
if err == nil && price > 0 {
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
updatedItems[i] = localdb.LocalConfigItem{
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
@@ -804,8 +843,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCfg.TotalPrice = &total
|
localCfg.TotalPrice = &total
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if pricelist != nil {
|
||||||
localCfg.PricelistID = &latestPricelist.ServerID
|
localCfg.PricelistID = &pricelist.ServerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set price update timestamp and mark for sync
|
// Set price update timestamp and mark for sync
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ var (
|
|||||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||||
|
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectService struct {
|
type ProjectService struct {
|
||||||
@@ -108,7 +109,12 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
|||||||
localProject.Code = code
|
localProject.Code = code
|
||||||
}
|
}
|
||||||
if req.Variant != nil {
|
if req.Variant != nil {
|
||||||
localProject.Variant = strings.TrimSpace(*req.Variant)
|
newVariant := strings.TrimSpace(*req.Variant)
|
||||||
|
// Block renaming of the main variant (empty Variant) — there must always be a main.
|
||||||
|
if strings.TrimSpace(localProject.Variant) == "" && newVariant != "" {
|
||||||
|
return nil, ErrCannotRenameMainVariant
|
||||||
|
}
|
||||||
|
localProject.Variant = newVariant
|
||||||
if err := validateProjectVariantName(localProject.Variant); err != nil {
|
if err := validateProjectVariantName(localProject.Variant); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,13 +388,14 @@ func (s *QuoteService) lookupPricesByPricelistID(pricelistID uint, lotNames []st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback path (usually offline): local per-lot lookup.
|
// Fallback path (usually offline): batch local lookup (single query via index).
|
||||||
if s.localDB != nil {
|
if s.localDB != nil {
|
||||||
for _, lotName := range missing {
|
if localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID); err == nil {
|
||||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
if batchPrices, err := s.localDB.GetLocalPricesForLots(localPL.ID, missing); err == nil {
|
||||||
if found && price > 0 {
|
for lotName, price := range batchPrices {
|
||||||
result[lotName] = price
|
result[lotName] = price
|
||||||
loaded[lotName] = price
|
loaded[lotName] = price
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.updateCache(pricelistID, missing, loaded)
|
s.updateCache(pricelistID, missing, loaded)
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ func ensureClientSchemaStateTable(db *gorm.DB) error {
|
|||||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version",
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version",
|
||||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version",
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version",
|
||||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code",
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS local_pricelist_count INT NOT NULL DEFAULT 0 AFTER last_sync_error_text",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pricelist_items_count INT NOT NULL DEFAULT 0 AFTER local_pricelist_count",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS components_count INT NOT NULL DEFAULT 0 AFTER pricelist_items_count",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS db_size_bytes BIGINT NOT NULL DEFAULT 0 AFTER components_count",
|
||||||
} {
|
} {
|
||||||
if err := db.Exec(stmt).Error; err != nil {
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
||||||
@@ -215,6 +219,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
|
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
|
||||||
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
|
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
|
||||||
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
||||||
|
localPricelistCount := s.localDB.CountLocalPricelists()
|
||||||
|
pricelistItemsCount := s.localDB.CountAllPricelistItems()
|
||||||
|
componentsCount := s.localDB.CountComponents()
|
||||||
|
dbSizeBytes := s.localDB.DBFileSizeBytes()
|
||||||
return mariaDB.Exec(`
|
return mariaDB.Exec(`
|
||||||
INSERT INTO qt_client_schema_state (
|
INSERT INTO qt_client_schema_state (
|
||||||
username, hostname, app_version,
|
username, hostname, app_version,
|
||||||
@@ -222,9 +230,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
configurations_count, projects_count,
|
configurations_count, projects_count,
|
||||||
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
|
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
|
||||||
last_sync_error_code, last_sync_error_text,
|
last_sync_error_code, last_sync_error_text,
|
||||||
|
local_pricelist_count, pricelist_items_count, components_count, db_size_bytes,
|
||||||
last_checked_at, updated_at
|
last_checked_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
app_version = VALUES(app_version),
|
app_version = VALUES(app_version),
|
||||||
last_sync_at = VALUES(last_sync_at),
|
last_sync_at = VALUES(last_sync_at),
|
||||||
@@ -238,6 +247,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
competitor_pricelist_version = VALUES(competitor_pricelist_version),
|
competitor_pricelist_version = VALUES(competitor_pricelist_version),
|
||||||
last_sync_error_code = VALUES(last_sync_error_code),
|
last_sync_error_code = VALUES(last_sync_error_code),
|
||||||
last_sync_error_text = VALUES(last_sync_error_text),
|
last_sync_error_text = VALUES(last_sync_error_text),
|
||||||
|
local_pricelist_count = VALUES(local_pricelist_count),
|
||||||
|
pricelist_items_count = VALUES(pricelist_items_count),
|
||||||
|
components_count = VALUES(components_count),
|
||||||
|
db_size_bytes = VALUES(db_size_bytes),
|
||||||
last_checked_at = VALUES(last_checked_at),
|
last_checked_at = VALUES(last_checked_at),
|
||||||
updated_at = VALUES(updated_at)
|
updated_at = VALUES(updated_at)
|
||||||
`, username, hostname, appmeta.Version(),
|
`, username, hostname, appmeta.Version(),
|
||||||
@@ -245,6 +258,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
configurationsCount, projectsCount,
|
configurationsCount, projectsCount,
|
||||||
estimateVersion, warehouseVersion, competitorVersion,
|
estimateVersion, warehouseVersion, competitorVersion,
|
||||||
lastSyncErrorCode, lastSyncErrorText,
|
lastSyncErrorCode, lastSyncErrorText,
|
||||||
|
localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes,
|
||||||
checkedAt, checkedAt).Error
|
checkedAt, checkedAt).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
@@ -22,9 +23,10 @@ var ErrOffline = errors.New("database is offline")
|
|||||||
|
|
||||||
// Service handles synchronization between MariaDB and local SQLite
|
// Service handles synchronization between MariaDB and local SQLite
|
||||||
type Service struct {
|
type Service struct {
|
||||||
connMgr *db.ConnectionManager
|
connMgr *db.ConnectionManager
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
directDB *gorm.DB
|
directDB *gorm.DB
|
||||||
|
pricelistMu sync.Mutex // prevents concurrent pricelist syncs
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new sync service
|
// NewService creates a new sync service
|
||||||
@@ -939,9 +941,15 @@ func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.Local
|
|||||||
return localPL, nil
|
return localPL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed
|
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed.
|
||||||
// This should be called before creating a new configuration when online
|
// If a sync is already in progress, returns immediately without blocking.
|
||||||
func (s *Service) SyncPricelistsIfNeeded() error {
|
func (s *Service) SyncPricelistsIfNeeded() error {
|
||||||
|
if !s.pricelistMu.TryLock() {
|
||||||
|
slog.Debug("pricelist sync already in progress, skipping")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer s.pricelistMu.Unlock()
|
||||||
|
|
||||||
needSync, err := s.NeedSync()
|
needSync, err := s.NeedSync()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to check if sync needed", "error", err)
|
slog.Warn("failed to check if sync needed", "error", err)
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 text-right">
|
||||||
|
<span class="text-xs text-gray-400">v{{.AppVersion}}</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
||||||
|
|
||||||
<!-- Sync Info Modal -->
|
<!-- Sync Info Modal -->
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Ревизии - QuoteForge{{end}}
|
{{define "title"}}Ревизии - OFS{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Мои конфигурации - QuoteForge{{end}}
|
{{define "title"}}Мои конфигурации - OFS{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}QuoteForge - Конфигуратор{{end}}
|
{{define "title"}}OFS - Конфигуратор{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -477,6 +477,7 @@ function updateConfigBreadcrumbs() {
|
|||||||
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||||
configEl.title = fullConfigName;
|
configEl.title = fullConfigName;
|
||||||
versionEl.textContent = 'main';
|
versionEl.textContent = 'main';
|
||||||
|
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — OFS';
|
||||||
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||||
if (configNameLinkEl && configUUID) {
|
if (configNameLinkEl && configUUID) {
|
||||||
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||||
@@ -925,14 +926,9 @@ async function loadActivePricelists(force = false) {
|
|||||||
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
activePricelistsBySource[source] = data.pricelists || [];
|
activePricelistsBySource[source] = data.pricelists || [];
|
||||||
const existing = selectedPricelistIds[source];
|
// Do not reset the stored pricelist — it may be inactive but must be preserved
|
||||||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectedPricelistIds[source] = null;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
activePricelistsBySource[source] = [];
|
activePricelistsBySource[source] = [];
|
||||||
selectedPricelistIds[source] = null;
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
activePricelistsLoadedAt = Date.now();
|
activePricelistsLoadedAt = Date.now();
|
||||||
@@ -954,11 +950,25 @@ function renderPricelistSelectOptions(selectId, source) {
|
|||||||
select.value = '';
|
select.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
select.innerHTML = `<option value="">Авто (последний активный)</option>` + pricelists.map(pl => {
|
select.innerHTML = pricelists.map(pl => {
|
||||||
return `<option value="${pl.id}">${escapeHtml(pl.version)}</option>`;
|
return `<option value="${pl.id}">${escapeHtml(pl.version)}</option>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
const current = selectedPricelistIds[source];
|
const current = selectedPricelistIds[source];
|
||||||
select.value = current ? String(current) : '';
|
if (current) {
|
||||||
|
select.value = String(current);
|
||||||
|
// Stored pricelist may be inactive — add it as a virtual option if not found
|
||||||
|
if (!select.value) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = String(current);
|
||||||
|
opt.textContent = `ID ${current} (неактивный)`;
|
||||||
|
select.prepend(opt);
|
||||||
|
select.value = String(current);
|
||||||
|
}
|
||||||
|
} else if (pricelists.length > 0) {
|
||||||
|
// New config: pre-select the first (latest) pricelist
|
||||||
|
selectedPricelistIds[source] = Number(pricelists[0].id);
|
||||||
|
select.value = String(pricelists[0].id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPriceSettingsControls() {
|
function syncPriceSettingsControls() {
|
||||||
@@ -984,9 +994,9 @@ function getPricelistVersionById(source, id) {
|
|||||||
function renderPricelistSettingsSummary() {
|
function renderPricelistSettingsSummary() {
|
||||||
const summary = document.getElementById('pricelist-settings-summary');
|
const summary = document.getElementById('pricelist-settings-summary');
|
||||||
if (!summary) return;
|
if (!summary) return;
|
||||||
const estimate = selectedPricelistIds.estimate ? getPricelistVersionById('estimate', selectedPricelistIds.estimate) || `ID ${selectedPricelistIds.estimate}` : 'авто';
|
const estimate = selectedPricelistIds.estimate ? getPricelistVersionById('estimate', selectedPricelistIds.estimate) || `ID ${selectedPricelistIds.estimate}` : '—';
|
||||||
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
|
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : '—';
|
||||||
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
|
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : '—';
|
||||||
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
|
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
|
||||||
const stockFilterState = onlyInStock ? ' | Только наличие: вкл' : '';
|
const stockFilterState = onlyInStock ? ' | Только наличие: вкл' : '';
|
||||||
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}${stockFilterState}`;
|
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}${stockFilterState}`;
|
||||||
@@ -1062,16 +1072,16 @@ function applyPriceSettings() {
|
|||||||
const inStockVal = Boolean(document.getElementById('settings-only-in-stock')?.checked);
|
const inStockVal = Boolean(document.getElementById('settings-only-in-stock')?.checked);
|
||||||
|
|
||||||
const prevWarehouseID = currentWarehousePricelistID();
|
const prevWarehouseID = currentWarehousePricelistID();
|
||||||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
if (Number.isFinite(estimateVal) && estimateVal > 0) {
|
||||||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
selectedPricelistIds.estimate = estimateVal;
|
||||||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
|
||||||
if (selectedPricelistIds.estimate) {
|
|
||||||
resolvedAutoPricelistIds.estimate = null;
|
resolvedAutoPricelistIds.estimate = null;
|
||||||
}
|
}
|
||||||
if (selectedPricelistIds.warehouse) {
|
if (Number.isFinite(warehouseVal) && warehouseVal > 0) {
|
||||||
|
selectedPricelistIds.warehouse = warehouseVal;
|
||||||
resolvedAutoPricelistIds.warehouse = null;
|
resolvedAutoPricelistIds.warehouse = null;
|
||||||
}
|
}
|
||||||
if (selectedPricelistIds.competitor) {
|
if (Number.isFinite(competitorVal) && competitorVal > 0) {
|
||||||
|
selectedPricelistIds.competitor = competitorVal;
|
||||||
resolvedAutoPricelistIds.competitor = null;
|
resolvedAutoPricelistIds.competitor = null;
|
||||||
}
|
}
|
||||||
disablePriceRefresh = disableVal;
|
disablePriceRefresh = disableVal;
|
||||||
@@ -2508,11 +2518,14 @@ async function refreshPrices() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const refreshPayload = {};
|
||||||
|
if (selectedPricelistIds.estimate) {
|
||||||
|
refreshPayload.pricelist_id = selectedPricelistIds.estimate;
|
||||||
|
}
|
||||||
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json'
|
body: JSON.stringify(refreshPayload)
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}QuoteForge - Партномера{{end}}
|
{{define "title"}}OFS - Партномера{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Прайслист - QuoteForge{{end}}
|
{{define "title"}}Прайслист - OFS{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Прайслисты - QuoteForge{{end}}
|
{{define "title"}}Прайслисты - OFS{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Проект - QuoteForge{{end}}
|
{{define "title"}}Проект - OFS{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -363,6 +363,7 @@ function renderVariantSelect() {
|
|||||||
if (item.uuid === projectUUID) {
|
if (item.uuid === projectUUID) {
|
||||||
option.className += ' font-semibold text-gray-900';
|
option.className += ' font-semibold text-gray-900';
|
||||||
label.textContent = variantLabel;
|
label.textContent = variantLabel;
|
||||||
|
document.title = (project && project.code ? project.code : '—') + ' / ' + variantLabel + ' — OFS';
|
||||||
}
|
}
|
||||||
option.textContent = variantLabel;
|
option.textContent = variantLabel;
|
||||||
option.onclick = function() {
|
option.onclick = function() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "title"}}Мои проекты - QuoteForge{{end}}
|
{{define "title"}}Мои проекты - OFS{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>QuoteForge - Настройка подключения</title>
|
<title>OFS - Настройка подключения</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
<script src="/static/vendor/tailwindcss.browser.js"></script>
|
<script src="/static/vendor/tailwindcss.browser.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user