Compare commits
23 Commits
v1.0.0
...
0bde12a39d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bde12a39d | ||
|
|
e0404186ad | ||
|
|
eda0e7cb47 | ||
|
|
693c1d05d7 | ||
|
|
7fb9dd0267 | ||
|
|
61646bea46 | ||
|
|
9495f929aa | ||
|
|
b80bde7dac | ||
|
|
e307a2765d | ||
|
|
6f1feb942a | ||
|
|
236e37376e | ||
|
|
ded6e09b5e | ||
|
|
96bbe0a510 | ||
|
|
b672cbf27d | ||
|
|
e206531364 | ||
|
|
9bd2acd4f7 | ||
| ec3c16f3fc | |||
| 1f739a3ab2 | |||
| be77256d4e | |||
| 143d217397 | |||
| 8b8d2f18f9 | |||
| 8c1c8ccace | |||
| f31ae69233 |
@@ -1,33 +0,0 @@
|
|||||||
# Git
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
server
|
|
||||||
*.exe
|
|
||||||
bin/
|
|
||||||
|
|
||||||
# Config with secrets
|
|
||||||
config.yaml
|
|
||||||
|
|
||||||
# Documentation
|
|
||||||
*.md
|
|
||||||
LICENSE
|
|
||||||
|
|
||||||
# Claude
|
|
||||||
.claude
|
|
||||||
|
|
||||||
# Test files
|
|
||||||
*_test.go
|
|
||||||
test_*.csv
|
|
||||||
test_*.xlsx
|
|
||||||
|
|
||||||
# Misc
|
|
||||||
.DS_Store
|
|
||||||
*.log
|
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,6 +1,18 @@
|
|||||||
# QuoteForge
|
# QuoteForge
|
||||||
config.yaml
|
config.yaml
|
||||||
|
|
||||||
|
# Local SQLite database (contains encrypted credentials)
|
||||||
|
/data/*.db
|
||||||
|
/data/*.db-journal
|
||||||
|
/data/*.db-shm
|
||||||
|
/data/*.db-wal
|
||||||
|
|
||||||
|
# Binaries
|
||||||
|
/server
|
||||||
|
/importer
|
||||||
|
/cron
|
||||||
|
/bin/
|
||||||
|
|
||||||
# ---> macOS
|
# ---> macOS
|
||||||
# General
|
# General
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -8,7 +20,7 @@ config.yaml
|
|||||||
.LSOverride
|
.LSOverride
|
||||||
|
|
||||||
# Icon must end with two \r
|
# Icon must end with two \r
|
||||||
Icon
|
Icon
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
._*
|
._*
|
||||||
|
|||||||
487
CLAUDE.md
487
CLAUDE.md
@@ -1,388 +1,149 @@
|
|||||||
# QuoteForge - Claude Code Instructions
|
# QuoteForge - Claude Code Instructions
|
||||||
|
|
||||||
## Project Overview
|
## Overview
|
||||||
|
Корпоративный конфигуратор серверов и формирование КП. MariaDB (RFQ_LOG) + SQLite для оффлайн.
|
||||||
|
|
||||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG.
|
## Development Phases
|
||||||
|
|
||||||
|
### Phase 1: Pricelists in MariaDB ✅ DONE
|
||||||
|
### Phase 2: Local SQLite Database ✅ DONE
|
||||||
|
|
||||||
|
### Phase 2.5: Full Offline Mode 🔶 IN PROGRESS
|
||||||
|
**Local-first architecture:** приложение ВСЕГДА работает с SQLite, MariaDB только для синхронизации.
|
||||||
|
|
||||||
|
**Принцип работы:**
|
||||||
|
- ВСЕ операции (CRUD) выполняются в SQLite
|
||||||
|
- При создании конфигурации:
|
||||||
|
1. Если online → проверить новые прайслисты на сервере → скачать если есть
|
||||||
|
2. Далее работаем с local_pricelists (и online, и offline одинаково)
|
||||||
|
- Background sync: push pending_changes → pull updates
|
||||||
|
|
||||||
|
**DONE:**
|
||||||
|
- ✅ Sync queue table (pending_changes) - `internal/localdb/models.go`
|
||||||
|
- ✅ Model converters: MariaDB ↔ SQLite - `internal/localdb/converters.go`
|
||||||
|
- ✅ LocalConfigurationService: все CRUD через SQLite - `internal/services/local_configuration.go`
|
||||||
|
- ✅ Pre-create pricelist check: `SyncPricelistsIfNeeded()` - `internal/services/sync/service.go`
|
||||||
|
- ✅ Push pending changes: `PushPendingChanges()` - sync service + handlers
|
||||||
|
- ✅ Sync API endpoints: `/api/sync/push`, `/pending/count`, `/pending`
|
||||||
|
- ✅ Integrate LocalConfigurationService in main.go (replace ConfigurationService)
|
||||||
|
- ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`)
|
||||||
|
- ✅ ConfigurationGetter interface for handler compatibility
|
||||||
|
- ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go`
|
||||||
|
- ✅ UI: sync status indicator (pending badge + sync button + offline/online dot) - `web/templates/partials/sync_status.html`
|
||||||
|
- ✅ RefreshPrices for local mode:
|
||||||
|
- `RefreshPrices()` / `RefreshPricesNoAuth()` в `local_configuration.go`
|
||||||
|
- Берёт цены из `local_components.current_price`
|
||||||
|
- Graceful degradation при отсутствии компонента
|
||||||
|
- Добавлено поле `price_updated_at` в `LocalConfiguration` (models.go:72)
|
||||||
|
- Обновлены converters для PriceUpdatedAt
|
||||||
|
- UI кнопка "Пересчитать цену" работает offline/online
|
||||||
|
- ✅ Fixed sync bugs:
|
||||||
|
- Duplicate entry error при update конфигураций (`sync/service.go:334-365`)
|
||||||
|
- pushConfigurationUpdate теперь проверяет наличие server_id перед update
|
||||||
|
- Если нет ID → получает из LocalConfiguration.ServerID или ищет на сервере
|
||||||
|
- Fixed setup.go: `settings.Password` → `settings.PasswordEncrypted`
|
||||||
|
|
||||||
|
**TODO:**
|
||||||
|
- ❌ Conflict resolution (Phase 4, last-write-wins default)
|
||||||
|
|
||||||
|
### UI Improvements 🔶 IN PROGRESS
|
||||||
|
|
||||||
|
**1. Sync icon + pricelist badge в header (tasks 4+2):**
|
||||||
|
- ❌ `sync_status.html`: заменить текст Online/Offline на SVG иконку
|
||||||
|
- ❌ Кнопка sync → иконка (circular arrows) вместо текста
|
||||||
|
- ❌ Dropdown при клике: Push changes, Full sync, статус последней синхронизации
|
||||||
|
- ❌ `configs.html`: рядом с кнопкой "Создать" показать badge с версией активного прайслиста
|
||||||
|
- ❌ Загружать через `/api/pricelists/latest` при DOMContentLoaded
|
||||||
|
|
||||||
|
**2. Прайслисты → вкладка в "Администратор цен" (task 1):**
|
||||||
|
- ❌ `base.html`: убрать отдельную ссылку "Прайслисты" из навигации
|
||||||
|
- ❌ `admin_pricing.html`: добавить 4-ю вкладку "Прайслисты"
|
||||||
|
- ❌ Перенести логику из `pricelists.html` (table, create modal, CRUD) в эту вкладку
|
||||||
|
- ❌ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists` или удалить
|
||||||
|
|
||||||
|
**3. Страница настроек: расширить + синхронизация (task 3):**
|
||||||
|
- ❌ `setup.html`: переделать на `{{template "base" .}}` структуру
|
||||||
|
- ❌ Увеличить до `max-w-4xl`, разделить на 2 секции
|
||||||
|
- ❌ Секция A: Подключение к БД (текущая форма)
|
||||||
|
- ❌ Секция B: Синхронизация данных:
|
||||||
|
- Статус Online/Offline
|
||||||
|
- Кнопки: "Синхронизировать всё", "Обновить компоненты", "Обновить прайслисты"
|
||||||
|
- Журнал синхронизации (последние N операций)
|
||||||
|
- ❌ Возможно: новый API endpoint для sync log
|
||||||
|
|
||||||
|
### Phase 3: Projects and Specifications
|
||||||
|
- qt_projects, qt_specifications tables (MariaDB)
|
||||||
|
- Replace qt_configurations → Project/Specification hierarchy
|
||||||
|
- Fields: opty, customer_requirement, variant, qty, rev
|
||||||
|
- Local projects/specs with server sync
|
||||||
|
|
||||||
|
### Phase 4: Price Versioning
|
||||||
|
- Bind specifications to pricelist versions
|
||||||
|
- Price diff comparison
|
||||||
|
- Auto-cleanup expired pricelists (>1 year, usage_count=0)
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind CDN | excelize
|
||||||
|
|
||||||
- **Language:** Go 1.22+
|
## Key Tables
|
||||||
- **Web Framework:** Gin (github.com/gin-gonic/gin)
|
|
||||||
- **ORM:** GORM (gorm.io/gorm)
|
|
||||||
- **Database:** MariaDB 11 (existing database RFQ_LOG)
|
|
||||||
- **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
|
### READ-ONLY (external systems)
|
||||||
|
- `lot` (lot_name PK, lot_description)
|
||||||
|
- `lot_log` (lot, supplier, date, price, quality, comments)
|
||||||
|
- `supplier` (supplier_name PK)
|
||||||
|
|
||||||
```
|
### MariaDB (qt_* prefix)
|
||||||
quoteforge/
|
- `qt_lot_metadata` - component prices, methods, popularity
|
||||||
├── cmd/
|
- `qt_categories` - category codes and names
|
||||||
│ ├── server/main.go # Main HTTP server
|
- `qt_pricelists` - version snapshots (YYYY-MM-DD-NNN format)
|
||||||
│ └── importer/main.go # Import metadata from lot table
|
- `qt_pricelist_items` - prices per pricelist
|
||||||
├── internal/
|
- `qt_projects` - uuid, opty, customer_requirement, name (Phase 3)
|
||||||
│ ├── config/config.go # YAML config loading
|
- `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3)
|
||||||
│ ├── models/ # GORM models
|
|
||||||
│ ├── handlers/ # Gin HTTP handlers
|
|
||||||
│ ├── services/ # Business logic
|
|
||||||
│ ├── middleware/ # Auth, CORS, roles
|
|
||||||
│ └── repository/ # Database queries
|
|
||||||
├── web/
|
|
||||||
│ ├── templates/ # Go HTML templates
|
|
||||||
│ └── static/ # CSS, JS
|
|
||||||
├── migrations/ # SQL migration files
|
|
||||||
├── config.yaml
|
|
||||||
└── go.mod
|
|
||||||
```
|
|
||||||
|
|
||||||
## Existing Database Tables (READ-ONLY - DO NOT MODIFY)
|
### SQLite (data/quoteforge.db)
|
||||||
|
- `connection_settings` - encrypted DB credentials (PasswordEncrypted field)
|
||||||
|
- `local_pricelists/items` - cached from server
|
||||||
|
- `local_components` - lot cache for offline search (with current_price)
|
||||||
|
- `local_configurations` - UUID, items, price_updated_at, sync_status (pending/synced/conflict), server_id
|
||||||
|
- `local_projects/specifications` - Phase 3
|
||||||
|
- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at, attempts, last_error)
|
||||||
|
|
||||||
These tables are used by other systems. Our app only reads from them:
|
## Business Logic
|
||||||
|
|
||||||
```sql
|
**Part number parsing:** `CPU_AMD_9654` → category=`CPU`, model=`AMD_9654`
|
||||||
-- Component catalog
|
|
||||||
CREATE TABLE lot (
|
|
||||||
lot_name CHAR(255) PRIMARY KEY, -- e.g., "CPU_AMD_9654", "MB_INTEL_4.Sapphire_2S"
|
|
||||||
lot_description VARCHAR(10000)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Price history from suppliers
|
**Price methods:** manual | median | average | weighted_median
|
||||||
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
|
|
||||||
date DATE NOT NULL,
|
|
||||||
price DOUBLE NOT NULL,
|
|
||||||
quality CHAR(255),
|
|
||||||
comments VARCHAR(15000),
|
|
||||||
FOREIGN KEY (lot) REFERENCES lot(lot_name),
|
|
||||||
FOREIGN KEY (supplier) REFERENCES supplier(supplier_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Supplier catalog
|
**Price freshness:** fresh (<30d, ≥3 quotes) | normal (<60d) | stale (<90d) | critical
|
||||||
CREATE TABLE supplier (
|
|
||||||
supplier_name CHAR(255) PRIMARY KEY,
|
|
||||||
supplier_comment VARCHAR(10000)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## New Tables (prefix qt_)
|
**Pricelist version:** `YYYY-MM-DD-NNN` (e.g., `2024-01-31-001`)
|
||||||
|
|
||||||
QuoteForge creates these 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,
|
|
||||||
model VARCHAR(100), -- Parsed: CPU_AMD_9654 → "9654"
|
|
||||||
specs JSON,
|
|
||||||
current_price DECIMAL(12,2),
|
|
||||||
price_method ENUM('manual', 'median', 'average', 'weighted_median') DEFAULT 'median',
|
|
||||||
price_period_days INT DEFAULT 90,
|
|
||||||
price_updated_at TIMESTAMP,
|
|
||||||
request_count INT DEFAULT 0,
|
|
||||||
last_request_date DATE,
|
|
||||||
popularity_score DECIMAL(10,4),
|
|
||||||
FOREIGN KEY (lot_name) REFERENCES lot(lot_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 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
|
|
||||||
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,
|
|
||||||
quotes_total INT DEFAULT 0,
|
|
||||||
quotes_last_30d INT DEFAULT 0,
|
|
||||||
quotes_last_7d INT DEFAULT 0,
|
|
||||||
total_quantity INT DEFAULT 0,
|
|
||||||
total_revenue DECIMAL(14,2) DEFAULT 0,
|
|
||||||
trend_direction ENUM('up', 'stable', 'down') DEFAULT 'stable',
|
|
||||||
trend_percent DECIMAL(5,2) DEFAULT 0,
|
|
||||||
last_used_at TIMESTAMP
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Business Logic
|
|
||||||
|
|
||||||
### 1. Part Number Parsing
|
|
||||||
|
|
||||||
Extract category and model from lot_name:
|
|
||||||
```go
|
|
||||||
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
|
|
||||||
// "MB_INTEL_4.Sapphire_2S" → category="MB", model="INTEL_4.Sapphire_2S"
|
|
||||||
// "MEM_DDR5_64G_5600" → category="MEM", model="DDR5_64G_5600"
|
|
||||||
// "GPU_NV_RTX_4090_PCIe" → category="GPU", model="NV_RTX_4090_PCIe"
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```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)
|
|
||||||
|
|
||||||
```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
|
|
||||||
} else if daysSinceUpdate < 60 {
|
|
||||||
return "normal" // yellow
|
|
||||||
} else if daysSinceUpdate < 90 {
|
|
||||||
return "stale" // orange
|
|
||||||
}
|
|
||||||
return "critical" // red
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
- **no_recent_quotes** (MEDIUM): popular component, no supplier quotes > 90 days
|
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### Auth
|
| Group | Endpoints |
|
||||||
```
|
|-------|-----------|
|
||||||
POST /api/auth/login → {"username", "password"} → {"token", "refresh_token"}
|
| Setup | GET/POST /setup, POST /setup/test |
|
||||||
POST /api/auth/logout
|
| Components | GET /api/components, /api/categories |
|
||||||
POST /api/auth/refresh
|
| Pricelists | CRUD /api/pricelists, GET /latest, POST /compare |
|
||||||
GET /api/auth/me → current user info
|
| Projects | CRUD /api/projects/:uuid (Phase 3) |
|
||||||
```
|
| Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) |
|
||||||
|
| Configs | POST /:uuid/refresh-prices (обновить цены из local_components) |
|
||||||
### Components
|
| Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict |
|
||||||
```
|
| Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export |
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Quote Builder
|
|
||||||
```
|
|
||||||
POST /api/quote/validate → {"items": [...]} → {"valid": bool, "errors": [], "warnings": []}
|
|
||||||
POST /api/quote/calculate → {"items": [...]} → {"items": [...], "total": 45000.00}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### htmx Partials
|
|
||||||
```
|
|
||||||
GET /partials/components?category=CPU&vendor=AMD → HTML fragment
|
|
||||||
GET /partials/cart → cart HTML
|
|
||||||
GET /partials/summary → price summary HTML
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
- Use **htmx** for interactivity (hx-get, hx-post, hx-target, hx-swap)
|
|
||||||
- Use **Tailwind CSS** via CDN
|
|
||||||
- Minimal custom JavaScript
|
|
||||||
- Color scheme for price freshness:
|
|
||||||
- `text-green-600 bg-green-50` - fresh
|
|
||||||
- `text-yellow-600 bg-yellow-50` - normal
|
|
||||||
- `text-orange-600 bg-orange-50` - stale
|
|
||||||
- `text-red-600 bg-red-50` - critical
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run development server
|
go run ./cmd/server # Dev server
|
||||||
go run ./cmd/server
|
go run ./cmd/cron -job=X # cleanup-pricelists | update-prices | update-popularity
|
||||||
|
|
||||||
# Run importer (one-time setup)
|
|
||||||
go run ./cmd/importer
|
|
||||||
|
|
||||||
# Run cron jobs manually
|
|
||||||
go run ./cmd/cron -job=alerts # Check and generate alerts
|
|
||||||
go run ./cmd/cron -job=update-prices # Recalculate all prices
|
|
||||||
go run ./cmd/cron -job=reset-counters # Reset usage counters
|
|
||||||
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 ./cmd/server
|
||||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge-cron ./cmd/cron
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
go test ./...
|
|
||||||
````
|
|
||||||
|
|
||||||
## Cron Jobs
|
|
||||||
|
|
||||||
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 * * *)
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
- gofmt, structured logging (slog), wrap errors with context
|
||||||
|
- snake_case files, PascalCase types
|
||||||
|
- RBAC disabled: DB username = user_id via `models.EnsureDBUser()`
|
||||||
|
|
||||||
- Use standard Go formatting (gofmt)
|
## UI Guidelines
|
||||||
- Error handling: always check errors, wrap with context
|
- htmx (hx-get/post/target/swap), Tailwind CDN
|
||||||
- Logging: use structured logging (slog or zerolog)
|
- Freshness colors: green (fresh) → yellow → orange → red (critical)
|
||||||
- Comments: in Russian or English, be consistent
|
- Sync status + offline indicator in header
|
||||||
- File naming: snake_case for files, PascalCase for types
|
|
||||||
|
|||||||
68
Dockerfile
68
Dockerfile
@@ -1,68 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM golang:1.24-alpine AS builder
|
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates tzdata
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy go mod files first for better caching
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the main binary
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
|
||||||
-ldflags="-s -w" \
|
|
||||||
-o /app/quoteforge \
|
|
||||||
./cmd/server
|
|
||||||
|
|
||||||
# Build the cron binary
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
|
||||||
-ldflags="-s -w" \
|
|
||||||
-o /app/quoteforge-cron \
|
|
||||||
./cmd/cron
|
|
||||||
|
|
||||||
# Final stage
|
|
||||||
FROM alpine:3.19
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata cron
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN adduser -D -g '' appuser
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy binary from builder
|
|
||||||
COPY --from=builder /app/quoteforge .
|
|
||||||
COPY --from=builder /app/quoteforge-cron .
|
|
||||||
|
|
||||||
# Copy cron job configuration
|
|
||||||
COPY crontab /etc/crontabs/appuser
|
|
||||||
RUN chmod 0600 /etc/crontabs/appuser
|
|
||||||
|
|
||||||
# Create log directory
|
|
||||||
RUN mkdir -p /var/log/cron
|
|
||||||
|
|
||||||
# Copy web templates and static files
|
|
||||||
COPY --from=builder /app/web ./web
|
|
||||||
|
|
||||||
# Copy migrations
|
|
||||||
COPY --from=builder /app/migrations ./migrations
|
|
||||||
|
|
||||||
# Copy example config (actual config should be mounted)
|
|
||||||
COPY --from=builder /app/config.example.yaml ./config.example.yaml
|
|
||||||
|
|
||||||
# Set ownership
|
|
||||||
RUN chown -R appuser:appuser /app
|
|
||||||
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
|
||||||
|
|
||||||
ENTRYPOINT ["/app/quoteforge"]
|
|
||||||
178
LOCAL_FIRST_INTEGRATION.md
Normal file
178
LOCAL_FIRST_INTEGRATION.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Local-First Architecture Integration Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
QuoteForge теперь поддерживает local-first архитектуру: приложение ВСЕГДА работает с SQLite (localdb), MariaDB используется только для синхронизации.
|
||||||
|
|
||||||
|
## Реализованные компоненты
|
||||||
|
|
||||||
|
### 1. Конвертеры моделей (`internal/localdb/converters.go`)
|
||||||
|
|
||||||
|
Конвертеры между MariaDB и SQLite моделями:
|
||||||
|
- `ConfigurationToLocal()` / `LocalToConfiguration()`
|
||||||
|
- `PricelistToLocal()` / `LocalToPricelist()`
|
||||||
|
- `ComponentToLocal()` / `LocalToComponent()`
|
||||||
|
|
||||||
|
### 2. LocalDB методы (`internal/localdb/localdb.go`)
|
||||||
|
|
||||||
|
Добавлены методы для работы с pending changes:
|
||||||
|
- `MarkChangesSynced(ids []int64)` - помечает изменения как синхронизированные
|
||||||
|
- `GetPendingCount()` - возвращает количество несинхронизированных изменений
|
||||||
|
|
||||||
|
### 3. Sync Service расширения (`internal/services/sync/service.go`)
|
||||||
|
|
||||||
|
Новые методы:
|
||||||
|
- `SyncPricelistsIfNeeded()` - проверяет и скачивает новые прайслисты при необходимости
|
||||||
|
- `PushPendingChanges()` - отправляет все pending changes на сервер
|
||||||
|
- `pushSingleChange()` - обрабатывает один pending change
|
||||||
|
- `pushConfigurationCreate/Update/Delete()` - специфичные методы для конфигураций
|
||||||
|
|
||||||
|
**ВАЖНО**: Конструктор изменен - теперь требует `ConfigurationRepository`:
|
||||||
|
```go
|
||||||
|
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. LocalConfigurationService (`internal/services/local_configuration.go`)
|
||||||
|
|
||||||
|
Новый сервис для работы с конфигурациями в local-first режиме:
|
||||||
|
- Все операции CRUD работают через SQLite
|
||||||
|
- Автоматически добавляет изменения в pending_changes
|
||||||
|
- При создании конфигурации (если online) проверяет новые прайслисты
|
||||||
|
|
||||||
|
```go
|
||||||
|
localConfigService := services.NewLocalConfigurationService(
|
||||||
|
localDB,
|
||||||
|
syncService,
|
||||||
|
quoteService,
|
||||||
|
isOnlineFunc,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Sync Handler расширения (`internal/handlers/sync.go`)
|
||||||
|
|
||||||
|
Новые endpoints:
|
||||||
|
- `POST /api/sync/push` - отправить pending changes на сервер
|
||||||
|
- `GET /api/sync/pending/count` - получить количество pending changes
|
||||||
|
- `GET /api/sync/pending` - получить список pending changes
|
||||||
|
|
||||||
|
## Интеграция
|
||||||
|
|
||||||
|
### Шаг 1: Обновить main.go
|
||||||
|
|
||||||
|
```go
|
||||||
|
// В cmd/server/main.go
|
||||||
|
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
||||||
|
|
||||||
|
// Создать isOnline функцию
|
||||||
|
isOnlineFunc := func() bool {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return sqlDB.Ping() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создать LocalConfigurationService
|
||||||
|
localConfigService := services.NewLocalConfigurationService(
|
||||||
|
local,
|
||||||
|
syncService,
|
||||||
|
quoteService,
|
||||||
|
isOnlineFunc,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 2: Обновить ConfigurationHandler
|
||||||
|
|
||||||
|
Заменить `ConfigurationService` на `LocalConfigurationService` в handlers:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Было:
|
||||||
|
configHandler := handlers.NewConfigurationHandler(configService, exportService)
|
||||||
|
|
||||||
|
// Стало:
|
||||||
|
configHandler := handlers.NewConfigurationHandler(localConfigService, exportService)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 3: Добавить endpoints для sync
|
||||||
|
|
||||||
|
В роутере добавить:
|
||||||
|
```go
|
||||||
|
syncGroup := router.Group("/api/sync")
|
||||||
|
{
|
||||||
|
syncGroup.POST("/push", syncHandler.PushPendingChanges)
|
||||||
|
syncGroup.GET("/pending/count", syncHandler.GetPendingCount)
|
||||||
|
syncGroup.GET("/pending", syncHandler.GetPendingChanges)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
### Создание конфигурации
|
||||||
|
|
||||||
|
1. Пользователь создает конфигурацию
|
||||||
|
2. `LocalConfigurationService.Create()`:
|
||||||
|
- Если online → `SyncPricelistsIfNeeded()` проверяет новые прайслисты
|
||||||
|
- Сохраняет конфигурацию в SQLite
|
||||||
|
- Добавляет в `pending_changes` с operation="create"
|
||||||
|
3. Конфигурация доступна локально сразу
|
||||||
|
|
||||||
|
### Синхронизация с сервером
|
||||||
|
|
||||||
|
**Manual sync:**
|
||||||
|
```bash
|
||||||
|
POST /api/sync/push
|
||||||
|
```
|
||||||
|
|
||||||
|
**Background sync (TODO):**
|
||||||
|
- Периодический worker вызывает `syncService.PushPendingChanges()`
|
||||||
|
- Проверяет online статус
|
||||||
|
- Отправляет все pending changes на сервер
|
||||||
|
- Удаляет успешно синхронизированные записи
|
||||||
|
|
||||||
|
### Offline режим
|
||||||
|
|
||||||
|
1. Все операции работают нормально через SQLite
|
||||||
|
2. Изменения копятся в `pending_changes`
|
||||||
|
3. При восстановлении соединения автоматически синхронизируются
|
||||||
|
|
||||||
|
## Pending Changes Queue
|
||||||
|
|
||||||
|
Таблица `pending_changes`:
|
||||||
|
```go
|
||||||
|
type PendingChange struct {
|
||||||
|
ID int64 // Auto-increment
|
||||||
|
EntityType string // "configuration", "project", "specification"
|
||||||
|
EntityUUID string // UUID сущности
|
||||||
|
Operation string // "create", "update", "delete"
|
||||||
|
Payload string // JSON snapshot сущности
|
||||||
|
CreatedAt time.Time
|
||||||
|
Attempts int // Счетчик попыток синхронизации
|
||||||
|
LastError string // Последняя ошибка синхронизации
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TODO для Phase 2.5
|
||||||
|
|
||||||
|
- [ ] Background sync worker (автоматическая синхронизация каждые N минут)
|
||||||
|
- [ ] Conflict resolution (при конфликтах обновления)
|
||||||
|
- [ ] UI: pending counter в header
|
||||||
|
- [ ] UI: manual sync button
|
||||||
|
- [ ] UI: conflict alerts
|
||||||
|
- [ ] Retry logic для failed pending changes
|
||||||
|
- [ ] RefreshPrices для local mode (через local_components)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compile
|
||||||
|
go build ./cmd/server
|
||||||
|
|
||||||
|
# Run
|
||||||
|
./quoteforge
|
||||||
|
|
||||||
|
# Check pending changes
|
||||||
|
curl http://localhost:8080/api/sync/pending/count
|
||||||
|
|
||||||
|
# Manual sync
|
||||||
|
curl -X POST http://localhost:8080/api/sync/push
|
||||||
|
```
|
||||||
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;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Внимание:** После отмены миграции функционал пересчета цен перестанет работать корректно.
|
||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
|||||||
162
cmd/migrate/main.go
Normal file
162
cmd/migrate/main.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||||
|
localDBPath := flag.String("localdb", "./data/settings.db", "path to local SQLite database")
|
||||||
|
dryRun := flag.Bool("dry-run", false, "show what would be migrated without actually doing it")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
log.Println("QuoteForge Configuration Migration Tool")
|
||||||
|
log.Println("========================================")
|
||||||
|
|
||||||
|
// Load config for MariaDB connection
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to MariaDB
|
||||||
|
log.Printf("Connecting to MariaDB at %s:%d...", cfg.Database.Host, cfg.Database.Port)
|
||||||
|
mariaDB, 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 MariaDB: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Connected to MariaDB")
|
||||||
|
|
||||||
|
// Initialize local SQLite
|
||||||
|
log.Printf("Opening local SQLite at %s...", *localDBPath)
|
||||||
|
local, err := localdb.New(*localDBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize local database: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Local SQLite initialized")
|
||||||
|
|
||||||
|
// Count configurations in MariaDB
|
||||||
|
var serverCount int64
|
||||||
|
if err := mariaDB.Model(&models.Configuration{}).Count(&serverCount).Error; err != nil {
|
||||||
|
log.Fatalf("Failed to count configurations: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Found %d configurations in MariaDB", serverCount)
|
||||||
|
|
||||||
|
if serverCount == 0 {
|
||||||
|
log.Println("No configurations to migrate")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all configurations from MariaDB
|
||||||
|
var configs []models.Configuration
|
||||||
|
if err := mariaDB.Preload("User").Find(&configs).Error; err != nil {
|
||||||
|
log.Fatalf("Failed to fetch configurations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check existing local configurations
|
||||||
|
localCount := local.CountConfigurations()
|
||||||
|
log.Printf("Found %d configurations in local SQLite", localCount)
|
||||||
|
|
||||||
|
if *dryRun {
|
||||||
|
log.Println("\n[DRY RUN] Would migrate the following configurations:")
|
||||||
|
for _, c := range configs {
|
||||||
|
userName := "unknown"
|
||||||
|
if c.User != nil {
|
||||||
|
userName = c.User.Username
|
||||||
|
}
|
||||||
|
log.Printf(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items))
|
||||||
|
}
|
||||||
|
log.Printf("\nTotal: %d configurations", len(configs))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate configurations
|
||||||
|
log.Println("\nMigrating configurations...")
|
||||||
|
migrated := 0
|
||||||
|
skipped := 0
|
||||||
|
errors := 0
|
||||||
|
|
||||||
|
for _, c := range configs {
|
||||||
|
// Check if already exists
|
||||||
|
existing, err := local.GetConfigurationByUUID(c.UUID)
|
||||||
|
if err == nil && existing.ID > 0 {
|
||||||
|
log.Printf(" SKIP: %s (already exists)", c.Name)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert items
|
||||||
|
localItems := make(localdb.LocalConfigItems, len(c.Items))
|
||||||
|
for i, item := range c.Items {
|
||||||
|
localItems[i] = localdb.LocalConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create local configuration
|
||||||
|
now := time.Now()
|
||||||
|
localConfig := &localdb.LocalConfiguration{
|
||||||
|
UUID: c.UUID,
|
||||||
|
ServerID: &c.ID,
|
||||||
|
Name: c.Name,
|
||||||
|
Items: localItems,
|
||||||
|
TotalPrice: c.TotalPrice,
|
||||||
|
CustomPrice: c.CustomPrice,
|
||||||
|
Notes: c.Notes,
|
||||||
|
IsTemplate: c.IsTemplate,
|
||||||
|
ServerCount: c.ServerCount,
|
||||||
|
CreatedAt: c.CreatedAt,
|
||||||
|
UpdatedAt: now,
|
||||||
|
SyncedAt: &now,
|
||||||
|
SyncStatus: "synced",
|
||||||
|
OriginalUserID: c.UserID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveConfiguration(localConfig); err != nil {
|
||||||
|
log.Printf(" ERROR: %s - %v", c.Name, err)
|
||||||
|
errors++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(" OK: %s (%d items)", c.Name, len(c.Items))
|
||||||
|
migrated++
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("\n========================================")
|
||||||
|
log.Printf("Migration complete!")
|
||||||
|
log.Printf(" Migrated: %d", migrated)
|
||||||
|
log.Printf(" Skipped: %d", skipped)
|
||||||
|
log.Printf(" Errors: %d", errors)
|
||||||
|
|
||||||
|
// Save connection settings to local SQLite if not exists
|
||||||
|
if !local.HasSettings() {
|
||||||
|
log.Println("\nSaving connection settings to local SQLite...")
|
||||||
|
if err := local.SaveSettings(
|
||||||
|
cfg.Database.Host,
|
||||||
|
cfg.Database.Port,
|
||||||
|
cfg.Database.Name,
|
||||||
|
cfg.Database.User,
|
||||||
|
cfg.Database.Password,
|
||||||
|
); err != nil {
|
||||||
|
log.Printf("Warning: Failed to save settings: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Connection settings saved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nDone! You can now run the server with: go run ./cmd/server")
|
||||||
|
}
|
||||||
@@ -3,53 +3,97 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
localDBPath = "./data/settings.db"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "config.yaml", "path to config file")
|
configPath := flag.String("config", "config.yaml", "path to config file (optional, for server settings)")
|
||||||
migrate := flag.Bool("migrate", false, "run database migrations")
|
migrate := flag.Bool("migrate", false, "run database migrations")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
cfg, err := config.Load(*configPath)
|
// Initialize local SQLite database (always used)
|
||||||
|
local, err := localdb.New(localDBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to load config", "error", err)
|
slog.Error("failed to initialize local database", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if running in setup mode (no connection settings)
|
||||||
|
if !local.HasSettings() {
|
||||||
|
slog.Info("no database settings found, starting setup mode")
|
||||||
|
runSetupMode(local)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config for server settings (optional)
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
// Use defaults if config file doesn't exist
|
||||||
|
slog.Info("config file not found, using defaults", "path", *configPath)
|
||||||
|
cfg = &config.Config{}
|
||||||
|
}
|
||||||
|
setConfigDefaults(cfg)
|
||||||
|
|
||||||
setupLogger(cfg.Logging)
|
setupLogger(cfg.Logging)
|
||||||
|
|
||||||
|
// Get DSN from local SQLite
|
||||||
|
dsn, err := local.GetDSN()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get database settings", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to MariaDB
|
||||||
|
db, err := setupDatabaseFromDSN(dsn)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to connect to database", "error", err)
|
||||||
|
slog.Info("you may need to reconfigure connection at /setup")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbUser := local.GetDBUser()
|
||||||
|
|
||||||
|
// Ensure DB user exists in qt_users table (for foreign key constraint)
|
||||||
|
dbUserID, err := models.EnsureDBUser(db, dbUser)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to ensure DB user exists", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("starting QuoteForge server",
|
slog.Info("starting QuoteForge server",
|
||||||
"host", cfg.Server.Host,
|
"host", cfg.Server.Host,
|
||||||
"port", cfg.Server.Port,
|
"port", cfg.Server.Port,
|
||||||
"mode", cfg.Server.Mode,
|
"db_user", dbUser,
|
||||||
|
"db_user_id", dbUserID,
|
||||||
)
|
)
|
||||||
|
|
||||||
db, err := setupDatabase(cfg.Database)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to connect to database", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if *migrate {
|
if *migrate {
|
||||||
slog.Info("running database migrations...")
|
slog.Info("running database migrations...")
|
||||||
if err := models.Migrate(db); err != nil {
|
if err := models.Migrate(db); err != nil {
|
||||||
@@ -60,22 +104,23 @@ func main() {
|
|||||||
slog.Error("seeding categories failed", "error", err)
|
slog.Error("seeding categories failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
// Create default admin user (admin / admin123)
|
|
||||||
adminHash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
|
||||||
if err := models.SeedAdminUser(db, string(adminHash)); err != nil {
|
|
||||||
slog.Error("seeding admin user failed", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
slog.Info("migrations completed")
|
slog.Info("migrations completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
gin.SetMode(cfg.Server.Mode)
|
gin.SetMode(cfg.Server.Mode)
|
||||||
router, err := setupRouter(db, cfg)
|
router, syncService, err := setupRouter(db, cfg, local, dbUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to setup router", "error", err)
|
slog.Error("failed to setup router", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start background sync worker
|
||||||
|
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||||
|
defer workerCancel()
|
||||||
|
|
||||||
|
syncWorker := sync.NewWorker(syncService, db, 5*time.Minute)
|
||||||
|
go syncWorker.Start(workerCtx)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Address(),
|
Addr: cfg.Address(),
|
||||||
Handler: router,
|
Handler: router,
|
||||||
@@ -97,6 +142,11 @@ func main() {
|
|||||||
|
|
||||||
slog.Info("shutting down server...")
|
slog.Info("shutting down server...")
|
||||||
|
|
||||||
|
// Stop background sync worker first
|
||||||
|
syncWorker.Stop()
|
||||||
|
workerCancel()
|
||||||
|
|
||||||
|
// Then shutdown HTTP server
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -107,6 +157,110 @@ func main() {
|
|||||||
slog.Info("server stopped")
|
slog.Info("server stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setConfigDefaults(cfg *config.Config) {
|
||||||
|
if cfg.Server.Host == "" {
|
||||||
|
cfg.Server.Host = "0.0.0.0"
|
||||||
|
}
|
||||||
|
if cfg.Server.Port == 0 {
|
||||||
|
cfg.Server.Port = 8080
|
||||||
|
}
|
||||||
|
if cfg.Server.Mode == "" {
|
||||||
|
cfg.Server.Mode = "release"
|
||||||
|
}
|
||||||
|
if cfg.Server.ReadTimeout == 0 {
|
||||||
|
cfg.Server.ReadTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.Server.WriteTimeout == 0 {
|
||||||
|
cfg.Server.WriteTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.Pricing.DefaultMethod == "" {
|
||||||
|
cfg.Pricing.DefaultMethod = "weighted_median"
|
||||||
|
}
|
||||||
|
if cfg.Pricing.DefaultPeriodDays == 0 {
|
||||||
|
cfg.Pricing.DefaultPeriodDays = 90
|
||||||
|
}
|
||||||
|
if cfg.Pricing.FreshnessGreenDays == 0 {
|
||||||
|
cfg.Pricing.FreshnessGreenDays = 30
|
||||||
|
}
|
||||||
|
if cfg.Pricing.FreshnessYellowDays == 0 {
|
||||||
|
cfg.Pricing.FreshnessYellowDays = 60
|
||||||
|
}
|
||||||
|
if cfg.Pricing.FreshnessRedDays == 0 {
|
||||||
|
cfg.Pricing.FreshnessRedDays = 90
|
||||||
|
}
|
||||||
|
if cfg.Pricing.MinQuotesForMedian == 0 {
|
||||||
|
cfg.Pricing.MinQuotesForMedian = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSetupMode starts a minimal server that only serves the setup page
|
||||||
|
func runSetupMode(local *localdb.LocalDB) {
|
||||||
|
restartSig := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", restartSig)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to create setup handler", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
|
router.Static("/static", "web/static")
|
||||||
|
|
||||||
|
// Setup routes only
|
||||||
|
router.GET("/", func(c *gin.Context) {
|
||||||
|
c.Redirect(http.StatusFound, "/setup")
|
||||||
|
})
|
||||||
|
router.GET("/setup", setupHandler.ShowSetup)
|
||||||
|
router.POST("/setup", setupHandler.SaveConnection)
|
||||||
|
router.POST("/setup/test", setupHandler.TestConnection)
|
||||||
|
router.GET("/setup/status", setupHandler.GetStatus)
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
router.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "setup_required",
|
||||||
|
"time": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
addr := ":8080"
|
||||||
|
slog.Info("starting setup mode server", "address", addr)
|
||||||
|
slog.Info("open http://localhost:8080/setup to configure database connection")
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: router,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("server error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-quit:
|
||||||
|
slog.Info("setup mode server stopped")
|
||||||
|
case <-restartSig:
|
||||||
|
slog.Info("restarting application with saved settings...")
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
srv.Shutdown(ctx)
|
||||||
|
|
||||||
|
// Restart process with same arguments
|
||||||
|
restartProcess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setupLogger(cfg config.LoggingConfig) {
|
func setupLogger(cfg config.LoggingConfig) {
|
||||||
var level slog.Level
|
var level slog.Level
|
||||||
switch cfg.Level {
|
switch cfg.Level {
|
||||||
@@ -132,10 +286,10 @@ func setupLogger(cfg config.LoggingConfig) {
|
|||||||
slog.SetDefault(slog.New(handler))
|
slog.SetDefault(slog.New(handler))
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
|
||||||
gormLogger := logger.Default.LogMode(logger.Silent)
|
gormLogger := logger.Default.LogMode(logger.Silent)
|
||||||
|
|
||||||
db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
Logger: gormLogger,
|
Logger: gormLogger,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -147,44 +301,65 @@ func setupDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
sqlDB.SetMaxOpenConns(25)
|
||||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
sqlDB.SetMaxIdleConns(5)
|
||||||
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
|
func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
|
||||||
// Repositories
|
// Repositories
|
||||||
userRepo := repository.NewUserRepository(db)
|
|
||||||
componentRepo := repository.NewComponentRepository(db)
|
componentRepo := repository.NewComponentRepository(db)
|
||||||
categoryRepo := repository.NewCategoryRepository(db)
|
categoryRepo := repository.NewCategoryRepository(db)
|
||||||
priceRepo := repository.NewPriceRepository(db)
|
priceRepo := repository.NewPriceRepository(db)
|
||||||
configRepo := repository.NewConfigurationRepository(db)
|
|
||||||
alertRepo := repository.NewAlertRepository(db)
|
alertRepo := repository.NewAlertRepository(db)
|
||||||
statsRepo := repository.NewStatsRepository(db)
|
statsRepo := repository.NewStatsRepository(db)
|
||||||
|
pricelistRepo := repository.NewPricelistRepository(db)
|
||||||
|
configRepo := repository.NewConfigurationRepository(db)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
authService := services.NewAuthService(userRepo, cfg.Auth)
|
|
||||||
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||||
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||||
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||||
configService := services.NewConfigurationService(configRepo, componentRepo, quoteService)
|
|
||||||
exportService := services.NewExportService(cfg.Export, categoryRepo)
|
exportService := services.NewExportService(cfg.Export, categoryRepo)
|
||||||
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||||
|
pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo)
|
||||||
|
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
||||||
|
|
||||||
|
// isOnline function for local-first architecture
|
||||||
|
isOnline := func() bool {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return sqlDB.Ping() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local-first configuration service (replaces old ConfigurationService)
|
||||||
|
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
authHandler := handlers.NewAuthHandler(authService, userRepo)
|
|
||||||
componentHandler := handlers.NewComponentHandler(componentService)
|
componentHandler := handlers.NewComponentHandler(componentService)
|
||||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||||
configHandler := handlers.NewConfigurationHandler(configService, exportService)
|
|
||||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
||||||
pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
||||||
|
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
|
||||||
|
syncHandler, err := handlers.NewSyncHandler(local, syncService, db, "web/templates")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup handler (for reconfiguration) - no restart signal in normal mode
|
||||||
|
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Web handler (templates)
|
// Web handler (templates)
|
||||||
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
|
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router
|
// Router
|
||||||
@@ -192,6 +367,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
|
|||||||
router.Use(gin.Recovery())
|
router.Use(gin.Recovery())
|
||||||
router.Use(requestLogger())
|
router.Use(requestLogger())
|
||||||
router.Use(middleware.CORS())
|
router.Use(middleware.CORS())
|
||||||
|
router.Use(middleware.OfflineDetector(db, local))
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
router.Static("/static", "web/static")
|
router.Static("/static", "web/static")
|
||||||
@@ -229,20 +405,40 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
|
|||||||
"lot_count": lotCount,
|
"lot_count": lotCount,
|
||||||
"lot_log_count": lotLogCount,
|
"lot_log_count": lotLogCount,
|
||||||
"metadata_count": metadataCount,
|
"metadata_count": metadataCount,
|
||||||
|
"db_user": local.GetDBUser(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Current user info (DB user, not app user)
|
||||||
|
router.GET("/api/current-user", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"username": local.GetDBUser(),
|
||||||
|
"role": "db_user",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Setup routes (for reconfiguration)
|
||||||
|
router.GET("/setup", setupHandler.ShowSetup)
|
||||||
|
router.POST("/setup", setupHandler.SaveConnection)
|
||||||
|
router.POST("/setup/test", setupHandler.TestConnection)
|
||||||
|
router.GET("/setup/status", setupHandler.GetStatus)
|
||||||
|
|
||||||
// Web pages
|
// Web pages
|
||||||
router.GET("/", webHandler.Index)
|
router.GET("/", webHandler.Index)
|
||||||
router.GET("/login", webHandler.Login)
|
|
||||||
router.GET("/configs", webHandler.Configs)
|
router.GET("/configs", webHandler.Configs)
|
||||||
router.GET("/configurator", webHandler.Configurator)
|
router.GET("/configurator", webHandler.Configurator)
|
||||||
|
router.GET("/pricelists", func(c *gin.Context) {
|
||||||
|
// Redirect to admin/pricing with pricelists tab
|
||||||
|
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
|
||||||
|
})
|
||||||
|
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||||
router.GET("/admin/pricing", webHandler.AdminPricing)
|
router.GET("/admin/pricing", webHandler.AdminPricing)
|
||||||
|
|
||||||
// htmx partials
|
// htmx partials
|
||||||
partials := router.Group("/partials")
|
partials := router.Group("/partials")
|
||||||
{
|
{
|
||||||
partials.GET("/components", webHandler.ComponentsPartial)
|
partials.GET("/components", webHandler.ComponentsPartial)
|
||||||
|
partials.GET("/sync-status", syncHandler.SyncStatusPartial)
|
||||||
}
|
}
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
@@ -252,16 +448,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auth (public)
|
// Components (public read)
|
||||||
auth := api.Group("/auth")
|
|
||||||
{
|
|
||||||
auth.POST("/login", authHandler.Login)
|
|
||||||
auth.POST("/refresh", authHandler.Refresh)
|
|
||||||
auth.POST("/logout", authHandler.Logout)
|
|
||||||
auth.GET("/me", middleware.Auth(authService), authHandler.Me)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Components (public read, for quote builder)
|
|
||||||
components := api.Group("/components")
|
components := api.Group("/components")
|
||||||
{
|
{
|
||||||
components.GET("", componentHandler.List)
|
components.GET("", componentHandler.List)
|
||||||
@@ -271,44 +458,155 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
|
|||||||
// Categories (public)
|
// Categories (public)
|
||||||
api.GET("/categories", componentHandler.GetCategories)
|
api.GET("/categories", componentHandler.GetCategories)
|
||||||
|
|
||||||
// Quote (public, for anonymous quote building)
|
// Quote (public)
|
||||||
quote := api.Group("/quote")
|
quote := api.Group("/quote")
|
||||||
{
|
{
|
||||||
quote.POST("/validate", quoteHandler.Validate)
|
quote.POST("/validate", quoteHandler.Validate)
|
||||||
quote.POST("/calculate", quoteHandler.Calculate)
|
quote.POST("/calculate", quoteHandler.Calculate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export (public, for anonymous exports)
|
// Export (public)
|
||||||
export := api.Group("/export")
|
export := api.Group("/export")
|
||||||
{
|
{
|
||||||
export.POST("/csv", exportHandler.ExportCSV)
|
export.POST("/csv", exportHandler.ExportCSV)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configurations (requires auth)
|
// Pricelists (public - RBAC disabled in Phase 1-3)
|
||||||
configs := api.Group("/configs")
|
pricelists := api.Group("/pricelists")
|
||||||
configs.Use(middleware.Auth(authService))
|
|
||||||
configs.Use(middleware.RequireEditor())
|
|
||||||
{
|
{
|
||||||
configs.GET("", configHandler.List)
|
pricelists.GET("", pricelistHandler.List)
|
||||||
configs.POST("", configHandler.Create)
|
pricelists.GET("/can-write", pricelistHandler.CanWrite)
|
||||||
configs.GET("/:uuid", configHandler.Get)
|
pricelists.GET("/latest", pricelistHandler.GetLatest)
|
||||||
configs.PUT("/:uuid", configHandler.Update)
|
pricelists.GET("/:id", pricelistHandler.Get)
|
||||||
configs.PATCH("/:uuid/rename", configHandler.Rename)
|
pricelists.GET("/:id/items", pricelistHandler.GetItems)
|
||||||
configs.POST("/:uuid/clone", configHandler.Clone)
|
pricelists.POST("", pricelistHandler.Create)
|
||||||
configs.DELETE("/:uuid", configHandler.Delete)
|
pricelists.DELETE("/:id", pricelistHandler.Delete)
|
||||||
// configs.GET("/:uuid/export", configHandler.ExportJSON)
|
|
||||||
configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV)
|
|
||||||
// configs.POST("/import", configHandler.ImportJSON)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Admin routes
|
// Configurations (public - RBAC disabled)
|
||||||
admin := router.Group("/admin")
|
configs := api.Group("/configs")
|
||||||
admin.Use(middleware.Auth(authService))
|
{
|
||||||
{
|
configs.GET("", func(c *gin.Context) {
|
||||||
// Pricing admin
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pricingAdmin := admin.Group("/pricing")
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||||
pricingAdmin.Use(middleware.RequirePricingAdmin())
|
|
||||||
|
cfgs, total, err := configService.ListAll(page, perPage)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"configurations": cfgs,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
configs.POST("", func(c *gin.Context) {
|
||||||
|
var req services.CreateConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := configService.Create(dbUserID, &req) // use DB user ID
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, config)
|
||||||
|
})
|
||||||
|
|
||||||
|
configs.GET("/:uuid", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
config, err := configService.GetByUUIDNoAuth(uuid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
})
|
||||||
|
|
||||||
|
configs.PUT("/:uuid", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
var req services.CreateConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := configService.UpdateNoAuth(uuid, &req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
})
|
||||||
|
|
||||||
|
configs.DELETE("/:uuid", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
if err := configService.DeleteNoAuth(uuid); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||||
|
})
|
||||||
|
|
||||||
|
configs.PATCH("/:uuid/rename", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := configService.RenameNoAuth(uuid, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
})
|
||||||
|
|
||||||
|
configs.POST("/:uuid/clone", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := configService.CloneNoAuth(uuid, req.Name, dbUserID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, config)
|
||||||
|
})
|
||||||
|
|
||||||
|
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
config, err := configService.RefreshPricesNoAuth(uuid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pricing admin (public - RBAC disabled)
|
||||||
|
pricingAdmin := api.Group("/admin/pricing")
|
||||||
{
|
{
|
||||||
pricingAdmin.GET("/stats", pricingHandler.GetStats)
|
pricingAdmin.GET("/stats", pricingHandler.GetStats)
|
||||||
pricingAdmin.GET("/components", pricingHandler.ListComponents)
|
pricingAdmin.GET("/components", pricingHandler.ListComponents)
|
||||||
@@ -322,9 +620,42 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert)
|
pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert)
|
||||||
pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert)
|
pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync API (for offline mode)
|
||||||
|
syncAPI := api.Group("/sync")
|
||||||
|
{
|
||||||
|
syncAPI.GET("/status", syncHandler.GetStatus)
|
||||||
|
syncAPI.GET("/info", syncHandler.GetInfo)
|
||||||
|
syncAPI.POST("/components", syncHandler.SyncComponents)
|
||||||
|
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
||||||
|
syncAPI.POST("/all", syncHandler.SyncAll)
|
||||||
|
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
||||||
|
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
||||||
|
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return router, nil
|
return router, syncService, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restartProcess restarts the current process with the same arguments
|
||||||
|
func restartProcess() {
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get executable path", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := os.Args
|
||||||
|
env := os.Environ()
|
||||||
|
|
||||||
|
slog.Info("executing restart", "executable", executable, "args", args)
|
||||||
|
|
||||||
|
err = syscall.Exec(executable, args, env)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to restart process", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestLogger() gin.HandlerFunc {
|
func requestLogger() gin.HandlerFunc {
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
quoteforge:
|
|
||||||
build: .
|
|
||||||
container_name: quoteforge
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- ./config.yaml:/app/config.yaml:ro
|
|
||||||
environment:
|
|
||||||
- TZ=Europe/Moscow
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 3
|
|
||||||
start_period: 5s
|
|
||||||
|
|
||||||
quoteforge-cron:
|
|
||||||
build: .
|
|
||||||
container_name: quoteforge-cron
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ./config.yaml:/app/config.yaml:ro
|
|
||||||
- ./logs:/app/logs
|
|
||||||
command: /usr/sbin/crond -f -l 8
|
|
||||||
environment:
|
|
||||||
- TZ=Europe/Moscow
|
|
||||||
10
go.mod
10
go.mod
@@ -4,19 +4,22 @@ go 1.24.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.43.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/mysql v1.5.2
|
gorm.io/driver/mysql v1.5.2
|
||||||
gorm.io/gorm v1.25.5
|
gorm.io/gorm v1.25.7
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
@@ -31,6 +34,7 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
@@ -39,4 +43,8 @@ require (
|
|||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
google.golang.org/protobuf v1.30.0 // indirect
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
|
modernc.org/libc v1.22.5 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
modernc.org/sqlite v1.23.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
26
go.sum
26
go.sum
@@ -7,12 +7,18 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -32,6 +38,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
|
|||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
@@ -56,6 +64,9 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ
|
|||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -85,8 +96,9 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
|||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
@@ -98,6 +110,14 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|||||||
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
|
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
|
||||||
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
|
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
|
||||||
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|||||||
@@ -181,6 +181,25 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, config)
|
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) {
|
// func (h *ConfigurationHandler) ExportJSON(c *gin.Context) {
|
||||||
// userID := middleware.GetUserID(c)
|
// userID := middleware.GetUserID(c)
|
||||||
// uuid := c.Param("uuid")
|
// uuid := c.Param("uuid")
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import (
|
|||||||
|
|
||||||
type ExportHandler struct {
|
type ExportHandler struct {
|
||||||
exportService *services.ExportService
|
exportService *services.ExportService
|
||||||
configService *services.ConfigurationService
|
configService services.ConfigurationGetter
|
||||||
componentService *services.ComponentService
|
componentService *services.ComponentService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExportHandler(
|
func NewExportHandler(
|
||||||
exportService *services.ExportService,
|
exportService *services.ExportService,
|
||||||
configService *services.ConfigurationService,
|
configService services.ConfigurationGetter,
|
||||||
componentService *services.ComponentService,
|
componentService *services.ComponentService,
|
||||||
) *ExportHandler {
|
) *ExportHandler {
|
||||||
return &ExportHandler{
|
return &ExportHandler{
|
||||||
|
|||||||
134
internal/handlers/pricelist.go
Normal file
134
internal/handlers/pricelist.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PricelistHandler struct {
|
||||||
|
service *pricelist.Service
|
||||||
|
localDB *localdb.LocalDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *PricelistHandler {
|
||||||
|
return &PricelistHandler{service: service, localDB: localDB}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all pricelists with pagination
|
||||||
|
func (h *PricelistHandler) List(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||||
|
|
||||||
|
pricelists, total, err := h.service.List(page, perPage)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"pricelists": pricelists,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a single pricelist by ID
|
||||||
|
func (h *PricelistHandler) Get(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pl, err := h.service.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, pl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new pricelist from current prices
|
||||||
|
func (h *PricelistHandler) Create(c *gin.Context) {
|
||||||
|
// Get the database username as the creator
|
||||||
|
createdBy := h.localDB.GetDBUser()
|
||||||
|
if createdBy == "" {
|
||||||
|
createdBy = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
pl, err := h.service.CreateFromCurrentPrices(createdBy)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, pl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a pricelist by ID
|
||||||
|
func (h *PricelistHandler) Delete(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.Delete(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItems returns items for a pricelist with pagination
|
||||||
|
func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
||||||
|
search := c.Query("search")
|
||||||
|
|
||||||
|
items, total, err := h.service.GetItems(uint(id), page, perPage, search)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanWrite returns whether the current user can create pricelists
|
||||||
|
func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
||||||
|
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||||
|
c.JSON(http.StatusOK, gin.H{"can_write": canWrite, "debug": debugInfo})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatest returns the most recent active pricelist
|
||||||
|
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||||||
|
pl, err := h.service.GetLatestActive()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no active pricelists found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, pl)
|
||||||
|
}
|
||||||
220
internal/handlers/setup.go
Normal file
220
internal/handlers/setup.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SetupHandler struct {
|
||||||
|
localDB *localdb.LocalDB
|
||||||
|
templates map[string]*template.Template
|
||||||
|
restartSig chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
"sub": func(a, b int) int { return a - b },
|
||||||
|
"add": func(a, b int) int { return a + b },
|
||||||
|
}
|
||||||
|
|
||||||
|
templates := make(map[string]*template.Template)
|
||||||
|
|
||||||
|
// Load setup template (standalone, no base needed)
|
||||||
|
setupPath := filepath.Join(templatesPath, "setup.html")
|
||||||
|
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing setup template: %w", err)
|
||||||
|
}
|
||||||
|
templates["setup.html"] = tmpl
|
||||||
|
|
||||||
|
return &SetupHandler{
|
||||||
|
localDB: localDB,
|
||||||
|
templates: templates,
|
||||||
|
restartSig: restartSig,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowSetup renders the database setup form
|
||||||
|
func (h *SetupHandler) ShowSetup(c *gin.Context) {
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
|
// Get existing settings if any
|
||||||
|
settings, _ := h.localDB.GetSettings()
|
||||||
|
|
||||||
|
data := gin.H{
|
||||||
|
"Settings": settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl := h.templates["setup.html"]
|
||||||
|
if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Template error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnection tests the database connection without saving
|
||||||
|
func (h *SetupHandler) TestConnection(c *gin.Context) {
|
||||||
|
host := c.PostForm("host")
|
||||||
|
portStr := c.PostForm("port")
|
||||||
|
database := c.PostForm("database")
|
||||||
|
user := c.PostForm("user")
|
||||||
|
password := c.PostForm("password")
|
||||||
|
|
||||||
|
port := 3306
|
||||||
|
if p, err := strconv.Atoi(portStr); err == nil {
|
||||||
|
port = p
|
||||||
|
}
|
||||||
|
|
||||||
|
// If password is empty, try to use saved password
|
||||||
|
if password == "" {
|
||||||
|
if settings, err := h.localDB.GetSettings(); err == nil && settings != nil {
|
||||||
|
password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||||||
|
user, password, host, port, database)
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("Connection failed: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("Failed to get database handle: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("Ping failed: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for required tables
|
||||||
|
var lotCount int64
|
||||||
|
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("Table 'lot' not found or inaccessible: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check write permission
|
||||||
|
canWrite := testWritePermission(db)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"lot_count": lotCount,
|
||||||
|
"can_write": canWrite,
|
||||||
|
"message": fmt.Sprintf("Connected successfully! Found %d components.", lotCount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConnection saves the connection settings and signals restart
|
||||||
|
func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||||
|
host := c.PostForm("host")
|
||||||
|
portStr := c.PostForm("port")
|
||||||
|
database := c.PostForm("database")
|
||||||
|
user := c.PostForm("user")
|
||||||
|
password := c.PostForm("password")
|
||||||
|
|
||||||
|
port := 3306
|
||||||
|
if p, err := strconv.Atoi(portStr); err == nil {
|
||||||
|
port = p
|
||||||
|
}
|
||||||
|
|
||||||
|
// If password is empty, use saved password
|
||||||
|
if password == "" {
|
||||||
|
if settings, err := h.localDB.GetSettings(); err == nil && settings != nil {
|
||||||
|
password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection first
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||||||
|
user, password, host, port, database)
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("Connection failed: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, _ := db.DB()
|
||||||
|
sqlDB.Close()
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("Failed to save settings: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Settings saved. Restarting application...",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Signal restart after response is sent
|
||||||
|
if h.restartSig != nil {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond) // Give time for response to be sent
|
||||||
|
h.restartSig <- struct{}{}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns the current setup status
|
||||||
|
func (h *SetupHandler) GetStatus(c *gin.Context) {
|
||||||
|
hasSettings := h.localDB.HasSettings()
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"configured": hasSettings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWritePermission(db *gorm.DB) bool {
|
||||||
|
// Simple check: try to create a temporary table and drop it
|
||||||
|
testTable := fmt.Sprintf("qt_write_test_%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
// Try to create a test table
|
||||||
|
err := db.Exec(fmt.Sprintf("CREATE TABLE %s (id INT)", testTable)).Error
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop it immediately
|
||||||
|
db.Exec(fmt.Sprintf("DROP TABLE %s", testTable))
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
378
internal/handlers/sync.go
Normal file
378
internal/handlers/sync.go
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncHandler handles sync API endpoints
|
||||||
|
type SyncHandler struct {
|
||||||
|
localDB *localdb.LocalDB
|
||||||
|
syncService *sync.Service
|
||||||
|
mariaDB *gorm.DB
|
||||||
|
tmpl *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSyncHandler creates a new sync handler
|
||||||
|
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB, templatesPath string) (*SyncHandler, error) {
|
||||||
|
// Load sync_status partial template
|
||||||
|
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
||||||
|
tmpl, err := template.ParseFiles(partialPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SyncHandler{
|
||||||
|
localDB: localDB,
|
||||||
|
syncService: syncService,
|
||||||
|
mariaDB: mariaDB,
|
||||||
|
tmpl: tmpl,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncStatusResponse represents the sync status
|
||||||
|
type SyncStatusResponse struct {
|
||||||
|
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||||
|
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||||
|
IsOnline bool `json:"is_online"`
|
||||||
|
ComponentsCount int64 `json:"components_count"`
|
||||||
|
PricelistsCount int64 `json:"pricelists_count"`
|
||||||
|
ServerPricelists int `json:"server_pricelists"`
|
||||||
|
NeedComponentSync bool `json:"need_component_sync"`
|
||||||
|
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns current sync status
|
||||||
|
// GET /api/sync/status
|
||||||
|
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||||
|
// Check online status by pinging MariaDB
|
||||||
|
isOnline := h.checkOnline()
|
||||||
|
|
||||||
|
// Get sync times
|
||||||
|
lastComponentSync := h.localDB.GetComponentSyncTime()
|
||||||
|
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||||
|
|
||||||
|
// Get counts
|
||||||
|
componentsCount := h.localDB.CountLocalComponents()
|
||||||
|
pricelistsCount := h.localDB.CountLocalPricelists()
|
||||||
|
|
||||||
|
// Get server pricelist count if online
|
||||||
|
serverPricelists := 0
|
||||||
|
needPricelistSync := false
|
||||||
|
if isOnline {
|
||||||
|
status, err := h.syncService.GetStatus()
|
||||||
|
if err == nil {
|
||||||
|
serverPricelists = status.ServerPricelists
|
||||||
|
needPricelistSync = status.NeedsSync
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if component sync is needed (older than 24 hours)
|
||||||
|
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||||
|
LastComponentSync: lastComponentSync,
|
||||||
|
LastPricelistSync: lastPricelistSync,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ComponentsCount: componentsCount,
|
||||||
|
PricelistsCount: pricelistsCount,
|
||||||
|
ServerPricelists: serverPricelists,
|
||||||
|
NeedComponentSync: needComponentSync,
|
||||||
|
NeedPricelistSync: needPricelistSync,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncResultResponse represents sync operation result
|
||||||
|
type SyncResultResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Synced int `json:"synced"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncComponents syncs components from MariaDB to local SQLite
|
||||||
|
// POST /api/sync/components
|
||||||
|
func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
||||||
|
if !h.checkOnline() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Database is offline",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.localDB.SyncComponents(h.mariaDB)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("component sync failed", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, SyncResultResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Components synced successfully",
|
||||||
|
Synced: result.TotalSynced,
|
||||||
|
Duration: result.Duration.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncPricelists syncs pricelists from MariaDB to local SQLite
|
||||||
|
// POST /api/sync/pricelists
|
||||||
|
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||||
|
if !h.checkOnline() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Database is offline",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
synced, err := h.syncService.SyncPricelists()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("pricelist sync failed", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, SyncResultResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Pricelists synced successfully",
|
||||||
|
Synced: synced,
|
||||||
|
Duration: time.Since(startTime).String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncAllResponse represents result of full sync
|
||||||
|
type SyncAllResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ComponentsSynced int `json:"components_synced"`
|
||||||
|
PricelistsSynced int `json:"pricelists_synced"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncAll syncs both components and pricelists
|
||||||
|
// POST /api/sync/all
|
||||||
|
func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||||
|
if !h.checkOnline() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Database is offline",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
var componentsSynced, pricelistsSynced int
|
||||||
|
|
||||||
|
// Sync components
|
||||||
|
compResult, err := h.localDB.SyncComponents(h.mariaDB)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("component sync failed during full sync", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Component sync failed: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
componentsSynced = compResult.TotalSynced
|
||||||
|
|
||||||
|
// Sync pricelists
|
||||||
|
pricelistsSynced, err = h.syncService.SyncPricelists()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("pricelist sync failed during full sync", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Pricelist sync failed: " + err.Error(),
|
||||||
|
"components_synced": componentsSynced,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, SyncAllResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Full sync completed successfully",
|
||||||
|
ComponentsSynced: componentsSynced,
|
||||||
|
PricelistsSynced: pricelistsSynced,
|
||||||
|
Duration: time.Since(startTime).String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkOnline checks if MariaDB is accessible
|
||||||
|
func (h *SyncHandler) checkOnline() bool {
|
||||||
|
sqlDB, err := h.mariaDB.DB()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushPendingChanges pushes all pending changes to the server
|
||||||
|
// POST /api/sync/push
|
||||||
|
func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
|
||||||
|
if !h.checkOnline() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Database is offline",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
pushed, err := h.syncService.PushPendingChanges()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("push pending changes failed", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, SyncResultResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Pending changes pushed successfully",
|
||||||
|
Synced: pushed,
|
||||||
|
Duration: time.Since(startTime).String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingCount returns the number of pending changes
|
||||||
|
// GET /api/sync/pending/count
|
||||||
|
func (h *SyncHandler) GetPendingCount(c *gin.Context) {
|
||||||
|
count := h.localDB.GetPendingCount()
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"count": count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingChanges returns all pending changes
|
||||||
|
// GET /api/sync/pending
|
||||||
|
func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
||||||
|
changes, err := h.localDB.GetPendingChanges()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"changes": changes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncInfoResponse represents sync information
|
||||||
|
type SyncInfoResponse struct {
|
||||||
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
|
IsOnline bool `json:"is_online"`
|
||||||
|
ErrorCount int `json:"error_count"`
|
||||||
|
Errors []SyncError `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncError represents a sync error
|
||||||
|
type SyncError struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInfo returns sync information for modal
|
||||||
|
// GET /api/sync/info
|
||||||
|
func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||||
|
// Check online status by pinging MariaDB
|
||||||
|
isOnline := h.checkOnline()
|
||||||
|
|
||||||
|
// Get sync times
|
||||||
|
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||||
|
|
||||||
|
// Get error count (only changes with LastError != "")
|
||||||
|
errorCount := int(h.localDB.CountErroredChanges())
|
||||||
|
|
||||||
|
// Get recent errors (last 10)
|
||||||
|
changes, err := h.localDB.GetPendingChanges()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get pending changes for sync info", "error", err)
|
||||||
|
// Even if we can't get changes, we can still return the error count
|
||||||
|
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||||
|
LastSyncAt: lastPricelistSync,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ErrorCount: errorCount,
|
||||||
|
Errors: []SyncError{}, // Return empty errors list
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var errors []SyncError
|
||||||
|
for _, change := range changes {
|
||||||
|
// Check if there's a last error and it's not empty
|
||||||
|
if change.LastError != "" {
|
||||||
|
errors = append(errors, SyncError{
|
||||||
|
Timestamp: change.CreatedAt,
|
||||||
|
Message: change.LastError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to last 10 errors
|
||||||
|
if len(errors) > 10 {
|
||||||
|
errors = errors[:10]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||||
|
LastSyncAt: lastPricelistSync,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ErrorCount: errorCount,
|
||||||
|
Errors: errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncStatusPartial renders the sync status partial for htmx
|
||||||
|
// GET /partials/sync-status
|
||||||
|
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||||
|
// Check online status from middleware
|
||||||
|
isOfflineValue, exists := c.Get("is_offline")
|
||||||
|
isOffline := false
|
||||||
|
if exists {
|
||||||
|
isOffline = isOfflineValue.(bool)
|
||||||
|
} else {
|
||||||
|
// Fallback: check directly if middleware didn't set it
|
||||||
|
isOffline = !h.checkOnline()
|
||||||
|
slog.Warn("is_offline not found in context, checking directly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending count
|
||||||
|
pendingCount := h.localDB.GetPendingCount()
|
||||||
|
|
||||||
|
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount)
|
||||||
|
|
||||||
|
data := gin.H{
|
||||||
|
"IsOffline": isOffline,
|
||||||
|
"PendingCount": pendingCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
|
||||||
|
slog.Error("failed to render sync_status template", "error", err)
|
||||||
|
c.String(http.StatusInternalServerError, "Template error: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
basePath := filepath.Join(templatesPath, "base.html")
|
basePath := filepath.Join(templatesPath, "base.html")
|
||||||
|
|
||||||
// Load each page template with base
|
// Load each page template with base
|
||||||
simplePages := []string{"login.html", "configs.html", "admin_pricing.html"}
|
simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
|
||||||
for _, page := range simplePages {
|
for _, page := range simplePages {
|
||||||
pagePath := filepath.Join(templatesPath, page)
|
pagePath := filepath.Join(templatesPath, page)
|
||||||
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
||||||
@@ -154,6 +154,14 @@ func (h *WebHandler) AdminPricing(c *gin.Context) {
|
|||||||
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
|
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WebHandler) Pricelists(c *gin.Context) {
|
||||||
|
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebHandler) PricelistDetail(c *gin.Context) {
|
||||||
|
h.render(c, "pricelist_detail.html", gin.H{"ActivePage": "pricelists"})
|
||||||
|
}
|
||||||
|
|
||||||
// Partials for htmx
|
// Partials for htmx
|
||||||
|
|
||||||
func (h *WebHandler) ComponentsPartial(c *gin.Context) {
|
func (h *WebHandler) ComponentsPartial(c *gin.Context) {
|
||||||
|
|||||||
268
internal/localdb/components.go
Normal file
268
internal/localdb/components.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ComponentSyncResult contains statistics from component sync
|
||||||
|
type ComponentSyncResult struct {
|
||||||
|
TotalSynced int
|
||||||
|
NewCount int
|
||||||
|
UpdateCount int
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncComponents loads components from MariaDB (lot + qt_lot_metadata) into local_components
|
||||||
|
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Query to join lot with qt_lot_metadata
|
||||||
|
// Use LEFT JOIN to include lots without metadata
|
||||||
|
type componentRow struct {
|
||||||
|
LotName string
|
||||||
|
LotDescription string
|
||||||
|
Category *string
|
||||||
|
Model *string
|
||||||
|
CurrentPrice *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []componentRow
|
||||||
|
err := mariaDB.Raw(`
|
||||||
|
SELECT
|
||||||
|
l.lot_name,
|
||||||
|
l.lot_description,
|
||||||
|
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
||||||
|
m.model,
|
||||||
|
m.current_price
|
||||||
|
FROM lot l
|
||||||
|
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
||||||
|
LEFT JOIN qt_categories c ON m.category_id = c.id
|
||||||
|
WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL
|
||||||
|
ORDER BY l.lot_name
|
||||||
|
`).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
slog.Warn("no components found in MariaDB")
|
||||||
|
return &ComponentSyncResult{
|
||||||
|
Duration: time.Since(startTime),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing local components for comparison
|
||||||
|
existingMap := make(map[string]bool)
|
||||||
|
var existing []LocalComponent
|
||||||
|
if err := l.db.Find(&existing).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("reading existing local components: %w", err)
|
||||||
|
}
|
||||||
|
for _, c := range existing {
|
||||||
|
existingMap[c.LotName] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare components for batch insert/update
|
||||||
|
syncTime := time.Now()
|
||||||
|
components := make([]LocalComponent, 0, len(rows))
|
||||||
|
newCount := 0
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
category := ""
|
||||||
|
if row.Category != nil {
|
||||||
|
category = *row.Category
|
||||||
|
} else {
|
||||||
|
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||||
|
parts := strings.SplitN(row.LotName, "_", 2)
|
||||||
|
if len(parts) >= 1 {
|
||||||
|
category = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model := ""
|
||||||
|
if row.Model != nil {
|
||||||
|
model = *row.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := LocalComponent{
|
||||||
|
LotName: row.LotName,
|
||||||
|
LotDescription: row.LotDescription,
|
||||||
|
Category: category,
|
||||||
|
Model: model,
|
||||||
|
CurrentPrice: row.CurrentPrice,
|
||||||
|
SyncedAt: syncTime,
|
||||||
|
}
|
||||||
|
components = append(components, comp)
|
||||||
|
|
||||||
|
if !existingMap[row.LotName] {
|
||||||
|
newCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use transaction for bulk upsert
|
||||||
|
err = l.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Delete all existing and insert new (simpler than upsert for SQLite)
|
||||||
|
if err := tx.Where("1=1").Delete(&LocalComponent{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("clearing local components: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert
|
||||||
|
batchSize := 500
|
||||||
|
for i := 0; i < len(components); i += batchSize {
|
||||||
|
end := i + batchSize
|
||||||
|
if end > len(components) {
|
||||||
|
end = len(components)
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(components[i:end], batchSize).Error; err != nil {
|
||||||
|
return fmt.Errorf("inserting components batch: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last sync time
|
||||||
|
if err := l.SetComponentSyncTime(syncTime); err != nil {
|
||||||
|
slog.Warn("failed to update component sync time", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ComponentSyncResult{
|
||||||
|
TotalSynced: len(components),
|
||||||
|
NewCount: newCount,
|
||||||
|
UpdateCount: len(components) - newCount,
|
||||||
|
Duration: time.Since(startTime),
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("components synced",
|
||||||
|
"total", result.TotalSynced,
|
||||||
|
"new", result.NewCount,
|
||||||
|
"updated", result.UpdateCount,
|
||||||
|
"duration", result.Duration)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchLocalComponents searches components in local cache by query string
|
||||||
|
// Searches in lot_name, lot_description, category, and model fields
|
||||||
|
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
var components []LocalComponent
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
// Return all components with limit
|
||||||
|
err := l.db.Order("lot_name").Limit(limit).Find(&components).Error
|
||||||
|
return components, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search with LIKE on multiple fields
|
||||||
|
searchPattern := "%" + strings.ToLower(query) + "%"
|
||||||
|
|
||||||
|
err := l.db.Where(
|
||||||
|
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
|
||||||
|
searchPattern, searchPattern, searchPattern, searchPattern,
|
||||||
|
).Order("lot_name").Limit(limit).Find(&components).Error
|
||||||
|
|
||||||
|
return components, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchLocalComponentsByCategory searches components by category and optional query
|
||||||
|
func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string, limit int) ([]LocalComponent, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
var components []LocalComponent
|
||||||
|
db := l.db.Where("LOWER(category) = ?", strings.ToLower(category))
|
||||||
|
|
||||||
|
if query != "" {
|
||||||
|
searchPattern := "%" + strings.ToLower(query) + "%"
|
||||||
|
db = db.Where(
|
||||||
|
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(model) LIKE ?",
|
||||||
|
searchPattern, searchPattern, searchPattern,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Order("lot_name").Limit(limit).Find(&components).Error
|
||||||
|
return components, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalComponent returns a single component by lot_name
|
||||||
|
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
||||||
|
var component LocalComponent
|
||||||
|
err := l.db.Where("lot_name = ?", lotName).First(&component).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &component, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalComponentCategories returns distinct categories from local components
|
||||||
|
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
||||||
|
var categories []string
|
||||||
|
err := l.db.Model(&LocalComponent{}).
|
||||||
|
Distinct("category").
|
||||||
|
Where("category != ''").
|
||||||
|
Order("category").
|
||||||
|
Pluck("category", &categories).Error
|
||||||
|
return categories, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountLocalComponents returns the total number of local components
|
||||||
|
func (l *LocalDB) CountLocalComponents() int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&LocalComponent{}).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountLocalComponentsByCategory returns component count by category
|
||||||
|
func (l *LocalDB) CountLocalComponentsByCategory(category string) int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&LocalComponent{}).Where("LOWER(category) = ?", strings.ToLower(category)).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetComponentSyncTime returns the last component sync timestamp
|
||||||
|
func (l *LocalDB) GetComponentSyncTime() *time.Time {
|
||||||
|
var setting struct {
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
if err := l.db.Table("app_settings").
|
||||||
|
Where("key = ?", "last_component_sync").
|
||||||
|
First(&setting).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := time.Parse(time.RFC3339, setting.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetComponentSyncTime sets the last component sync timestamp
|
||||||
|
func (l *LocalDB) SetComponentSyncTime(t time.Time) error {
|
||||||
|
return l.db.Exec(`
|
||||||
|
INSERT INTO app_settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||||
|
`, "last_component_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedComponentSync checks if component sync is needed (older than specified hours)
|
||||||
|
func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
||||||
|
syncTime := l.GetComponentSyncTime()
|
||||||
|
if syncTime == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
||||||
|
}
|
||||||
163
internal/localdb/converters.go
Normal file
163
internal/localdb/converters.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigurationToLocal converts models.Configuration to LocalConfiguration
|
||||||
|
func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||||
|
items := make(LocalConfigItems, len(cfg.Items))
|
||||||
|
for i, item := range cfg.Items {
|
||||||
|
items[i] = LocalConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
local := &LocalConfiguration{
|
||||||
|
UUID: cfg.UUID,
|
||||||
|
Name: cfg.Name,
|
||||||
|
Items: items,
|
||||||
|
TotalPrice: cfg.TotalPrice,
|
||||||
|
CustomPrice: cfg.CustomPrice,
|
||||||
|
Notes: cfg.Notes,
|
||||||
|
IsTemplate: cfg.IsTemplate,
|
||||||
|
ServerCount: cfg.ServerCount,
|
||||||
|
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||||
|
CreatedAt: cfg.CreatedAt,
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SyncStatus: "pending",
|
||||||
|
OriginalUserID: cfg.UserID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ID > 0 {
|
||||||
|
serverID := cfg.ID
|
||||||
|
local.ServerID = &serverID
|
||||||
|
}
|
||||||
|
|
||||||
|
return local
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalToConfiguration converts LocalConfiguration to models.Configuration
|
||||||
|
func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||||
|
items := make(models.ConfigItems, len(local.Items))
|
||||||
|
for i, item := range local.Items {
|
||||||
|
items[i] = models.ConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &models.Configuration{
|
||||||
|
UUID: local.UUID,
|
||||||
|
UserID: local.OriginalUserID,
|
||||||
|
Name: local.Name,
|
||||||
|
Items: items,
|
||||||
|
TotalPrice: local.TotalPrice,
|
||||||
|
CustomPrice: local.CustomPrice,
|
||||||
|
Notes: local.Notes,
|
||||||
|
IsTemplate: local.IsTemplate,
|
||||||
|
ServerCount: local.ServerCount,
|
||||||
|
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||||
|
CreatedAt: local.CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if local.ServerID != nil {
|
||||||
|
cfg.ID = *local.ServerID
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// PricelistToLocal converts models.Pricelist to LocalPricelist
|
||||||
|
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
||||||
|
name := pl.Notification
|
||||||
|
if name == "" {
|
||||||
|
name = pl.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LocalPricelist{
|
||||||
|
ServerID: pl.ID,
|
||||||
|
Version: pl.Version,
|
||||||
|
Name: name,
|
||||||
|
CreatedAt: pl.CreatedAt,
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
IsUsed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalToPricelist converts LocalPricelist to models.Pricelist
|
||||||
|
func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
|
||||||
|
return &models.Pricelist{
|
||||||
|
ID: local.ServerID,
|
||||||
|
Version: local.Version,
|
||||||
|
Notification: local.Name,
|
||||||
|
CreatedAt: local.CreatedAt,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
|
||||||
|
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
|
||||||
|
return &LocalPricelistItem{
|
||||||
|
PricelistID: localPricelistID,
|
||||||
|
LotName: item.LotName,
|
||||||
|
Price: item.Price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem
|
||||||
|
func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem {
|
||||||
|
return &models.PricelistItem{
|
||||||
|
ID: local.ID,
|
||||||
|
PricelistID: serverPricelistID,
|
||||||
|
LotName: local.LotName,
|
||||||
|
Price: local.Price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComponentToLocal converts models.LotMetadata to LocalComponent
|
||||||
|
func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
|
||||||
|
var lotDesc string
|
||||||
|
var category string
|
||||||
|
|
||||||
|
if meta.Lot != nil {
|
||||||
|
lotDesc = meta.Lot.LotDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||||
|
if len(meta.LotName) > 0 {
|
||||||
|
for i, ch := range meta.LotName {
|
||||||
|
if ch == '_' {
|
||||||
|
category = meta.LotName[:i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LocalComponent{
|
||||||
|
LotName: meta.LotName,
|
||||||
|
LotDescription: lotDesc,
|
||||||
|
Category: category,
|
||||||
|
Model: meta.Model,
|
||||||
|
CurrentPrice: meta.CurrentPrice,
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalToComponent converts LocalComponent to models.LotMetadata
|
||||||
|
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
||||||
|
return &models.LotMetadata{
|
||||||
|
LotName: local.LotName,
|
||||||
|
Model: local.Model,
|
||||||
|
CurrentPrice: local.CurrentPrice,
|
||||||
|
Lot: &models.Lot{
|
||||||
|
LotName: local.LotName,
|
||||||
|
LotDescription: local.LotDescription,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
87
internal/localdb/encryption.go
Normal file
87
internal/localdb/encryption.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getEncryptionKey derives a 32-byte key from environment variable or machine ID
|
||||||
|
func getEncryptionKey() []byte {
|
||||||
|
key := os.Getenv("QUOTEFORGE_ENCRYPTION_KEY")
|
||||||
|
if key == "" {
|
||||||
|
// Fallback to a machine-based key (hostname + fixed salt)
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
key = hostname + "quoteforge-salt-2024"
|
||||||
|
}
|
||||||
|
// Hash to get exactly 32 bytes for AES-256
|
||||||
|
hash := sha256.Sum256([]byte(key))
|
||||||
|
return hash[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt encrypts plaintext using AES-256-GCM
|
||||||
|
func Encrypt(plaintext string) (string, error) {
|
||||||
|
if plaintext == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
key := getEncryptionKey()
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts ciphertext that was encrypted with Encrypt
|
||||||
|
func Decrypt(ciphertext string) (string, error) {
|
||||||
|
if ciphertext == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
key := getEncryptionKey()
|
||||||
|
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(data) < nonceSize {
|
||||||
|
return "", errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
417
internal/localdb/localdb.go
Normal file
417
internal/localdb/localdb.go
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionSettings stores MariaDB connection credentials
|
||||||
|
type ConnectionSettings struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Host string `gorm:"not null"`
|
||||||
|
Port int `gorm:"not null;default:3306"`
|
||||||
|
Database string `gorm:"not null"`
|
||||||
|
User string `gorm:"not null"`
|
||||||
|
PasswordEncrypted string `gorm:"not null"` // AES encrypted
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ConnectionSettings) TableName() string {
|
||||||
|
return "connection_settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalDB manages the local SQLite database for settings
|
||||||
|
type LocalDB struct {
|
||||||
|
db *gorm.DB
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new LocalDB instance
|
||||||
|
func New(dbPath string) (*LocalDB, error) {
|
||||||
|
// Ensure directory exists
|
||||||
|
dir := filepath.Dir(dbPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("creating data directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-migrate all local tables
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&ConnectionSettings{},
|
||||||
|
&LocalConfiguration{},
|
||||||
|
&LocalPricelist{},
|
||||||
|
&LocalPricelistItem{},
|
||||||
|
&LocalComponent{},
|
||||||
|
&AppSetting{},
|
||||||
|
&PendingChange{},
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("migrating sqlite database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("local SQLite database initialized", "path", dbPath)
|
||||||
|
|
||||||
|
return &LocalDB{
|
||||||
|
db: db,
|
||||||
|
path: dbPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasSettings returns true if connection settings exist
|
||||||
|
func (l *LocalDB) HasSettings() bool {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&ConnectionSettings{}).Count(&count)
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettings retrieves the connection settings with decrypted password
|
||||||
|
func (l *LocalDB) GetSettings() (*ConnectionSettings, error) {
|
||||||
|
var settings ConnectionSettings
|
||||||
|
if err := l.db.First(&settings).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("getting settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt password
|
||||||
|
password, err := Decrypt(settings.PasswordEncrypted)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypting password: %w", err)
|
||||||
|
}
|
||||||
|
settings.PasswordEncrypted = password // Return decrypted password in this field
|
||||||
|
|
||||||
|
return &settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSettings saves connection settings with encrypted password
|
||||||
|
func (l *LocalDB) SaveSettings(host string, port int, database, user, password string) error {
|
||||||
|
// Encrypt password
|
||||||
|
encrypted, err := Encrypt(password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encrypting password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := ConnectionSettings{
|
||||||
|
ID: 1, // Always use ID=1 for single settings row
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
Database: database,
|
||||||
|
User: user,
|
||||||
|
PasswordEncrypted: encrypted,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert: create or update
|
||||||
|
result := l.db.Save(&settings)
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("saving settings: %w", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("connection settings saved", "host", host, "port", port, "database", database, "user", user)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSettings removes all connection settings
|
||||||
|
func (l *LocalDB) DeleteSettings() error {
|
||||||
|
return l.db.Where("1=1").Delete(&ConnectionSettings{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDSN returns the MariaDB DSN string
|
||||||
|
func (l *LocalDB) GetDSN() (string, error) {
|
||||||
|
settings, err := l.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
|
settings.User,
|
||||||
|
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
|
||||||
|
settings.Host,
|
||||||
|
settings.Port,
|
||||||
|
settings.Database,
|
||||||
|
)
|
||||||
|
|
||||||
|
return dsn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB returns the underlying gorm.DB for advanced operations
|
||||||
|
func (l *LocalDB) DB() *gorm.DB {
|
||||||
|
return l.db
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection
|
||||||
|
func (l *LocalDB) Close() error {
|
||||||
|
sqlDB, err := l.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDBUser returns the database username from settings
|
||||||
|
func (l *LocalDB) GetDBUser() string {
|
||||||
|
settings, err := l.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return settings.User
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration methods
|
||||||
|
|
||||||
|
// SaveConfiguration saves a configuration to local SQLite
|
||||||
|
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
|
||||||
|
return l.db.Save(config).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigurations returns all local configurations
|
||||||
|
func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) {
|
||||||
|
var configs []LocalConfiguration
|
||||||
|
err := l.db.Order("created_at DESC").Find(&configs).Error
|
||||||
|
return configs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigurationByUUID returns a configuration by UUID
|
||||||
|
func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, error) {
|
||||||
|
var config LocalConfiguration
|
||||||
|
err := l.db.Where("uuid = ?", uuid).First(&config).Error
|
||||||
|
return &config, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteConfiguration deletes a configuration by UUID
|
||||||
|
func (l *LocalDB) DeleteConfiguration(uuid string) error {
|
||||||
|
return l.db.Where("uuid = ?", uuid).Delete(&LocalConfiguration{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountConfigurations returns the number of local configurations
|
||||||
|
func (l *LocalDB) CountConfigurations() int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&LocalConfiguration{}).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pricelist methods
|
||||||
|
|
||||||
|
// GetLastSyncTime returns the last sync timestamp
|
||||||
|
func (l *LocalDB) GetLastSyncTime() *time.Time {
|
||||||
|
var setting struct {
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
if err := l.db.Table("app_settings").
|
||||||
|
Where("key = ?", "last_pricelist_sync").
|
||||||
|
First(&setting).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := time.Parse(time.RFC3339, setting.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLastSyncTime sets the last sync timestamp
|
||||||
|
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
||||||
|
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
|
||||||
|
return l.db.Exec(`
|
||||||
|
INSERT INTO app_settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||||
|
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountLocalPricelists returns the number of local pricelists
|
||||||
|
func (l *LocalDB) CountLocalPricelists() int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&LocalPricelist{}).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||||
|
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||||
|
var pricelist LocalPricelist
|
||||||
|
if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pricelist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalPricelistByServerID returns a local pricelist by its server ID
|
||||||
|
func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) {
|
||||||
|
var pricelist LocalPricelist
|
||||||
|
if err := l.db.Where("server_id = ?", serverID).First(&pricelist).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pricelist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalPricelistByID returns a local pricelist by its local ID
|
||||||
|
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
||||||
|
var pricelist LocalPricelist
|
||||||
|
if err := l.db.First(&pricelist, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pricelist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveLocalPricelist saves a pricelist to local SQLite
|
||||||
|
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
|
||||||
|
return l.db.Save(pricelist).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalPricelists returns all local pricelists
|
||||||
|
func (l *LocalDB) GetLocalPricelists() ([]LocalPricelist, error) {
|
||||||
|
var pricelists []LocalPricelist
|
||||||
|
if err := l.db.Order("created_at DESC").Find(&pricelists).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pricelists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountLocalPricelistItems returns the number of items for a pricelist
|
||||||
|
func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveLocalPricelistItems saves pricelist items to local SQLite
|
||||||
|
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert
|
||||||
|
batchSize := 500
|
||||||
|
for i := 0; i < len(items); i += batchSize {
|
||||||
|
end := i + batchSize
|
||||||
|
if end > len(items) {
|
||||||
|
end = len(items)
|
||||||
|
}
|
||||||
|
if err := l.db.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalPricelistItems returns items for a local pricelist
|
||||||
|
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
|
||||||
|
var items []LocalPricelistItem
|
||||||
|
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
||||||
|
func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||||
|
var item LocalPricelistItem
|
||||||
|
if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).
|
||||||
|
First(&item).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return item.Price, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPricelistAsUsed marks a pricelist as used by a configuration
|
||||||
|
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
||||||
|
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
||||||
|
Update("is_used", isUsed).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLocalPricelist deletes a pricelist and its items
|
||||||
|
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
|
||||||
|
// Delete items first
|
||||||
|
if err := l.db.Where("pricelist_id = ?", id).Delete(&LocalPricelistItem{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Delete pricelist
|
||||||
|
return l.db.Delete(&LocalPricelist{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingChange methods
|
||||||
|
|
||||||
|
// AddPendingChange adds a change to the sync queue
|
||||||
|
func (l *LocalDB) AddPendingChange(entityType, entityUUID, operation, payload string) error {
|
||||||
|
change := PendingChange{
|
||||||
|
EntityType: entityType,
|
||||||
|
EntityUUID: entityUUID,
|
||||||
|
Operation: operation,
|
||||||
|
Payload: payload,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
Attempts: 0,
|
||||||
|
}
|
||||||
|
return l.db.Create(&change).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingChanges returns all pending changes ordered by creation time
|
||||||
|
func (l *LocalDB) GetPendingChanges() ([]PendingChange, error) {
|
||||||
|
var changes []PendingChange
|
||||||
|
err := l.db.Order("created_at ASC").Find(&changes).Error
|
||||||
|
return changes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingChangesByEntity returns pending changes for a specific entity
|
||||||
|
func (l *LocalDB) GetPendingChangesByEntity(entityType, entityUUID string) ([]PendingChange, error) {
|
||||||
|
var changes []PendingChange
|
||||||
|
err := l.db.Where("entity_type = ? AND entity_uuid = ?", entityType, entityUUID).
|
||||||
|
Order("created_at ASC").Find(&changes).Error
|
||||||
|
return changes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePendingChange removes a change from the sync queue after successful sync
|
||||||
|
func (l *LocalDB) DeletePendingChange(id int64) error {
|
||||||
|
return l.db.Delete(&PendingChange{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementPendingChangeAttempts updates the attempt counter and last error
|
||||||
|
func (l *LocalDB) IncrementPendingChangeAttempts(id int64, errorMsg string) error {
|
||||||
|
return l.db.Model(&PendingChange{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
|
"attempts": gorm.Expr("attempts + 1"),
|
||||||
|
"last_error": errorMsg,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountPendingChanges returns the total number of pending changes
|
||||||
|
func (l *LocalDB) CountPendingChanges() int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&PendingChange{}).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountPendingChangesByType returns the number of pending changes by entity type
|
||||||
|
func (l *LocalDB) CountPendingChangesByType(entityType string) int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&PendingChange{}).Where("entity_type = ?", entityType).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountErroredChanges returns the number of pending changes with errors
|
||||||
|
func (l *LocalDB) CountErroredChanges() int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&PendingChange{}).Where("last_error != ?", "").Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkChangesSynced marks multiple pending changes as synced by deleting them
|
||||||
|
func (l *LocalDB) MarkChangesSynced(ids []int64) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return l.db.Where("id IN ?", ids).Delete(&PendingChange{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingCount returns the total number of pending changes (alias for CountPendingChanges)
|
||||||
|
func (l *LocalDB) GetPendingCount() int64 {
|
||||||
|
return l.CountPendingChanges()
|
||||||
|
}
|
||||||
139
internal/localdb/models.go
Normal file
139
internal/localdb/models.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppSetting stores application settings in local SQLite
|
||||||
|
type AppSetting struct {
|
||||||
|
Key string `gorm:"primaryKey" json:"key"`
|
||||||
|
Value string `gorm:"not null" json:"value"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AppSetting) TableName() string {
|
||||||
|
return "app_settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalConfigItem represents an item in a configuration
|
||||||
|
type LocalConfigItem struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalConfigItems is a slice of LocalConfigItem that can be stored as JSON
|
||||||
|
type LocalConfigItems []LocalConfigItem
|
||||||
|
|
||||||
|
func (c LocalConfigItems) Value() (driver.Value, error) {
|
||||||
|
return json.Marshal(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LocalConfigItems) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*c = make(LocalConfigItems, 0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var bytes []byte
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []byte:
|
||||||
|
bytes = v
|
||||||
|
case string:
|
||||||
|
bytes = []byte(v)
|
||||||
|
default:
|
||||||
|
return errors.New("type assertion failed for LocalConfigItems")
|
||||||
|
}
|
||||||
|
return json.Unmarshal(bytes, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c LocalConfigItems) Total() float64 {
|
||||||
|
var total float64
|
||||||
|
for _, item := range c {
|
||||||
|
total += item.UnitPrice * float64(item.Quantity)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalConfiguration stores configurations in local SQLite
|
||||||
|
type LocalConfiguration struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||||
|
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
||||||
|
TotalPrice *float64 `json:"total_price"`
|
||||||
|
CustomPrice *float64 `json:"custom_price"`
|
||||||
|
Notes string `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 `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
SyncedAt *time.Time `json:"synced_at"`
|
||||||
|
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||||
|
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LocalConfiguration) TableName() string {
|
||||||
|
return "local_configurations"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalPricelist stores cached pricelists from server
|
||||||
|
type LocalPricelist struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
|
||||||
|
Version string `gorm:"uniqueIndex;not null" json:"version"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
|
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LocalPricelist) TableName() string {
|
||||||
|
return "local_pricelists"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalPricelistItem stores pricelist items
|
||||||
|
type LocalPricelistItem struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
||||||
|
LotName string `gorm:"not null" json:"lot_name"`
|
||||||
|
Price float64 `gorm:"not null" json:"price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LocalPricelistItem) TableName() string {
|
||||||
|
return "local_pricelist_items"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalComponent stores cached components for offline search
|
||||||
|
type LocalComponent struct {
|
||||||
|
LotName string `gorm:"primaryKey" json:"lot_name"`
|
||||||
|
LotDescription string `json:"lot_description"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
CurrentPrice *float64 `json:"current_price"`
|
||||||
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LocalComponent) TableName() string {
|
||||||
|
return "local_components"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingChange stores changes that need to be synced to the server
|
||||||
|
type PendingChange struct {
|
||||||
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
EntityType string `gorm:"not null;index" json:"entity_type"` // "configuration", "project", "specification"
|
||||||
|
EntityUUID string `gorm:"not null;index" json:"entity_uuid"`
|
||||||
|
Operation string `gorm:"not null" json:"operation"` // "create", "update", "delete"
|
||||||
|
Payload string `gorm:"type:text" json:"payload"` // JSON snapshot of the entity
|
||||||
|
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||||
|
Attempts int `gorm:"default:0" json:"attempts"` // Retry count for sync
|
||||||
|
LastError string `gorm:"type:text" json:"last_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PendingChange) TableName() string {
|
||||||
|
return "pending_changes"
|
||||||
|
}
|
||||||
43
internal/middleware/offline.go
Normal file
43
internal/middleware/offline.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OfflineDetector creates middleware that detects offline mode
|
||||||
|
// Sets context values:
|
||||||
|
// - "is_offline" (bool) - true if MariaDB is unavailable
|
||||||
|
// - "localdb" (*localdb.LocalDB) - local database instance for fallback
|
||||||
|
func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
isOffline := !checkMariaDBOnline(mariaDB)
|
||||||
|
|
||||||
|
// Set context values for handlers
|
||||||
|
c.Set("is_offline", isOffline)
|
||||||
|
c.Set("localdb", local)
|
||||||
|
|
||||||
|
if isOffline {
|
||||||
|
slog.Debug("offline mode detected - MariaDB unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMariaDBOnline checks if MariaDB is accessible
|
||||||
|
func checkMariaDBOnline(mariaDB *gorm.DB) bool {
|
||||||
|
sqlDB, err := mariaDB.DB()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -40,17 +40,18 @@ func (c ConfigItems) Total() float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||||
UserID uint `gorm:"not null" json:"user_id"`
|
UserID uint `gorm:"not null" json:"user_id"`
|
||||||
Name string `gorm:"size:200;not null" json:"name"`
|
Name string `gorm:"size:200;not null" json:"name"`
|
||||||
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||||
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
|
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
|
||||||
Notes string `gorm:"type:text" json:"notes"`
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
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"`
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "gorm.io/gorm"
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
// AllModels returns all models for auto-migration
|
// AllModels returns all models for auto-migration
|
||||||
func AllModels() []interface{} {
|
func AllModels() []interface{} {
|
||||||
@@ -12,12 +17,28 @@ func AllModels() []interface{} {
|
|||||||
&PriceOverride{},
|
&PriceOverride{},
|
||||||
&PricingAlert{},
|
&PricingAlert{},
|
||||||
&ComponentUsageStats{},
|
&ComponentUsageStats{},
|
||||||
|
&Pricelist{},
|
||||||
|
&PricelistItem{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate runs auto-migration for all QuoteForge tables
|
// Migrate runs auto-migration for all QuoteForge tables
|
||||||
|
// Handles MySQL constraint errors gracefully for existing tables
|
||||||
func Migrate(db *gorm.DB) error {
|
func Migrate(db *gorm.DB) error {
|
||||||
return db.AutoMigrate(AllModels()...)
|
for _, model := range AllModels() {
|
||||||
|
if err := db.AutoMigrate(model); err != nil {
|
||||||
|
// Skip known MySQL constraint errors for existing tables
|
||||||
|
errStr := err.Error()
|
||||||
|
if strings.Contains(errStr, "Can't DROP") ||
|
||||||
|
strings.Contains(errStr, "Duplicate key name") ||
|
||||||
|
strings.Contains(errStr, "check that it exists") {
|
||||||
|
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedCategories inserts default categories if not exist
|
// SeedCategories inserts default categories if not exist
|
||||||
@@ -49,3 +70,35 @@ func SeedAdminUser(db *gorm.DB, passwordHash string) error {
|
|||||||
}
|
}
|
||||||
return db.Create(admin).Error
|
return db.Create(admin).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureDBUser creates or returns the user corresponding to the database connection username.
|
||||||
|
// This is used when RBAC is disabled - configurations are owned by the DB user.
|
||||||
|
// Returns the user ID that should be used for all operations.
|
||||||
|
func EnsureDBUser(db *gorm.DB, dbUsername string) (uint, error) {
|
||||||
|
if dbUsername == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
err := db.Where("username = ?", dbUsername).First(&user).Error
|
||||||
|
if err == nil {
|
||||||
|
return user.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// User doesn't exist, create it
|
||||||
|
user = User{
|
||||||
|
Username: dbUsername,
|
||||||
|
Email: dbUsername + "@db.local",
|
||||||
|
PasswordHash: "-", // No password - this is a DB user, not an app user
|
||||||
|
Role: RoleAdmin,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
slog.Error("failed to create DB user", "username", dbUsername, "error", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("created DB user for configurations", "username", dbUsername, "user_id", user.ID)
|
||||||
|
return user.ID, nil
|
||||||
|
}
|
||||||
|
|||||||
58
internal/models/pricelist.go
Normal file
58
internal/models/pricelist.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pricelist represents a versioned snapshot of prices
|
||||||
|
type Pricelist struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Version string `gorm:"size:20;uniqueIndex;not null" json:"version"` // Format: YYYY-MM-DD-NNN
|
||||||
|
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
CreatedBy string `gorm:"size:100" json:"created_by"`
|
||||||
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
|
UsageCount int `gorm:"default:0" json:"usage_count"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
ItemCount int `gorm:"-" json:"item_count,omitempty"` // Virtual field for display
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Pricelist) TableName() string {
|
||||||
|
return "qt_pricelists"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PricelistItem represents a single item in a pricelist
|
||||||
|
type PricelistItem struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
|
||||||
|
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
|
||||||
|
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
||||||
|
PriceMethod string `gorm:"size:20" json:"price_method"`
|
||||||
|
|
||||||
|
// Price calculation settings (snapshot from qt_lot_metadata)
|
||||||
|
PricePeriodDays int `gorm:"default:90" json:"price_period_days"`
|
||||||
|
PriceCoefficient float64 `gorm:"type:decimal(5,2);default:0" json:"price_coefficient"`
|
||||||
|
ManualPrice *float64 `gorm:"type:decimal(12,2)" json:"manual_price,omitempty"`
|
||||||
|
MetaPrices string `gorm:"size:1000" json:"meta_prices,omitempty"`
|
||||||
|
|
||||||
|
// Virtual fields for display
|
||||||
|
LotDescription string `gorm:"-" json:"lot_description,omitempty"`
|
||||||
|
Category string `gorm:"-" json:"category,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PricelistItem) TableName() string {
|
||||||
|
return "qt_pricelist_items"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PricelistSummary is used for list views
|
||||||
|
type PricelistSummary struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Notification string `json:"notification"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
UsageCount int `json:"usage_count"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
ItemCount int64 `json:"item_count"`
|
||||||
|
}
|
||||||
@@ -73,3 +73,18 @@ func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Con
|
|||||||
|
|
||||||
return configs, total, err
|
return configs, total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAll returns all configurations without user filter
|
||||||
|
func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configuration, int64, error) {
|
||||||
|
var configs []models.Configuration
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
r.db.Model(&models.Configuration{}).Count(&total)
|
||||||
|
err := r.db.
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(limit).
|
||||||
|
Find(&configs).Error
|
||||||
|
|
||||||
|
return configs, total, err
|
||||||
|
}
|
||||||
|
|||||||
259
internal/repository/pricelist.go
Normal file
259
internal/repository/pricelist.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PricelistRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPricelistRepository(db *gorm.DB) *PricelistRepository {
|
||||||
|
return &PricelistRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns pricelists with pagination
|
||||||
|
func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&models.Pricelist{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("counting pricelists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pricelists []models.Pricelist
|
||||||
|
if err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get item counts for each pricelist
|
||||||
|
summaries := make([]models.PricelistSummary, len(pricelists))
|
||||||
|
for i, pl := range pricelists {
|
||||||
|
var itemCount int64
|
||||||
|
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
|
||||||
|
|
||||||
|
summaries[i] = models.PricelistSummary{
|
||||||
|
ID: pl.ID,
|
||||||
|
Version: pl.Version,
|
||||||
|
Notification: pl.Notification,
|
||||||
|
CreatedAt: pl.CreatedAt,
|
||||||
|
CreatedBy: pl.CreatedBy,
|
||||||
|
IsActive: pl.IsActive,
|
||||||
|
UsageCount: pl.UsageCount,
|
||||||
|
ExpiresAt: pl.ExpiresAt,
|
||||||
|
ItemCount: itemCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns a pricelist by ID
|
||||||
|
func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
|
||||||
|
var pricelist models.Pricelist
|
||||||
|
if err := r.db.First(&pricelist, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("getting pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get item count
|
||||||
|
var itemCount int64
|
||||||
|
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
|
||||||
|
pricelist.ItemCount = int(itemCount)
|
||||||
|
|
||||||
|
return &pricelist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByVersion returns a pricelist by version string
|
||||||
|
func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) {
|
||||||
|
var pricelist models.Pricelist
|
||||||
|
if err := r.db.Where("version = ?", version).First(&pricelist).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("getting pricelist by version: %w", err)
|
||||||
|
}
|
||||||
|
return &pricelist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestActive returns the most recent active pricelist
|
||||||
|
func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
|
||||||
|
var pricelist models.Pricelist
|
||||||
|
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
||||||
|
}
|
||||||
|
return &pricelist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new pricelist
|
||||||
|
func (r *PricelistRepository) Create(pricelist *models.Pricelist) error {
|
||||||
|
if err := r.db.Create(pricelist).Error; err != nil {
|
||||||
|
return fmt.Errorf("creating pricelist: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a pricelist
|
||||||
|
func (r *PricelistRepository) Update(pricelist *models.Pricelist) error {
|
||||||
|
if err := r.db.Save(pricelist).Error; err != nil {
|
||||||
|
return fmt.Errorf("updating pricelist: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a pricelist if usage_count is 0
|
||||||
|
func (r *PricelistRepository) Delete(id uint) error {
|
||||||
|
pricelist, err := r.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pricelist.UsageCount > 0 {
|
||||||
|
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete items first
|
||||||
|
if err := r.db.Where("pricelist_id = ?", id).Delete(&models.PricelistItem{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("deleting pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete pricelist
|
||||||
|
if err := r.db.Delete(&models.Pricelist{}, id).Error; err != nil {
|
||||||
|
return fmt.Errorf("deleting pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateItems batch inserts pricelist items
|
||||||
|
func (r *PricelistRepository) CreateItems(items []models.PricelistItem) error {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use batch insert for better performance
|
||||||
|
batchSize := 500
|
||||||
|
for i := 0; i < len(items); i += batchSize {
|
||||||
|
end := i + batchSize
|
||||||
|
if end > len(items) {
|
||||||
|
end = len(items)
|
||||||
|
}
|
||||||
|
if err := r.db.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
||||||
|
return fmt.Errorf("batch inserting pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItems returns pricelist items with pagination
|
||||||
|
func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, search string) ([]models.PricelistItem, int64, error) {
|
||||||
|
var total int64
|
||||||
|
query := r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pricelistID)
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
query = query.Where("lot_name LIKE ?", "%"+search+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("counting pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []models.PricelistItem
|
||||||
|
if err := query.Order("lot_name").Offset(offset).Limit(limit).Find(&items).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("listing pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with lot descriptions
|
||||||
|
for i := range items {
|
||||||
|
var lot models.Lot
|
||||||
|
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
|
||||||
|
items[i].LotDescription = lot.LotDescription
|
||||||
|
}
|
||||||
|
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||||
|
parts := strings.SplitN(items[i].LotName, "_", 2)
|
||||||
|
if len(parts) >= 1 {
|
||||||
|
items[i].Category = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
|
||||||
|
func (r *PricelistRepository) GenerateVersion() (string, error) {
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := r.db.Model(&models.Pricelist{}).
|
||||||
|
Where("version LIKE ?", today+"%").
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return "", fmt.Errorf("counting today's pricelists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%03d", today, count+1), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanWrite checks if the current database user has INSERT permission on qt_pricelists
|
||||||
|
func (r *PricelistRepository) CanWrite() bool {
|
||||||
|
canWrite, _ := r.CanWriteDebug()
|
||||||
|
return canWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanWriteDebug checks write permission and returns debug info
|
||||||
|
// Uses raw SQL with explicit columns to avoid schema mismatch issues
|
||||||
|
func (r *PricelistRepository) CanWriteDebug() (bool, string) {
|
||||||
|
// Check if table exists first
|
||||||
|
var count int64
|
||||||
|
if err := r.db.Table("qt_pricelists").Count(&count).Error; err != nil {
|
||||||
|
return false, fmt.Sprintf("table check failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use raw SQL with only essential columns that always exist
|
||||||
|
// This avoids GORM model validation and schema mismatch issues
|
||||||
|
tx := r.db.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return false, fmt.Sprintf("begin tx failed: %v", tx.Error)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() // Always rollback - this is just a permission test
|
||||||
|
|
||||||
|
testVersion := fmt.Sprintf("test-%06d", time.Now().Unix()%1000000)
|
||||||
|
|
||||||
|
// Raw SQL insert with only core columns
|
||||||
|
err := tx.Exec(`
|
||||||
|
INSERT INTO qt_pricelists (version, created_by, is_active)
|
||||||
|
VALUES (?, 'system', 1)
|
||||||
|
`, testVersion).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Check if it's a permission error vs other errors
|
||||||
|
errStr := err.Error()
|
||||||
|
if strings.Contains(errStr, "INSERT command denied") ||
|
||||||
|
strings.Contains(errStr, "Access denied") {
|
||||||
|
return false, "no write permission"
|
||||||
|
}
|
||||||
|
return false, fmt.Sprintf("insert failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementUsageCount increments the usage count for a pricelist
|
||||||
|
func (r *PricelistRepository) IncrementUsageCount(id uint) error {
|
||||||
|
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).
|
||||||
|
UpdateColumn("usage_count", gorm.Expr("usage_count + 1")).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecrementUsageCount decrements the usage count for a pricelist
|
||||||
|
func (r *PricelistRepository) DecrementUsageCount(id uint) error {
|
||||||
|
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).
|
||||||
|
UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExpiredUnused returns pricelists that are expired and unused
|
||||||
|
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
|
||||||
|
var pricelists []models.Pricelist
|
||||||
|
if err := r.db.Where("expires_at < ? AND usage_count = 0", time.Now()).
|
||||||
|
Find(&pricelists).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("getting expired pricelists: %w", err)
|
||||||
|
}
|
||||||
|
return pricelists, nil
|
||||||
|
}
|
||||||
399
internal/repository/unified.go
Normal file
399
internal/repository/unified.go
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DataSource defines the unified interface for data access
|
||||||
|
// It abstracts whether data comes from MariaDB (online) or SQLite (offline)
|
||||||
|
type DataSource interface {
|
||||||
|
// Components
|
||||||
|
GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error)
|
||||||
|
GetComponent(lotName string) (*models.LotMetadata, error)
|
||||||
|
|
||||||
|
// Configurations
|
||||||
|
SaveConfiguration(cfg *models.Configuration) error
|
||||||
|
GetConfigurations(userID uint) ([]models.Configuration, error)
|
||||||
|
GetConfigurationByUUID(uuid string) (*models.Configuration, error)
|
||||||
|
DeleteConfiguration(uuid string) error
|
||||||
|
|
||||||
|
// Pricelists (read-only in offline mode)
|
||||||
|
GetPricelists() ([]models.PricelistSummary, error)
|
||||||
|
GetPricelistByID(id uint) (*models.Pricelist, error)
|
||||||
|
GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error)
|
||||||
|
GetLatestPricelist() (*models.Pricelist, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnifiedRepo implements DataSource with automatic online/offline switching
|
||||||
|
type UnifiedRepo struct {
|
||||||
|
mariaDB *gorm.DB
|
||||||
|
localDB *localdb.LocalDB
|
||||||
|
isOnline bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUnifiedRepo creates a new unified repository
|
||||||
|
func NewUnifiedRepo(mariaDB *gorm.DB, localDB *localdb.LocalDB, isOnline bool) *UnifiedRepo {
|
||||||
|
return &UnifiedRepo{
|
||||||
|
mariaDB: mariaDB,
|
||||||
|
localDB: localDB,
|
||||||
|
isOnline: isOnline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOnlineStatus updates the online/offline status
|
||||||
|
func (r *UnifiedRepo) SetOnlineStatus(online bool) {
|
||||||
|
r.isOnline = online
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsOnline returns the current online/offline status
|
||||||
|
func (r *UnifiedRepo) IsOnline() bool {
|
||||||
|
return r.isOnline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component methods
|
||||||
|
|
||||||
|
// GetComponents returns components from MariaDB (online) or local cache (offline)
|
||||||
|
func (r *UnifiedRepo) GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||||
|
if r.isOnline {
|
||||||
|
return r.getComponentsOnline(filter, offset, limit)
|
||||||
|
}
|
||||||
|
return r.getComponentsOffline(filter, offset, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UnifiedRepo) getComponentsOnline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||||
|
repo := NewComponentRepository(r.mariaDB)
|
||||||
|
return repo.List(filter, offset, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||||
|
var components []localdb.LocalComponent
|
||||||
|
query := r.localDB.DB().Model(&localdb.LocalComponent{})
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if filter.Category != "" {
|
||||||
|
query = query.Where("category = ?", filter.Category)
|
||||||
|
}
|
||||||
|
if filter.Search != "" {
|
||||||
|
search := "%" + filter.Search + "%"
|
||||||
|
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
|
||||||
|
}
|
||||||
|
if filter.HasPrice {
|
||||||
|
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
sortDir := "ASC"
|
||||||
|
if filter.SortDir == "desc" {
|
||||||
|
sortDir = "DESC"
|
||||||
|
}
|
||||||
|
switch filter.SortField {
|
||||||
|
case "current_price":
|
||||||
|
query = query.Order("current_price " + sortDir)
|
||||||
|
case "lot_name":
|
||||||
|
query = query.Order("lot_name " + sortDir)
|
||||||
|
default:
|
||||||
|
query = query.Order("lot_name ASC")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Offset(offset).Limit(limit).Find(&components).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("fetching offline components: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to models.LotMetadata
|
||||||
|
result := make([]models.LotMetadata, len(components))
|
||||||
|
for i, comp := range components {
|
||||||
|
result[i] = models.LotMetadata{
|
||||||
|
LotName: comp.LotName,
|
||||||
|
Model: comp.Model,
|
||||||
|
CurrentPrice: comp.CurrentPrice,
|
||||||
|
Lot: &models.Lot{
|
||||||
|
LotName: comp.LotName,
|
||||||
|
LotDescription: comp.LotDescription,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetComponent returns a single component by lot name
|
||||||
|
func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error) {
|
||||||
|
if r.isOnline {
|
||||||
|
repo := NewComponentRepository(r.mariaDB)
|
||||||
|
return repo.GetByLotName(lotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var comp localdb.LocalComponent
|
||||||
|
if err := r.localDB.DB().Where("lot_name = ?", lotName).First(&comp).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching offline component: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.LotMetadata{
|
||||||
|
LotName: comp.LotName,
|
||||||
|
Model: comp.Model,
|
||||||
|
CurrentPrice: comp.CurrentPrice,
|
||||||
|
Lot: &models.Lot{
|
||||||
|
LotName: comp.LotName,
|
||||||
|
LotDescription: comp.LotDescription,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration methods
|
||||||
|
|
||||||
|
// SaveConfiguration saves a configuration (online: MariaDB, offline: SQLite + pending_changes)
|
||||||
|
func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
|
||||||
|
if r.isOnline {
|
||||||
|
repo := NewConfigurationRepository(r.mariaDB)
|
||||||
|
return repo.Create(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline: save to local SQLite and queue for sync
|
||||||
|
localCfg := &localdb.LocalConfiguration{
|
||||||
|
UUID: cfg.UUID,
|
||||||
|
Name: cfg.Name,
|
||||||
|
TotalPrice: cfg.TotalPrice,
|
||||||
|
CustomPrice: cfg.CustomPrice,
|
||||||
|
Notes: cfg.Notes,
|
||||||
|
IsTemplate: cfg.IsTemplate,
|
||||||
|
ServerCount: cfg.ServerCount,
|
||||||
|
CreatedAt: cfg.CreatedAt,
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SyncStatus: "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert items
|
||||||
|
localItems := make(localdb.LocalConfigItems, len(cfg.Items))
|
||||||
|
for i, item := range cfg.Items {
|
||||||
|
localItems[i] = localdb.LocalConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localCfg.Items = localItems
|
||||||
|
|
||||||
|
if err := r.localDB.SaveConfiguration(localCfg); err != nil {
|
||||||
|
return fmt.Errorf("saving local configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to pending changes queue
|
||||||
|
payload, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling configuration for sync: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigurations returns all configurations for a user
|
||||||
|
func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, error) {
|
||||||
|
if r.isOnline {
|
||||||
|
repo := NewConfigurationRepository(r.mariaDB)
|
||||||
|
configs, _, err := repo.ListByUser(userID, 0, 1000)
|
||||||
|
return configs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline: get from local SQLite
|
||||||
|
localConfigs, err := r.localDB.GetConfigurations()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching local configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to models.Configuration
|
||||||
|
result := make([]models.Configuration, len(localConfigs))
|
||||||
|
for i, lc := range localConfigs {
|
||||||
|
items := make(models.ConfigItems, len(lc.Items))
|
||||||
|
for j, item := range lc.Items {
|
||||||
|
items[j] = models.ConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result[i] = models.Configuration{
|
||||||
|
UUID: lc.UUID,
|
||||||
|
Name: lc.Name,
|
||||||
|
Items: items,
|
||||||
|
TotalPrice: lc.TotalPrice,
|
||||||
|
CustomPrice: lc.CustomPrice,
|
||||||
|
Notes: lc.Notes,
|
||||||
|
IsTemplate: lc.IsTemplate,
|
||||||
|
ServerCount: lc.ServerCount,
|
||||||
|
CreatedAt: lc.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigurationByUUID returns a configuration by UUID
|
||||||
|
func (r *UnifiedRepo) GetConfigurationByUUID(uuid string) (*models.Configuration, error) {
|
||||||
|
if r.isOnline {
|
||||||
|
repo := NewConfigurationRepository(r.mariaDB)
|
||||||
|
return repo.GetByUUID(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg, err := r.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching local configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make(models.ConfigItems, len(localCfg.Items))
|
||||||
|
for i, item := range localCfg.Items {
|
||||||
|
items[i] = models.ConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.Configuration{
|
||||||
|
UUID: localCfg.UUID,
|
||||||
|
Name: localCfg.Name,
|
||||||
|
Items: items,
|
||||||
|
TotalPrice: localCfg.TotalPrice,
|
||||||
|
CustomPrice: localCfg.CustomPrice,
|
||||||
|
Notes: localCfg.Notes,
|
||||||
|
IsTemplate: localCfg.IsTemplate,
|
||||||
|
ServerCount: localCfg.ServerCount,
|
||||||
|
CreatedAt: localCfg.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteConfiguration deletes a configuration
|
||||||
|
func (r *UnifiedRepo) DeleteConfiguration(uuid string) error {
|
||||||
|
if r.isOnline {
|
||||||
|
// Get ID first
|
||||||
|
cfg, err := r.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
repo := NewConfigurationRepository(r.mariaDB)
|
||||||
|
return repo.Delete(cfg.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline: delete from local and queue sync
|
||||||
|
if err := r.localDB.DeleteConfiguration(uuid); err != nil {
|
||||||
|
return fmt.Errorf("deleting local configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.localDB.AddPendingChange("configuration", uuid, "delete", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pricelist methods
|
||||||
|
|
||||||
|
// GetPricelists returns all pricelists
|
||||||
|
func (r *UnifiedRepo) GetPricelists() ([]models.PricelistSummary, error) {
|
||||||
|
if r.isOnline {
|
||||||
|
repo := NewPricelistRepository(r.mariaDB)
|
||||||
|
summaries, _, err := repo.List(0, 1000)
|
||||||
|
return summaries, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline: get from local cache
|
||||||
|
localPLs, err := r.localDB.GetLocalPricelists()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching local pricelists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries := make([]models.PricelistSummary, len(localPLs))
|
||||||
|
for i, pl := range localPLs {
|
||||||
|
itemCount := r.localDB.CountLocalPricelistItems(pl.ID)
|
||||||
|
summaries[i] = models.PricelistSummary{
|
||||||
|
ID: pl.ServerID,
|
||||||
|
Version: pl.Version,
|
||||||
|
CreatedAt: pl.CreatedAt,
|
||||||
|
ItemCount: itemCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPricelistByID returns a pricelist by ID
|
||||||
|
func (r *UnifiedRepo) GetPricelistByID(id uint) (*models.Pricelist, error) {
|
||||||
|
if r.isOnline {
|
||||||
|
repo := NewPricelistRepository(r.mariaDB)
|
||||||
|
return repo.GetByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline: get from local cache
|
||||||
|
localPL, err := r.localDB.GetLocalPricelistByServerID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching local pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemCount := r.localDB.CountLocalPricelistItems(localPL.ID)
|
||||||
|
return &models.Pricelist{
|
||||||
|
ID: localPL.ServerID,
|
||||||
|
Version: localPL.Version,
|
||||||
|
CreatedAt: localPL.CreatedAt,
|
||||||
|
ItemCount: int(itemCount),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPricelistItems returns items for a pricelist
|
||||||
|
func (r *UnifiedRepo) GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error) {
|
||||||
|
if r.isOnline {
|
||||||
|
repo := NewPricelistRepository(r.mariaDB)
|
||||||
|
items, _, err := repo.GetItems(pricelistID, 0, 100000, "")
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline: get from local cache
|
||||||
|
// First find the local pricelist by server ID
|
||||||
|
localPL, err := r.localDB.GetLocalPricelistByServerID(pricelistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching local pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localItems, err := r.localDB.GetLocalPricelistItems(localPL.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching local pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]models.PricelistItem, len(localItems))
|
||||||
|
for i, item := range localItems {
|
||||||
|
items[i] = models.PricelistItem{
|
||||||
|
ID: item.ID,
|
||||||
|
PricelistID: pricelistID,
|
||||||
|
LotName: item.LotName,
|
||||||
|
Price: item.Price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestPricelist returns the latest pricelist
|
||||||
|
func (r *UnifiedRepo) GetLatestPricelist() (*models.Pricelist, error) {
|
||||||
|
if r.isOnline {
|
||||||
|
repo := NewPricelistRepository(r.mariaDB)
|
||||||
|
return repo.GetLatestActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline: get from local cache
|
||||||
|
localPL, err := r.localDB.GetLatestLocalPricelist()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching latest local pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemCount := r.localDB.CountLocalPricelistItems(localPL.ID)
|
||||||
|
return &models.Pricelist{
|
||||||
|
ID: localPL.ServerID,
|
||||||
|
Version: localPL.Version,
|
||||||
|
CreatedAt: localPL.CreatedAt,
|
||||||
|
ItemCount: int(itemCount),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
@@ -13,6 +14,12 @@ var (
|
|||||||
ErrConfigForbidden = errors.New("access to configuration forbidden")
|
ErrConfigForbidden = errors.New("access to configuration forbidden")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ConfigurationGetter is an interface for services that can retrieve configurations
|
||||||
|
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
|
||||||
|
type ConfigurationGetter interface {
|
||||||
|
GetByUUID(uuid string, userID uint) (*models.Configuration, error)
|
||||||
|
}
|
||||||
|
|
||||||
type ConfigurationService struct {
|
type ConfigurationService struct {
|
||||||
configRepo *repository.ConfigurationRepository
|
configRepo *repository.ConfigurationRepository
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
@@ -193,6 +200,149 @@ func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]mod
|
|||||||
return s.configRepo.ListByUser(userID, offset, perPage)
|
return s.configRepo.ListByUser(userID, offset, perPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAll returns all configurations without user filter (for use when auth is disabled)
|
||||||
|
func (s *ConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 || perPage > 100 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
return s.configRepo.ListAll(offset, perPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUUIDNoAuth returns configuration without ownership check (for use when auth is disabled)
|
||||||
|
func (s *ConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||||
|
config, err := s.configRepo.GetByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNoAuth updates configuration without ownership check
|
||||||
|
func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
|
config, err := s.configRepo.GetByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
total := req.Items.Total()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNoAuth deletes configuration without ownership check
|
||||||
|
func (s *ConfigurationService) DeleteNoAuth(uuid string) error {
|
||||||
|
config, err := s.configRepo.GetByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return ErrConfigNotFound
|
||||||
|
}
|
||||||
|
return s.configRepo.Delete(config.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameNoAuth renames configuration without ownership check
|
||||||
|
func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) {
|
||||||
|
config, err := s.configRepo.GetByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Name = newName
|
||||||
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneNoAuth clones configuration without ownership check
|
||||||
|
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) {
|
||||||
|
original, err := s.configRepo.GetByUUID(configUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
total := original.Items.Total()
|
||||||
|
if original.ServerCount > 1 {
|
||||||
|
total *= float64(original.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := &models.Configuration{
|
||||||
|
UUID: uuid.New().String(),
|
||||||
|
UserID: userID, // Use provided user ID
|
||||||
|
Name: newName,
|
||||||
|
Items: original.Items,
|
||||||
|
TotalPrice: &total,
|
||||||
|
CustomPrice: original.CustomPrice,
|
||||||
|
Notes: original.Notes,
|
||||||
|
IsTemplate: false,
|
||||||
|
ServerCount: original.ServerCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.configRepo.Create(clone); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshPricesNoAuth refreshes prices without ownership check
|
||||||
|
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||||
|
config, err := s.configRepo.GetByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||||
|
for i, item := range config.Items {
|
||||||
|
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||||
|
if err != nil || metadata.CurrentPrice == nil {
|
||||||
|
updatedItems[i] = item
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedItems[i] = models.ConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: *metadata.CurrentPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Items = updatedItems
|
||||||
|
total := updatedItems.Total()
|
||||||
|
if config.ServerCount > 1 {
|
||||||
|
total *= float64(config.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.TotalPrice = &total
|
||||||
|
now := time.Now()
|
||||||
|
config.PriceUpdatedAt = &now
|
||||||
|
|
||||||
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
|
func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
@@ -205,6 +355,58 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config
|
|||||||
return s.configRepo.ListTemplates(offset, perPage)
|
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
|
// // Export configuration as JSON
|
||||||
// type ConfigExport struct {
|
// type ConfigExport struct {
|
||||||
// Name string `json:"name"`
|
// Name string `json:"name"`
|
||||||
|
|||||||
623
internal/services/local_configuration.go
Normal file
623
internal/services/local_configuration.go
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LocalConfigurationService handles configurations in local-first mode
|
||||||
|
// All operations go through SQLite, MariaDB is used only for sync
|
||||||
|
type LocalConfigurationService struct {
|
||||||
|
localDB *localdb.LocalDB
|
||||||
|
syncService *sync.Service
|
||||||
|
quoteService *QuoteService
|
||||||
|
isOnline func() bool // Function to check if we're online
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLocalConfigurationService creates a new local-first configuration service
|
||||||
|
func NewLocalConfigurationService(
|
||||||
|
localDB *localdb.LocalDB,
|
||||||
|
syncService *sync.Service,
|
||||||
|
quoteService *QuoteService,
|
||||||
|
isOnline func() bool,
|
||||||
|
) *LocalConfigurationService {
|
||||||
|
return &LocalConfigurationService{
|
||||||
|
localDB: localDB,
|
||||||
|
syncService: syncService,
|
||||||
|
quoteService: quoteService,
|
||||||
|
isOnline: isOnline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new configuration in local SQLite and queues it for sync
|
||||||
|
func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
|
// If online, check for new pricelists first
|
||||||
|
if s.isOnline() {
|
||||||
|
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||||
|
// Log but don't fail - we can still use local pricelists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := req.Items.Total()
|
||||||
|
if req.ServerCount > 1 {
|
||||||
|
total *= float64(req.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &models.Configuration{
|
||||||
|
UUID: uuid.New().String(),
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
Items: req.Items,
|
||||||
|
TotalPrice: &total,
|
||||||
|
CustomPrice: req.CustomPrice,
|
||||||
|
Notes: req.Notes,
|
||||||
|
IsTemplate: req.IsTemplate,
|
||||||
|
ServerCount: req.ServerCount,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to local model
|
||||||
|
localCfg := localdb.ConfigurationToLocal(cfg)
|
||||||
|
|
||||||
|
// Save to local SQLite
|
||||||
|
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to pending sync queue
|
||||||
|
payload, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record usage stats
|
||||||
|
_ = s.quoteService.RecordUsage(req.Items)
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUUID returns a configuration from local SQLite
|
||||||
|
func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models.Configuration, error) {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to models.Configuration
|
||||||
|
cfg := localdb.LocalToConfiguration(localCfg)
|
||||||
|
|
||||||
|
// Allow access if user owns config or it's a template
|
||||||
|
if cfg.UserID != userID && !cfg.IsTemplate {
|
||||||
|
return nil, ErrConfigForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a configuration in local SQLite and queues it for sync
|
||||||
|
func (s *LocalConfigurationService) Update(uuid string, userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if localCfg.OriginalUserID != userID {
|
||||||
|
return nil, ErrConfigForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
total := req.Items.Total()
|
||||||
|
if req.ServerCount > 1 {
|
||||||
|
total *= float64(req.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields
|
||||||
|
localCfg.Name = req.Name
|
||||||
|
localCfg.Items = localdb.LocalConfigItems{}
|
||||||
|
for _, item := range req.Items {
|
||||||
|
localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
localCfg.TotalPrice = &total
|
||||||
|
localCfg.CustomPrice = req.CustomPrice
|
||||||
|
localCfg.Notes = req.Notes
|
||||||
|
localCfg.IsTemplate = req.IsTemplate
|
||||||
|
localCfg.ServerCount = req.ServerCount
|
||||||
|
localCfg.UpdatedAt = time.Now()
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
// Save to local SQLite
|
||||||
|
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to pending sync queue
|
||||||
|
cfg := localdb.LocalToConfiguration(localCfg)
|
||||||
|
payload, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a configuration from local SQLite and queues it for sync
|
||||||
|
func (s *LocalConfigurationService) Delete(uuid string, userID uint) error {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if localCfg.OriginalUserID != userID {
|
||||||
|
return ErrConfigForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from local SQLite
|
||||||
|
if err := s.localDB.DeleteConfiguration(uuid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to pending sync queue
|
||||||
|
if err := s.localDB.AddPendingChange("configuration", uuid, "delete", ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename renames a configuration
|
||||||
|
func (s *LocalConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if localCfg.OriginalUserID != userID {
|
||||||
|
return nil, ErrConfigForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg.Name = newName
|
||||||
|
localCfg.UpdatedAt = time.Now()
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to pending sync queue
|
||||||
|
cfg := localdb.LocalToConfiguration(localCfg)
|
||||||
|
payload, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone clones a configuration
|
||||||
|
func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) {
|
||||||
|
original, err := s.GetByUUID(configUUID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
total := original.Items.Total()
|
||||||
|
if original.ServerCount > 1 {
|
||||||
|
total *= float64(original.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := &models.Configuration{
|
||||||
|
UUID: uuid.New().String(),
|
||||||
|
UserID: userID,
|
||||||
|
Name: newName,
|
||||||
|
Items: original.Items,
|
||||||
|
TotalPrice: &total,
|
||||||
|
CustomPrice: original.CustomPrice,
|
||||||
|
Notes: original.Notes,
|
||||||
|
IsTemplate: false,
|
||||||
|
ServerCount: original.ServerCount,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg := localdb.ConfigurationToLocal(clone)
|
||||||
|
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to pending sync queue
|
||||||
|
payload, err := json.Marshal(clone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.localDB.AddPendingChange("configuration", clone.UUID, "create", string(payload)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser returns all configurations for a user from local SQLite
|
||||||
|
func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
|
||||||
|
// Get all local configurations
|
||||||
|
localConfigs, err := s.localDB.GetConfigurations()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by user
|
||||||
|
var userConfigs []models.Configuration
|
||||||
|
for _, lc := range localConfigs {
|
||||||
|
if lc.OriginalUserID == userID || lc.IsTemplate {
|
||||||
|
userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int64(len(userConfigs))
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 || perPage > 100 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
start := offset
|
||||||
|
if start > len(userConfigs) {
|
||||||
|
start = len(userConfigs)
|
||||||
|
}
|
||||||
|
end := start + perPage
|
||||||
|
if end > len(userConfigs) {
|
||||||
|
end = len(userConfigs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userConfigs[start:end], total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshPrices updates all component prices in the configuration from local cache
|
||||||
|
func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) {
|
||||||
|
// Get configuration from local SQLite
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if localCfg.OriginalUserID != userID {
|
||||||
|
return nil, ErrConfigForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update prices for all items
|
||||||
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
|
for i, item := range localCfg.Items {
|
||||||
|
// Get current component price from local cache
|
||||||
|
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||||
|
if err != nil || component.CurrentPrice == nil {
|
||||||
|
// Keep original item if component not found or no price available
|
||||||
|
updatedItems[i] = item
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update item with current price from local cache
|
||||||
|
updatedItems[i] = localdb.LocalConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: *component.CurrentPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update configuration
|
||||||
|
localCfg.Items = updatedItems
|
||||||
|
total := updatedItems.Total()
|
||||||
|
|
||||||
|
// If server count is greater than 1, multiply the total by server count
|
||||||
|
if localCfg.ServerCount > 1 {
|
||||||
|
total *= float64(localCfg.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg.TotalPrice = &total
|
||||||
|
|
||||||
|
// Set price update timestamp and mark for sync
|
||||||
|
now := time.Now()
|
||||||
|
localCfg.PriceUpdatedAt = &now
|
||||||
|
localCfg.UpdatedAt = now
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
// Save to local SQLite
|
||||||
|
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to pending sync queue
|
||||||
|
cfg := localdb.LocalToConfiguration(localCfg)
|
||||||
|
payload, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUUIDNoAuth returns configuration without ownership check
|
||||||
|
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
return localdb.LocalToConfiguration(localCfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNoAuth updates configuration without ownership check
|
||||||
|
func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
total := req.Items.Total()
|
||||||
|
if req.ServerCount > 1 {
|
||||||
|
total *= float64(req.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg.Name = req.Name
|
||||||
|
localCfg.Items = localdb.LocalConfigItems{}
|
||||||
|
for _, item := range req.Items {
|
||||||
|
localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
localCfg.TotalPrice = &total
|
||||||
|
localCfg.CustomPrice = req.CustomPrice
|
||||||
|
localCfg.Notes = req.Notes
|
||||||
|
localCfg.IsTemplate = req.IsTemplate
|
||||||
|
localCfg.ServerCount = req.ServerCount
|
||||||
|
localCfg.UpdatedAt = time.Now()
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := localdb.LocalToConfiguration(localCfg)
|
||||||
|
payload, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNoAuth deletes configuration without ownership check
|
||||||
|
func (s *LocalConfigurationService) DeleteNoAuth(uuid string) error {
|
||||||
|
if err := s.localDB.DeleteConfiguration(uuid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.localDB.AddPendingChange("configuration", uuid, "delete", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameNoAuth renames configuration without ownership check
|
||||||
|
func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg.Name = newName
|
||||||
|
localCfg.UpdatedAt = time.Now()
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := localdb.LocalToConfiguration(localCfg)
|
||||||
|
payload, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneNoAuth clones configuration without ownership check
|
||||||
|
func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) {
|
||||||
|
original, err := s.GetByUUIDNoAuth(configUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
total := original.Items.Total()
|
||||||
|
if original.ServerCount > 1 {
|
||||||
|
total *= float64(original.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := &models.Configuration{
|
||||||
|
UUID: uuid.New().String(),
|
||||||
|
UserID: userID,
|
||||||
|
Name: newName,
|
||||||
|
Items: original.Items,
|
||||||
|
TotalPrice: &total,
|
||||||
|
CustomPrice: original.CustomPrice,
|
||||||
|
Notes: original.Notes,
|
||||||
|
IsTemplate: false,
|
||||||
|
ServerCount: original.ServerCount,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg := localdb.ConfigurationToLocal(clone)
|
||||||
|
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(clone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.localDB.AddPendingChange("configuration", clone.UUID, "create", string(payload)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAll returns all configurations without user filter
|
||||||
|
func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
|
||||||
|
localConfigs, err := s.localDB.GetConfigurations()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
configs := make([]models.Configuration, len(localConfigs))
|
||||||
|
for i, lc := range localConfigs {
|
||||||
|
configs[i] = *localdb.LocalToConfiguration(&lc)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int64(len(configs))
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 || perPage > 100 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
start := offset
|
||||||
|
if start > len(configs) {
|
||||||
|
start = len(configs)
|
||||||
|
}
|
||||||
|
end := start + perPage
|
||||||
|
if end > len(configs) {
|
||||||
|
end = len(configs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs[start:end], total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTemplates returns all template configurations
|
||||||
|
func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
|
||||||
|
localConfigs, err := s.localDB.GetConfigurations()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var templates []models.Configuration
|
||||||
|
for _, lc := range localConfigs {
|
||||||
|
if lc.IsTemplate {
|
||||||
|
templates = append(templates, *localdb.LocalToConfiguration(&lc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int64(len(templates))
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 || perPage > 100 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
start := offset
|
||||||
|
if start > len(templates) {
|
||||||
|
start = len(templates)
|
||||||
|
}
|
||||||
|
end := start + perPage
|
||||||
|
if end > len(templates) {
|
||||||
|
end = len(templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates[start:end], total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check
|
||||||
|
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||||
|
// Get configuration from local SQLite
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update prices for all items
|
||||||
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
|
for i, item := range localCfg.Items {
|
||||||
|
// Get current component price from local cache
|
||||||
|
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||||
|
if err != nil || component.CurrentPrice == nil {
|
||||||
|
// Keep original item if component not found or no price available
|
||||||
|
updatedItems[i] = item
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update item with current price from local cache
|
||||||
|
updatedItems[i] = localdb.LocalConfigItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: *component.CurrentPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update configuration
|
||||||
|
localCfg.Items = updatedItems
|
||||||
|
total := updatedItems.Total()
|
||||||
|
|
||||||
|
// If server count is greater than 1, multiply the total by server count
|
||||||
|
if localCfg.ServerCount > 1 {
|
||||||
|
total *= float64(localCfg.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg.TotalPrice = &total
|
||||||
|
|
||||||
|
// Set price update timestamp and mark for sync
|
||||||
|
now := time.Now()
|
||||||
|
localCfg.PriceUpdatedAt = &now
|
||||||
|
localCfg.UpdatedAt = now
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
// Save to local SQLite
|
||||||
|
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to pending sync queue
|
||||||
|
cfg := localdb.LocalToConfiguration(localCfg)
|
||||||
|
payload, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
156
internal/services/pricelist/service.go
Normal file
156
internal/services/pricelist/service.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package pricelist
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *repository.PricelistRepository
|
||||||
|
componentRepo *repository.ComponentRepository
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *Service {
|
||||||
|
return &Service{
|
||||||
|
repo: repo,
|
||||||
|
componentRepo: componentRepo,
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
||||||
|
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
||||||
|
version, err := s.repo.GenerateVersion()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generating version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
|
||||||
|
|
||||||
|
pricelist := &models.Pricelist{
|
||||||
|
Version: version,
|
||||||
|
CreatedBy: createdBy,
|
||||||
|
IsActive: true,
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Create(pricelist); err != nil {
|
||||||
|
return nil, fmt.Errorf("creating pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all components with prices from qt_lot_metadata
|
||||||
|
var metadata []models.LotMetadata
|
||||||
|
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("getting lot metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pricelist items with all price settings
|
||||||
|
items := make([]models.PricelistItem, 0, len(metadata))
|
||||||
|
for _, m := range metadata {
|
||||||
|
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, models.PricelistItem{
|
||||||
|
PricelistID: pricelist.ID,
|
||||||
|
LotName: m.LotName,
|
||||||
|
Price: *m.CurrentPrice,
|
||||||
|
PriceMethod: string(m.PriceMethod),
|
||||||
|
PricePeriodDays: m.PricePeriodDays,
|
||||||
|
PriceCoefficient: m.PriceCoefficient,
|
||||||
|
ManualPrice: m.ManualPrice,
|
||||||
|
MetaPrices: m.MetaPrices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.CreateItems(items); err != nil {
|
||||||
|
// Clean up the pricelist if items creation fails
|
||||||
|
s.repo.Delete(pricelist.ID)
|
||||||
|
return nil, fmt.Errorf("creating pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pricelist.ItemCount = len(items)
|
||||||
|
|
||||||
|
slog.Info("pricelist created",
|
||||||
|
"id", pricelist.ID,
|
||||||
|
"version", pricelist.Version,
|
||||||
|
"items", len(items),
|
||||||
|
"created_by", createdBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return pricelist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns pricelists with pagination
|
||||||
|
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
return s.repo.List(offset, perPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns a pricelist by ID
|
||||||
|
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
|
||||||
|
return s.repo.GetByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItems returns pricelist items with pagination
|
||||||
|
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 {
|
||||||
|
perPage = 50
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
return s.repo.GetItems(pricelistID, offset, perPage, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a pricelist by ID
|
||||||
|
func (s *Service) Delete(id uint) error {
|
||||||
|
return s.repo.Delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanWrite returns true if the user can create pricelists
|
||||||
|
func (s *Service) CanWrite() bool {
|
||||||
|
return s.repo.CanWrite()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanWriteDebug returns write permission status with debug info
|
||||||
|
func (s *Service) CanWriteDebug() (bool, string) {
|
||||||
|
return s.repo.CanWriteDebug()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestActive returns the most recent active pricelist
|
||||||
|
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
||||||
|
return s.repo.GetLatestActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupExpired deletes expired and unused pricelists
|
||||||
|
func (s *Service) CleanupExpired() (int, error) {
|
||||||
|
expired, err := s.repo.GetExpiredUnused()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted := 0
|
||||||
|
for _, pl := range expired {
|
||||||
|
if err := s.repo.Delete(pl.ID); err != nil {
|
||||||
|
slog.Warn("failed to delete expired pricelist", "id", pl.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("cleaned up expired pricelists", "deleted", deleted)
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
398
internal/services/sync/service.go
Normal file
398
internal/services/sync/service.go
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service handles synchronization between MariaDB and local SQLite
|
||||||
|
type Service struct {
|
||||||
|
pricelistRepo *repository.PricelistRepository
|
||||||
|
configRepo *repository.ConfigurationRepository
|
||||||
|
localDB *localdb.LocalDB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new sync service
|
||||||
|
func NewService(pricelistRepo *repository.PricelistRepository, configRepo *repository.ConfigurationRepository, localDB *localdb.LocalDB) *Service {
|
||||||
|
return &Service{
|
||||||
|
pricelistRepo: pricelistRepo,
|
||||||
|
configRepo: configRepo,
|
||||||
|
localDB: localDB,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncStatus represents the current sync status
|
||||||
|
type SyncStatus struct {
|
||||||
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
|
ServerPricelists int `json:"server_pricelists"`
|
||||||
|
LocalPricelists int `json:"local_pricelists"`
|
||||||
|
NeedsSync bool `json:"needs_sync"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns the current sync status
|
||||||
|
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||||
|
lastSync := s.localDB.GetLastSyncTime()
|
||||||
|
|
||||||
|
// Count server pricelists
|
||||||
|
serverPricelists, _, err := s.pricelistRepo.List(0, 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("counting server pricelists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count local pricelists
|
||||||
|
localCount := s.localDB.CountLocalPricelists()
|
||||||
|
|
||||||
|
needsSync, _ := s.NeedSync()
|
||||||
|
|
||||||
|
return &SyncStatus{
|
||||||
|
LastSyncAt: lastSync,
|
||||||
|
ServerPricelists: len(serverPricelists),
|
||||||
|
LocalPricelists: int(localCount),
|
||||||
|
NeedsSync: needsSync,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedSync checks if synchronization is needed
|
||||||
|
// Returns true if there are new pricelists on server or last sync was >1 hour ago
|
||||||
|
func (s *Service) NeedSync() (bool, error) {
|
||||||
|
lastSync := s.localDB.GetLastSyncTime()
|
||||||
|
|
||||||
|
// If never synced, need sync
|
||||||
|
if lastSync == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If last sync was more than 1 hour ago, suggest sync
|
||||||
|
if time.Since(*lastSync) > time.Hour {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are new pricelists on server
|
||||||
|
latestServer, err := s.pricelistRepo.GetLatestActive()
|
||||||
|
if err != nil {
|
||||||
|
// If no pricelists on server, no need to sync
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
latestLocal, err := s.localDB.GetLatestLocalPricelist()
|
||||||
|
if err != nil {
|
||||||
|
// No local pricelists, need to sync
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server has newer pricelist, need sync
|
||||||
|
if latestServer.ID != latestLocal.ServerID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
||||||
|
func (s *Service) SyncPricelists() (int, error) {
|
||||||
|
slog.Info("starting pricelist sync")
|
||||||
|
|
||||||
|
// Get all active pricelists from server (up to 100)
|
||||||
|
serverPricelists, _, err := s.pricelistRepo.List(0, 100)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("getting server pricelists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
synced := 0
|
||||||
|
for _, pl := range serverPricelists {
|
||||||
|
// Check if pricelist already exists locally
|
||||||
|
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||||
|
if existing != nil {
|
||||||
|
// Already synced, skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create local pricelist
|
||||||
|
localPL := &localdb.LocalPricelist{
|
||||||
|
ServerID: pl.ID,
|
||||||
|
Version: pl.Version,
|
||||||
|
Name: pl.Notification, // Using notification as name
|
||||||
|
CreatedAt: pl.CreatedAt,
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
IsUsed: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
|
||||||
|
slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
synced++
|
||||||
|
slog.Debug("synced pricelist", "version", pl.Version, "server_id", pl.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last sync time
|
||||||
|
s.localDB.SetLastSyncTime(time.Now())
|
||||||
|
|
||||||
|
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
||||||
|
return synced, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncPricelistItems synchronizes items for a specific pricelist
|
||||||
|
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||||
|
// Get local pricelist
|
||||||
|
localPL, err := s.localDB.GetLocalPricelistByID(localPricelistID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("getting local pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if items already exist
|
||||||
|
existingCount := s.localDB.CountLocalPricelistItems(localPricelistID)
|
||||||
|
if existingCount > 0 {
|
||||||
|
slog.Debug("pricelist items already synced", "pricelist_id", localPricelistID, "count", existingCount)
|
||||||
|
return int(existingCount), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get items from server
|
||||||
|
serverItems, _, err := s.pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("getting server pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert and save locally
|
||||||
|
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||||
|
for i, item := range serverItems {
|
||||||
|
localItems[i] = localdb.LocalPricelistItem{
|
||||||
|
PricelistID: localPricelistID,
|
||||||
|
LotName: item.LotName,
|
||||||
|
Price: item.Price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||||
|
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("synced pricelist items", "pricelist_id", localPricelistID, "items", len(localItems))
|
||||||
|
return len(localItems), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
|
||||||
|
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
|
||||||
|
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("local pricelist not found for server ID %d", serverPricelistID)
|
||||||
|
}
|
||||||
|
return s.SyncPricelistItems(localPL.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
||||||
|
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
||||||
|
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPricelistForOffline returns a pricelist suitable for offline use
|
||||||
|
// If items are not synced, it will sync them first
|
||||||
|
func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.LocalPricelist, error) {
|
||||||
|
// Ensure pricelist is synced
|
||||||
|
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||||
|
if err != nil {
|
||||||
|
// Try to sync pricelists first
|
||||||
|
if _, err := s.SyncPricelists(); err != nil {
|
||||||
|
return nil, fmt.Errorf("syncing pricelists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try again
|
||||||
|
localPL, err = s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pricelist not found on server: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure items are synced
|
||||||
|
if _, err := s.SyncPricelistItems(localPL.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("syncing pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return localPL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed
|
||||||
|
// This should be called before creating a new configuration when online
|
||||||
|
func (s *Service) SyncPricelistsIfNeeded() error {
|
||||||
|
needSync, err := s.NeedSync()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to check if sync needed", "error", err)
|
||||||
|
return nil // Don't fail on check error
|
||||||
|
}
|
||||||
|
|
||||||
|
if !needSync {
|
||||||
|
slog.Debug("pricelists are up to date, no sync needed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("new pricelists detected, syncing...")
|
||||||
|
_, err = s.SyncPricelists()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("syncing pricelists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushPendingChanges pushes all pending changes to the server
|
||||||
|
func (s *Service) PushPendingChanges() (int, error) {
|
||||||
|
changes, err := s.localDB.GetPendingChanges()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("getting pending changes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changes) == 0 {
|
||||||
|
slog.Debug("no pending changes to push")
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("pushing pending changes", "count", len(changes))
|
||||||
|
pushed := 0
|
||||||
|
var syncedIDs []int64
|
||||||
|
|
||||||
|
for _, change := range changes {
|
||||||
|
err := s.pushSingleChange(&change)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
||||||
|
// Increment attempts
|
||||||
|
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
syncedIDs = append(syncedIDs, change.ID)
|
||||||
|
pushed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark synced changes as complete by deleting them
|
||||||
|
if len(syncedIDs) > 0 {
|
||||||
|
if err := s.localDB.MarkChangesSynced(syncedIDs); err != nil {
|
||||||
|
slog.Error("failed to mark changes as synced", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("pending changes pushed", "pushed", pushed, "failed", len(changes)-pushed)
|
||||||
|
return pushed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushSingleChange pushes a single pending change to the server
|
||||||
|
func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
|
||||||
|
switch change.EntityType {
|
||||||
|
case "configuration":
|
||||||
|
return s.pushConfigurationChange(change)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown entity type: %s", change.EntityType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushConfigurationChange pushes a configuration change to the server
|
||||||
|
func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
||||||
|
switch change.Operation {
|
||||||
|
case "create":
|
||||||
|
return s.pushConfigurationCreate(change)
|
||||||
|
case "update":
|
||||||
|
return s.pushConfigurationUpdate(change)
|
||||||
|
case "delete":
|
||||||
|
return s.pushConfigurationDelete(change)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown operation: %s", change.Operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushConfigurationCreate creates a configuration on the server
|
||||||
|
func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||||
|
var cfg models.Configuration
|
||||||
|
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
|
||||||
|
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create on server
|
||||||
|
if err := s.configRepo.Create(&cfg); err != nil {
|
||||||
|
return fmt.Errorf("creating configuration on server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local configuration with server ID
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
||||||
|
if err == nil {
|
||||||
|
serverID := cfg.ID
|
||||||
|
localCfg.ServerID = &serverID
|
||||||
|
localCfg.SyncStatus = "synced"
|
||||||
|
s.localDB.SaveConfiguration(localCfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("configuration created on server", "uuid", cfg.UUID, "server_id", cfg.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushConfigurationUpdate updates a configuration on the server
|
||||||
|
func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||||
|
var cfg models.Configuration
|
||||||
|
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
|
||||||
|
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have a server ID before updating
|
||||||
|
// If the payload doesn't have ID, get it from local configuration
|
||||||
|
if cfg.ID == 0 {
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting local configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if localCfg.ServerID == nil {
|
||||||
|
// Configuration hasn't been synced yet, try to find it on server by UUID
|
||||||
|
serverCfg, err := s.configRepo.GetByUUID(cfg.UUID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("configuration not yet synced to server: %w", err)
|
||||||
|
}
|
||||||
|
cfg.ID = serverCfg.ID
|
||||||
|
|
||||||
|
// Update local with server ID
|
||||||
|
serverID := serverCfg.ID
|
||||||
|
localCfg.ServerID = &serverID
|
||||||
|
s.localDB.SaveConfiguration(localCfg)
|
||||||
|
} else {
|
||||||
|
cfg.ID = *localCfg.ServerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update on server
|
||||||
|
if err := s.configRepo.Update(&cfg); err != nil {
|
||||||
|
return fmt.Errorf("updating configuration on server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local sync status
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
||||||
|
if err == nil {
|
||||||
|
localCfg.SyncStatus = "synced"
|
||||||
|
s.localDB.SaveConfiguration(localCfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("configuration updated on server", "uuid", cfg.UUID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushConfigurationDelete deletes a configuration from the server
|
||||||
|
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||||
|
// Get the configuration from server by UUID to get the ID
|
||||||
|
cfg, err := s.configRepo.GetByUUID(change.EntityUUID)
|
||||||
|
if err != nil {
|
||||||
|
// Already deleted or not found, consider it successful
|
||||||
|
slog.Warn("configuration not found on server, considering delete successful", "uuid", change.EntityUUID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from server
|
||||||
|
if err := s.configRepo.Delete(cfg.ID); err != nil {
|
||||||
|
return fmt.Errorf("deleting configuration from server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("configuration deleted from server", "uuid", change.EntityUUID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
93
internal/services/sync/worker.go
Normal file
93
internal/services/sync/worker.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Worker performs background synchronization at regular intervals
|
||||||
|
type Worker struct {
|
||||||
|
service *Service
|
||||||
|
db *gorm.DB
|
||||||
|
interval time.Duration
|
||||||
|
logger *slog.Logger
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorker creates a new background sync worker
|
||||||
|
func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker {
|
||||||
|
return &Worker{
|
||||||
|
service: service,
|
||||||
|
db: db,
|
||||||
|
interval: interval,
|
||||||
|
logger: slog.Default(),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isOnline checks if the database connection is available
|
||||||
|
func (w *Worker) isOnline() bool {
|
||||||
|
sqlDB, err := w.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return sqlDB.Ping() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the background sync loop in a goroutine
|
||||||
|
func (w *Worker) Start(ctx context.Context) {
|
||||||
|
w.logger.Info("starting background sync worker", "interval", w.interval)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(w.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run once immediately
|
||||||
|
w.runSync()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
w.logger.Info("background sync worker stopped by context")
|
||||||
|
return
|
||||||
|
case <-w.stopCh:
|
||||||
|
w.logger.Info("background sync worker stopped")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w.runSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully stops the worker
|
||||||
|
func (w *Worker) Stop() {
|
||||||
|
w.logger.Info("stopping background sync worker")
|
||||||
|
close(w.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSync performs a single sync iteration
|
||||||
|
func (w *Worker) runSync() {
|
||||||
|
// Check if online
|
||||||
|
if !w.isOnline() {
|
||||||
|
w.logger.Debug("offline, skipping background sync")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push pending changes first
|
||||||
|
pushed, err := w.service.PushPendingChanges()
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Warn("background sync: failed to push pending changes", "error", err)
|
||||||
|
} else if pushed > 0 {
|
||||||
|
w.logger.Info("background sync: pushed pending changes", "count", pushed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check for new pricelists
|
||||||
|
err = w.service.SyncPricelistsIfNeeded()
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Warn("background sync: failed to sync pricelists", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.logger.Info("background sync cycle completed")
|
||||||
|
}
|
||||||
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">
|
<div class="flex gap-4">
|
||||||
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
|
<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('components')" id="btn-components" class="text-gray-600">Компоненты</button>
|
||||||
|
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
|
||||||
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
|
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
|
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
|
||||||
@@ -53,6 +54,60 @@
|
|||||||
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pricelists Tab Content (hidden by default) -->
|
||||||
|
<div id="pricelists-tab-content" class="hidden">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold">Прайслисты</h2>
|
||||||
|
<div id="pricelists-create-btn-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Исп.</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Статус</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Modal -->
|
||||||
|
<div id="pricelists-create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Создать прайслист</h2>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">
|
||||||
|
Будет создан снимок текущих цен из базы данных.<br>
|
||||||
|
Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span>
|
||||||
|
</p>
|
||||||
|
<form id="pricelists-create-form" class="space-y-4">
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button type="button" onclick="closePricelistsCreateModal()"
|
||||||
|
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div id="pagination" class="flex justify-between items-center mt-4 pt-4 border-t hidden">
|
<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>
|
<span id="page-info" class="text-sm text-gray-600"></span>
|
||||||
@@ -157,6 +212,10 @@ let currentSearch = '';
|
|||||||
let componentsCache = [];
|
let componentsCache = [];
|
||||||
let sortField = 'popularity_score';
|
let sortField = 'popularity_score';
|
||||||
let sortDir = 'desc';
|
let sortDir = 'desc';
|
||||||
|
let pricelistsPage = 1;
|
||||||
|
let pricelistsCanWrite = false;
|
||||||
|
let isCreatingPricelist = false;
|
||||||
|
let cachedDbUsername = null;
|
||||||
|
|
||||||
async function loadTab(tab) {
|
async function loadTab(tab) {
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
@@ -166,6 +225,7 @@ async function loadTab(tab) {
|
|||||||
|
|
||||||
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
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('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||||
|
document.getElementById('btn-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||||
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 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
|
// Show/hide elements based on tab
|
||||||
@@ -173,35 +233,42 @@ async function loadTab(tab) {
|
|||||||
document.getElementById('search-bar').className = 'mb-4';
|
document.getElementById('search-bar').className = 'mb-4';
|
||||||
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
|
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
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
|
||||||
|
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||||
|
document.getElementById('tab-content').className = '';
|
||||||
} else if (tab === 'all-configs') {
|
} else if (tab === 'all-configs') {
|
||||||
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for 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('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
|
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
|
||||||
|
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||||
|
document.getElementById('tab-content').className = '';
|
||||||
|
} else if (tab === 'pricelists') {
|
||||||
|
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||||
|
document.getElementById('pagination').className = 'hidden';
|
||||||
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||||
|
document.getElementById('pricelists-tab-content').className = '';
|
||||||
|
document.getElementById('tab-content').className = 'hidden';
|
||||||
|
// Load pricelists when pricelists tab is selected
|
||||||
|
checkPricelistWritePermission();
|
||||||
|
loadPricelists(1);
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('search-bar').className = 'mb-4 hidden';
|
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||||
document.getElementById('pagination').className = 'hidden';
|
document.getElementById('pagination').className = 'hidden';
|
||||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||||
|
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||||
|
document.getElementById('tab-content').className = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadData();
|
if (tab !== 'pricelists') {
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
|
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (currentTab === 'alerts') {
|
if (currentTab === 'alerts') {
|
||||||
const resp = await fetch('/admin/pricing/alerts?per_page=100', {
|
const resp = await fetch('/api/admin/pricing/alerts?per_page=100');
|
||||||
headers: {'Authorization': 'Bearer ' + token}
|
|
||||||
});
|
|
||||||
if (resp.status === 401) { logout(); return; }
|
|
||||||
if (resp.status === 403) { window.location.href = '/'; return; }
|
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
renderAlerts(data.alerts || []);
|
renderAlerts(data.alerts || []);
|
||||||
} else if (currentTab === 'all-configs') {
|
} else if (currentTab === 'all-configs') {
|
||||||
@@ -210,17 +277,13 @@ async function loadData() {
|
|||||||
if (currentSearch) {
|
if (currentSearch) {
|
||||||
url += '&search=' + encodeURIComponent(currentSearch);
|
url += '&search=' + encodeURIComponent(currentSearch);
|
||||||
}
|
}
|
||||||
const resp = await fetch(url, {
|
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();
|
const data = await resp.json();
|
||||||
totalPages = Math.ceil(data.total / perPage);
|
totalPages = Math.ceil(data.total / perPage);
|
||||||
renderAllConfigs(data.configurations || []);
|
renderAllConfigs(data.configurations || []);
|
||||||
updatePagination(data.total);
|
updatePagination(data.total);
|
||||||
} else {
|
} else {
|
||||||
let url = '/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
|
let url = '/api/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
|
||||||
if (currentSearch) {
|
if (currentSearch) {
|
||||||
url += '&search=' + encodeURIComponent(currentSearch);
|
url += '&search=' + encodeURIComponent(currentSearch);
|
||||||
}
|
}
|
||||||
@@ -230,10 +293,7 @@ async function loadData() {
|
|||||||
if (sortDir) {
|
if (sortDir) {
|
||||||
url += '&dir=' + encodeURIComponent(sortDir);
|
url += '&dir=' + encodeURIComponent(sortDir);
|
||||||
}
|
}
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url);
|
||||||
headers: {'Authorization': 'Bearer ' + token}
|
|
||||||
});
|
|
||||||
if (resp.status === 401) { logout(); return; }
|
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
totalPages = Math.ceil(data.total / perPage);
|
totalPages = Math.ceil(data.total / perPage);
|
||||||
componentsCache = data.components || [];
|
componentsCache = data.components || [];
|
||||||
@@ -471,9 +531,6 @@ function onMethodChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPreview() {
|
async function fetchPreview() {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const lotName = document.getElementById('modal-lot-name').value;
|
const lotName = document.getElementById('modal-lot-name').value;
|
||||||
const method = document.getElementById('modal-method').value;
|
const method = document.getElementById('modal-method').value;
|
||||||
const periodDays = parseInt(document.getElementById('modal-period').value) || 0;
|
const periodDays = parseInt(document.getElementById('modal-period').value) || 0;
|
||||||
@@ -490,10 +547,9 @@ async function fetchPreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/admin/pricing/preview', {
|
const resp = await fetch('/api/admin/pricing/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + token,
|
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -508,8 +564,6 @@ async function fetchPreview() {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.status === 401) { logout(); return; }
|
|
||||||
|
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
@@ -584,12 +638,6 @@ function debounceFetchPreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function savePrice() {
|
async function savePrice() {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lotName = document.getElementById('modal-lot-name').value;
|
const lotName = document.getElementById('modal-lot-name').value;
|
||||||
const method = document.getElementById('modal-method').value;
|
const method = document.getElementById('modal-method').value;
|
||||||
const periodDaysStr = document.getElementById('modal-period').value;
|
const periodDaysStr = document.getElementById('modal-period').value;
|
||||||
@@ -630,17 +678,14 @@ async function savePrice() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/admin/pricing/update', {
|
const resp = await fetch('/api/admin/pricing/update', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + token,
|
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.status === 401) { logout(); return; }
|
|
||||||
|
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
closeModal();
|
closeModal();
|
||||||
loadData();
|
loadData();
|
||||||
@@ -683,12 +728,6 @@ function processMetaPrices(metaPrices, originalLotName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function recalculateAll() {
|
function recalculateAll() {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const btn = document.getElementById('btn-recalc');
|
const btn = document.getElementById('btn-recalc');
|
||||||
const progressContainer = document.getElementById('progress-container');
|
const progressContainer = document.getElementById('progress-container');
|
||||||
const progressBar = document.getElementById('progress-bar');
|
const progressBar = document.getElementById('progress-bar');
|
||||||
@@ -707,9 +746,8 @@ function recalculateAll() {
|
|||||||
progressStats.textContent = 'Подготовка...';
|
progressStats.textContent = 'Подготовка...';
|
||||||
|
|
||||||
// Use fetch with streaming for SSE
|
// Use fetch with streaming for SSE
|
||||||
fetch('/admin/pricing/recalculate-all', {
|
fetch('/api/admin/pricing/recalculate-all', {
|
||||||
method: 'POST',
|
method: 'POST'
|
||||||
headers: {'Authorization': 'Bearer ' + token}
|
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -842,7 +880,10 @@ function renderAllConfigs(configs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadTab('alerts');
|
// Check URL params for initial tab
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const initialTab = urlParams.get('tab') || 'alerts';
|
||||||
|
loadTab(initialTab);
|
||||||
|
|
||||||
// Add event listeners for preview updates
|
// Add event listeners for preview updates
|
||||||
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
||||||
@@ -850,6 +891,194 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.getElementById('modal-manual-price').addEventListener('input', debounceFetchPreview);
|
document.getElementById('modal-manual-price').addEventListener('input', debounceFetchPreview);
|
||||||
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
|
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pricelists functions
|
||||||
|
let canWrite = false;
|
||||||
|
|
||||||
|
async function checkPricelistWritePermission() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/pricelists/can-write');
|
||||||
|
const data = await resp.json();
|
||||||
|
pricelistsCanWrite = data.can_write;
|
||||||
|
|
||||||
|
if (pricelistsCanWrite) {
|
||||||
|
document.getElementById('pricelists-create-btn-container').innerHTML = `
|
||||||
|
<button onclick="openPricelistsCreateModal()" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||||
|
Создать прайслист
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to check pricelist write permission:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPricelists(page = 1) {
|
||||||
|
pricelistsPage = page;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
renderPricelists(data.pricelists || []);
|
||||||
|
renderPricelistsPagination(data.total, data.page, data.per_page);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('pricelists-body').innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-6 py-4 text-center text-red-500">
|
||||||
|
Ошибка загрузки: ${e.message}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
// Hide pagination when there's an error
|
||||||
|
document.getElementById('pricelists-pagination').innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPricelists(pricelists) {
|
||||||
|
if (pricelists.length === 0) {
|
||||||
|
document.getElementById('pricelists-body').innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
|
||||||
|
Прайслисты не найдены. ${pricelistsCanWrite ? 'Создайте первый прайслист.' : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = pricelists.map(pl => {
|
||||||
|
const date = new Date(pl.created_at).toLocaleDateString('ru-RU');
|
||||||
|
const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
|
||||||
|
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
|
||||||
|
|
||||||
|
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
|
||||||
|
if (pricelistsCanWrite && pl.usage_count === 0) {
|
||||||
|
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="font-mono text-sm">${pl.version}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.usage_count}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right">${actions}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.getElementById('pricelists-body').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPricelistsPagination(total, page, perPage) {
|
||||||
|
const totalPages = Math.ceil(total / perPage);
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
document.getElementById('pricelists-pagination').innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
const activeClass = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
|
||||||
|
html += `<button onclick="loadPricelists(${i})" class="px-3 py-1 rounded border ${activeClass}">${i}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pricelists-pagination').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPricelistsDbUsername() {
|
||||||
|
if (cachedDbUsername) {
|
||||||
|
document.getElementById('pricelists-db-username').textContent = cachedDbUsername;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/current-user');
|
||||||
|
const data = await resp.json();
|
||||||
|
cachedDbUsername = data.username || 'неизвестно';
|
||||||
|
document.getElementById('pricelists-db-username').textContent = cachedDbUsername;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('pricelists-db-username').textContent = 'неизвестно';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPricelistsCreateModal() {
|
||||||
|
document.getElementById('pricelists-create-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('pricelists-create-modal').classList.add('flex');
|
||||||
|
loadPricelistsDbUsername();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePricelistsCreateModal() {
|
||||||
|
document.getElementById('pricelists-create-modal').classList.add('hidden');
|
||||||
|
document.getElementById('pricelists-create-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPricelist() {
|
||||||
|
const resp = await fetch('/api/pricelists', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
throw new Error(data.error || 'Failed to create pricelist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePricelist(id) {
|
||||||
|
if (!confirm('Удалить этот прайслист?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/pricelists/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
throw new Error(data.error || 'Failed to delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Прайслист удален', 'success');
|
||||||
|
loadPricelists(pricelistsPage);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Ошибка: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pricelists-create-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isCreatingPricelist) return; // protection from double-submit
|
||||||
|
isCreatingPricelist = true;
|
||||||
|
|
||||||
|
const submitBtn = this.querySelector('button[type="submit"]');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Создание...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pl = await createPricelist();
|
||||||
|
closePricelistsCreateModal();
|
||||||
|
showToast(`Прайслист ${pl.version} создан (${pl.item_count} позиций)`, 'success');
|
||||||
|
loadPricelists(1);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Ошибка: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
isCreatingPricelist = false;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Создать';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -19,18 +19,20 @@
|
|||||||
<div class="flex items-center space-x-8">
|
<div class="flex items-center space-x-8">
|
||||||
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
|
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
|
||||||
<div class="hidden md:flex space-x-4">
|
<div class="hidden md:flex space-x-4">
|
||||||
<a href="/configs" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm" id="nav-configs" style="display:none;">Мои конфигурации</a>
|
<a href="/configurator" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Конфигуратор</a>
|
||||||
<a href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm" id="nav-admin" style="display:none;">Цены</a>
|
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm hidden">Администратор цен</a>
|
||||||
|
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center space-x-4">
|
||||||
<div id="user-logged-out">
|
<!-- Sync Status Indicator (htmx-powered) -->
|
||||||
<a href="/login" class="text-blue-600 hover:text-blue-800 text-sm">Войти</a>
|
<div id="sync-status"
|
||||||
</div>
|
class="flex items-center gap-3 text-sm"
|
||||||
<div id="user-logged-in" class="hidden">
|
hx-get="/partials/sync-status"
|
||||||
<span id="user-name" class="text-sm text-gray-700 mr-3"></span>
|
hx-trigger="load, refresh from:body, every 30s"
|
||||||
<button onclick="logout()" class="text-red-600 hover:text-red-800 text-sm">Выйти</button>
|
hx-swap="innerHTML">
|
||||||
</div>
|
</div>
|
||||||
|
<span id="db-user" class="text-sm text-gray-600"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,6 +44,52 @@
|
|||||||
|
|
||||||
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
||||||
|
|
||||||
|
<!-- Sync Info Modal -->
|
||||||
|
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
||||||
|
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-gray-900">Статус БД</h4>
|
||||||
|
<p id="modal-db-status" class="text-sm text-gray-600">Проверка...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-gray-900">Количество ошибок</h4>
|
||||||
|
<p id="modal-error-count" class="text-sm text-gray-600">0</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-gray-900">Последняя синхронизация</h4>
|
||||||
|
<p id="modal-last-sync" class="text-sm text-gray-600">-</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-gray-900">Список ошибок</h4>
|
||||||
|
<div id="modal-errors-list" class="mt-2 text-sm text-gray-600 max-h-40 overflow-y-auto">
|
||||||
|
<p>Нет ошибок</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
|
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
|
||||||
<div class="max-w-7xl mx-auto flex justify-between">
|
<div class="max-w-7xl mx-auto flex justify-between">
|
||||||
<span id="db-status">БД: проверка...</span>
|
<span id="db-status">БД: проверка...</span>
|
||||||
@@ -50,33 +98,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function initAuth() {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const user = localStorage.getItem('user');
|
|
||||||
|
|
||||||
if (token && user) {
|
|
||||||
try {
|
|
||||||
const userData = JSON.parse(user);
|
|
||||||
document.getElementById('user-logged-out').classList.add('hidden');
|
|
||||||
document.getElementById('user-logged-in').classList.remove('hidden');
|
|
||||||
document.getElementById('user-name').textContent = userData.username;
|
|
||||||
document.getElementById('nav-configs').style.display = 'block';
|
|
||||||
if (userData.role === 'admin' || userData.role === 'pricing_admin') {
|
|
||||||
document.getElementById('nav-admin').style.display = 'block';
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
logout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
localStorage.removeItem('refresh_token');
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(msg, type) {
|
function showToast(msg, type) {
|
||||||
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
|
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
|
||||||
const el = document.getElementById('toast');
|
const el = document.getElementById('toast');
|
||||||
@@ -84,15 +105,128 @@
|
|||||||
setTimeout(() => el.innerHTML = '', 3000);
|
setTimeout(() => el.innerHTML = '', 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open sync modal
|
||||||
|
function openSyncModal() {
|
||||||
|
const modal = document.getElementById('sync-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
// Load sync info when modal opens
|
||||||
|
loadSyncInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close sync modal
|
||||||
|
function closeSyncModal() {
|
||||||
|
const modal = document.getElementById('sync-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load sync info for modal
|
||||||
|
async function loadSyncInfo() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sync/info');
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
document.getElementById('modal-db-status').textContent = data.is_online ? 'Подключено' : 'Отключено';
|
||||||
|
document.getElementById('modal-error-count').textContent = data.error_count;
|
||||||
|
|
||||||
|
if (data.last_sync_at) {
|
||||||
|
const date = new Date(data.last_sync_at);
|
||||||
|
document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU');
|
||||||
|
} else {
|
||||||
|
document.getElementById('modal-last-sync').textContent = 'Нет данных';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load error list
|
||||||
|
const errorsList = document.getElementById('modal-errors-list');
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
errorsList.innerHTML = data.errors.map(error =>
|
||||||
|
`<div class="mb-1"><span class="font-medium">${error.timestamp}</span>: ${error.message}</div>`
|
||||||
|
).join('');
|
||||||
|
} else {
|
||||||
|
errorsList.innerHTML = '<p>Нет ошибок</p>';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to load sync info:', e);
|
||||||
|
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
||||||
|
document.getElementById('modal-error-count').textContent = '0';
|
||||||
|
document.getElementById('modal-last-sync').textContent = '-';
|
||||||
|
document.getElementById('modal-errors-list').innerHTML = '<p>Ошибка загрузки данных</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event delegation for sync dropdown and actions
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
checkDbStatus();
|
||||||
|
checkWritePermission();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event delegation for sync actions
|
||||||
|
document.body.addEventListener('click', function(e) {
|
||||||
|
// Handle sync button click (full sync only)
|
||||||
|
const syncButton = e.target.closest('#sync-button');
|
||||||
|
if (syncButton) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const button = syncButton;
|
||||||
|
|
||||||
|
// Add loading state
|
||||||
|
const originalHTML = button.innerHTML;
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = '<svg class="animate-spin w-4 h-4 inline mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
|
||||||
|
|
||||||
|
fullSync(button, originalHTML);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refactored sync action function to reduce duplication
|
||||||
|
async function syncAction(endpoint, successMessage, button, originalHTML) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(endpoint, { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showToast(successMessage, 'success');
|
||||||
|
// Update last sync time - removed since dropdown is gone
|
||||||
|
// loadLastSyncTime();
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
htmx.trigger('#sync-status', 'refresh');
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Ошибка: ' + error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
// Reset button state
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = originalHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushPendingChanges(button, originalHTML) {
|
||||||
|
syncAction('/api/sync/push', 'Изменения синхронизированы', button, originalHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullSync(button, originalHTML) {
|
||||||
|
syncAction('/api/sync/all', 'Полная синхронизация завершена', button, originalHTML);
|
||||||
|
}
|
||||||
|
|
||||||
async function checkDbStatus() {
|
async function checkDbStatus() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/db-status');
|
const resp = await fetch('/api/db-status');
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const statusEl = document.getElementById('db-status');
|
const statusEl = document.getElementById('db-status');
|
||||||
const countsEl = document.getElementById('db-counts');
|
const countsEl = document.getElementById('db-counts');
|
||||||
|
const userEl = document.getElementById('db-user');
|
||||||
|
|
||||||
if (data.connected) {
|
if (data.connected) {
|
||||||
statusEl.innerHTML = '<span class="text-green-400">БД: подключено</span>';
|
statusEl.innerHTML = '<span class="text-green-400">БД: подключено</span>';
|
||||||
|
if (data.db_user) {
|
||||||
|
userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
statusEl.innerHTML = '<span class="text-red-400">БД: ошибка - ' + data.error + '</span>';
|
statusEl.innerHTML = '<span class="text-red-400">БД: ошибка - ' + data.error + '</span>';
|
||||||
}
|
}
|
||||||
@@ -103,10 +237,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
async function checkWritePermission() {
|
||||||
initAuth();
|
try {
|
||||||
checkDbStatus();
|
const resp = await fetch('/api/pricelists/can-write');
|
||||||
});
|
const data = await resp.json();
|
||||||
|
if (data.can_write) {
|
||||||
|
const link = document.getElementById('admin-pricing-link');
|
||||||
|
if (link) link.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to check write permission:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load last sync time for dropdown (removed since dropdown is gone)
|
||||||
|
// async function loadLastSyncTime() {
|
||||||
|
// try {
|
||||||
|
// const resp = await fetch('/api/sync/status');
|
||||||
|
// const data = await resp.json();
|
||||||
|
// if (data.last_pricelist_sync) {
|
||||||
|
// const date = new Date(data.last_pricelist_sync);
|
||||||
|
// document.getElementById('last-sync-time').textContent = date.toLocaleString('ru-RU');
|
||||||
|
// } else {
|
||||||
|
// document.getElementById('last-sync-time').textContent = 'Нет данных';
|
||||||
|
// }
|
||||||
|
// } catch(e) {
|
||||||
|
// console.error('Failed to load last sync time:', e);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Call functions immediately to ensure they run even before DOMContentLoaded
|
||||||
|
// This ensures username and admin link are visible ASAP
|
||||||
|
checkDbStatus();
|
||||||
|
checkWritePermission();
|
||||||
|
|
||||||
|
// Load last sync time - removed since dropdown is gone
|
||||||
|
// document.addEventListener('DOMContentLoaded', loadLastSyncTime);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,6 +10,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
|
||||||
|
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
||||||
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
Активный прайслист: <span id="pricelist-version">-</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="configs-list">
|
<div id="configs-list">
|
||||||
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,38 +108,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
async function loadConfigs() {
|
// Pagination state
|
||||||
const token = localStorage.getItem('token');
|
let currentPage = 1;
|
||||||
|
let totalPages = 1;
|
||||||
if (!token) {
|
let perPage = 20;
|
||||||
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', {
|
|
||||||
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 || []);
|
|
||||||
} catch(e) {
|
|
||||||
document.getElementById('configs-list').innerHTML =
|
|
||||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderConfigs(configs) {
|
function renderConfigs(configs) {
|
||||||
if (configs.length === 0) {
|
if (configs.length === 0) {
|
||||||
@@ -198,20 +179,13 @@ function escapeHtml(text) {
|
|||||||
|
|
||||||
async function deleteConfig(uuid) {
|
async function deleteConfig(uuid) {
|
||||||
if (!confirm('Удалить?')) return;
|
if (!confirm('Удалить?')) return;
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
await fetch('/api/configs/' + uuid, {
|
await fetch('/api/configs/' + uuid, {
|
||||||
method: 'DELETE',
|
method: 'DELETE'
|
||||||
headers: {'Authorization': 'Bearer ' + token}
|
|
||||||
});
|
});
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRenameModal(uuid, currentName) {
|
function openRenameModal(uuid, currentName) {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.getElementById('rename-uuid').value = uuid;
|
document.getElementById('rename-uuid').value = uuid;
|
||||||
document.getElementById('rename-input').value = currentName;
|
document.getElementById('rename-input').value = currentName;
|
||||||
document.getElementById('rename-modal').classList.remove('hidden');
|
document.getElementById('rename-modal').classList.remove('hidden');
|
||||||
@@ -226,7 +200,6 @@ function closeRenameModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renameConfig() {
|
async function renameConfig() {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const uuid = document.getElementById('rename-uuid').value;
|
const uuid = document.getElementById('rename-uuid').value;
|
||||||
const name = document.getElementById('rename-input').value.trim();
|
const name = document.getElementById('rename-input').value.trim();
|
||||||
|
|
||||||
@@ -239,17 +212,11 @@ async function renameConfig() {
|
|||||||
const resp = await fetch('/api/configs/' + uuid + '/rename', {
|
const resp = await fetch('/api/configs/' + uuid + '/rename', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + token,
|
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name: name })
|
body: JSON.stringify({ name: name })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.status === 401) {
|
|
||||||
logout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
alert('Ошибка: ' + (err.error || 'Не удалось переименовать'));
|
alert('Ошибка: ' + (err.error || 'Не удалось переименовать'));
|
||||||
@@ -264,11 +231,6 @@ async function renameConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCloneModal(uuid, currentName) {
|
function openCloneModal(uuid, currentName) {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.getElementById('clone-uuid').value = uuid;
|
document.getElementById('clone-uuid').value = uuid;
|
||||||
document.getElementById('clone-input').value = currentName + ' (копия)';
|
document.getElementById('clone-input').value = currentName + ' (копия)';
|
||||||
document.getElementById('clone-modal').classList.remove('hidden');
|
document.getElementById('clone-modal').classList.remove('hidden');
|
||||||
@@ -283,7 +245,6 @@ function closeCloneModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cloneConfig() {
|
async function cloneConfig() {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const uuid = document.getElementById('clone-uuid').value;
|
const uuid = document.getElementById('clone-uuid').value;
|
||||||
const name = document.getElementById('clone-input').value.trim();
|
const name = document.getElementById('clone-input').value.trim();
|
||||||
|
|
||||||
@@ -296,17 +257,11 @@ async function cloneConfig() {
|
|||||||
const resp = await fetch('/api/configs/' + uuid + '/clone', {
|
const resp = await fetch('/api/configs/' + uuid + '/clone', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + token,
|
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name: name })
|
body: JSON.stringify({ name: name })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.status === 401) {
|
|
||||||
logout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
alert('Ошибка: ' + (err.error || 'Не удалось скопировать'));
|
alert('Ошибка: ' + (err.error || 'Не удалось скопировать'));
|
||||||
@@ -321,11 +276,6 @@ async function cloneConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.getElementById('opportunity-number').value = '';
|
document.getElementById('opportunity-number').value = '';
|
||||||
document.getElementById('create-modal').classList.remove('hidden');
|
document.getElementById('create-modal').classList.remove('hidden');
|
||||||
document.getElementById('create-modal').classList.add('flex');
|
document.getElementById('create-modal').classList.add('flex');
|
||||||
@@ -338,7 +288,6 @@ function closeCreateModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createConfig() {
|
async function createConfig() {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const name = document.getElementById('opportunity-number').value.trim();
|
const name = document.getElementById('opportunity-number').value.trim();
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -350,7 +299,6 @@ async function createConfig() {
|
|||||||
const resp = await fetch('/api/configs', {
|
const resp = await fetch('/api/configs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + token,
|
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -361,11 +309,6 @@ async function createConfig() {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.status === 401) {
|
|
||||||
logout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
alert('Ошибка: ' + (err.error || 'Не удалось создать'));
|
alert('Ошибка: ' + (err.error || 'Не удалось создать'));
|
||||||
@@ -421,11 +364,6 @@ document.getElementById('clone-input').addEventListener('keydown', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination functions
|
|
||||||
let currentPage = 1;
|
|
||||||
let totalPages = 1;
|
|
||||||
let perPage = 20;
|
|
||||||
|
|
||||||
function prevPage() {
|
function prevPage() {
|
||||||
if (currentPage > 1) {
|
if (currentPage > 1) {
|
||||||
currentPage--;
|
currentPage--;
|
||||||
@@ -451,27 +389,12 @@ function updatePagination(total) {
|
|||||||
|
|
||||||
// Load configs with pagination
|
// Load configs with pagination
|
||||||
async function loadConfigs() {
|
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 {
|
try {
|
||||||
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage, {
|
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage);
|
||||||
headers: {'Authorization': 'Bearer ' + token}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resp.status === 401) {
|
if (!resp.ok) {
|
||||||
logout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resp.status === 403) {
|
|
||||||
document.getElementById('configs-list').innerHTML =
|
document.getElementById('configs-list').innerHTML =
|
||||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Нет доступа</div>';
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,7 +407,34 @@ async function loadConfigs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', loadConfigs);
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadConfigs();
|
||||||
|
|
||||||
|
// Load latest pricelist version for badge
|
||||||
|
loadLatestPricelistVersion();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadLatestPricelistVersion() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/pricelists/latest');
|
||||||
|
if (resp.ok) {
|
||||||
|
const pricelist = await resp.json();
|
||||||
|
document.getElementById('pricelist-version').textContent = pricelist.version;
|
||||||
|
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
// Show error in badge
|
||||||
|
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
|
||||||
|
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||||
|
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
// Show error in badge
|
||||||
|
console.error('Failed to load pricelist version:', e);
|
||||||
|
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
|
||||||
|
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||||
|
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,14 @@
|
|||||||
<span id="config-name">Конфигуратор</span>
|
<span id="config-name">Конфигуратор</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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 onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||||
Сохранить
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
|
<span id="price-update-date" class="text-sm text-gray-500"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -265,9 +269,8 @@ async function loadCategoriesFromAPI() {
|
|||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
document.addEventListener('DOMContentLoaded', async function() {
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
const token = localStorage.getItem('token');
|
// RBAC disabled - no token check required
|
||||||
|
if (!configUUID) {
|
||||||
if (!token || !configUUID) {
|
|
||||||
window.location.href = '/configs';
|
window.location.href = '/configs';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -276,16 +279,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
await loadCategoriesFromAPI();
|
await loadCategoriesFromAPI();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/configs/' + configUUID, {
|
const resp = await fetch('/api/configs/' + configUUID);
|
||||||
headers: {'Authorization': 'Bearer ' + token}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resp.status === 401) {
|
if (resp.status === 404) {
|
||||||
window.location.href = '/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resp.status === 403 || resp.status === 404) {
|
|
||||||
showToast('Конфигурация не найдена', 'error');
|
showToast('Конфигурация не найдена', 'error');
|
||||||
window.location.href = '/configs';
|
window.location.href = '/configs';
|
||||||
return;
|
return;
|
||||||
@@ -315,6 +311,11 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
if (config.custom_price) {
|
if (config.custom_price) {
|
||||||
document.getElementById('custom-price-input').value = config.custom_price.toFixed(2);
|
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) {
|
} catch(e) {
|
||||||
showToast('Ошибка загрузки конфигурации', 'error');
|
showToast('Ошибка загрузки конфигурации', 'error');
|
||||||
window.location.href = '/configs';
|
window.location.href = '/configs';
|
||||||
@@ -1110,8 +1111,8 @@ function triggerAutoSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig(showNotification = true) {
|
async function saveConfig(showNotification = true) {
|
||||||
const token = localStorage.getItem('token');
|
// RBAC disabled - no token check required
|
||||||
if (!token || !configUUID) return;
|
if (!configUUID) return;
|
||||||
|
|
||||||
// Get custom price if set
|
// Get custom price if set
|
||||||
const customPriceInput = document.getElementById('custom-price-input');
|
const customPriceInput = document.getElementById('custom-price-input');
|
||||||
@@ -1125,7 +1126,6 @@ async function saveConfig(showNotification = true) {
|
|||||||
const resp = await fetch('/api/configs/' + configUUID, {
|
const resp = await fetch('/api/configs/' + configUUID, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + token,
|
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1137,11 +1137,6 @@ async function saveConfig(showNotification = true) {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.status === 401) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (showNotification) {
|
if (showNotification) {
|
||||||
showToast('Ошибка сохранения', 'error');
|
showToast('Ошибка сохранения', 'error');
|
||||||
@@ -1298,6 +1293,80 @@ async function exportCSVWithCustomPrice() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshPrices() {
|
||||||
|
// RBAC disabled - no token check required
|
||||||
|
if (!configUUID) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
37
web/templates/partials/sync_status.html
Normal file
37
web/templates/partials/sync_status.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{{define "sync_status"}}
|
||||||
|
<div class="flex items-center gap-2 relative">
|
||||||
|
{{if .IsOffline}}
|
||||||
|
<span class="flex items-center gap-1 text-red-600 cursor-pointer" title="Offline" onclick="openSyncModal()">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="flex items-center gap-1 text-green-600 cursor-pointer" title="Online" onclick="openSyncModal()">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if gt .PendingCount 0}}
|
||||||
|
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" onclick="openSyncModal()">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
{{.PendingCount}}
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Sync button (full sync only) -->
|
||||||
|
<div class="relative">
|
||||||
|
<button id="sync-button"
|
||||||
|
aria-label="Синхронизация"
|
||||||
|
class="text-gray-600 hover:text-gray-800 text-xs flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
270
web/templates/pricelist_detail.html
Normal file
270
web/templates/pricelist_detail.html
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
{{define "title"}}Прайслист - QuoteForge{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="/pricelists" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pricelist-info" class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div id="pl-notification" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800"></div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Версия</p>
|
||||||
|
<p id="pl-version" class="font-mono">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Дата создания</p>
|
||||||
|
<p id="pl-date">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Автор</p>
|
||||||
|
<p id="pl-author">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Позиций</p>
|
||||||
|
<p id="pl-items">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Использований</p>
|
||||||
|
<p id="pl-usage">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Статус</p>
|
||||||
|
<p id="pl-status">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Истекает</p>
|
||||||
|
<p id="pl-expires">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div class="p-4 border-b">
|
||||||
|
<input type="text" id="search-input" placeholder="Поиск по артикулу..."
|
||||||
|
class="w-full md:w-64 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена, $</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="items-body" class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div id="items-pagination" class="p-4 border-t flex justify-between items-center">
|
||||||
|
<span id="items-info" class="text-sm text-gray-500"></span>
|
||||||
|
<div id="items-pages" class="space-x-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const pricelistId = window.location.pathname.split('/').pop();
|
||||||
|
let currentPage = 1;
|
||||||
|
let searchQuery = '';
|
||||||
|
let searchTimeout = null;
|
||||||
|
|
||||||
|
async function loadPricelistInfo() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/pricelists/${pricelistId}`);
|
||||||
|
if (!resp.ok) throw new Error('Pricelist not found');
|
||||||
|
|
||||||
|
const pl = await resp.json();
|
||||||
|
|
||||||
|
document.getElementById('page-title').textContent = `Прайслист ${pl.version}`;
|
||||||
|
document.getElementById('pl-version').textContent = pl.version;
|
||||||
|
document.getElementById('pl-date').textContent = new Date(pl.created_at).toLocaleDateString('ru-RU');
|
||||||
|
document.getElementById('pl-author').textContent = pl.created_by || '-';
|
||||||
|
document.getElementById('pl-items').textContent = pl.item_count;
|
||||||
|
document.getElementById('pl-usage').textContent = pl.usage_count;
|
||||||
|
|
||||||
|
// Show notification if present and pricelist is active
|
||||||
|
const notificationEl = document.getElementById('pl-notification');
|
||||||
|
if (pl.notification && pl.is_active) {
|
||||||
|
notificationEl.textContent = pl.notification;
|
||||||
|
notificationEl.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
notificationEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass = pl.is_active ? 'text-green-600' : 'text-gray-600';
|
||||||
|
document.getElementById('pl-status').innerHTML = `<span class="${statusClass}">${pl.is_active ? 'Активен' : 'Неактивен'}</span>`;
|
||||||
|
|
||||||
|
if (pl.expires_at) {
|
||||||
|
document.getElementById('pl-expires').textContent = new Date(pl.expires_at).toLocaleDateString('ru-RU');
|
||||||
|
} else {
|
||||||
|
document.getElementById('pl-expires').textContent = '-';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('page-title').textContent = 'Ошибка';
|
||||||
|
showToast('Прайслист не найден', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems(page = 1) {
|
||||||
|
currentPage = page;
|
||||||
|
try {
|
||||||
|
let url = `/api/pricelists/${pricelistId}/items?page=${page}&per_page=50`;
|
||||||
|
if (searchQuery) {
|
||||||
|
url += `&search=${encodeURIComponent(searchQuery)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(url);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
renderItems(data.items || []);
|
||||||
|
renderItemsPagination(data.total, data.page, data.per_page);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('items-body').innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-6 py-4 text-center text-red-500">
|
||||||
|
Ошибка загрузки: ${e.message}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPriceSettings(item) {
|
||||||
|
// Format price settings to match admin pricing interface style
|
||||||
|
let settings = [];
|
||||||
|
const hasManualPrice = item.manual_price && item.manual_price > 0;
|
||||||
|
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
|
||||||
|
|
||||||
|
// Method indicator
|
||||||
|
if (hasManualPrice) {
|
||||||
|
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
|
||||||
|
} else if (item.price_method === 'average') {
|
||||||
|
settings.push('Сред');
|
||||||
|
} else {
|
||||||
|
settings.push('Мед');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Period (only if not manual price)
|
||||||
|
if (!hasManualPrice) {
|
||||||
|
const period = item.price_period_days !== undefined && item.price_period_days !== null ? item.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 (item.price_coefficient && item.price_coefficient !== 0) {
|
||||||
|
settings.push((item.price_coefficient > 0 ? '+' : '') + item.price_coefficient + '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta article indicator
|
||||||
|
if (hasMeta) {
|
||||||
|
settings.push('<span class="text-purple-600 font-medium">МЕТА</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings.join(' | ') || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItems(items) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
document.getElementById('items-body').innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-6 py-4 text-center text-gray-500">
|
||||||
|
${searchQuery ? 'Ничего не найдено' : 'Позиции не найдены'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = items.map(item => {
|
||||||
|
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
const description = item.lot_description || '-';
|
||||||
|
const truncatedDesc = description.length > 60 ? description.substring(0, 60) + '...' : description;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap">
|
||||||
|
<span class="font-mono text-sm">${item.lot_name}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap">
|
||||||
|
<span class="px-2 py-1 text-xs bg-gray-100 rounded">${item.category || '-'}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-500" title="${description}">${truncatedDesc}</td>
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${price}</td>
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.getElementById('items-body').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItemsPagination(total, page, perPage) {
|
||||||
|
const totalPages = Math.ceil(total / perPage);
|
||||||
|
const start = (page - 1) * perPage + 1;
|
||||||
|
const end = Math.min(page * perPage, total);
|
||||||
|
|
||||||
|
document.getElementById('items-info').textContent = `${start}-${end} из ${total}`;
|
||||||
|
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
document.getElementById('items-pages').innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Previous button
|
||||||
|
if (page > 1) {
|
||||||
|
html += `<button onclick="loadItems(${page - 1})" class="px-2 py-1 text-sm text-gray-600 hover:text-gray-900">←</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page numbers (show max 5 pages)
|
||||||
|
const startPage = Math.max(1, page - 2);
|
||||||
|
const endPage = Math.min(totalPages, startPage + 4);
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
const activeClass = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
|
||||||
|
html += `<button onclick="loadItems(${i})" class="px-3 py-1 text-sm rounded border ${activeClass}">${i}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
if (page < totalPages) {
|
||||||
|
html += `<button onclick="loadItems(${page + 1})" class="px-2 py-1 text-sm text-gray-600 hover:text-gray-900">→</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('items-pages').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('search-input').addEventListener('input', function(e) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
searchQuery = e.target.value.trim();
|
||||||
|
loadItems(1);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadPricelistInfo();
|
||||||
|
loadItems(1);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
234
web/templates/pricelists.html
Normal file
234
web/templates/pricelists.html
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
{{define "title"}}Прайслисты - QuoteForge{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Прайслисты</h1>
|
||||||
|
<div id="create-btn-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Исп.</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Статус</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pagination" class="flex justify-center space-x-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Modal -->
|
||||||
|
<div id="create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Создать прайслист</h2>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">
|
||||||
|
Будет создан снимок текущих цен из базы данных.<br>
|
||||||
|
Автор прайслиста: <span id="db-username" class="font-medium">загрузка...</span>
|
||||||
|
</p>
|
||||||
|
<form id="create-form" class="space-y-4">
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button type="button" onclick="closeCreateModal()"
|
||||||
|
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let canWrite = false;
|
||||||
|
let currentPage = 1;
|
||||||
|
|
||||||
|
async function checkPricelistWritePermission() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/pricelists/can-write');
|
||||||
|
const data = await resp.json();
|
||||||
|
canWrite = data.can_write;
|
||||||
|
|
||||||
|
if (canWrite) {
|
||||||
|
document.getElementById('create-btn-container').innerHTML = `
|
||||||
|
<button onclick="openCreateModal()" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||||
|
Создать прайслист
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to check pricelist write permission:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPricelists(page = 1) {
|
||||||
|
currentPage = page;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
renderPricelists(data.pricelists || []);
|
||||||
|
renderPagination(data.total, data.page, data.per_page);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('pricelists-body').innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-6 py-4 text-center text-red-500">
|
||||||
|
Ошибка загрузки: ${e.message}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPricelists(pricelists) {
|
||||||
|
if (pricelists.length === 0) {
|
||||||
|
document.getElementById('pricelists-body').innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
|
||||||
|
Прайслисты не найдены. ${canWrite ? 'Создайте первый прайслист.' : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = pricelists.map(pl => {
|
||||||
|
const date = new Date(pl.created_at).toLocaleDateString('ru-RU');
|
||||||
|
const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
|
||||||
|
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
|
||||||
|
|
||||||
|
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
|
||||||
|
if (canWrite && pl.usage_count === 0) {
|
||||||
|
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="font-mono text-sm">${pl.version}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.usage_count}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right">${actions}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.getElementById('pricelists-body').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(total, page, perPage) {
|
||||||
|
const totalPages = Math.ceil(total / perPage);
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
document.getElementById('pagination').innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
const activeClass = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
|
||||||
|
html += `<button onclick="loadPricelists(${i})" class="px-3 py-1 rounded border ${activeClass}">${i}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pagination').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDbUsername() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/current-user');
|
||||||
|
const data = await resp.json();
|
||||||
|
document.getElementById('db-username').textContent = data.username || 'неизвестно';
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('db-username').textContent = 'неизвестно';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
document.getElementById('create-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('create-modal').classList.add('flex');
|
||||||
|
loadDbUsername();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
document.getElementById('create-modal').classList.add('hidden');
|
||||||
|
document.getElementById('create-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPricelist() {
|
||||||
|
const resp = await fetch('/api/pricelists', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
throw new Error(data.error || 'Failed to create pricelist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePricelist(id) {
|
||||||
|
if (!confirm('Удалить этот прайслист?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/pricelists/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
throw new Error(data.error || 'Failed to delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Прайслист удален', 'success');
|
||||||
|
loadPricelists(currentPage);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Ошибка: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('create-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pl = await createPricelist();
|
||||||
|
closeCreateModal();
|
||||||
|
showToast(`Прайслист ${pl.version} создан (${pl.item_count} позиций)`, 'success');
|
||||||
|
loadPricelists(1);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Ошибка: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
checkPricelistWritePermission();
|
||||||
|
loadPricelists(1);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
190
web/templates/setup.html
Normal file
190
web/templates/setup.html
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
{{define "setup.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>QuoteForge - Настройка подключения</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
<div class="max-w-md w-full mx-4">
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-blue-600">QuoteForge</h1>
|
||||||
|
<p class="text-gray-600 mt-2">Настройка подключения к базе данных</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="setup-form" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Хост сервера</label>
|
||||||
|
<input type="text" name="host" id="host"
|
||||||
|
value="{{if .Settings}}{{.Settings.Host}}{{else}}localhost{{end}}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="localhost или IP-адрес">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Порт</label>
|
||||||
|
<input type="number" name="port" id="port"
|
||||||
|
value="{{if .Settings}}{{.Settings.Port}}{{else}}3306{{end}}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="3306">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">База данных</label>
|
||||||
|
<input type="text" name="database" id="database"
|
||||||
|
value="{{if .Settings}}{{.Settings.Database}}{{else}}RFQ_LOG{{end}}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="RFQ_LOG">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Пользователь</label>
|
||||||
|
<input type="text" name="user" id="user"
|
||||||
|
value="{{if .Settings}}{{.Settings.User}}{{end}}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="username">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||||
|
<input type="password" name="password" id="password"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="{{if .Settings}}********{{else}}password{{end}}">
|
||||||
|
{{if .Settings}}
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы сохранить текущий пароль</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="hidden p-3 rounded-md text-sm"></div>
|
||||||
|
|
||||||
|
<div class="flex space-x-3 pt-4">
|
||||||
|
{{if .Settings}}
|
||||||
|
<a href="/"
|
||||||
|
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition text-center">
|
||||||
|
Назад
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
<button type="button" onclick="testConnection()"
|
||||||
|
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition">
|
||||||
|
Проверить
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-gray-500 text-sm mt-4">
|
||||||
|
QuoteForge v1.0 - Конфигуратор серверов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showStatus(message, type) {
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800');
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
status.classList.add('bg-green-100', 'text-green-800');
|
||||||
|
} else if (type === 'error') {
|
||||||
|
status.classList.add('bg-red-100', 'text-red-800');
|
||||||
|
} else {
|
||||||
|
status.classList.add('bg-blue-100', 'text-blue-800');
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
showStatus('Проверка подключения...', 'info');
|
||||||
|
|
||||||
|
const formData = new FormData(document.getElementById('setup-form'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/setup/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
let msg = data.message;
|
||||||
|
if (data.can_write) {
|
||||||
|
msg += ' Права на запись: есть.';
|
||||||
|
} else {
|
||||||
|
msg += ' Права на запись: только чтение.';
|
||||||
|
}
|
||||||
|
showStatus(msg, 'success');
|
||||||
|
} else {
|
||||||
|
showStatus(data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showStatus('Ошибка сети: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkServerReady() {
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 30; // 30 seconds max
|
||||||
|
|
||||||
|
const checkInterval = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/health', { method: 'GET' });
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
// Check if we're out of setup mode
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
showStatus('✓ Приложение запущено! Перенаправление...', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Server still restarting, continue polling
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
showStatus('Сервер не отвечает. Обновите страницу вручную.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000); // Check every second
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('setup-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
showStatus('Сохранение настроек...', 'info');
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showStatus('✓ ' + data.message, 'success');
|
||||||
|
// Wait for restart and redirect to home
|
||||||
|
setTimeout(() => {
|
||||||
|
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
|
||||||
|
// Poll until server is back
|
||||||
|
checkServerReady();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
showStatus(data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showStatus('Ошибка сети: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user