Compare commits
8 Commits
v1.17
...
8b8d2f18f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b8d2f18f9 | |||
| 8c1c8ccace | |||
| f31ae69233 | |||
| 3132ab2fa2 | |||
| 73acc5410f | |||
| 68d0e9a540 | |||
| 8309a5dc0e | |||
|
|
48921c699d |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,6 +1,12 @@
|
||||
# QuoteForge
|
||||
config.yaml
|
||||
|
||||
# Binaries
|
||||
/server
|
||||
/importer
|
||||
/cron
|
||||
/bin/
|
||||
|
||||
# ---> macOS
|
||||
# General
|
||||
.DS_Store
|
||||
@@ -8,7 +14,7 @@ config.yaml
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
657
CLAUDE.md
657
CLAUDE.md
@@ -2,17 +2,40 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG.
|
||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение работает с серверной базой данных MariaDB (RFQ_LOG) и локальной SQLite для оффлайн-работы.
|
||||
|
||||
## Development Phases
|
||||
|
||||
### Phase 1: Pricelists in MariaDB
|
||||
- Настройка подключения к БД при первом запуске
|
||||
- Таблицы qt_pricelists и qt_pricelist_items
|
||||
- CRUD операции для прайслистов (при наличии прав записи)
|
||||
|
||||
### Phase 2: Projects and Specifications
|
||||
- Таблицы qt_projects и qt_specifications
|
||||
- Замена qt_configurations на новую структуру
|
||||
- Поля: opty, customer_requirement, variant, qty, rev
|
||||
|
||||
### Phase 3: Local SQLite Database
|
||||
- Локальное хранение настроек подключения
|
||||
- Кэширование прайслистов
|
||||
- Локальные проекты и спецификации
|
||||
- Синхронизация с сервером
|
||||
|
||||
### Phase 4: Price Versioning
|
||||
- Привязка спецификаций к версиям прайслистов
|
||||
- Актуализация прайслистов с показом разницы цен
|
||||
- Автоочистка старых прайслистов (>1 года, usage_count=0)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Language:** Go 1.22+
|
||||
- **Web Framework:** Gin (github.com/gin-gonic/gin)
|
||||
- **ORM:** GORM (gorm.io/gorm)
|
||||
- **Database:** MariaDB 11 (existing database RFQ_LOG)
|
||||
- **Server Database:** MariaDB 11 (existing database RFQ_LOG)
|
||||
- **Local Database:** SQLite (github.com/glebarez/sqlite for pure Go)
|
||||
- **Frontend:** HTML templates + htmx + Tailwind CSS (CDN)
|
||||
- **Excel Export:** excelize (github.com/xuri/excelize/v2)
|
||||
- **Auth:** JWT (github.com/golang-jwt/jwt/v5)
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -20,20 +43,47 @@ QuoteForge — корпоративный инструмент для конфи
|
||||
quoteforge/
|
||||
├── cmd/
|
||||
│ ├── server/main.go # Main HTTP server
|
||||
│ ├── priceupdater/main.go # Cron job for price updates & alerts
|
||||
│ └── importer/main.go # Import metadata from lot table
|
||||
│ ├── importer/main.go # Import metadata from lot table
|
||||
│ └── cron/main.go # Cron jobs
|
||||
├── internal/
|
||||
│ ├── config/config.go # YAML config loading
|
||||
│ ├── models/ # GORM models
|
||||
│ ├── handlers/ # Gin HTTP handlers
|
||||
│ ├── services/ # Business logic
|
||||
│ ├── middleware/ # Auth, CORS, roles
|
||||
│ └── repository/ # Database queries
|
||||
│ ├── config/
|
||||
│ │ └── config.go # Load settings from SQLite
|
||||
│ ├── db/
|
||||
│ │ ├── mariadb.go # Server DB connection
|
||||
│ │ └── sqlite.go # Local DB connection
|
||||
│ ├── models/
|
||||
│ │ ├── lot.go # Existing lot tables
|
||||
│ │ ├── pricelist.go # Pricelists
|
||||
│ │ ├── project.go # Projects
|
||||
│ │ ├── specification.go # Specifications
|
||||
│ │ └── local_models.go # SQLite models
|
||||
│ ├── handlers/
|
||||
│ │ ├── setup_handler.go # Initial DB setup
|
||||
│ │ ├── pricelist_handler.go # Pricelist CRUD
|
||||
│ │ ├── project_handler.go # Project CRUD
|
||||
│ │ ├── spec_handler.go # Specification CRUD
|
||||
│ │ └── sync_handler.go # Sync operations
|
||||
│ ├── services/
|
||||
│ │ ├── pricelist_service.go # Pricelist business logic
|
||||
│ │ ├── project_service.go # Project business logic
|
||||
│ │ ├── sync_service.go # Sync with server
|
||||
│ │ └── price_service.go # Price calculations
|
||||
│ ├── middleware/
|
||||
│ │ └── db_check.go # Check DB connection
|
||||
│ └── repository/
|
||||
│ ├── mariadb_repo.go # Server DB queries
|
||||
│ └── sqlite_repo.go # Local DB queries
|
||||
├── web/
|
||||
│ ├── templates/ # Go HTML templates
|
||||
│ └── static/ # CSS, JS
|
||||
├── migrations/ # SQL migration files
|
||||
├── config.yaml
|
||||
│ ├── templates/
|
||||
│ │ ├── setup.html # DB connection setup
|
||||
│ │ ├── projects.html # Project list
|
||||
│ │ ├── project_detail.html # Project with specs
|
||||
│ │ ├── spec_editor.html # Specification editor
|
||||
│ │ └── pricelists.html # Pricelist management
|
||||
│ └── static/
|
||||
├── data/ # SQLite database location
|
||||
│ └── quoteforge.db
|
||||
├── migrations/
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
@@ -44,15 +94,15 @@ These tables are used by other systems. Our app only reads from them:
|
||||
```sql
|
||||
-- Component catalog
|
||||
CREATE TABLE lot (
|
||||
lot_name CHAR(255) PRIMARY KEY, -- e.g., "CPU_AMD_9654", "MB_INTEL_4.Sapphire_2S"
|
||||
lot_name CHAR(255) PRIMARY KEY,
|
||||
lot_description VARCHAR(10000)
|
||||
);
|
||||
|
||||
-- Price history from suppliers
|
||||
CREATE TABLE lot_log (
|
||||
lot_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
lot CHAR(255) NOT NULL, -- FK → lot.lot_name
|
||||
supplier CHAR(255) NOT NULL, -- FK → supplier.supplier_name
|
||||
lot CHAR(255) NOT NULL,
|
||||
supplier CHAR(255) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
price DOUBLE NOT NULL,
|
||||
quality CHAR(255),
|
||||
@@ -68,29 +118,16 @@ CREATE TABLE supplier (
|
||||
);
|
||||
```
|
||||
|
||||
## New Tables (prefix qt_)
|
||||
## New MariaDB Tables (prefix qt_)
|
||||
|
||||
QuoteForge creates these tables:
|
||||
### Core Tables
|
||||
|
||||
```sql
|
||||
-- Users
|
||||
CREATE TABLE qt_users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role ENUM('viewer', 'editor', 'pricing_admin', 'admin') DEFAULT 'viewer',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Component metadata (extends lot table)
|
||||
CREATE TABLE qt_lot_metadata (
|
||||
lot_name CHAR(255) PRIMARY KEY,
|
||||
category_id INT,
|
||||
vendor VARCHAR(50), -- Parsed from lot_name: CPU_AMD_9654 → "AMD"
|
||||
model VARCHAR(100), -- Parsed: CPU_AMD_9654 → "9654"
|
||||
model VARCHAR(100),
|
||||
specs JSON,
|
||||
current_price DECIMAL(12,2),
|
||||
price_method ENUM('manual', 'median', 'average', 'weighted_median') DEFAULT 'median',
|
||||
@@ -105,51 +142,13 @@ CREATE TABLE qt_lot_metadata (
|
||||
-- Categories
|
||||
CREATE TABLE qt_categories (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
code VARCHAR(20) UNIQUE NOT NULL, -- MB, CPU, MEM, GPU, SSD, HDD, RAID, NIC, HCA, HBA, DPU, PS
|
||||
code VARCHAR(20) UNIQUE NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
name_ru VARCHAR(100),
|
||||
display_order INT DEFAULT 0,
|
||||
is_required BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Saved configurations
|
||||
CREATE TABLE qt_configurations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid VARCHAR(36) UNIQUE NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
items JSON NOT NULL, -- [{"lot_name": "CPU_AMD_9654", "quantity": 2, "unit_price": 11500}]
|
||||
total_price DECIMAL(12,2),
|
||||
notes TEXT,
|
||||
is_template BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES qt_users(id)
|
||||
);
|
||||
|
||||
-- Price overrides
|
||||
CREATE TABLE qt_price_overrides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
lot_name CHAR(255) NOT NULL,
|
||||
price DECIMAL(12,2) NOT NULL,
|
||||
valid_from DATE NOT NULL,
|
||||
valid_until DATE,
|
||||
reason TEXT,
|
||||
created_by INT NOT NULL,
|
||||
FOREIGN KEY (lot_name) REFERENCES lot(lot_name)
|
||||
);
|
||||
|
||||
-- Alerts for pricing admins
|
||||
CREATE TABLE qt_pricing_alerts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
lot_name CHAR(255) NOT NULL,
|
||||
alert_type ENUM('high_demand_stale_price', 'price_spike', 'price_drop', 'no_recent_quotes', 'trending_no_price') NOT NULL,
|
||||
severity ENUM('low', 'medium', 'high', 'critical') DEFAULT 'medium',
|
||||
message TEXT NOT NULL,
|
||||
details JSON,
|
||||
status ENUM('new', 'acknowledged', 'resolved', 'ignored') DEFAULT 'new',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Usage statistics
|
||||
CREATE TABLE qt_component_usage_stats (
|
||||
lot_name CHAR(255) PRIMARY KEY,
|
||||
@@ -160,52 +159,330 @@ CREATE TABLE qt_component_usage_stats (
|
||||
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 TIMESTAMP
|
||||
last_used_at TIMESTAMP,
|
||||
config_count INT DEFAULT 0 -- Number of configurations using this component
|
||||
);
|
||||
```
|
||||
|
||||
### Pricelist Tables
|
||||
|
||||
```sql
|
||||
-- Pricelists (versioned price snapshots)
|
||||
CREATE TABLE qt_pricelists (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
version VARCHAR(20) NOT NULL, -- Format: "YYYY-MM-DD-NNN" (e.g., "2024-01-31-001")
|
||||
name VARCHAR(200), -- Optional display name
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(100), -- Username of creator
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
usage_count INT DEFAULT 0, -- How many specifications use this pricelist
|
||||
expires_at DATE, -- Auto-calculated: created_at + 1 year
|
||||
UNIQUE KEY (version)
|
||||
);
|
||||
|
||||
-- Pricelist items
|
||||
CREATE TABLE qt_pricelist_items (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
pricelist_id INT NOT NULL,
|
||||
lot_name CHAR(255) NOT NULL,
|
||||
price DECIMAL(12,2) NOT NULL,
|
||||
price_method ENUM('manual', 'median', 'average', 'weighted_median'),
|
||||
FOREIGN KEY (pricelist_id) REFERENCES qt_pricelists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (lot_name) REFERENCES lot(lot_name),
|
||||
INDEX idx_pricelist_lot (pricelist_id, lot_name)
|
||||
);
|
||||
```
|
||||
|
||||
### Project Tables
|
||||
|
||||
```sql
|
||||
-- Projects (group of specifications)
|
||||
CREATE TABLE qt_projects (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid VARCHAR(36) UNIQUE NOT NULL,
|
||||
opty VARCHAR(50), -- Opportunity/project number
|
||||
customer_requirement TEXT, -- Link to customer requirements/TZ
|
||||
name VARCHAR(200) NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Specifications (replaces qt_configurations)
|
||||
CREATE TABLE qt_specifications (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid VARCHAR(36) UNIQUE NOT NULL,
|
||||
project_id INT NOT NULL,
|
||||
pricelist_id INT NOT NULL, -- Bound to specific pricelist version
|
||||
variant VARCHAR(50) NOT NULL, -- Calculation variant (A, B, C, Base, Extended...)
|
||||
rev INT DEFAULT 1, -- Revision number
|
||||
qty INT DEFAULT 1, -- Number of servers
|
||||
items JSON NOT NULL, -- [{"lot_name": "CPU_AMD_9654", "quantity": 2, "unit_price": 11500}]
|
||||
total_price DECIMAL(12,2),
|
||||
custom_price DECIMAL(12,2), -- User-defined target price
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES qt_projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (pricelist_id) REFERENCES qt_pricelists(id),
|
||||
UNIQUE KEY (project_id, variant, rev)
|
||||
);
|
||||
```
|
||||
|
||||
### Legacy Tables (will be deprecated)
|
||||
|
||||
```sql
|
||||
-- Users (RBAC disabled in Phase 1-3)
|
||||
CREATE TABLE qt_users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role ENUM('viewer', 'editor', 'pricing_admin', 'admin') DEFAULT 'viewer',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Price overrides (for future use)
|
||||
CREATE TABLE qt_price_overrides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
lot_name CHAR(255) NOT NULL,
|
||||
price DECIMAL(12,2) NOT NULL,
|
||||
valid_from DATE NOT NULL,
|
||||
valid_until DATE,
|
||||
reason TEXT,
|
||||
created_by INT NOT NULL,
|
||||
FOREIGN KEY (lot_name) REFERENCES lot(lot_name)
|
||||
);
|
||||
|
||||
-- Alerts (for future use)
|
||||
CREATE TABLE qt_pricing_alerts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
lot_name CHAR(255) NOT NULL,
|
||||
alert_type ENUM('high_demand_stale_price', 'price_spike', 'price_drop', 'no_recent_quotes', 'trending_no_price') NOT NULL,
|
||||
severity ENUM('low', 'medium', 'high', 'critical') DEFAULT 'medium',
|
||||
message TEXT NOT NULL,
|
||||
details JSON,
|
||||
status ENUM('new', 'acknowledged', 'resolved', 'ignored') DEFAULT 'new',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
## Local SQLite Database
|
||||
|
||||
Located at `data/quoteforge.db`:
|
||||
|
||||
```sql
|
||||
-- Application settings (connection credentials stored encrypted)
|
||||
CREATE TABLE app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
-- Keys: db_host, db_port, db_name, db_user, db_password (encrypted), last_sync
|
||||
|
||||
-- Cached pricelists from server
|
||||
CREATE TABLE local_pricelists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INT NOT NULL, -- ID on MariaDB server
|
||||
version TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
created_at TEXT,
|
||||
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
is_used INTEGER DEFAULT 0 -- 1 if used by any specification
|
||||
);
|
||||
|
||||
CREATE TABLE local_pricelist_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pricelist_id INTEGER NOT NULL,
|
||||
lot_name TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
FOREIGN KEY (pricelist_id) REFERENCES local_pricelists(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Local projects (can be synced to server)
|
||||
CREATE TABLE local_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT UNIQUE NOT NULL,
|
||||
server_id INTEGER, -- NULL if not synced yet
|
||||
opty TEXT,
|
||||
customer_requirement TEXT,
|
||||
name TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
synced_at TEXT, -- NULL if has local changes
|
||||
sync_status TEXT DEFAULT 'local' -- 'local', 'synced', 'modified'
|
||||
);
|
||||
|
||||
-- Local specifications
|
||||
CREATE TABLE local_specifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT UNIQUE NOT NULL,
|
||||
server_id INTEGER,
|
||||
project_id INTEGER NOT NULL,
|
||||
pricelist_id INTEGER NOT NULL,
|
||||
variant TEXT NOT NULL,
|
||||
rev INTEGER DEFAULT 1,
|
||||
qty INTEGER DEFAULT 1,
|
||||
items TEXT NOT NULL, -- JSON string
|
||||
total_price REAL,
|
||||
custom_price REAL,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
synced_at TEXT,
|
||||
sync_status TEXT DEFAULT 'local',
|
||||
FOREIGN KEY (project_id) REFERENCES local_projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (pricelist_id) REFERENCES local_pricelists(id)
|
||||
);
|
||||
|
||||
-- Component cache (for offline search)
|
||||
CREATE TABLE local_components (
|
||||
lot_name TEXT PRIMARY KEY,
|
||||
lot_description TEXT,
|
||||
category TEXT,
|
||||
model TEXT,
|
||||
synced_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
## Key Business Logic
|
||||
|
||||
### 1. Part Number Parsing
|
||||
### 1. Database Connection Setup
|
||||
|
||||
Extract category, vendor, model from lot_name:
|
||||
```go
|
||||
// "CPU_AMD_9654" → category="CPU", vendor="AMD", model="9654"
|
||||
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", vendor="INTEL", model="4.Sapphire_2S_32xDDR5"
|
||||
// "MEM_DDR5_64G_5600" → category="MEM", vendor="DDR5", model="64G_5600"
|
||||
// "GPU_NV_RTX_4090_PCIe" → category="GPU", vendor="NV", model="RTX_4090_PCIe"
|
||||
// First launch: show setup form
|
||||
// User provides: host, port, database, username, password
|
||||
// Credentials are encrypted and stored in SQLite
|
||||
// Connection is tested before saving
|
||||
|
||||
func ParsePartNumber(lotName string) (category, vendor, model string) {
|
||||
parts := strings.SplitN(lotName, "_", 3)
|
||||
if len(parts) >= 1 { category = parts[0] }
|
||||
if len(parts) >= 2 { vendor = parts[1] }
|
||||
if len(parts) >= 3 { model = parts[2] }
|
||||
func SetupConnection(host, port, dbName, user, password string) error {
|
||||
// Test connection to MariaDB
|
||||
// If successful, encrypt and save to SQLite
|
||||
// Create/migrate qt_* tables if user has permissions
|
||||
}
|
||||
|
||||
func CheckWritePermission(db *gorm.DB, tableName string) bool {
|
||||
// Check if current user can INSERT into table
|
||||
// Used to enable/disable pricelist creation UI
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Pricelist Creation
|
||||
|
||||
```go
|
||||
// Create snapshot of current prices from qt_lot_metadata
|
||||
// Version format: YYYY-MM-DD-NNN (NNN = sequential number for the day)
|
||||
|
||||
func CreatePricelist(name string, createdBy string) (*Pricelist, error) {
|
||||
version := generateVersion() // e.g., "2024-01-31-001"
|
||||
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
|
||||
|
||||
// Copy all prices from qt_lot_metadata
|
||||
// Insert into qt_pricelists and qt_pricelist_items
|
||||
}
|
||||
|
||||
func generateVersion() string {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
// Count existing pricelists for today
|
||||
// Return "YYYY-MM-DD-NNN"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Pricelist Comparison
|
||||
|
||||
```go
|
||||
type PriceDiff struct {
|
||||
LotName string
|
||||
OldPrice float64
|
||||
NewPrice float64
|
||||
Difference float64
|
||||
PercentDiff float64
|
||||
}
|
||||
|
||||
// Compare two pricelists and return differences
|
||||
func ComparePricelists(oldID, newID int) ([]PriceDiff, error)
|
||||
|
||||
// Compare specification's pricelist with latest available
|
||||
func GetSpecificationPriceDiff(specUUID string) ([]PriceDiff, float64, error) {
|
||||
// Returns item diffs and total price difference
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Specification Upgrade
|
||||
|
||||
```go
|
||||
// Upgrade specification to use newer pricelist
|
||||
func UpgradeSpecificationPricelist(specUUID string, newPricelistID int) error {
|
||||
// Update pricelist_id
|
||||
// Recalculate prices from new pricelist
|
||||
// Increment revision number
|
||||
// Update old pricelist usage_count--
|
||||
// Update new pricelist usage_count++
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Pricelist Cleanup
|
||||
|
||||
```go
|
||||
// Cron job: cleanup old unused pricelists
|
||||
// Run weekly: 0 4 * * 0
|
||||
func CleanupOldPricelists() error {
|
||||
// Delete pricelists where:
|
||||
// - expires_at < NOW()
|
||||
// - usage_count = 0
|
||||
// - is_active = false OR created_at < NOW() - 1 year
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Sync Service
|
||||
|
||||
```go
|
||||
// Sync pricelists from server to local SQLite
|
||||
func SyncPricelists() error {
|
||||
// Fetch all active pricelists from MariaDB
|
||||
// Update local_pricelists table
|
||||
// For pricelists used by local specs, also sync items
|
||||
}
|
||||
|
||||
// Check if sync is needed
|
||||
func NeedSync() bool {
|
||||
// Compare last_sync timestamp with server
|
||||
// Return true if new pricelists available
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Part Number Parsing
|
||||
|
||||
```go
|
||||
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
|
||||
// "MB_INTEL_4.Sapphire_2S" → category="MB", model="INTEL_4.Sapphire_2S"
|
||||
|
||||
func ParsePartNumber(lotName string) (category, model string) {
|
||||
parts := strings.SplitN(lotName, "_", 2)
|
||||
if len(parts) >= 1 {
|
||||
category = parts[0]
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
model = parts[1]
|
||||
}
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Price Calculation Methods
|
||||
### 8. Price Calculation Methods
|
||||
|
||||
```go
|
||||
// Median - simple median of prices in period
|
||||
func CalculateMedian(prices []float64) float64
|
||||
|
||||
// Average - arithmetic mean
|
||||
func CalculateAverage(prices []float64) float64
|
||||
|
||||
// Weighted Median - recent prices have higher weight (exponential decay)
|
||||
// weight = e^(-days_since_quote / decay_days)
|
||||
func CalculateWeightedMedian(prices []PricePoint, decayDays int) float64
|
||||
```
|
||||
|
||||
### 3. Price Freshness (color coding)
|
||||
### 9. Price Freshness
|
||||
|
||||
```go
|
||||
// Green: < 30 days AND >= 3 quotes
|
||||
// Yellow: 30-60 days OR 1-2 quotes
|
||||
// Orange: 60-90 days
|
||||
// Red: > 90 days OR no price
|
||||
|
||||
func GetPriceFreshness(daysSinceUpdate int, quoteCount int) string {
|
||||
if daysSinceUpdate < 30 && quoteCount >= 3 {
|
||||
return "fresh" // green
|
||||
@@ -218,95 +495,80 @@ func GetPriceFreshness(daysSinceUpdate int, quoteCount int) string {
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Component Sorting
|
||||
|
||||
Sort by: popularity + price freshness. Components without prices go to the bottom.
|
||||
|
||||
```go
|
||||
// Sort score = popularity_score * 10 + freshness_bonus - no_price_penalty
|
||||
// freshness_bonus: fresh=100, normal=50, stale=10, critical=0
|
||||
// no_price_penalty: -1000 if current_price is NULL or 0
|
||||
```
|
||||
|
||||
### 5. Alert Generation
|
||||
|
||||
Generate alerts when:
|
||||
- **high_demand_stale_price** (CRITICAL): >= 5 quotes/month AND price > 60 days old
|
||||
- **trending_no_price** (HIGH): trend_percent > 50% AND no price set
|
||||
- **price_spike** (MEDIUM): price increased > 20% from previous period
|
||||
- **no_recent_quotes** (MEDIUM): popular component, no supplier quotes > 90 days
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth
|
||||
### Setup (no auth required)
|
||||
```
|
||||
POST /api/auth/login → {"username", "password"} → {"token", "refresh_token"}
|
||||
POST /api/auth/logout
|
||||
POST /api/auth/refresh
|
||||
GET /api/auth/me → current user info
|
||||
GET /setup → DB connection form (if not configured)
|
||||
POST /setup → Save connection settings
|
||||
POST /setup/test → Test connection without saving
|
||||
GET /setup/status → Check if configured and connected
|
||||
```
|
||||
|
||||
### Components
|
||||
```
|
||||
GET /api/components → list with pagination
|
||||
GET /api/components?category=CPU&vendor=AMD → filtered
|
||||
GET /api/components/:lot_name → single component details
|
||||
GET /api/categories → category list
|
||||
GET /api/components → List with pagination
|
||||
GET /api/components?category=CPU&search=AMD → Filtered list
|
||||
GET /api/components/:lot_name → Single component details
|
||||
GET /api/categories → Category list
|
||||
```
|
||||
|
||||
### Quote Builder
|
||||
### Pricelists
|
||||
```
|
||||
POST /api/quote/validate → {"items": [...]} → {"valid": bool, "errors": [], "warnings": []}
|
||||
POST /api/quote/calculate → {"items": [...]} → {"items": [...], "total": 45000.00}
|
||||
GET /api/pricelists → List all pricelists
|
||||
POST /api/pricelists → Create new pricelist (requires write permission)
|
||||
GET /api/pricelists/:id → Pricelist details
|
||||
GET /api/pricelists/:id/items → Pricelist items with pagination
|
||||
DELETE /api/pricelists/:id → Delete pricelist (if usage_count=0)
|
||||
GET /api/pricelists/latest → Get latest active pricelist
|
||||
POST /api/pricelists/compare → Compare two pricelists
|
||||
```
|
||||
|
||||
### Projects
|
||||
```
|
||||
GET /api/projects → List all projects
|
||||
POST /api/projects → Create project
|
||||
GET /api/projects/:uuid → Project with specifications
|
||||
PUT /api/projects/:uuid → Update project
|
||||
DELETE /api/projects/:uuid → Delete project and specs
|
||||
```
|
||||
|
||||
### Specifications
|
||||
```
|
||||
GET /api/projects/:uuid/specs → List specifications
|
||||
POST /api/projects/:uuid/specs → Create specification
|
||||
GET /api/specs/:spec_uuid → Specification details
|
||||
PUT /api/specs/:spec_uuid → Update specification
|
||||
DELETE /api/specs/:spec_uuid → Delete specification
|
||||
POST /api/specs/:spec_uuid/upgrade → Upgrade to new pricelist
|
||||
GET /api/specs/:spec_uuid/diff → Show price diff with latest pricelist
|
||||
POST /api/specs/:spec_uuid/new-revision → Create new revision
|
||||
```
|
||||
|
||||
### Sync
|
||||
```
|
||||
GET /api/sync/status → Sync status (last sync, pending changes)
|
||||
POST /api/sync/pricelists → Sync pricelists from server
|
||||
POST /api/sync/push → Push local changes to server
|
||||
POST /api/sync/pull → Pull all data from server
|
||||
```
|
||||
|
||||
### Export
|
||||
```
|
||||
POST /api/export/csv → {"items": [...], "name": "Config 1"} → CSV file
|
||||
POST /api/export/xlsx → {"items": [...], "name": "Config 1"} → XLSX file
|
||||
```
|
||||
|
||||
### Configurations
|
||||
```
|
||||
GET /api/configs → list user's configurations
|
||||
POST /api/configs → save new configuration
|
||||
GET /api/configs/:uuid → get by UUID
|
||||
PUT /api/configs/:uuid → update
|
||||
DELETE /api/configs/:uuid → delete
|
||||
GET /api/configs/:uuid/export → export as JSON
|
||||
POST /api/configs/import → import from JSON
|
||||
```
|
||||
|
||||
### Pricing Admin (requires role: pricing_admin or admin)
|
||||
```
|
||||
GET /admin/pricing/stats → dashboard stats
|
||||
GET /admin/pricing/components → components with pricing info
|
||||
GET /admin/pricing/components/:lot_name → component pricing details
|
||||
POST /admin/pricing/update → update price method/value
|
||||
POST /admin/pricing/recalculate-all → recalculate all prices
|
||||
|
||||
GET /admin/pricing/alerts → list alerts
|
||||
POST /admin/pricing/alerts/:id/acknowledge → mark as seen
|
||||
POST /admin/pricing/alerts/:id/resolve → mark as resolved
|
||||
POST /admin/pricing/alerts/:id/ignore → dismiss alert
|
||||
POST /api/export/xlsx → Export specification as XLSX
|
||||
POST /api/export/pdf → Export specification as PDF (future)
|
||||
GET /api/specs/:uuid/export → Export single spec
|
||||
GET /api/projects/:uuid/export → Export all project specs
|
||||
```
|
||||
|
||||
### htmx Partials
|
||||
```
|
||||
GET /partials/components?category=CPU&vendor=AMD → HTML fragment
|
||||
GET /partials/cart → cart HTML
|
||||
GET /partials/summary → price summary HTML
|
||||
GET /partials/components?category=CPU → Component list HTML
|
||||
GET /partials/spec-items/:spec_uuid → Specification items HTML
|
||||
GET /partials/price-diff/:spec_uuid → Price diff table HTML
|
||||
GET /partials/project-specs/:project_uuid → Project specifications list
|
||||
```
|
||||
|
||||
## User Roles
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| viewer | View components, create quotes, export |
|
||||
| editor | + save/load configurations |
|
||||
| pricing_admin | + manage prices, view alerts |
|
||||
| admin | + manage users |
|
||||
|
||||
## Frontend Guidelines
|
||||
|
||||
- **Mobile-first** design
|
||||
@@ -318,6 +580,8 @@ GET /partials/summary → price summary HTML
|
||||
- `text-yellow-600 bg-yellow-50` - normal
|
||||
- `text-orange-600 bg-orange-50` - stale
|
||||
- `text-red-600 bg-red-50` - critical
|
||||
- Sync status indicator in header
|
||||
- Offline mode indicator when server unavailable
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -325,51 +589,50 @@ GET /partials/summary → price summary HTML
|
||||
# Run development server
|
||||
go run ./cmd/server
|
||||
|
||||
# Run price updater (cron job)
|
||||
go run ./cmd/priceupdater
|
||||
|
||||
# Run importer (one-time setup)
|
||||
go run ./cmd/importer
|
||||
|
||||
# Run cron jobs manually
|
||||
go run ./cmd/cron -job=cleanup-pricelists # Remove old unused pricelists
|
||||
go run ./cmd/cron -job=update-prices # Recalculate all prices
|
||||
go run ./cmd/cron -job=update-popularity # Update popularity scores
|
||||
|
||||
# Build for production
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge-cron ./cmd/cron
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Dependencies (go.mod)
|
||||
## Cron Jobs
|
||||
|
||||
```go
|
||||
module git.mchus.pro/mchus/quoteforge
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
gorm.io/gorm v1.25.5
|
||||
gorm.io/driver/mysql v1.5.2
|
||||
github.com/xuri/excelize/v2 v2.8.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/google/uuid v1.5.0
|
||||
golang.org/x/crypto v0.17.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
```
|
||||
|
||||
## Development Priorities
|
||||
|
||||
1. **Phase 1 (MVP):** Project setup, models, component API, basic UI, CSV export
|
||||
2. **Phase 2:** JWT auth with roles, pricing admin UI, all price methods
|
||||
3. **Phase 3:** Save/load configs, JSON import/export, XLSX export, cron jobs
|
||||
4. **Phase 4:** Usage stats, alerts system, dashboard
|
||||
5. **Phase 5:** Polish, tests, Docker, documentation
|
||||
- **Pricelist cleanup**: Weekly on Sunday at 4 AM (0 4 * * 0)
|
||||
- **Price updates**: Daily at 2 AM (0 2 * * *)
|
||||
- **Popularity score updates**: Daily at 3 AM (0 3 * * *)
|
||||
|
||||
## Code Style
|
||||
|
||||
- Use standard Go formatting (gofmt)
|
||||
- Error handling: always check errors, wrap with context
|
||||
- Logging: use structured logging (slog or zerolog)
|
||||
- Logging: use structured logging (slog)
|
||||
- Comments: in Russian or English, be consistent
|
||||
- File naming: snake_case for files, PascalCase for types
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### From qt_configurations to qt_specifications
|
||||
|
||||
When migrating existing data:
|
||||
1. Create a default project for orphan configurations
|
||||
2. Create initial pricelist from current qt_lot_metadata prices
|
||||
3. Convert qt_configurations to qt_specifications with default variant="Base", rev=1
|
||||
4. Link all specs to initial pricelist
|
||||
|
||||
### RBAC Disabled
|
||||
|
||||
During Phase 1-3, RBAC is disabled:
|
||||
- No login required
|
||||
- All users have full access
|
||||
- Write permissions determined by MariaDB user privileges
|
||||
- qt_users table exists but not used
|
||||
|
||||
121
MIGRATION_PRICE_REFRESH.md
Normal file
121
MIGRATION_PRICE_REFRESH.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Миграция: Функционал пересчета цен в конфигураторе
|
||||
|
||||
## Описание изменений
|
||||
|
||||
Добавлен функционал автоматического обновления цен компонентов в сохраненных конфигурациях.
|
||||
|
||||
### Новые возможности
|
||||
|
||||
1. **Кнопка "Пересчитать цену"** на странице конфигуратора
|
||||
- Обновляет цены всех компонентов в конфигурации до актуальных значений из базы данных
|
||||
- Сохраняет количество компонентов, обновляя только цены
|
||||
- Отображает время последнего обновления цен
|
||||
|
||||
2. **Поле `price_updated_at`** в таблице конфигураций
|
||||
- Хранит дату и время последнего обновления цен
|
||||
- Отображается на странице конфигуратора в удобном формате ("5 мин. назад", "2 ч. назад" и т.д.)
|
||||
|
||||
### Изменения в базе данных
|
||||
|
||||
Добавлено новое поле в таблицу `qt_configurations`:
|
||||
```sql
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL
|
||||
AFTER server_count;
|
||||
```
|
||||
|
||||
### Новый API endpoint
|
||||
|
||||
```
|
||||
POST /api/configs/:uuid/refresh-prices
|
||||
```
|
||||
|
||||
**Требования:**
|
||||
- Авторизация: Bearer Token
|
||||
- Роль: editor или выше
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "...",
|
||||
"name": "Конфигурация 1",
|
||||
"items": [
|
||||
{
|
||||
"lot_name": "CPU_AMD_9654",
|
||||
"quantity": 2,
|
||||
"unit_price": 11500.00
|
||||
}
|
||||
],
|
||||
"total_price": 23000.00,
|
||||
"price_updated_at": "2026-01-31T12:34:56Z",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Применение изменений
|
||||
|
||||
### 1. Обновление базы данных
|
||||
|
||||
Запустите сервер с флагом миграции:
|
||||
```bash
|
||||
./quoteforge -migrate -config config.yaml
|
||||
```
|
||||
|
||||
Или выполните SQL миграцию вручную:
|
||||
```bash
|
||||
mysql -u user -p RFQ_LOG < migrations/004_add_price_updated_at.sql
|
||||
```
|
||||
|
||||
### 2. Перезапуск сервера
|
||||
|
||||
После применения миграции перезапустите сервер:
|
||||
```bash
|
||||
./quoteforge -config config.yaml
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
1. Откройте любую сохраненную конфигурацию в конфигураторе
|
||||
2. Нажмите кнопку **"Пересчитать цену"** рядом с кнопкой "Сохранить"
|
||||
3. Все цены компонентов будут обновлены до актуальных значений
|
||||
4. Конфигурация автоматически сохраняется с обновленными ценами
|
||||
5. Под кнопками отображается время последнего обновления цен
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Измененные файлы
|
||||
|
||||
- `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt`
|
||||
- `internal/services/configuration.go` - добавлен метод `RefreshPrices()`
|
||||
- `internal/handlers/configuration.go` - добавлен обработчик `RefreshPrices()`
|
||||
- `cmd/server/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
|
||||
- `web/templates/index.html` - добавлена кнопка и JavaScript функции
|
||||
- `migrations/004_add_price_updated_at.sql` - SQL миграция
|
||||
- `CLAUDE.md` - обновлена документация
|
||||
|
||||
### Логика обновления цен
|
||||
|
||||
1. Получение конфигурации по UUID
|
||||
2. Проверка прав доступа (пользователь должен быть владельцем)
|
||||
3. Для каждого компонента в конфигурации:
|
||||
- Получение актуальной цены из `qt_lot_metadata.current_price`
|
||||
- Обновление `unit_price` в items
|
||||
4. Пересчет `total_price` с учетом `server_count`
|
||||
5. Установка `price_updated_at` на текущее время
|
||||
6. Сохранение конфигурации
|
||||
|
||||
### Обработка ошибок
|
||||
|
||||
- Если компонент не найден или у него нет цены - сохраняется старая цена
|
||||
- При ошибках доступа возвращается 403 Forbidden
|
||||
- При отсутствии конфигурации возвращается 404 Not Found
|
||||
|
||||
## Отмена изменений (Rollback)
|
||||
|
||||
Для отмены миграции выполните:
|
||||
```sql
|
||||
ALTER TABLE qt_configurations DROP COLUMN price_updated_at;
|
||||
```
|
||||
|
||||
**Внимание:** После отмены миграции функционал пересчета цен перестанет работать корректно.
|
||||
71
README.md
71
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
**Server Configuration & Quotation Tool**
|
||||
|
||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений. Позволяет быстро собрать спецификацию сервера из каталога компонентов с автоматическим расчётом цен.
|
||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG.
|
||||
|
||||

|
||||

|
||||
@@ -16,7 +16,6 @@ QuoteForge — корпоративный инструмент для конфи
|
||||
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
|
||||
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
|
||||
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
|
||||
- 📤 **Импорт/экспорт JSON** — обмен конфигурациями между пользователями
|
||||
|
||||
### Для ценовых администраторов
|
||||
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
|
||||
@@ -83,23 +82,23 @@ auth:
|
||||
### 3. Миграции базы данных
|
||||
|
||||
```bash
|
||||
make migrate
|
||||
go run ./cmd/server -migrate
|
||||
```
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
|
||||
```bash
|
||||
make seed
|
||||
go run ./cmd/importer
|
||||
```
|
||||
|
||||
### 5. Запуск
|
||||
|
||||
```bash
|
||||
# Development
|
||||
make run
|
||||
go run ./cmd/server
|
||||
|
||||
# Production
|
||||
make build
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||
./bin/quoteforge
|
||||
```
|
||||
|
||||
@@ -120,9 +119,8 @@ docker-compose up -d
|
||||
```
|
||||
quoteforge/
|
||||
├── cmd/
|
||||
│ ├── server/ # Основной сервер
|
||||
│ ├── priceupdater/ # Cron job обновления цен
|
||||
│ └── importer/ # Импорт данных
|
||||
│ ├── server/main.go # Main HTTP server
|
||||
│ └── importer/main.go # Import metadata from lot table
|
||||
├── internal/
|
||||
│ ├── config/ # Конфигурация
|
||||
│ ├── models/ # GORM модели
|
||||
@@ -137,7 +135,7 @@ quoteforge/
|
||||
├── config.yaml # Конфигурация
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── Makefile
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
## Роли пользователей
|
||||
@@ -165,30 +163,59 @@ GET /api/configs # Сохранённые конфигурации
|
||||
|
||||
## Cron Jobs
|
||||
|
||||
Добавьте в crontab:
|
||||
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
The Docker setup includes a dedicated cron service that runs the following jobs:
|
||||
|
||||
- **Alerts check**: Every hour (0 * * * *)
|
||||
- **Price updates**: Daily at 2 AM (0 2 * * *)
|
||||
- **Usage counter reset**: Weekly on Sunday at 1 AM (0 1 * * 0)
|
||||
- **Popularity score updates**: Daily at 3 AM (0 3 * * *)
|
||||
|
||||
To enable cron jobs in Docker, run:
|
||||
|
||||
```bash
|
||||
# Обновление цен — каждую ночь в 2:00
|
||||
0 2 * * * /opt/quoteforge/bin/priceupdater
|
||||
|
||||
# Генерация алертов — каждый час
|
||||
0 * * * * /opt/quoteforge/bin/priceupdater --alerts-only
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Manual Cron Job Execution
|
||||
|
||||
You can also run cron jobs manually using the quoteforge-cron binary:
|
||||
|
||||
```bash
|
||||
# Check and generate alerts
|
||||
go run ./cmd/cron -job=alerts
|
||||
|
||||
# Recalculate all prices
|
||||
go run ./cmd/cron -job=update-prices
|
||||
|
||||
# Reset usage counters
|
||||
go run ./cmd/cron -job=reset-counters
|
||||
|
||||
# Update popularity scores
|
||||
go run ./cmd/cron -job=update-popularity
|
||||
```
|
||||
|
||||
### Cron Job Details
|
||||
|
||||
- **Alerts check**: Generates alerts for components with high demand and stale prices, trending components without prices, and components with no recent quotes
|
||||
- **Price updates**: Recalculates prices for all components using configured methods (median, weighted median, average)
|
||||
- **Usage counter reset**: Resets weekly and monthly usage counters for components
|
||||
- **Popularity score updates**: Recalculates popularity scores based on supplier quote activity
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
# Запуск в режиме разработки (hot reload)
|
||||
make dev
|
||||
go run ./cmd/server
|
||||
|
||||
# Запуск тестов
|
||||
make test
|
||||
|
||||
# Линтер
|
||||
make lint
|
||||
go test ./...
|
||||
|
||||
# Сборка для Linux
|
||||
make build-linux
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Apply migration to add custom_price column
|
||||
# Usage: ./apply_migration.sh
|
||||
|
||||
# Load database config from config.yaml or environment
|
||||
DB_HOST="${DB_HOST:-localhost}"
|
||||
DB_PORT="${DB_PORT:-3306}"
|
||||
DB_NAME="${DB_NAME:-RFQ_LOG}"
|
||||
DB_USER="${DB_USER:-root}"
|
||||
DB_PASS="${DB_PASS}"
|
||||
|
||||
echo "Applying migration: 002_add_custom_price.sql"
|
||||
echo "Database: $DB_NAME at $DB_HOST:$DB_PORT"
|
||||
|
||||
if [ -z "$DB_PASS" ]; then
|
||||
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" "$DB_NAME" < migrations/002_add_custom_price.sql
|
||||
else
|
||||
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" < migrations/002_add_custom_price.sql
|
||||
fi
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Migration applied successfully!"
|
||||
else
|
||||
echo "Migration failed!"
|
||||
exit 1
|
||||
fi
|
||||
85
cmd/cron/main.go
Normal file
85
cmd/cron/main.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||
cronJob := flag.String("job", "", "type of cron job to run (alerts, update-prices)")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// Ensure tables exist
|
||||
if err := models.Migrate(db); err != nil {
|
||||
log.Fatalf("Migration failed: %v", err)
|
||||
}
|
||||
|
||||
// Initialize repositories
|
||||
statsRepo := repository.NewStatsRepository(db)
|
||||
alertRepo := repository.NewAlertRepository(db)
|
||||
componentRepo := repository.NewComponentRepository(db)
|
||||
priceRepo := repository.NewPriceRepository(db)
|
||||
|
||||
// Initialize services
|
||||
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||
|
||||
switch *cronJob {
|
||||
case "alerts":
|
||||
log.Println("Running alerts check...")
|
||||
if err := alertService.CheckAndGenerateAlerts(); err != nil {
|
||||
log.Printf("Error running alerts check: %v", err)
|
||||
} else {
|
||||
log.Println("Alerts check completed successfully")
|
||||
}
|
||||
case "update-prices":
|
||||
log.Println("Recalculating all prices...")
|
||||
updated, errors := pricingService.RecalculateAllPrices()
|
||||
log.Printf("Prices recalculated: %d updated, %d errors", updated, errors)
|
||||
case "reset-counters":
|
||||
log.Println("Resetting usage counters...")
|
||||
if err := statsRepo.ResetWeeklyCounters(); err != nil {
|
||||
log.Printf("Error resetting weekly counters: %v", err)
|
||||
}
|
||||
if err := statsRepo.ResetMonthlyCounters(); err != nil {
|
||||
log.Printf("Error resetting monthly counters: %v", err)
|
||||
}
|
||||
log.Println("Usage counters reset completed")
|
||||
case "update-popularity":
|
||||
log.Println("Updating popularity scores...")
|
||||
if err := statsRepo.UpdatePopularityScores(); err != nil {
|
||||
log.Printf("Error updating popularity scores: %v", err)
|
||||
} else {
|
||||
log.Println("Popularity scores updated successfully")
|
||||
}
|
||||
default:
|
||||
log.Println("No valid cron job specified. Available jobs:")
|
||||
log.Println(" - alerts: Check and generate alerts")
|
||||
log.Println(" - update-prices: Recalculate all prices")
|
||||
log.Println(" - reset-counters: Reset usage counters")
|
||||
log.Println(" - update-popularity: Update popularity scores")
|
||||
}
|
||||
}
|
||||
@@ -295,6 +295,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
|
||||
configs.PUT("/:uuid", configHandler.Update)
|
||||
configs.PATCH("/:uuid/rename", configHandler.Rename)
|
||||
configs.POST("/:uuid/clone", configHandler.Clone)
|
||||
configs.POST("/:uuid/refresh-prices", configHandler.RefreshPrices)
|
||||
configs.DELETE("/:uuid", configHandler.Delete)
|
||||
// configs.GET("/:uuid/export", configHandler.ExportJSON)
|
||||
configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV)
|
||||
|
||||
15
crontab
Normal file
15
crontab
Normal file
@@ -0,0 +1,15 @@
|
||||
# Cron jobs for QuoteForge
|
||||
# Run alerts check every hour
|
||||
0 * * * * /app/quoteforge-cron -job=alerts
|
||||
|
||||
# Run price updates daily at 2 AM
|
||||
0 2 * * * /app/quoteforge-cron -job=update-prices
|
||||
|
||||
# Reset weekly counters every Sunday at 1 AM
|
||||
0 1 * * 0 /app/quoteforge-cron -job=reset-counters
|
||||
|
||||
# Update popularity scores daily at 3 AM
|
||||
0 3 * * * /app/quoteforge-cron -job=update-popularity
|
||||
|
||||
# Log rotation (optional)
|
||||
# 0 0 * * * /usr/bin/logrotate /etc/logrotate.conf
|
||||
@@ -22,9 +22,10 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
filter := repository.ComponentFilter{
|
||||
Category: c.Query("category"),
|
||||
Search: c.Query("search"),
|
||||
HasPrice: c.Query("has_price") == "true",
|
||||
Category: c.Query("category"),
|
||||
Search: c.Query("search"),
|
||||
HasPrice: c.Query("has_price") == "true",
|
||||
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
|
||||
}
|
||||
|
||||
result, err := h.componentService.List(filter, page, perPage)
|
||||
|
||||
@@ -181,6 +181,25 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, config)
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) RefreshPrices(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
config, err := h.configService.RefreshPrices(uuid, userID)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
status = http.StatusNotFound
|
||||
} else if err == services.ErrConfigForbidden {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
// func (h *ConfigurationHandler) ExportJSON(c *gin.Context) {
|
||||
// userID := middleware.GetUserID(c)
|
||||
// uuid := c.Param("uuid")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -80,7 +81,8 @@ func (h *PricingHandler) GetStats(c *gin.Context) {
|
||||
|
||||
type ComponentWithCount struct {
|
||||
models.LotMetadata
|
||||
QuoteCount int64 `json:"quote_count"`
|
||||
QuoteCount int64 `json:"quote_count"`
|
||||
UsedInMeta []string `json:"used_in_meta,omitempty"` // List of meta-articles that use this component
|
||||
}
|
||||
|
||||
func (h *PricingHandler) ListComponents(c *gin.Context) {
|
||||
@@ -116,12 +118,16 @@ func (h *PricingHandler) ListComponents(c *gin.Context) {
|
||||
|
||||
counts, _ := h.priceRepo.GetQuoteCounts(lotNames)
|
||||
|
||||
// Get meta usage information
|
||||
metaUsage := h.getMetaUsageMap(lotNames)
|
||||
|
||||
// Combine components with counts
|
||||
result := make([]ComponentWithCount, len(components))
|
||||
for i, comp := range components {
|
||||
result[i] = ComponentWithCount{
|
||||
LotMetadata: comp,
|
||||
QuoteCount: counts[comp.LotName],
|
||||
UsedInMeta: metaUsage[comp.LotName],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +139,79 @@ func (h *PricingHandler) ListComponents(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// getMetaUsageMap returns a map of lot_name -> list of meta-articles that use this component
|
||||
func (h *PricingHandler) getMetaUsageMap(lotNames []string) map[string][]string {
|
||||
result := make(map[string][]string)
|
||||
|
||||
// Get all components with meta_prices
|
||||
var metaComponents []models.LotMetadata
|
||||
h.db.Where("meta_prices IS NOT NULL AND meta_prices != ''").Find(&metaComponents)
|
||||
|
||||
// Build reverse lookup: which components are used in which meta-articles
|
||||
for _, meta := range metaComponents {
|
||||
sources := strings.Split(meta.MetaPrices, ",")
|
||||
for _, source := range sources {
|
||||
source = strings.TrimSpace(source)
|
||||
if source == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle wildcard patterns
|
||||
if strings.HasSuffix(source, "*") {
|
||||
prefix := strings.TrimSuffix(source, "*")
|
||||
for _, lotName := range lotNames {
|
||||
if strings.HasPrefix(lotName, prefix) && lotName != meta.LotName {
|
||||
result[lotName] = append(result[lotName], meta.LotName)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct match
|
||||
for _, lotName := range lotNames {
|
||||
if lotName == source && lotName != meta.LotName {
|
||||
result[lotName] = append(result[lotName], meta.LotName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// expandMetaPrices expands meta_prices string to list of actual lot names
|
||||
func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []string {
|
||||
sources := strings.Split(metaPrices, ",")
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, source := range sources {
|
||||
source = strings.TrimSpace(source)
|
||||
if source == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(source, "*") {
|
||||
// Wildcard pattern - find matching lots
|
||||
prefix := strings.TrimSuffix(source, "*")
|
||||
var matchingLots []string
|
||||
h.db.Model(&models.LotMetadata{}).
|
||||
Where("lot_name LIKE ? AND lot_name != ?", prefix+"%", excludeLot).
|
||||
Pluck("lot_name", &matchingLots)
|
||||
for _, lot := range matchingLots {
|
||||
if !seen[lot] {
|
||||
result = append(result, lot)
|
||||
seen[lot] = true
|
||||
}
|
||||
}
|
||||
} else if source != excludeLot && !seen[source] {
|
||||
result = append(result, source)
|
||||
seen[source] = true
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
|
||||
lotName := c.Param("lot_name")
|
||||
|
||||
@@ -161,6 +240,11 @@ type UpdatePriceRequest struct {
|
||||
Coefficient float64 `json:"coefficient"`
|
||||
ManualPrice *float64 `json:"manual_price"`
|
||||
ClearManual bool `json:"clear_manual"`
|
||||
MetaEnabled bool `json:"meta_enabled"`
|
||||
MetaPrices string `json:"meta_prices"`
|
||||
MetaMethod string `json:"meta_method"`
|
||||
MetaPeriod int `json:"meta_period"`
|
||||
IsHidden bool `json:"is_hidden"`
|
||||
}
|
||||
|
||||
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
||||
@@ -185,6 +269,16 @@ func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
||||
// Update coefficient
|
||||
updates["price_coefficient"] = req.Coefficient
|
||||
|
||||
// Handle meta prices
|
||||
if req.MetaEnabled && req.MetaPrices != "" {
|
||||
updates["meta_prices"] = req.MetaPrices
|
||||
} else {
|
||||
updates["meta_prices"] = ""
|
||||
}
|
||||
|
||||
// Handle hidden flag
|
||||
updates["is_hidden"] = req.IsHidden
|
||||
|
||||
// Handle manual price
|
||||
if req.ClearManual {
|
||||
updates["manual_price"] = nil
|
||||
@@ -236,18 +330,47 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
|
||||
method = models.PriceMethodMedian
|
||||
}
|
||||
|
||||
// Get prices based on period
|
||||
var prices []float64
|
||||
if periodDays > 0 {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||
lotName, periodDays).Pluck("price", &prices)
|
||||
// Determine which lot names to use for price calculation
|
||||
lotNames := []string{lotName}
|
||||
if comp.MetaPrices != "" {
|
||||
lotNames = h.expandMetaPrices(comp.MetaPrices, lotName)
|
||||
}
|
||||
|
||||
// If no prices in period, try all time
|
||||
if len(prices) == 0 {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices)
|
||||
// Get prices based on period from all relevant lots
|
||||
var prices []float64
|
||||
for _, ln := range lotNames {
|
||||
var lotPrices []float64
|
||||
if strings.HasSuffix(ln, "*") {
|
||||
pattern := strings.TrimSuffix(ln, "*") + "%"
|
||||
if periodDays > 0 {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||
pattern, periodDays).Pluck("price", &lotPrices)
|
||||
} else {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
|
||||
}
|
||||
} else {
|
||||
if periodDays > 0 {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||
ln, periodDays).Pluck("price", &lotPrices)
|
||||
} else {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices)
|
||||
}
|
||||
}
|
||||
prices = append(prices, lotPrices...)
|
||||
}
|
||||
|
||||
// If no prices in period, try all time
|
||||
if len(prices) == 0 && periodDays > 0 {
|
||||
for _, ln := range lotNames {
|
||||
var lotPrices []float64
|
||||
if strings.HasSuffix(ln, "*") {
|
||||
pattern := strings.TrimSuffix(ln, "*") + "%"
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
|
||||
} else {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices)
|
||||
}
|
||||
prices = append(prices, lotPrices...)
|
||||
}
|
||||
} else {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices)
|
||||
}
|
||||
|
||||
if len(prices) == 0 {
|
||||
@@ -255,6 +378,7 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
|
||||
}
|
||||
|
||||
// Calculate price based on method
|
||||
sortFloat64s(prices)
|
||||
var finalPrice float64
|
||||
switch method {
|
||||
case models.PriceMethodMedian:
|
||||
@@ -295,61 +419,95 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||
h.db.Find(&components)
|
||||
total := int64(len(components))
|
||||
|
||||
// Pre-load all lot names for efficient wildcard matching
|
||||
var allLotNames []string
|
||||
h.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames)
|
||||
lotNameSet := make(map[string]bool, len(allLotNames))
|
||||
for _, ln := range allLotNames {
|
||||
lotNameSet[ln] = true
|
||||
}
|
||||
|
||||
// Pre-load latest quote dates for all lots (for checking updates)
|
||||
type LotDate struct {
|
||||
Lot string
|
||||
Date time.Time
|
||||
}
|
||||
var latestDates []LotDate
|
||||
h.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates)
|
||||
lotLatestDate := make(map[string]time.Time, len(latestDates))
|
||||
for _, ld := range latestDates {
|
||||
lotLatestDate[ld.Lot] = ld.Date
|
||||
}
|
||||
|
||||
// Send initial progress
|
||||
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "starting"})
|
||||
c.Writer.Flush()
|
||||
|
||||
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "processing", "updated": 0, "skipped": 0, "manual": 0, "errors": 0})
|
||||
c.Writer.Flush()
|
||||
|
||||
// Process components individually to respect their settings
|
||||
var updated, skipped, manual, errors int
|
||||
var updated, skipped, manual, unchanged, errors int
|
||||
now := time.Now()
|
||||
progressCounter := 0
|
||||
|
||||
for i, comp := range components {
|
||||
// If manual price is set, use it
|
||||
for _, comp := range components {
|
||||
progressCounter++
|
||||
|
||||
// If manual price is set, skip recalculation
|
||||
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
||||
err := h.db.Model(&models.LotMetadata{}).
|
||||
Where("lot_name = ?", comp.LotName).
|
||||
Updates(map[string]interface{}{
|
||||
"current_price": *comp.ManualPrice,
|
||||
"price_updated_at": now,
|
||||
}).Error
|
||||
if err != nil {
|
||||
errors++
|
||||
} else {
|
||||
manual++
|
||||
}
|
||||
manual++
|
||||
goto sendProgress
|
||||
}
|
||||
|
||||
// Calculate price based on component's individual settings
|
||||
{
|
||||
var basePrice *float64
|
||||
periodDays := comp.PricePeriodDays
|
||||
method := comp.PriceMethod
|
||||
if method == "" {
|
||||
method = models.PriceMethodMedian
|
||||
}
|
||||
|
||||
// Build query based on period
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if periodDays > 0 {
|
||||
query = `SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`
|
||||
args = []interface{}{comp.LotName, periodDays}
|
||||
// Determine source lots for price calculation (using cached lot names)
|
||||
var sourceLots []string
|
||||
if comp.MetaPrices != "" {
|
||||
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
|
||||
} else {
|
||||
query = `SELECT price FROM lot_log WHERE lot = ? ORDER BY price`
|
||||
args = []interface{}{comp.LotName}
|
||||
sourceLots = []string{comp.LotName}
|
||||
}
|
||||
|
||||
if len(sourceLots) == 0 {
|
||||
skipped++
|
||||
goto sendProgress
|
||||
}
|
||||
|
||||
// Check if there are new quotes since last update (using cached dates)
|
||||
if comp.PriceUpdatedAt != nil {
|
||||
hasNewData := false
|
||||
for _, lot := range sourceLots {
|
||||
if latestDate, ok := lotLatestDate[lot]; ok {
|
||||
if latestDate.After(*comp.PriceUpdatedAt) {
|
||||
hasNewData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasNewData {
|
||||
unchanged++
|
||||
goto sendProgress
|
||||
}
|
||||
}
|
||||
|
||||
// Get prices from source lots
|
||||
var prices []float64
|
||||
h.db.Raw(query, args...).Pluck("price", &prices)
|
||||
if periodDays > 0 {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||
sourceLots, periodDays).Pluck("price", &prices)
|
||||
} else {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
|
||||
sourceLots).Pluck("price", &prices)
|
||||
}
|
||||
|
||||
// If no prices in period, try all time
|
||||
if len(prices) == 0 && periodDays > 0 {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, comp.LotName).Pluck("price", &prices)
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices)
|
||||
}
|
||||
|
||||
if len(prices) == 0 {
|
||||
@@ -358,24 +516,22 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Calculate price based on method
|
||||
var basePrice float64
|
||||
switch method {
|
||||
case models.PriceMethodMedian:
|
||||
median := calculateMedian(prices)
|
||||
basePrice = &median
|
||||
basePrice = calculateMedian(prices)
|
||||
case models.PriceMethodAverage:
|
||||
avg := calculateAverage(prices)
|
||||
basePrice = &avg
|
||||
basePrice = calculateAverage(prices)
|
||||
default:
|
||||
median := calculateMedian(prices)
|
||||
basePrice = &median
|
||||
basePrice = calculateMedian(prices)
|
||||
}
|
||||
|
||||
if basePrice == nil || *basePrice <= 0 {
|
||||
if basePrice <= 0 {
|
||||
skipped++
|
||||
goto sendProgress
|
||||
}
|
||||
|
||||
finalPrice := *basePrice
|
||||
finalPrice := basePrice
|
||||
|
||||
// Apply coefficient
|
||||
if comp.PriceCoefficient != 0 {
|
||||
@@ -397,16 +553,18 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||
}
|
||||
|
||||
sendProgress:
|
||||
// Send progress update every 50 components
|
||||
if (i+1)%50 == 0 || i == len(components)-1 {
|
||||
// Send progress update every 10 components to reduce overhead
|
||||
if progressCounter%10 == 0 || progressCounter == int(total) {
|
||||
c.SSEvent("progress", gin.H{
|
||||
"current": updated + skipped + manual + errors,
|
||||
"total": total,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
"manual": manual,
|
||||
"errors": errors,
|
||||
"status": "processing",
|
||||
"current": updated + skipped + manual + unchanged + errors,
|
||||
"total": total,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
"manual": manual,
|
||||
"unchanged": unchanged,
|
||||
"errors": errors,
|
||||
"status": "processing",
|
||||
"lot_name": comp.LotName,
|
||||
})
|
||||
c.Writer.Flush()
|
||||
}
|
||||
@@ -417,13 +575,14 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||
|
||||
// Send completion
|
||||
c.SSEvent("progress", gin.H{
|
||||
"current": updated + skipped + manual + errors,
|
||||
"total": total,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
"manual": manual,
|
||||
"errors": errors,
|
||||
"status": "completed",
|
||||
"current": updated + skipped + manual + unchanged + errors,
|
||||
"total": total,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
"manual": manual,
|
||||
"unchanged": unchanged,
|
||||
"errors": errors,
|
||||
"status": "completed",
|
||||
})
|
||||
c.Writer.Flush()
|
||||
}
|
||||
@@ -503,6 +662,8 @@ type PreviewPriceRequest struct {
|
||||
Method string `json:"method"`
|
||||
PeriodDays int `json:"period_days"`
|
||||
Coefficient float64 `json:"coefficient"`
|
||||
MetaEnabled bool `json:"meta_enabled"`
|
||||
MetaPrices string `json:"meta_prices"`
|
||||
}
|
||||
|
||||
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
@@ -519,22 +680,48 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all prices for calculations
|
||||
// Determine which lot names to use for price calculation
|
||||
lotNames := []string{req.LotName}
|
||||
if req.MetaEnabled && req.MetaPrices != "" {
|
||||
lotNames = h.expandMetaPrices(req.MetaPrices, req.LotName)
|
||||
}
|
||||
|
||||
// Get all prices for calculations (from all relevant lots)
|
||||
var allPrices []float64
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, req.LotName).Pluck("price", &allPrices)
|
||||
for _, lotName := range lotNames {
|
||||
var lotPrices []float64
|
||||
if strings.HasSuffix(lotName, "*") {
|
||||
// Wildcard pattern
|
||||
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
|
||||
} else {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &lotPrices)
|
||||
}
|
||||
allPrices = append(allPrices, lotPrices...)
|
||||
}
|
||||
|
||||
// Calculate median for all time
|
||||
var medianAllTime *float64
|
||||
if len(allPrices) > 0 {
|
||||
sortFloat64s(allPrices)
|
||||
median := calculateMedian(allPrices)
|
||||
medianAllTime = &median
|
||||
}
|
||||
|
||||
// Get quote count
|
||||
// Get quote count (from all relevant lots)
|
||||
var quoteCount int64
|
||||
h.db.Model(&models.LotLog{}).Where("lot = ?", req.LotName).Count("eCount)
|
||||
for _, lotName := range lotNames {
|
||||
var count int64
|
||||
if strings.HasSuffix(lotName, "*") {
|
||||
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
||||
h.db.Model(&models.LotLog{}).Where("lot LIKE ?", pattern).Count(&count)
|
||||
} else {
|
||||
h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count)
|
||||
}
|
||||
quoteCount += count
|
||||
}
|
||||
|
||||
// Get last received price
|
||||
// Get last received price (from the main lot only)
|
||||
var lastPrice struct {
|
||||
Price *float64
|
||||
Date *time.Time
|
||||
@@ -549,8 +736,18 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
|
||||
var prices []float64
|
||||
if req.PeriodDays > 0 {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||
req.LotName, req.PeriodDays).Pluck("price", &prices)
|
||||
for _, lotName := range lotNames {
|
||||
var lotPrices []float64
|
||||
if strings.HasSuffix(lotName, "*") {
|
||||
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||
pattern, req.PeriodDays).Pluck("price", &lotPrices)
|
||||
} else {
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||
lotName, req.PeriodDays).Pluck("price", &lotPrices)
|
||||
}
|
||||
prices = append(prices, lotPrices...)
|
||||
}
|
||||
// Fall back to all time if no prices in period
|
||||
if len(prices) == 0 {
|
||||
prices = allPrices
|
||||
@@ -561,6 +758,7 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
|
||||
var newPrice *float64
|
||||
if len(prices) > 0 {
|
||||
sortFloat64s(prices)
|
||||
var basePrice float64
|
||||
if method == "average" {
|
||||
basePrice = calculateAverage(prices)
|
||||
@@ -585,3 +783,38 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
"last_price_date": lastPrice.Date,
|
||||
})
|
||||
}
|
||||
|
||||
// sortFloat64s sorts a slice of float64 in ascending order
|
||||
func sortFloat64s(data []float64) {
|
||||
sort.Float64s(data)
|
||||
}
|
||||
|
||||
// expandMetaPricesWithCache expands meta_prices using pre-loaded lot names (no DB queries)
|
||||
func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string {
|
||||
sources := strings.Split(metaPrices, ",")
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, source := range sources {
|
||||
source = strings.TrimSpace(source)
|
||||
if source == "" || source == excludeLot {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(source, "*") {
|
||||
// Wildcard pattern - find matching lots from cache
|
||||
prefix := strings.TrimSuffix(source, "*")
|
||||
for _, lot := range allLotNames {
|
||||
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
|
||||
result = append(result, lot)
|
||||
seen[lot] = true
|
||||
}
|
||||
}
|
||||
} else if !seen[source] {
|
||||
result = append(result, source)
|
||||
seen[source] = true
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -40,16 +40,18 @@ func (c ConfigItems) Total() float64 {
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
UserID uint `gorm:"not null" json:"user_id"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
UserID uint `gorm:"not null" json:"user_id"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ type LotMetadata struct {
|
||||
RequestCount int `gorm:"default:0" json:"request_count"`
|
||||
LastRequestDate *time.Time `gorm:"type:date" json:"last_request_date"`
|
||||
PopularityScore float64 `gorm:"type:decimal(10,4);default:0" json:"popularity_score"`
|
||||
MetaPrices string `gorm:"size:1000" json:"meta_prices"`
|
||||
MetaMethod string `gorm:"size:20" json:"meta_method"`
|
||||
MetaPeriodDays int `gorm:"default:90" json:"meta_period_days"`
|
||||
IsHidden bool `gorm:"default:false" json:"is_hidden"`
|
||||
|
||||
// Relations
|
||||
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`
|
||||
|
||||
@@ -16,11 +16,12 @@ func NewComponentRepository(db *gorm.DB) *ComponentRepository {
|
||||
}
|
||||
|
||||
type ComponentFilter struct {
|
||||
Category string
|
||||
Search string
|
||||
HasPrice bool
|
||||
SortField string
|
||||
SortDir string
|
||||
Category string
|
||||
Search string
|
||||
HasPrice bool
|
||||
ExcludeHidden bool
|
||||
SortField string
|
||||
SortDir string
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||
@@ -42,6 +43,9 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
|
||||
if filter.HasPrice {
|
||||
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
||||
}
|
||||
if filter.ExcludeHidden {
|
||||
query = query.Where("is_hidden = ? OR is_hidden IS NULL", false)
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
@@ -37,11 +38,17 @@ type CreateConfigRequest struct {
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
total := req.Items.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
config := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
@@ -51,6 +58,7 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
|
||||
CustomPrice: req.CustomPrice,
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(config); err != nil {
|
||||
@@ -89,12 +97,18 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
|
||||
|
||||
total := req.Items.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
config.Name = req.Name
|
||||
config.Items = req.Items
|
||||
config.TotalPrice = &total
|
||||
config.CustomPrice = req.CustomPrice
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
@@ -144,6 +158,11 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
|
||||
// Create copy with new UUID and name
|
||||
total := original.Items.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
if original.ServerCount > 1 {
|
||||
total *= float64(original.ServerCount)
|
||||
}
|
||||
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
@@ -153,6 +172,7 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false, // Clone is never a template
|
||||
ServerCount: original.ServerCount,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
@@ -186,6 +206,58 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config
|
||||
return s.configRepo.ListTemplates(offset, perPage)
|
||||
}
|
||||
|
||||
// RefreshPrices updates all component prices in the configuration with current prices
|
||||
func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if config.UserID != userID {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
// Update prices for all items
|
||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||
for i, item := range config.Items {
|
||||
// Get current component price
|
||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||
if err != nil || metadata.CurrentPrice == nil {
|
||||
// Keep original item if component not found or no price available
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
// Update item with current price
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *metadata.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
config.Items = updatedItems
|
||||
total := updatedItems.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
if config.ServerCount > 1 {
|
||||
total *= float64(config.ServerCount)
|
||||
}
|
||||
|
||||
config.TotalPrice = &total
|
||||
|
||||
// Set price update timestamp
|
||||
now := time.Now()
|
||||
config.PriceUpdatedAt = &now
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// // Export configuration as JSON
|
||||
// type ConfigExport struct {
|
||||
// Name string `json:"name"`
|
||||
|
||||
2
migrations/003_add_is_hidden.sql
Normal file
2
migrations/003_add_is_hidden.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add is_hidden column to qt_lot_metadata table
|
||||
ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator';
|
||||
4
migrations/004_add_price_updated_at.sql
Normal file
4
migrations/004_add_price_updated_at.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Add price_updated_at column to qt_configurations table
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL
|
||||
AFTER server_count;
|
||||
@@ -9,6 +9,7 @@
|
||||
<div class="flex gap-4">
|
||||
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
|
||||
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
|
||||
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
|
||||
</div>
|
||||
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
|
||||
Пересчитать цены
|
||||
@@ -76,13 +77,29 @@
|
||||
<input type="text" id="modal-lot-name" readonly class="w-full px-3 py-2 border rounded bg-gray-100">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2">
|
||||
<input type="checkbox" id="modal-meta-enabled" class="mr-2" onchange="toggleMetaPrice()">
|
||||
<span class="text-sm font-medium text-gray-700">Мета артикул</span>
|
||||
</div>
|
||||
<div id="meta-price-fields" class="hidden mt-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Источники цен</label>
|
||||
<input type="text" id="modal-meta-prices" class="w-full px-3 py-2 border rounded" placeholder="Артикулы через запятую (например: CPU_AMD_9654, MB_INTEL_4.Sapphire_2S)">
|
||||
<p class="text-xs text-gray-500 mt-1">Артикулы, чьи цены будут использоваться в расчете. Для автоматического подбора используйте * в конце (например: CPU_AMD_9654*)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Метод расчёта</label>
|
||||
<select id="modal-method" class="w-full px-3 py-2 border rounded">
|
||||
<select id="modal-method" class="w-full px-3 py-2 border rounded" onchange="onMethodChange()">
|
||||
<option value="median">Медиана</option>
|
||||
<option value="average">Среднее</option>
|
||||
<option value="manual">Установить цену вручную</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="manual-price-field" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Ручная цена (USD)</label>
|
||||
<input type="number" id="modal-manual-price" step="0.01" class="w-full px-3 py-2 border rounded" placeholder="Цена USD">
|
||||
<p class="text-xs text-gray-500 mt-1">Ручная цена сохраняется при пересчёте</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Период расчёта</label>
|
||||
@@ -101,13 +118,9 @@
|
||||
<p class="text-xs text-gray-500 mt-1">Например: 30 для +30%, -10 для -10%</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4">
|
||||
<label class="flex items-center mb-2">
|
||||
<input type="checkbox" id="modal-manual-enabled" class="mr-2" onchange="toggleManualPrice()">
|
||||
<span class="text-sm font-medium text-gray-700">Установить цену вручную</span>
|
||||
</label>
|
||||
<input type="number" id="modal-manual-price" step="0.01" class="w-full px-3 py-2 border rounded" placeholder="Цена USD" disabled>
|
||||
<p class="text-xs text-gray-500 mt-1">Ручная цена сохраняется при пересчёте</p>
|
||||
<div class="flex items-center pt-2 border-t">
|
||||
<input type="checkbox" id="modal-hidden" class="mr-2">
|
||||
<span class="text-sm font-medium text-gray-700">Скрыть из конфигуратора</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-3 rounded space-y-2">
|
||||
@@ -153,8 +166,22 @@ async function loadTab(tab) {
|
||||
|
||||
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||
document.getElementById('search-bar').className = tab === 'components' ? 'mb-4' : 'mb-4 hidden';
|
||||
document.getElementById('pagination').className = tab === 'components' ? 'flex justify-between items-center mt-4 pt-4 border-t' : 'hidden';
|
||||
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
|
||||
|
||||
// Show/hide elements based on tab
|
||||
if (tab === 'components') {
|
||||
document.getElementById('search-bar').className = 'mb-4';
|
||||
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
|
||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
|
||||
} else if (tab === 'all-configs') {
|
||||
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
|
||||
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
|
||||
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
|
||||
} else {
|
||||
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||
document.getElementById('pagination').className = 'hidden';
|
||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||
}
|
||||
|
||||
await loadData();
|
||||
}
|
||||
@@ -177,6 +204,21 @@ async function loadData() {
|
||||
if (resp.status === 403) { window.location.href = '/'; return; }
|
||||
const data = await resp.json();
|
||||
renderAlerts(data.alerts || []);
|
||||
} else if (currentTab === 'all-configs') {
|
||||
// Load all configurations for all users
|
||||
let url = '/api/configs?page=' + currentPage + '&per_page=' + perPage;
|
||||
if (currentSearch) {
|
||||
url += '&search=' + encodeURIComponent(currentSearch);
|
||||
}
|
||||
const resp = await fetch(url, {
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
});
|
||||
if (resp.status === 401) { logout(); return; }
|
||||
if (resp.status === 403) { window.location.href = '/'; return; }
|
||||
const data = await resp.json();
|
||||
totalPages = Math.ceil(data.total / perPage);
|
||||
renderAllConfigs(data.configurations || []);
|
||||
updatePagination(data.total);
|
||||
} else {
|
||||
let url = '/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
|
||||
if (currentSearch) {
|
||||
@@ -273,33 +315,77 @@ function renderComponents(components, total) {
|
||||
const desc = c.lot && c.lot.lot_description ? c.lot.lot_description : '—';
|
||||
const quoteCount = c.quote_count || 0;
|
||||
const popularity = c.popularity_score ? c.popularity_score.toFixed(2) : '0.00';
|
||||
const isHidden = c.is_hidden || quoteCount === 0;
|
||||
const usedInMeta = c.used_in_meta && c.used_in_meta.length > 0;
|
||||
|
||||
// Determine status indicator (colored dot)
|
||||
let dotColor, dotTitle;
|
||||
if (usedInMeta) {
|
||||
// Used as source for meta-articles - cyan
|
||||
dotColor = 'bg-cyan-500';
|
||||
dotTitle = 'Используется в мета: ' + c.used_in_meta.join(', ');
|
||||
} else if (!isHidden) {
|
||||
// Available in configurator - green
|
||||
dotColor = 'bg-green-500';
|
||||
dotTitle = 'Доступен в конфигураторе';
|
||||
} else {
|
||||
// Hidden and not used - gray
|
||||
dotColor = 'bg-gray-400';
|
||||
dotTitle = 'Скрыт из конфигуратора';
|
||||
}
|
||||
|
||||
// Build settings summary
|
||||
let settings = [];
|
||||
const method = c.price_method || 'median';
|
||||
settings.push(method === 'median' ? 'М' : 'С');
|
||||
const period = c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90;
|
||||
if (period === 7) settings.push('1н');
|
||||
else if (period === 30) settings.push('1м');
|
||||
else if (period === 90) settings.push('3м');
|
||||
else if (period === 365) settings.push('1г');
|
||||
else if (period === 0) settings.push('все');
|
||||
else settings.push(period + 'д');
|
||||
if (c.price_coefficient && c.price_coefficient !== 0) {
|
||||
settings.push((c.price_coefficient > 0 ? '+' : '') + c.price_coefficient + '%');
|
||||
}
|
||||
if (c.manual_price && c.manual_price > 0) {
|
||||
settings.push('РУЧН');
|
||||
let settingsHtml = '';
|
||||
|
||||
if (isHidden) {
|
||||
settingsHtml = '<span class="text-red-600 font-medium">СКРЫТ</span>';
|
||||
} else {
|
||||
let settings = [];
|
||||
const method = c.price_method || 'median';
|
||||
const hasManualPrice = c.manual_price && c.manual_price > 0;
|
||||
const hasMeta = c.meta_prices && c.meta_prices.trim() !== '';
|
||||
|
||||
// Method indicator
|
||||
if (hasManualPrice) {
|
||||
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
|
||||
} else if (method === 'average') {
|
||||
settings.push('Сред');
|
||||
} else {
|
||||
settings.push('Мед');
|
||||
}
|
||||
|
||||
// Period (only if not manual price)
|
||||
if (!hasManualPrice) {
|
||||
const period = c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90;
|
||||
if (period === 7) settings.push('1н');
|
||||
else if (period === 30) settings.push('1м');
|
||||
else if (period === 90) settings.push('3м');
|
||||
else if (period === 365) settings.push('1г');
|
||||
else if (period === 0) settings.push('все');
|
||||
else settings.push(period + 'д');
|
||||
}
|
||||
|
||||
// Coefficient
|
||||
if (c.price_coefficient && c.price_coefficient !== 0) {
|
||||
settings.push((c.price_coefficient > 0 ? '+' : '') + c.price_coefficient + '%');
|
||||
}
|
||||
|
||||
// Meta article indicator
|
||||
if (hasMeta) {
|
||||
settings.push('<span class="text-purple-600 font-medium">МЕТА</span>');
|
||||
}
|
||||
|
||||
settingsHtml = settings.join(' | ');
|
||||
}
|
||||
|
||||
html += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + idx + ')">';
|
||||
html += '<td class="px-3 py-2 text-sm font-mono">' + escapeHtml(c.lot_name) + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm font-mono"><span class="inline-flex items-center gap-2"><span class="w-2.5 h-2.5 rounded-full ' + dotColor + ' flex-shrink-0" title="' + escapeHtml(dotTitle) + '"></span>' + escapeHtml(c.lot_name) + '</span></td>';
|
||||
html += '<td class="px-3 py-2 text-sm">' + escapeHtml(category) + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate">' + escapeHtml(desc) + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-right">' + popularity + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-right">' + quoteCount + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-right font-medium">' + price + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">' + settings.join(' | ') + '</span></td>';
|
||||
html += '<td class="px-3 py-2 text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">' + settingsHtml + '</span></td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
@@ -320,14 +406,41 @@ function openModal(idx) {
|
||||
if (!c) return;
|
||||
|
||||
document.getElementById('modal-lot-name').value = c.lot_name;
|
||||
document.getElementById('modal-method').value = c.price_method || 'median';
|
||||
document.getElementById('modal-period').value = String(c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90);
|
||||
document.getElementById('modal-coefficient').value = c.price_coefficient || 0;
|
||||
|
||||
const hasManual = c.manual_price && c.manual_price > 0;
|
||||
document.getElementById('modal-manual-enabled').checked = hasManual;
|
||||
document.getElementById('modal-manual-price').value = hasManual ? c.manual_price : '';
|
||||
document.getElementById('modal-manual-price').disabled = !hasManual;
|
||||
if (hasManual) {
|
||||
document.getElementById('modal-method').value = 'manual';
|
||||
document.getElementById('modal-manual-price').value = c.manual_price;
|
||||
document.getElementById('manual-price-field').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('modal-method').value = c.price_method || 'median';
|
||||
document.getElementById('modal-manual-price').value = '';
|
||||
document.getElementById('manual-price-field').classList.add('hidden');
|
||||
}
|
||||
document.getElementById('modal-period').value = String(c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90);
|
||||
|
||||
// Load meta prices settings
|
||||
const hasMeta = c.meta_prices && c.meta_prices.trim() !== '';
|
||||
document.getElementById('modal-meta-enabled').checked = hasMeta;
|
||||
document.getElementById('modal-meta-prices').value = c.meta_prices || '';
|
||||
if (hasMeta) {
|
||||
document.getElementById('meta-price-fields').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('meta-price-fields').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Load hidden flag
|
||||
const quoteCount = c.quote_count || 0;
|
||||
const hiddenCheckbox = document.getElementById('modal-hidden');
|
||||
if (quoteCount === 0) {
|
||||
// Если нет котировок - чекбокс установлен и заблокирован
|
||||
hiddenCheckbox.checked = true;
|
||||
hiddenCheckbox.disabled = true;
|
||||
} else {
|
||||
hiddenCheckbox.checked = c.is_hidden || false;
|
||||
hiddenCheckbox.disabled = false;
|
||||
}
|
||||
|
||||
// Reset price displays while loading
|
||||
document.getElementById('modal-last-price').textContent = '...';
|
||||
@@ -343,6 +456,20 @@ function openModal(idx) {
|
||||
fetchPreview();
|
||||
}
|
||||
|
||||
function onMethodChange() {
|
||||
const method = document.getElementById('modal-method').value;
|
||||
const manualField = document.getElementById('manual-price-field');
|
||||
if (method === 'manual') {
|
||||
manualField.classList.remove('hidden');
|
||||
// При выборе "Установить цену вручную" снимаем галочку "Мета артикул"
|
||||
document.getElementById('modal-meta-enabled').checked = false;
|
||||
document.getElementById('meta-price-fields').classList.add('hidden');
|
||||
} else {
|
||||
manualField.classList.add('hidden');
|
||||
}
|
||||
fetchPreview();
|
||||
}
|
||||
|
||||
async function fetchPreview() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
@@ -351,6 +478,16 @@ async function fetchPreview() {
|
||||
const method = document.getElementById('modal-method').value;
|
||||
const periodDays = parseInt(document.getElementById('modal-period').value) || 0;
|
||||
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
|
||||
const metaEnabled = document.getElementById('modal-meta-enabled').checked;
|
||||
let metaPrices = '';
|
||||
let metaMethod = '';
|
||||
let metaPeriod = 0;
|
||||
|
||||
if (metaEnabled) {
|
||||
metaPrices = document.getElementById('modal-meta-prices').value.trim();
|
||||
metaMethod = method;
|
||||
metaPeriod = periodDays;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/admin/pricing/preview', {
|
||||
@@ -363,7 +500,11 @@ async function fetchPreview() {
|
||||
lot_name: lotName,
|
||||
method: method,
|
||||
period_days: periodDays,
|
||||
coefficient: coefficient
|
||||
coefficient: coefficient,
|
||||
meta_enabled: metaEnabled,
|
||||
meta_prices: metaPrices,
|
||||
meta_method: metaMethod,
|
||||
meta_period: metaPeriod
|
||||
})
|
||||
});
|
||||
|
||||
@@ -413,11 +554,24 @@ function closeModal() {
|
||||
document.getElementById('price-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function toggleManualPrice() {
|
||||
const enabled = document.getElementById('modal-manual-enabled').checked;
|
||||
document.getElementById('modal-manual-price').disabled = !enabled;
|
||||
if (!enabled) {
|
||||
document.getElementById('modal-manual-price').value = '';
|
||||
function toggleMetaPrice() {
|
||||
const enabled = document.getElementById('modal-meta-enabled').checked;
|
||||
const fields = document.getElementById('meta-price-fields');
|
||||
fields.classList.toggle('hidden', !enabled);
|
||||
|
||||
if (enabled) {
|
||||
// When enabling meta price, reset method to median if it was manual
|
||||
const method = document.getElementById('modal-method').value;
|
||||
if (method === 'manual') {
|
||||
document.getElementById('modal-method').value = 'median';
|
||||
document.getElementById('manual-price-field').classList.add('hidden');
|
||||
document.getElementById('modal-manual-price').value = '';
|
||||
}
|
||||
// Auto-fill with wildcard pattern
|
||||
const lotName = document.getElementById('modal-lot-name').value;
|
||||
if (lotName) {
|
||||
autoFillMetaPrices(lotName);
|
||||
}
|
||||
}
|
||||
fetchPreview();
|
||||
}
|
||||
@@ -441,15 +595,34 @@ async function savePrice() {
|
||||
const periodDaysStr = document.getElementById('modal-period').value;
|
||||
const periodDays = periodDaysStr !== '' ? parseInt(periodDaysStr) : 90;
|
||||
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
|
||||
const manualEnabled = document.getElementById('modal-manual-enabled').checked;
|
||||
const manualEnabled = method === 'manual';
|
||||
const manualPrice = manualEnabled ? parseFloat(document.getElementById('modal-manual-price').value) : null;
|
||||
const metaEnabled = document.getElementById('modal-meta-enabled').checked;
|
||||
const hiddenCheckbox = document.getElementById('modal-hidden');
|
||||
// Если чекбокс заблокирован (нет котировок), всегда true
|
||||
const isHidden = hiddenCheckbox.disabled ? true : hiddenCheckbox.checked;
|
||||
|
||||
let metaPrices = '';
|
||||
let metaMethod = '';
|
||||
let metaPeriod = 0;
|
||||
|
||||
if (metaEnabled) {
|
||||
metaPrices = document.getElementById('modal-meta-prices').value.trim();
|
||||
metaMethod = manualEnabled ? 'median' : method;
|
||||
metaPeriod = periodDays;
|
||||
}
|
||||
|
||||
const body = {
|
||||
lot_name: lotName,
|
||||
method: method,
|
||||
method: manualEnabled ? 'median' : method,
|
||||
period_days: periodDays,
|
||||
coefficient: coefficient,
|
||||
clear_manual: !manualEnabled
|
||||
clear_manual: !manualEnabled,
|
||||
meta_enabled: metaEnabled,
|
||||
meta_prices: metaPrices,
|
||||
meta_method: metaMethod,
|
||||
meta_period: metaPeriod,
|
||||
is_hidden: isHidden
|
||||
};
|
||||
|
||||
if (manualEnabled && manualPrice > 0) {
|
||||
@@ -480,6 +653,35 @@ async function savePrice() {
|
||||
}
|
||||
}
|
||||
|
||||
// Function to process meta prices and handle regex patterns
|
||||
function processMetaPrices(metaPrices, originalLotName) {
|
||||
if (!metaPrices) return [];
|
||||
|
||||
// Split by comma and clean up
|
||||
let lots = metaPrices.split(',').map(lot => lot.trim()).filter(lot => lot.length > 0);
|
||||
|
||||
// Handle wildcard patterns (ending with *)
|
||||
const processedLots = [];
|
||||
const originalPrefix = originalLotName.split('_')[0] + '_'; // Get first part like "CPU_" from "CPU_AMD_9654"
|
||||
|
||||
lots.forEach(lot => {
|
||||
if (lot.endsWith('*')) {
|
||||
// Wildcard pattern - find all components that start with the prefix
|
||||
const prefix = lot.slice(0, -1); // Remove the *
|
||||
// In real implementation, this would be handled by backend
|
||||
// For now, we'll just add the prefix as is to indicate it's a pattern
|
||||
processedLots.push(prefix + '*');
|
||||
} else {
|
||||
// Regular component name
|
||||
processedLots.push(lot);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove duplicates and original lot name
|
||||
const uniqueLots = [...new Set(processedLots)];
|
||||
return uniqueLots.filter(lot => lot !== originalLotName);
|
||||
}
|
||||
|
||||
function recalculateAll() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
@@ -544,10 +746,10 @@ function recalculateAll() {
|
||||
progressText.textContent = 'Пересчёт завершён!';
|
||||
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
||||
} else {
|
||||
progressText.textContent = 'Обработка компонентов...';
|
||||
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
|
||||
}
|
||||
|
||||
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ручные: ' + (data.manual || 0) + ' | Нет данных: ' + (data.skipped || 0) + ' | Ошибок: ' + (data.errors || 0);
|
||||
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Без изменений: ' + (data.unchanged || 0) + ' | Ручные: ' + (data.manual || 0) + ' | Нет данных: ' + (data.skipped || 0) + ' | Ошибок: ' + (data.errors || 0);
|
||||
} catch(e) {
|
||||
console.log('Parse error:', e, line);
|
||||
}
|
||||
@@ -588,13 +790,65 @@ function toggleSortDir() {
|
||||
loadData();
|
||||
}
|
||||
|
||||
// Render all configurations for admin view
|
||||
function renderAllConfigs(configs) {
|
||||
if (configs.length === 0) {
|
||||
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет конфигураций</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="overflow-x-auto"><table class="w-full"><thead class="bg-gray-50"><tr>';
|
||||
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
|
||||
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Пользователь</th>';
|
||||
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Серверы</th>';
|
||||
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '</tr></thead><tbody class="divide-y">';
|
||||
|
||||
configs.forEach(c => {
|
||||
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
||||
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
|
||||
const serverCount = c.server_count ? c.server_count : 1;
|
||||
const username = c.user ? c.user.username : '—';
|
||||
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm font-medium">' + escapeHtml(c.name) + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-gray-500">' + username + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-gray-500">' + serverCount + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-right">' + total + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-right space-x-2">';
|
||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" 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">';
|
||||
html += '<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>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<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>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="Удалить">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<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>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
html += '</td></tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
document.getElementById('tab-content').innerHTML = html;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTab('alerts');
|
||||
|
||||
// Add event listeners for preview updates
|
||||
document.getElementById('modal-method').addEventListener('change', fetchPreview);
|
||||
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
||||
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
|
||||
document.getElementById('modal-manual-price').addEventListener('input', debounceFetchPreview);
|
||||
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -4,15 +4,24 @@
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
||||
|
||||
<div id="configs-list">
|
||||
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button onclick="openCreateModal()" class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
+ Создать новую конфигурацию
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="configs-list">
|
||||
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="flex justify-between items-center mt-4 pt-4 border-t hidden">
|
||||
<span id="page-info" class="text-sm text-gray-600"></span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="prevPage()" id="btn-prev" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Назад</button>
|
||||
<button onclick="nextPage()" id="btn-next" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Вперед</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for creating new configuration -->
|
||||
@@ -132,8 +141,10 @@ function renderConfigs(configs) {
|
||||
|
||||
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">Цена (за 1 шт)</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-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '</tr></thead><tbody class="divide-y">';
|
||||
@@ -141,14 +152,37 @@ function renderConfigs(configs) {
|
||||
configs.forEach(c => {
|
||||
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
||||
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
|
||||
const serverCount = c.server_count ? c.server_count : 1;
|
||||
|
||||
// Calculate price per unit (total / server count)
|
||||
let pricePerUnit = '—';
|
||||
if (c.total_price && serverCount > 0) {
|
||||
const unitPrice = c.total_price / serverCount;
|
||||
pricePerUnit = '$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||
}
|
||||
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800">Копировать</button>';
|
||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800">Переименовать</button>';
|
||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800">Удалить</button>';
|
||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" 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">';
|
||||
html += '<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>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<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>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="Удалить">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<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>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
html += '</td></tr>';
|
||||
});
|
||||
|
||||
@@ -322,7 +356,8 @@ async function createConfig() {
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
items: [],
|
||||
notes: ''
|
||||
notes: '',
|
||||
server_count: 1
|
||||
})
|
||||
});
|
||||
|
||||
@@ -386,6 +421,69 @@ document.getElementById('clone-input').addEventListener('keydown', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
// Pagination functions
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let perPage = 20;
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
loadConfigs();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
loadConfigs();
|
||||
}
|
||||
}
|
||||
|
||||
function updatePagination(total) {
|
||||
totalPages = Math.ceil(total / perPage);
|
||||
document.getElementById('page-info').textContent =
|
||||
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
|
||||
document.getElementById('btn-prev').disabled = currentPage <= 1;
|
||||
document.getElementById('btn-next').disabled = currentPage >= totalPages;
|
||||
document.getElementById('pagination').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Load configs with pagination
|
||||
async function loadConfigs() {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (!token) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center"><a href="/login" class="text-blue-600">Войдите для просмотра</a></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage, {
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp.status === 403) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Нет доступа</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
renderConfigs(data.configurations || []);
|
||||
updatePagination(data.total);
|
||||
} catch(e) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadConfigs);
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -14,10 +14,29 @@
|
||||
<span id="config-name">Конфигуратор</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div id="save-buttons" class="hidden space-x-2">
|
||||
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
||||
<button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Пересчитать цену
|
||||
</button>
|
||||
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||
Сохранить
|
||||
</button>
|
||||
<span id="price-update-date" class="text-sm text-gray-500"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server count input -->
|
||||
<div class="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Количество серверов</label>
|
||||
<input type="number" id="server-count" min="1" value="1"
|
||||
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="updateServerCount()">
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,6 +223,7 @@ let allComponents = [];
|
||||
let cart = [];
|
||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||
let serverCount = 1; // Server count for the configuration
|
||||
|
||||
// Autocomplete state
|
||||
let autocompleteInput = null;
|
||||
@@ -280,6 +300,11 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
document.getElementById('config-name').textContent = config.name;
|
||||
document.getElementById('save-buttons').classList.remove('hidden');
|
||||
|
||||
// Set server count from config
|
||||
serverCount = config.server_count || 1;
|
||||
document.getElementById('server-count').value = serverCount;
|
||||
document.getElementById('total-server-count').textContent = serverCount;
|
||||
|
||||
if (config.items && config.items.length > 0) {
|
||||
cart = config.items.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
@@ -294,6 +319,11 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
if (config.custom_price) {
|
||||
document.getElementById('custom-price-input').value = config.custom_price.toFixed(2);
|
||||
}
|
||||
|
||||
// Display price update date if available
|
||||
if (config.price_updated_at) {
|
||||
updatePriceUpdateDate(config.price_updated_at);
|
||||
}
|
||||
} catch(e) {
|
||||
showToast('Ошибка загрузки конфигурации', 'error');
|
||||
window.location.href = '/configs';
|
||||
@@ -323,6 +353,22 @@ async function loadAllComponents() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateServerCount() {
|
||||
const serverCountInput = document.getElementById('server-count');
|
||||
const newCount = parseInt(serverCountInput.value) || 1;
|
||||
serverCount = Math.max(1, newCount);
|
||||
serverCountInput.value = serverCount;
|
||||
|
||||
// Update total server count display
|
||||
document.getElementById('total-server-count').textContent = serverCount;
|
||||
|
||||
// Update cart UI to reflect the server count
|
||||
updateCartUI();
|
||||
|
||||
// Trigger auto-save
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function getCategoryFromLotName(lotName) {
|
||||
const parts = lotName.split('_');
|
||||
return parts[0] || '';
|
||||
@@ -1081,6 +1127,9 @@ async function saveConfig(showNotification = true) {
|
||||
const customPriceValue = parseFloat(customPriceInput.value);
|
||||
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
||||
|
||||
// Get server count
|
||||
const serverCountValue = serverCount;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID, {
|
||||
method: 'PUT',
|
||||
@@ -1092,7 +1141,8 @@ async function saveConfig(showNotification = true) {
|
||||
name: configName,
|
||||
items: cart,
|
||||
custom_price: customPrice,
|
||||
notes: ''
|
||||
notes: '',
|
||||
server_count: serverCountValue
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1257,6 +1307,86 @@ async function exportCSVWithCustomPrice() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPrices() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || !configUUID) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
showToast('Ошибка обновления цен', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await resp.json();
|
||||
|
||||
// Update cart with new prices
|
||||
if (config.items && config.items.length > 0) {
|
||||
cart = config.items.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
description: item.description || '',
|
||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||
}));
|
||||
}
|
||||
|
||||
// Update price update date
|
||||
if (config.price_updated_at) {
|
||||
updatePriceUpdateDate(config.price_updated_at);
|
||||
}
|
||||
|
||||
// Re-render UI
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
|
||||
showToast('Цены обновлены', 'success');
|
||||
} catch(e) {
|
||||
showToast('Ошибка обновления цен', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePriceUpdateDate(dateStr) {
|
||||
if (!dateStr) {
|
||||
document.getElementById('price-update-date').textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
let timeAgo;
|
||||
if (diffMins < 1) {
|
||||
timeAgo = 'только что';
|
||||
} else if (diffMins < 60) {
|
||||
timeAgo = diffMins + ' мин. назад';
|
||||
} else if (diffHours < 24) {
|
||||
timeAgo = diffHours + ' ч. назад';
|
||||
} else if (diffDays < 7) {
|
||||
timeAgo = diffDays + ' дн. назад';
|
||||
} else {
|
||||
timeAgo = date.toLocaleDateString('ru-RU');
|
||||
}
|
||||
|
||||
document.getElementById('price-update-date').textContent = 'Обновлено: ' + timeAgo;
|
||||
}
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user