Add initial backend implementation
- Go module with Gin, GORM, JWT, excelize dependencies - Configuration loading from YAML with all settings - GORM models for users, categories, components, configurations, alerts - Repository layer for all entities - Services: auth (JWT), pricing (median/average/weighted), components, quotes, configurations, export (CSV/XLSX), alerts - Middleware: JWT auth, role-based access, CORS - HTTP handlers for all API endpoints - Main server with dependency injection and graceful shutdown Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
375
CLAUDE.md
Normal file
375
CLAUDE.md
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
# QuoteForge - Claude Code Instructions
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Language:** Go 1.22+
|
||||||
|
- **Web Framework:** Gin (github.com/gin-gonic/gin)
|
||||||
|
- **ORM:** GORM (gorm.io/gorm)
|
||||||
|
- **Database:** MariaDB 11 (existing database RFQ_LOG)
|
||||||
|
- **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
|
||||||
|
|
||||||
|
```
|
||||||
|
quoteforge/
|
||||||
|
├── cmd/
|
||||||
|
│ ├── server/main.go # Main HTTP server
|
||||||
|
│ ├── priceupdater/main.go # Cron job for price updates & alerts
|
||||||
|
│ └── importer/main.go # Import metadata from lot table
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/config.go # YAML config loading
|
||||||
|
│ ├── 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)
|
||||||
|
|
||||||
|
These tables are used by other systems. Our app only reads from them:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Component catalog
|
||||||
|
CREATE TABLE lot (
|
||||||
|
lot_name CHAR(255) PRIMARY KEY, -- e.g., "CPU_AMD_9654", "MB_INTEL_4.Sapphire_2S"
|
||||||
|
lot_description VARCHAR(10000)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Price history from suppliers
|
||||||
|
CREATE TABLE lot_log (
|
||||||
|
lot_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
lot CHAR(255) NOT NULL, -- FK → lot.lot_name
|
||||||
|
supplier CHAR(255) NOT NULL, -- FK → supplier.supplier_name
|
||||||
|
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
|
||||||
|
CREATE TABLE supplier (
|
||||||
|
supplier_name CHAR(255) PRIMARY KEY,
|
||||||
|
supplier_comment VARCHAR(10000)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## New Tables (prefix qt_)
|
||||||
|
|
||||||
|
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,
|
||||||
|
vendor VARCHAR(50), -- Parsed from lot_name: CPU_AMD_9654 → "AMD"
|
||||||
|
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, vendor, model from lot_name:
|
||||||
|
```go
|
||||||
|
// "CPU_AMD_9654" → category="CPU", vendor="AMD", model="9654"
|
||||||
|
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", vendor="INTEL", model="4.Sapphire_2S_32xDDR5"
|
||||||
|
// "MEM_DDR5_64G_5600" → category="MEM", vendor="DDR5", model="64G_5600"
|
||||||
|
// "GPU_NV_RTX_4090_PCIe" → category="GPU", vendor="NV", model="RTX_4090_PCIe"
|
||||||
|
|
||||||
|
func ParsePartNumber(lotName string) (category, vendor, model string) {
|
||||||
|
parts := strings.SplitN(lotName, "_", 3)
|
||||||
|
if len(parts) >= 1 { category = parts[0] }
|
||||||
|
if len(parts) >= 2 { vendor = parts[1] }
|
||||||
|
if len(parts) >= 3 { model = parts[2] }
|
||||||
|
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
|
||||||
|
- **price_spike** (MEDIUM): price increased > 20% from previous period
|
||||||
|
- **no_recent_quotes** (MEDIUM): popular component, no supplier quotes > 90 days
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
```
|
||||||
|
POST /api/auth/login → {"username", "password"} → {"token", "refresh_token"}
|
||||||
|
POST /api/auth/logout
|
||||||
|
POST /api/auth/refresh
|
||||||
|
GET /api/auth/me → current user info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
```
|
||||||
|
GET /api/components → list with pagination
|
||||||
|
GET /api/components?category=CPU&vendor=AMD → filtered
|
||||||
|
GET /api/components/:lot_name → single component details
|
||||||
|
GET /api/categories → category list
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
POST /api/configs/import → import from JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pricing Admin (requires role: pricing_admin or admin)
|
||||||
|
```
|
||||||
|
GET /admin/pricing/stats → dashboard stats
|
||||||
|
GET /admin/pricing/components → components with pricing info
|
||||||
|
GET /admin/pricing/components/:lot_name → component pricing details
|
||||||
|
POST /admin/pricing/update → update price method/value
|
||||||
|
POST /admin/pricing/recalculate-all → recalculate all prices
|
||||||
|
|
||||||
|
GET /admin/pricing/alerts → list alerts
|
||||||
|
POST /admin/pricing/alerts/:id/acknowledge → mark as seen
|
||||||
|
POST /admin/pricing/alerts/:id/resolve → mark as resolved
|
||||||
|
POST /admin/pricing/alerts/:id/ignore → dismiss alert
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run development server
|
||||||
|
go run ./cmd/server
|
||||||
|
|
||||||
|
# Run price updater (cron job)
|
||||||
|
go run ./cmd/priceupdater
|
||||||
|
|
||||||
|
# Run importer (one-time setup)
|
||||||
|
go run ./cmd/importer
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies (go.mod)
|
||||||
|
|
||||||
|
```go
|
||||||
|
module github.com/mchus/quoteforge
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1
|
||||||
|
gorm.io/gorm v1.25.5
|
||||||
|
gorm.io/driver/mysql v1.5.2
|
||||||
|
github.com/xuri/excelize/v2 v2.8.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
|
github.com/google/uuid v1.5.0
|
||||||
|
golang.org/x/crypto v0.17.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Priorities
|
||||||
|
|
||||||
|
1. **Phase 1 (MVP):** Project setup, models, component API, basic UI, CSV export
|
||||||
|
2. **Phase 2:** JWT auth with roles, pricing admin UI, all price methods
|
||||||
|
3. **Phase 3:** Save/load configs, JSON import/export, XLSX export, cron jobs
|
||||||
|
4. **Phase 4:** Usage stats, alerts system, dashboard
|
||||||
|
5. **Phase 5:** Polish, tests, Docker, documentation
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Use standard Go formatting (gofmt)
|
||||||
|
- Error handling: always check errors, wrap with context
|
||||||
|
- Logging: use structured logging (slog or zerolog)
|
||||||
|
- Comments: in Russian or English, be consistent
|
||||||
|
- File naming: snake_case for files, PascalCase for types
|
||||||
289
cmd/server/main.go
Normal file
289
cmd/server/main.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mchus/quoteforge/internal/config"
|
||||||
|
"github.com/mchus/quoteforge/internal/handlers"
|
||||||
|
"github.com/mchus/quoteforge/internal/middleware"
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"github.com/mchus/quoteforge/internal/repository"
|
||||||
|
"github.com/mchus/quoteforge/internal/services"
|
||||||
|
"github.com/mchus/quoteforge/internal/services/alerts"
|
||||||
|
"github.com/mchus/quoteforge/internal/services/pricing"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||||
|
migrate := flag.Bool("migrate", false, "run database migrations")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load config", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLogger(cfg.Logging)
|
||||||
|
|
||||||
|
slog.Info("starting QuoteForge server",
|
||||||
|
"host", cfg.Server.Host,
|
||||||
|
"port", cfg.Server.Port,
|
||||||
|
"mode", cfg.Server.Mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
db, err := setupDatabase(cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to connect to database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *migrate {
|
||||||
|
slog.Info("running database migrations...")
|
||||||
|
if err := models.Migrate(db); err != nil {
|
||||||
|
slog.Error("migration failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := models.SeedCategories(db); err != nil {
|
||||||
|
slog.Error("seeding categories failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
slog.Info("migrations completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
gin.SetMode(cfg.Server.Mode)
|
||||||
|
router := setupRouter(db, cfg)
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.Address(),
|
||||||
|
Handler: router,
|
||||||
|
ReadTimeout: cfg.Server.ReadTimeout,
|
||||||
|
WriteTimeout: cfg.Server.WriteTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("server listening", "address", cfg.Address())
|
||||||
|
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)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
slog.Info("shutting down server...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
slog.Error("server forced to shutdown", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("server stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupLogger(cfg config.LoggingConfig) {
|
||||||
|
var level slog.Level
|
||||||
|
switch cfg.Level {
|
||||||
|
case "debug":
|
||||||
|
level = slog.LevelDebug
|
||||||
|
case "warn":
|
||||||
|
level = slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
level = slog.LevelError
|
||||||
|
default:
|
||||||
|
level = slog.LevelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &slog.HandlerOptions{Level: level}
|
||||||
|
|
||||||
|
var handler slog.Handler
|
||||||
|
if cfg.Format == "json" {
|
||||||
|
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||||
|
} else {
|
||||||
|
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.SetDefault(slog.New(handler))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||||
|
gormLogger := logger.Default.LogMode(logger.Silent)
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{
|
||||||
|
Logger: gormLogger,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||||
|
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||||
|
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
||||||
|
// Repositories
|
||||||
|
userRepo := repository.NewUserRepository(db)
|
||||||
|
componentRepo := repository.NewComponentRepository(db)
|
||||||
|
categoryRepo := repository.NewCategoryRepository(db)
|
||||||
|
priceRepo := repository.NewPriceRepository(db)
|
||||||
|
configRepo := repository.NewConfigurationRepository(db)
|
||||||
|
alertRepo := repository.NewAlertRepository(db)
|
||||||
|
statsRepo := repository.NewStatsRepository(db)
|
||||||
|
|
||||||
|
// Services
|
||||||
|
authService := services.NewAuthService(userRepo, cfg.Auth)
|
||||||
|
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||||
|
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||||
|
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||||
|
configService := services.NewConfigurationService(configRepo, componentRepo, quoteService)
|
||||||
|
exportService := services.NewExportService(cfg.Export)
|
||||||
|
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
authHandler := handlers.NewAuthHandler(authService, userRepo)
|
||||||
|
componentHandler := handlers.NewComponentHandler(componentService)
|
||||||
|
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||||
|
configHandler := handlers.NewConfigurationHandler(configService, exportService)
|
||||||
|
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
||||||
|
pricingHandler := handlers.NewPricingHandler(pricingService, alertService, componentRepo, statsRepo)
|
||||||
|
|
||||||
|
// Router
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(gin.Recovery())
|
||||||
|
router.Use(requestLogger())
|
||||||
|
router.Use(middleware.CORS())
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
router.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"time": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
api := router.Group("/api")
|
||||||
|
{
|
||||||
|
api.GET("/ping", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auth (public)
|
||||||
|
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.GET("", componentHandler.List)
|
||||||
|
components.GET("/:lot_name", componentHandler.Get)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories (public)
|
||||||
|
api.GET("/categories", componentHandler.GetCategories)
|
||||||
|
api.GET("/vendors", componentHandler.GetVendors)
|
||||||
|
|
||||||
|
// Quote (public, for anonymous quote building)
|
||||||
|
quote := api.Group("/quote")
|
||||||
|
{
|
||||||
|
quote.POST("/validate", quoteHandler.Validate)
|
||||||
|
quote.POST("/calculate", quoteHandler.Calculate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export (public, for anonymous exports)
|
||||||
|
export := api.Group("/export")
|
||||||
|
{
|
||||||
|
export.POST("/csv", exportHandler.ExportCSV)
|
||||||
|
export.POST("/xlsx", exportHandler.ExportXLSX)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configurations (requires auth)
|
||||||
|
configs := api.Group("/configs")
|
||||||
|
configs.Use(middleware.Auth(authService))
|
||||||
|
configs.Use(middleware.RequireEditor())
|
||||||
|
{
|
||||||
|
configs.GET("", configHandler.List)
|
||||||
|
configs.POST("", configHandler.Create)
|
||||||
|
configs.GET("/:uuid", configHandler.Get)
|
||||||
|
configs.PUT("/:uuid", configHandler.Update)
|
||||||
|
configs.DELETE("/:uuid", configHandler.Delete)
|
||||||
|
configs.GET("/:uuid/export", configHandler.ExportJSON)
|
||||||
|
configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV)
|
||||||
|
configs.GET("/:uuid/xlsx", exportHandler.ExportConfigXLSX)
|
||||||
|
configs.POST("/import", configHandler.ImportJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
admin := router.Group("/admin")
|
||||||
|
admin.Use(middleware.Auth(authService))
|
||||||
|
{
|
||||||
|
// Pricing admin
|
||||||
|
pricingAdmin := admin.Group("/pricing")
|
||||||
|
pricingAdmin.Use(middleware.RequirePricingAdmin())
|
||||||
|
{
|
||||||
|
pricingAdmin.GET("/stats", pricingHandler.GetStats)
|
||||||
|
pricingAdmin.GET("/components", pricingHandler.ListComponents)
|
||||||
|
pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing)
|
||||||
|
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
|
||||||
|
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
|
||||||
|
|
||||||
|
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
|
||||||
|
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)
|
||||||
|
pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert)
|
||||||
|
pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLogger() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
query := c.Request.URL.RawQuery
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
latency := time.Since(start)
|
||||||
|
status := c.Writer.Status()
|
||||||
|
|
||||||
|
slog.Info("request",
|
||||||
|
"method", c.Request.Method,
|
||||||
|
"path", path,
|
||||||
|
"query", query,
|
||||||
|
"status", status,
|
||||||
|
"latency", latency,
|
||||||
|
"ip", c.ClientIP(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
config.example.yaml
Normal file
58
config.example.yaml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# QuoteForge Configuration
|
||||||
|
# Copy this file to config.yaml and update values
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
mode: "release" # debug | release
|
||||||
|
read_timeout: "30s"
|
||||||
|
write_timeout: "30s"
|
||||||
|
|
||||||
|
database:
|
||||||
|
host: "localhost"
|
||||||
|
port: 3306
|
||||||
|
name: "RFQ_LOG"
|
||||||
|
user: "quoteforge"
|
||||||
|
password: "CHANGE_ME"
|
||||||
|
max_open_conns: 25
|
||||||
|
max_idle_conns: 5
|
||||||
|
conn_max_lifetime: "5m"
|
||||||
|
|
||||||
|
auth:
|
||||||
|
jwt_secret: "CHANGE_ME_MIN_32_CHARACTERS_LONG"
|
||||||
|
token_expiry: "24h"
|
||||||
|
refresh_expiry: "168h" # 7 days
|
||||||
|
|
||||||
|
pricing:
|
||||||
|
default_method: "weighted_median" # median | average | weighted_median
|
||||||
|
default_period_days: 90
|
||||||
|
freshness_green_days: 30
|
||||||
|
freshness_yellow_days: 60
|
||||||
|
freshness_red_days: 90
|
||||||
|
min_quotes_for_median: 3
|
||||||
|
popularity_decay_days: 180
|
||||||
|
|
||||||
|
export:
|
||||||
|
temp_dir: "/tmp/quoteforge-exports"
|
||||||
|
max_file_age: "1h"
|
||||||
|
company_name: "Your Company Name"
|
||||||
|
|
||||||
|
alerts:
|
||||||
|
enabled: true
|
||||||
|
check_interval: "1h"
|
||||||
|
high_demand_threshold: 5 # КП за 30 дней
|
||||||
|
trending_threshold_percent: 50 # % роста для алерта
|
||||||
|
|
||||||
|
notifications:
|
||||||
|
email_enabled: false
|
||||||
|
smtp_host: "smtp.example.com"
|
||||||
|
smtp_port: 587
|
||||||
|
smtp_user: ""
|
||||||
|
smtp_password: ""
|
||||||
|
from_address: "quoteforge@example.com"
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: "info" # debug | info | warn | error
|
||||||
|
format: "json" # json | text
|
||||||
|
output: "stdout" # stdout | file
|
||||||
|
file_path: "/var/log/quoteforge/app.log"
|
||||||
47
go.mod
Normal file
47
go.mod
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
module github.com/mchus/quoteforge
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/xuri/excelize/v2 v2.10.0
|
||||||
|
golang.org/x/crypto v0.43.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
gorm.io/driver/mysql v1.5.2
|
||||||
|
gorm.io/gorm v1.25.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.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-sql-driver/mysql v1.7.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
|
golang.org/x/net v0.46.0 // indirect
|
||||||
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
|
)
|
||||||
118
go.sum
Normal file
118
go.sum
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||||
|
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||||
|
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||||
|
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
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/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-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
|
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||||
|
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/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
|
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
|
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||||
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
|
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||||
|
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||||
|
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||||
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
|
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/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=
|
||||||
|
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/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
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/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.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||||
|
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
176
internal/config/config.go
Normal file
176
internal/config/config.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `yaml:"server"`
|
||||||
|
Database DatabaseConfig `yaml:"database"`
|
||||||
|
Auth AuthConfig `yaml:"auth"`
|
||||||
|
Pricing PricingConfig `yaml:"pricing"`
|
||||||
|
Export ExportConfig `yaml:"export"`
|
||||||
|
Alerts AlertsConfig `yaml:"alerts"`
|
||||||
|
Notifications NotificationsConfig `yaml:"notifications"`
|
||||||
|
Logging LoggingConfig `yaml:"logging"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
Mode string `yaml:"mode"`
|
||||||
|
ReadTimeout time.Duration `yaml:"read_timeout"`
|
||||||
|
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
User string `yaml:"user"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
MaxOpenConns int `yaml:"max_open_conns"`
|
||||||
|
MaxIdleConns int `yaml:"max_idle_conns"`
|
||||||
|
ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DatabaseConfig) DSN() string {
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
|
d.User, d.Password, d.Host, d.Port, d.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
JWTSecret string `yaml:"jwt_secret"`
|
||||||
|
TokenExpiry time.Duration `yaml:"token_expiry"`
|
||||||
|
RefreshExpiry time.Duration `yaml:"refresh_expiry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PricingConfig struct {
|
||||||
|
DefaultMethod string `yaml:"default_method"`
|
||||||
|
DefaultPeriodDays int `yaml:"default_period_days"`
|
||||||
|
FreshnessGreenDays int `yaml:"freshness_green_days"`
|
||||||
|
FreshnessYellowDays int `yaml:"freshness_yellow_days"`
|
||||||
|
FreshnessRedDays int `yaml:"freshness_red_days"`
|
||||||
|
MinQuotesForMedian int `yaml:"min_quotes_for_median"`
|
||||||
|
PopularityDecayDays int `yaml:"popularity_decay_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportConfig struct {
|
||||||
|
TempDir string `yaml:"temp_dir"`
|
||||||
|
MaxFileAge time.Duration `yaml:"max_file_age"`
|
||||||
|
CompanyName string `yaml:"company_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertsConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
CheckInterval time.Duration `yaml:"check_interval"`
|
||||||
|
HighDemandThreshold int `yaml:"high_demand_threshold"`
|
||||||
|
TrendingThresholdPercent int `yaml:"trending_threshold_percent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationsConfig struct {
|
||||||
|
EmailEnabled bool `yaml:"email_enabled"`
|
||||||
|
SMTPHost string `yaml:"smtp_host"`
|
||||||
|
SMTPPort int `yaml:"smtp_port"`
|
||||||
|
SMTPUser string `yaml:"smtp_user"`
|
||||||
|
SMTPPassword string `yaml:"smtp_password"`
|
||||||
|
FromAddress string `yaml:"from_address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoggingConfig struct {
|
||||||
|
Level string `yaml:"level"`
|
||||||
|
Format string `yaml:"format"`
|
||||||
|
Output string `yaml:"output"`
|
||||||
|
FilePath string `yaml:"file_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.setDefaults()
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) setDefaults() {
|
||||||
|
if c.Server.Host == "" {
|
||||||
|
c.Server.Host = "0.0.0.0"
|
||||||
|
}
|
||||||
|
if c.Server.Port == 0 {
|
||||||
|
c.Server.Port = 8080
|
||||||
|
}
|
||||||
|
if c.Server.Mode == "" {
|
||||||
|
c.Server.Mode = "release"
|
||||||
|
}
|
||||||
|
if c.Server.ReadTimeout == 0 {
|
||||||
|
c.Server.ReadTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
if c.Server.WriteTimeout == 0 {
|
||||||
|
c.Server.WriteTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Database.Port == 0 {
|
||||||
|
c.Database.Port = 3306
|
||||||
|
}
|
||||||
|
if c.Database.MaxOpenConns == 0 {
|
||||||
|
c.Database.MaxOpenConns = 25
|
||||||
|
}
|
||||||
|
if c.Database.MaxIdleConns == 0 {
|
||||||
|
c.Database.MaxIdleConns = 5
|
||||||
|
}
|
||||||
|
if c.Database.ConnMaxLifetime == 0 {
|
||||||
|
c.Database.ConnMaxLifetime = 5 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Auth.TokenExpiry == 0 {
|
||||||
|
c.Auth.TokenExpiry = 24 * time.Hour
|
||||||
|
}
|
||||||
|
if c.Auth.RefreshExpiry == 0 {
|
||||||
|
c.Auth.RefreshExpiry = 7 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Pricing.DefaultMethod == "" {
|
||||||
|
c.Pricing.DefaultMethod = "weighted_median"
|
||||||
|
}
|
||||||
|
if c.Pricing.DefaultPeriodDays == 0 {
|
||||||
|
c.Pricing.DefaultPeriodDays = 90
|
||||||
|
}
|
||||||
|
if c.Pricing.FreshnessGreenDays == 0 {
|
||||||
|
c.Pricing.FreshnessGreenDays = 30
|
||||||
|
}
|
||||||
|
if c.Pricing.FreshnessYellowDays == 0 {
|
||||||
|
c.Pricing.FreshnessYellowDays = 60
|
||||||
|
}
|
||||||
|
if c.Pricing.FreshnessRedDays == 0 {
|
||||||
|
c.Pricing.FreshnessRedDays = 90
|
||||||
|
}
|
||||||
|
if c.Pricing.MinQuotesForMedian == 0 {
|
||||||
|
c.Pricing.MinQuotesForMedian = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Logging.Level == "" {
|
||||||
|
c.Logging.Level = "info"
|
||||||
|
}
|
||||||
|
if c.Logging.Format == "" {
|
||||||
|
c.Logging.Format = "json"
|
||||||
|
}
|
||||||
|
if c.Logging.Output == "" {
|
||||||
|
c.Logging.Output = "stdout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Address() string {
|
||||||
|
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
|
||||||
|
}
|
||||||
113
internal/handlers/auth.go
Normal file
113
internal/handlers/auth.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mchus/quoteforge/internal/middleware"
|
||||||
|
"github.com/mchus/quoteforge/internal/repository"
|
||||||
|
"github.com/mchus/quoteforge/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
authService *services.AuthService
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthHandler(authService *services.AuthService, userRepo *repository.UserRepository) *AuthHandler {
|
||||||
|
return &AuthHandler{
|
||||||
|
authService: authService,
|
||||||
|
userRepo: userRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
User UserResponse `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, user, err := h.authService.Login(req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, LoginResponse{
|
||||||
|
AccessToken: tokens.AccessToken,
|
||||||
|
RefreshToken: tokens.RefreshToken,
|
||||||
|
ExpiresAt: tokens.ExpiresAt,
|
||||||
|
User: UserResponse{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
Role: string(user.Role),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshRequest struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||||
|
var req RefreshRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := h.authService.RefreshTokens(req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Me(c *gin.Context) {
|
||||||
|
claims := middleware.GetClaims(c)
|
||||||
|
if claims == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userRepo.GetByID(claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, UserResponse{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
Role: string(user.Role),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
|
// JWT is stateless, logout is handled on client by discarding tokens
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||||
|
}
|
||||||
72
internal/handlers/component.go
Normal file
72
internal/handlers/component.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mchus/quoteforge/internal/repository"
|
||||||
|
"github.com/mchus/quoteforge/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ComponentHandler struct {
|
||||||
|
componentService *services.ComponentService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewComponentHandler(componentService *services.ComponentService) *ComponentHandler {
|
||||||
|
return &ComponentHandler{componentService: componentService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ComponentHandler) List(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||||
|
|
||||||
|
filter := repository.ComponentFilter{
|
||||||
|
Category: c.Query("category"),
|
||||||
|
Vendor: c.Query("vendor"),
|
||||||
|
Search: c.Query("search"),
|
||||||
|
HasPrice: c.Query("has_price") == "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.componentService.List(filter, page, perPage)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ComponentHandler) Get(c *gin.Context) {
|
||||||
|
lotName := c.Param("lot_name")
|
||||||
|
|
||||||
|
component, err := h.componentService.GetByLotName(lotName)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
||||||
|
categories, err := h.componentService.GetCategories()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ComponentHandler) GetVendors(c *gin.Context) {
|
||||||
|
category := c.Query("category")
|
||||||
|
|
||||||
|
vendors, err := h.componentService.GetVendors(category)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, vendors)
|
||||||
|
}
|
||||||
156
internal/handlers/configuration.go
Normal file
156
internal/handlers/configuration.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mchus/quoteforge/internal/middleware"
|
||||||
|
"github.com/mchus/quoteforge/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigurationHandler struct {
|
||||||
|
configService *services.ConfigurationService
|
||||||
|
exportService *services.ExportService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigurationHandler(
|
||||||
|
configService *services.ConfigurationService,
|
||||||
|
exportService *services.ExportService,
|
||||||
|
) *ConfigurationHandler {
|
||||||
|
return &ConfigurationHandler{
|
||||||
|
configService: configService,
|
||||||
|
exportService: exportService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigurationHandler) List(c *gin.Context) {
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||||
|
|
||||||
|
configs, total, err := h.configService.ListByUser(userID, page, perPage)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"configurations": configs,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigurationHandler) Create(c *gin.Context) {
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req services.CreateConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := h.configService.Create(userID, &req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigurationHandler) Get(c *gin.Context) {
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
|
config, err := h.configService.GetByUUID(uuid, userID)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusNotFound
|
||||||
|
if err == services.ErrConfigForbidden {
|
||||||
|
status = http.StatusForbidden
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigurationHandler) Update(c *gin.Context) {
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
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 := h.configService.Update(uuid, userID, &req)
|
||||||
|
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) Delete(c *gin.Context) {
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
|
err := h.configService.Delete(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, gin.H{"message": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigurationHandler) ExportJSON(c *gin.Context) {
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
|
data, err := h.configService.ExportJSON(uuid, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Disposition", "attachment; filename=config.json")
|
||||||
|
c.Data(http.StatusOK, "application/json", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigurationHandler) ImportJSON(c *gin.Context) {
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
data, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := h.configService.ImportJSON(userID, data)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, config)
|
||||||
|
}
|
||||||
174
internal/handlers/export.go
Normal file
174
internal/handlers/export.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mchus/quoteforge/internal/middleware"
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"github.com/mchus/quoteforge/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExportHandler struct {
|
||||||
|
exportService *services.ExportService
|
||||||
|
configService *services.ConfigurationService
|
||||||
|
componentService *services.ComponentService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExportHandler(
|
||||||
|
exportService *services.ExportService,
|
||||||
|
configService *services.ConfigurationService,
|
||||||
|
componentService *services.ComponentService,
|
||||||
|
) *ExportHandler {
|
||||||
|
return &ExportHandler{
|
||||||
|
exportService: exportService,
|
||||||
|
configService: configService,
|
||||||
|
componentService: componentService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Items []struct {
|
||||||
|
LotName string `json:"lot_name" binding:"required"`
|
||||||
|
Quantity int `json:"quantity" binding:"required,min=1"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
} `json:"items" binding:"required,min=1"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||||
|
var req ExportRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := h.buildExportData(&req)
|
||||||
|
|
||||||
|
csvData, err := h.exportService.ToCSV(data)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s_%s.csv", req.Name, time.Now().Format("20060102"))
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
|
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExportHandler) ExportXLSX(c *gin.Context) {
|
||||||
|
var req ExportRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := h.buildExportData(&req)
|
||||||
|
|
||||||
|
xlsxData, err := h.exportService.ToXLSX(data)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s_%s.xlsx", req.Name, time.Now().Format("20060102"))
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
|
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsxData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
||||||
|
items := make([]services.ExportItem, len(req.Items))
|
||||||
|
var total float64
|
||||||
|
|
||||||
|
for i, item := range req.Items {
|
||||||
|
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||||
|
items[i] = services.ExportItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: itemTotal,
|
||||||
|
}
|
||||||
|
total += itemTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
return &services.ExportData{
|
||||||
|
Name: req.Name,
|
||||||
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
Notes: req.Notes,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
|
config, err := h.configService.GetByUUID(uuid, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := h.configToExportData(config)
|
||||||
|
|
||||||
|
csvData, err := h.exportService.ToCSV(data)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s_%s.csv", config.Name, config.CreatedAt.Format("20060102"))
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
|
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExportHandler) ExportConfigXLSX(c *gin.Context) {
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
|
config, err := h.configService.GetByUUID(uuid, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := h.configToExportData(config)
|
||||||
|
|
||||||
|
xlsxData, err := h.exportService.ToXLSX(data)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s_%s.xlsx", config.Name, config.CreatedAt.Format("20060102"))
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
|
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsxData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExportHandler) configToExportData(config *models.Configuration) *services.ExportData {
|
||||||
|
items := make([]services.ExportItem, len(config.Items))
|
||||||
|
var total float64
|
||||||
|
|
||||||
|
for i, item := range config.Items {
|
||||||
|
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||||
|
items[i] = services.ExportItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: itemTotal,
|
||||||
|
}
|
||||||
|
total += itemTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
return &services.ExportData{
|
||||||
|
Name: config.Name,
|
||||||
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
Notes: config.Notes,
|
||||||
|
CreatedAt: config.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
210
internal/handlers/pricing.go
Normal file
210
internal/handlers/pricing.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mchus/quoteforge/internal/middleware"
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"github.com/mchus/quoteforge/internal/repository"
|
||||||
|
"github.com/mchus/quoteforge/internal/services/alerts"
|
||||||
|
"github.com/mchus/quoteforge/internal/services/pricing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PricingHandler struct {
|
||||||
|
pricingService *pricing.Service
|
||||||
|
alertService *alerts.Service
|
||||||
|
componentRepo *repository.ComponentRepository
|
||||||
|
statsRepo *repository.StatsRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPricingHandler(
|
||||||
|
pricingService *pricing.Service,
|
||||||
|
alertService *alerts.Service,
|
||||||
|
componentRepo *repository.ComponentRepository,
|
||||||
|
statsRepo *repository.StatsRepository,
|
||||||
|
) *PricingHandler {
|
||||||
|
return &PricingHandler{
|
||||||
|
pricingService: pricingService,
|
||||||
|
alertService: alertService,
|
||||||
|
componentRepo: componentRepo,
|
||||||
|
statsRepo: statsRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PricingHandler) GetStats(c *gin.Context) {
|
||||||
|
newAlerts, _ := h.alertService.GetNewAlertsCount()
|
||||||
|
topComponents, _ := h.statsRepo.GetTopComponents(10)
|
||||||
|
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"new_alerts_count": newAlerts,
|
||||||
|
"top_components": topComponents,
|
||||||
|
"trending_components": trendingComponents,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PricingHandler) ListComponents(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||||
|
|
||||||
|
filter := repository.ComponentFilter{
|
||||||
|
Category: c.Query("category"),
|
||||||
|
Vendor: c.Query("vendor"),
|
||||||
|
Search: c.Query("search"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 || perPage > 100 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
components, total, err := h.componentRepo.List(filter, offset, perPage)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"components": components,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
|
||||||
|
lotName := c.Param("lot_name")
|
||||||
|
|
||||||
|
component, err := h.componentRepo.GetByLotName(lotName)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := h.pricingService.GetPriceStats(lotName, 0)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"component": component,
|
||||||
|
"price_stats": stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdatePriceRequest struct {
|
||||||
|
LotName string `json:"lot_name" binding:"required"`
|
||||||
|
Method models.PriceMethod `json:"method"`
|
||||||
|
PeriodDays int `json:"period_days"`
|
||||||
|
ManualPrice *float64 `json:"manual_price"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
|
||||||
|
var req UpdatePriceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ManualPrice != nil && *req.ManualPrice > 0 {
|
||||||
|
err := h.pricingService.SetManualPrice(req.LotName, *req.ManualPrice, req.Reason, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Method != "" {
|
||||||
|
err := h.pricingService.UpdatePriceMethod(req.LotName, req.Method, req.PeriodDays)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "price updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||||
|
// This would be better as a background job
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"message": "recalculation started"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||||
|
|
||||||
|
filter := repository.AlertFilter{
|
||||||
|
Status: models.AlertStatus(c.Query("status")),
|
||||||
|
Severity: models.AlertSeverity(c.Query("severity")),
|
||||||
|
Type: models.AlertType(c.Query("type")),
|
||||||
|
LotName: c.Query("lot_name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
alertsList, total, err := h.alertService.List(filter, page, perPage)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"alerts": alertsList,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.alertService.Acknowledge(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "acknowledged"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PricingHandler) ResolveAlert(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.alertService.Resolve(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "resolved"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PricingHandler) IgnoreAlert(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.alertService.Ignore(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "ignored"})
|
||||||
|
}
|
||||||
51
internal/handlers/quote.go
Normal file
51
internal/handlers/quote.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mchus/quoteforge/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QuoteHandler struct {
|
||||||
|
quoteService *services.QuoteService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQuoteHandler(quoteService *services.QuoteService) *QuoteHandler {
|
||||||
|
return &QuoteHandler{quoteService: quoteService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuoteHandler) Validate(c *gin.Context) {
|
||||||
|
var req services.QuoteRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.quoteService.ValidateAndCalculate(&req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuoteHandler) Calculate(c *gin.Context) {
|
||||||
|
var req services.QuoteRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.quoteService.ValidateAndCalculate(&req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"items": result.Items,
|
||||||
|
"total": result.Total,
|
||||||
|
})
|
||||||
|
}
|
||||||
101
internal/middleware/auth.go
Normal file
101
internal/middleware/auth.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"github.com/mchus/quoteforge/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuthUserKey = "auth_user"
|
||||||
|
AuthClaimsKey = "auth_claims"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Auth(authService *services.AuthService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"error": "authorization header required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"error": "invalid authorization header format",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := authService.ValidateToken(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(AuthClaimsKey, claims)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireRole(roles ...models.UserRole) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
claims, exists := c.Get(AuthClaimsKey)
|
||||||
|
if !exists {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"error": "authentication required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authClaims := claims.(*services.Claims)
|
||||||
|
|
||||||
|
for _, role := range roles {
|
||||||
|
if authClaims.Role == role {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "insufficient permissions",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireEditor() gin.HandlerFunc {
|
||||||
|
return RequireRole(models.RoleEditor, models.RolePricingAdmin, models.RoleAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequirePricingAdmin() gin.HandlerFunc {
|
||||||
|
return RequireRole(models.RolePricingAdmin, models.RoleAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireAdmin() gin.HandlerFunc {
|
||||||
|
return RequireRole(models.RoleAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClaims extracts auth claims from context
|
||||||
|
func GetClaims(c *gin.Context) *services.Claims {
|
||||||
|
claims, exists := c.Get(AuthClaimsKey)
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return claims.(*services.Claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserID extracts user ID from context
|
||||||
|
func GetUserID(c *gin.Context) uint {
|
||||||
|
claims := GetClaims(c)
|
||||||
|
if claims == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return claims.UserID
|
||||||
|
}
|
||||||
22
internal/middleware/cors.go
Normal file
22
internal/middleware/cors.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CORS() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||||
|
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
|
||||||
|
c.Header("Access-Control-Max-Age", "86400")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
93
internal/models/alert.go
Normal file
93
internal/models/alert.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlertHighDemandStalePrice AlertType = "high_demand_stale_price"
|
||||||
|
AlertPriceSpike AlertType = "price_spike"
|
||||||
|
AlertPriceDrop AlertType = "price_drop"
|
||||||
|
AlertNoRecentQuotes AlertType = "no_recent_quotes"
|
||||||
|
AlertTrendingNoPrice AlertType = "trending_no_price"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertSeverity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SeverityLow AlertSeverity = "low"
|
||||||
|
SeverityMedium AlertSeverity = "medium"
|
||||||
|
SeverityHigh AlertSeverity = "high"
|
||||||
|
SeverityCritical AlertSeverity = "critical"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlertStatusNew AlertStatus = "new"
|
||||||
|
AlertStatusAcknowledged AlertStatus = "acknowledged"
|
||||||
|
AlertStatusResolved AlertStatus = "resolved"
|
||||||
|
AlertStatusIgnored AlertStatus = "ignored"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertDetails map[string]interface{}
|
||||||
|
|
||||||
|
func (d AlertDetails) Value() (driver.Value, error) {
|
||||||
|
return json.Marshal(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *AlertDetails) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*d = make(AlertDetails)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bytes, ok := value.([]byte)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("type assertion to []byte failed")
|
||||||
|
}
|
||||||
|
return json.Unmarshal(bytes, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PricingAlert struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
LotName string `gorm:"size:255;not null" json:"lot_name"`
|
||||||
|
AlertType AlertType `gorm:"type:enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price');not null" json:"alert_type"`
|
||||||
|
Severity AlertSeverity `gorm:"type:enum('low','medium','high','critical');default:'medium'" json:"severity"`
|
||||||
|
Message string `gorm:"type:text;not null" json:"message"`
|
||||||
|
Details AlertDetails `gorm:"type:json" json:"details"`
|
||||||
|
Status AlertStatus `gorm:"type:enum('new','acknowledged','resolved','ignored');default:'new'" json:"status"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PricingAlert) TableName() string {
|
||||||
|
return "qt_pricing_alerts"
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrendDirection string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TrendUp TrendDirection = "up"
|
||||||
|
TrendStable TrendDirection = "stable"
|
||||||
|
TrendDown TrendDirection = "down"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ComponentUsageStats struct {
|
||||||
|
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||||
|
QuotesTotal int `gorm:"default:0" json:"quotes_total"`
|
||||||
|
QuotesLast30d int `gorm:"default:0" json:"quotes_last_30d"`
|
||||||
|
QuotesLast7d int `gorm:"default:0" json:"quotes_last_7d"`
|
||||||
|
TotalQuantity int `gorm:"default:0" json:"total_quantity"`
|
||||||
|
TotalRevenue float64 `gorm:"type:decimal(14,2);default:0" json:"total_revenue"`
|
||||||
|
TrendDirection TrendDirection `gorm:"type:enum('up','stable','down');default:'stable'" json:"trend_direction"`
|
||||||
|
TrendPercent float64 `gorm:"type:decimal(5,2);default:0" json:"trend_percent"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ComponentUsageStats) TableName() string {
|
||||||
|
return "qt_component_usage_stats"
|
||||||
|
}
|
||||||
29
internal/models/category.go
Normal file
29
internal/models/category.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Code string `gorm:"size:20;uniqueIndex;not null" json:"code"`
|
||||||
|
Name string `gorm:"size:100;not null" json:"name"`
|
||||||
|
NameRu string `gorm:"size:100" json:"name_ru"`
|
||||||
|
DisplayOrder int `gorm:"default:0" json:"display_order"`
|
||||||
|
IsRequired bool `gorm:"default:false" json:"is_required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Category) TableName() string {
|
||||||
|
return "qt_categories"
|
||||||
|
}
|
||||||
|
|
||||||
|
var DefaultCategories = []Category{
|
||||||
|
{Code: "MB", Name: "Motherboard", NameRu: "Материнская плата", DisplayOrder: 1, IsRequired: true},
|
||||||
|
{Code: "CPU", Name: "Processor", NameRu: "Процессор", DisplayOrder: 2, IsRequired: true},
|
||||||
|
{Code: "MEM", Name: "Memory", NameRu: "Оперативная память", DisplayOrder: 3, IsRequired: true},
|
||||||
|
{Code: "GPU", Name: "Graphics Card", NameRu: "Видеокарта", DisplayOrder: 4},
|
||||||
|
{Code: "SSD", Name: "SSD Storage", NameRu: "SSD накопитель", DisplayOrder: 5},
|
||||||
|
{Code: "HDD", Name: "HDD Storage", NameRu: "HDD накопитель", DisplayOrder: 6},
|
||||||
|
{Code: "RAID", Name: "RAID Controller", NameRu: "RAID контроллер", DisplayOrder: 7},
|
||||||
|
{Code: "NIC", Name: "Network Card", NameRu: "Сетевая карта", DisplayOrder: 8},
|
||||||
|
{Code: "HCA", Name: "HCA Adapter", NameRu: "HCA адаптер", DisplayOrder: 9},
|
||||||
|
{Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 10},
|
||||||
|
{Code: "DPU", Name: "DPU", NameRu: "DPU", DisplayOrder: 11},
|
||||||
|
{Code: "PS", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 12},
|
||||||
|
}
|
||||||
74
internal/models/configuration.go
Normal file
74
internal/models/configuration.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigItem struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigItems []ConfigItem
|
||||||
|
|
||||||
|
func (c ConfigItems) Value() (driver.Value, error) {
|
||||||
|
return json.Marshal(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ConfigItems) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*c = make(ConfigItems, 0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bytes, ok := value.([]byte)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("type assertion to []byte failed")
|
||||||
|
}
|
||||||
|
return json.Unmarshal(bytes, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ConfigItems) Total() float64 {
|
||||||
|
var total float64
|
||||||
|
for _, item := range c {
|
||||||
|
total += item.UnitPrice * float64(item.Quantity)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
type Configuration struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||||
|
UserID uint `gorm:"not null" json:"user_id"`
|
||||||
|
Name string `gorm:"size:200;not null" json:"name"`
|
||||||
|
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||||
|
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||||
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
|
||||||
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Configuration) TableName() string {
|
||||||
|
return "qt_configurations"
|
||||||
|
}
|
||||||
|
|
||||||
|
type PriceOverride struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
LotName string `gorm:"size:255;not null" json:"lot_name"`
|
||||||
|
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
||||||
|
ValidFrom time.Time `gorm:"type:date;not null" json:"valid_from"`
|
||||||
|
ValidUntil *time.Time `gorm:"type:date" json:"valid_until"`
|
||||||
|
Reason string `gorm:"type:text" json:"reason"`
|
||||||
|
CreatedBy uint `gorm:"not null" json:"created_by"`
|
||||||
|
|
||||||
|
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PriceOverride) TableName() string {
|
||||||
|
return "qt_price_overrides"
|
||||||
|
}
|
||||||
38
internal/models/lot.go
Normal file
38
internal/models/lot.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Lot represents existing lot table (READ-ONLY)
|
||||||
|
type Lot struct {
|
||||||
|
LotName string `gorm:"column:lot_name;primaryKey;size:255"`
|
||||||
|
LotDescription string `gorm:"column:lot_description;size:10000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Lot) TableName() string {
|
||||||
|
return "lot"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotLog represents existing lot_log table (READ-ONLY)
|
||||||
|
type LotLog struct {
|
||||||
|
LotLogID uint `gorm:"column:lot_log_id;primaryKey;autoIncrement"`
|
||||||
|
Lot string `gorm:"column:lot;size:255;not null"`
|
||||||
|
Supplier string `gorm:"column:supplier;size:255;not null"`
|
||||||
|
Date time.Time `gorm:"column:date;type:date;not null"`
|
||||||
|
Price float64 `gorm:"column:price;not null"`
|
||||||
|
Quality string `gorm:"column:quality;size:255"`
|
||||||
|
Comments string `gorm:"column:comments;size:15000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LotLog) TableName() string {
|
||||||
|
return "lot_log"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supplier represents existing supplier table (READ-ONLY)
|
||||||
|
type Supplier struct {
|
||||||
|
SupplierName string `gorm:"column:supplier_name;primaryKey;size:255"`
|
||||||
|
SupplierComment string `gorm:"column:supplier_comment;size:10000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Supplier) TableName() string {
|
||||||
|
return "supplier"
|
||||||
|
}
|
||||||
87
internal/models/metadata.go
Normal file
87
internal/models/metadata.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PriceMethod string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PriceMethodManual PriceMethod = "manual"
|
||||||
|
PriceMethodMedian PriceMethod = "median"
|
||||||
|
PriceMethodAverage PriceMethod = "average"
|
||||||
|
PriceMethodWeightedMedian PriceMethod = "weighted_median"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Specs map[string]interface{}
|
||||||
|
|
||||||
|
func (s Specs) Value() (driver.Value, error) {
|
||||||
|
return json.Marshal(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Specs) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*s = make(Specs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bytes, ok := value.([]byte)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("type assertion to []byte failed")
|
||||||
|
}
|
||||||
|
return json.Unmarshal(bytes, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LotMetadata struct {
|
||||||
|
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||||
|
CategoryID *uint `gorm:"column:category_id" json:"category_id"`
|
||||||
|
Vendor string `gorm:"size:50" json:"vendor"`
|
||||||
|
Model string `gorm:"size:100" json:"model"`
|
||||||
|
Specs Specs `gorm:"type:json" json:"specs"`
|
||||||
|
CurrentPrice *float64 `gorm:"type:decimal(12,2)" json:"current_price"`
|
||||||
|
PriceMethod PriceMethod `gorm:"type:enum('manual','median','average','weighted_median');default:'median'" json:"price_method"`
|
||||||
|
PricePeriodDays int `gorm:"default:90" json:"price_period_days"`
|
||||||
|
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||||
|
RequestCount int `gorm:"default:0" json:"request_count"`
|
||||||
|
LastRequestDate *time.Time `gorm:"type:date" json:"last_request_date"`
|
||||||
|
PopularityScore float64 `gorm:"type:decimal(10,4);default:0" json:"popularity_score"`
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`
|
||||||
|
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LotMetadata) TableName() string {
|
||||||
|
return "qt_lot_metadata"
|
||||||
|
}
|
||||||
|
|
||||||
|
type PriceFreshness string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FreshnessFresh PriceFreshness = "fresh"
|
||||||
|
FreshnessNormal PriceFreshness = "normal"
|
||||||
|
FreshnessStale PriceFreshness = "stale"
|
||||||
|
FreshnessCritical PriceFreshness = "critical"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *LotMetadata) GetPriceFreshness(greenDays, yellowDays, redDays, minQuotes int) PriceFreshness {
|
||||||
|
if m.CurrentPrice == nil || *m.CurrentPrice == 0 {
|
||||||
|
return FreshnessCritical
|
||||||
|
}
|
||||||
|
if m.PriceUpdatedAt == nil {
|
||||||
|
return FreshnessCritical
|
||||||
|
}
|
||||||
|
|
||||||
|
daysSince := int(time.Since(*m.PriceUpdatedAt).Hours() / 24)
|
||||||
|
|
||||||
|
if daysSince < greenDays && m.RequestCount >= minQuotes {
|
||||||
|
return FreshnessFresh
|
||||||
|
} else if daysSince < yellowDays {
|
||||||
|
return FreshnessNormal
|
||||||
|
} else if daysSince < redDays {
|
||||||
|
return FreshnessStale
|
||||||
|
}
|
||||||
|
return FreshnessCritical
|
||||||
|
}
|
||||||
32
internal/models/models.go
Normal file
32
internal/models/models.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
// AllModels returns all models for auto-migration
|
||||||
|
func AllModels() []interface{} {
|
||||||
|
return []interface{}{
|
||||||
|
&User{},
|
||||||
|
&Category{},
|
||||||
|
&LotMetadata{},
|
||||||
|
&Configuration{},
|
||||||
|
&PriceOverride{},
|
||||||
|
&PricingAlert{},
|
||||||
|
&ComponentUsageStats{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate runs auto-migration for all QuoteForge tables
|
||||||
|
func Migrate(db *gorm.DB) error {
|
||||||
|
return db.AutoMigrate(AllModels()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedCategories inserts default categories if not exist
|
||||||
|
func SeedCategories(db *gorm.DB) error {
|
||||||
|
for _, cat := range DefaultCategories {
|
||||||
|
result := db.Where("code = ?", cat.Code).FirstOrCreate(&cat)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
39
internal/models/user.go
Normal file
39
internal/models/user.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type UserRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleViewer UserRole = "viewer"
|
||||||
|
RoleEditor UserRole = "editor"
|
||||||
|
RolePricingAdmin UserRole = "pricing_admin"
|
||||||
|
RoleAdmin UserRole = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Username string `gorm:"size:100;uniqueIndex;not null" json:"username"`
|
||||||
|
Email string `gorm:"size:255;uniqueIndex;not null" json:"email"`
|
||||||
|
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||||
|
Role UserRole `gorm:"type:enum('viewer','editor','pricing_admin','admin');default:'viewer'" json:"role"`
|
||||||
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (User) TableName() string {
|
||||||
|
return "qt_users"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) CanEdit() bool {
|
||||||
|
return u.Role == RoleEditor || u.Role == RolePricingAdmin || u.Role == RoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) CanManagePricing() bool {
|
||||||
|
return u.Role == RolePricingAdmin || u.Role == RoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) CanManageUsers() bool {
|
||||||
|
return u.Role == RoleAdmin
|
||||||
|
}
|
||||||
91
internal/repository/alert.go
Normal file
91
internal/repository/alert.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAlertRepository(db *gorm.DB) *AlertRepository {
|
||||||
|
return &AlertRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlertRepository) Create(alert *models.PricingAlert) error {
|
||||||
|
return r.db.Create(alert).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlertRepository) GetByID(id uint) (*models.PricingAlert, error) {
|
||||||
|
var alert models.PricingAlert
|
||||||
|
err := r.db.First(&alert, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &alert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlertRepository) Update(alert *models.PricingAlert) error {
|
||||||
|
return r.db.Save(alert).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertFilter struct {
|
||||||
|
Status models.AlertStatus
|
||||||
|
Severity models.AlertSeverity
|
||||||
|
Type models.AlertType
|
||||||
|
LotName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlertRepository) List(filter AlertFilter, offset, limit int) ([]models.PricingAlert, int64, error) {
|
||||||
|
var alerts []models.PricingAlert
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.PricingAlert{})
|
||||||
|
|
||||||
|
if filter.Status != "" {
|
||||||
|
query = query.Where("status = ?", filter.Status)
|
||||||
|
}
|
||||||
|
if filter.Severity != "" {
|
||||||
|
query = query.Where("severity = ?", filter.Severity)
|
||||||
|
}
|
||||||
|
if filter.Type != "" {
|
||||||
|
query = query.Where("alert_type = ?", filter.Type)
|
||||||
|
}
|
||||||
|
if filter.LotName != "" {
|
||||||
|
query = query.Where("lot_name = ?", filter.LotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
|
err := query.
|
||||||
|
Order("FIELD(severity, 'critical', 'high', 'medium', 'low')").
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(limit).
|
||||||
|
Find(&alerts).Error
|
||||||
|
|
||||||
|
return alerts, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlertRepository) CountByStatus(status models.AlertStatus) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.PricingAlert{}).
|
||||||
|
Where("status = ?", status).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlertRepository) UpdateStatus(id uint, status models.AlertStatus) error {
|
||||||
|
return r.db.Model(&models.PricingAlert{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Update("status", status).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlertRepository) ExistsByLotAndType(lotName string, alertType models.AlertType) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.PricingAlert{}).
|
||||||
|
Where("lot_name = ? AND alert_type = ? AND status IN ('new', 'acknowledged')", lotName, alertType).
|
||||||
|
Count(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
38
internal/repository/category.go
Normal file
38
internal/repository/category.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CategoryRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
|
||||||
|
return &CategoryRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CategoryRepository) GetAll() ([]models.Category, error) {
|
||||||
|
var categories []models.Category
|
||||||
|
err := r.db.Order("display_order ASC").Find(&categories).Error
|
||||||
|
return categories, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CategoryRepository) GetByCode(code string) (*models.Category, error) {
|
||||||
|
var category models.Category
|
||||||
|
err := r.db.Where("code = ?", code).First(&category).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &category, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) {
|
||||||
|
var category models.Category
|
||||||
|
err := r.db.First(&category, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &category, nil
|
||||||
|
}
|
||||||
127
internal/repository/component.go
Normal file
127
internal/repository/component.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ComponentRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewComponentRepository(db *gorm.DB) *ComponentRepository {
|
||||||
|
return &ComponentRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentFilter struct {
|
||||||
|
Category string
|
||||||
|
Vendor string
|
||||||
|
Search string
|
||||||
|
HasPrice bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||||
|
var components []models.LotMetadata
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.LotMetadata{}).
|
||||||
|
Preload("Lot").
|
||||||
|
Preload("Category")
|
||||||
|
|
||||||
|
if filter.Category != "" {
|
||||||
|
query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id").
|
||||||
|
Where("qt_categories.code = ?", filter.Category)
|
||||||
|
}
|
||||||
|
if filter.Vendor != "" {
|
||||||
|
query = query.Where("vendor = ?", filter.Vendor)
|
||||||
|
}
|
||||||
|
if filter.Search != "" {
|
||||||
|
search := "%" + filter.Search + "%"
|
||||||
|
query = query.Where("lot_name LIKE ? OR model LIKE ?", search, search)
|
||||||
|
}
|
||||||
|
if filter.HasPrice {
|
||||||
|
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
|
// Sort by popularity + freshness, no price goes last
|
||||||
|
err := query.
|
||||||
|
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
||||||
|
Order("popularity_score DESC").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(limit).
|
||||||
|
Find(&components).Error
|
||||||
|
|
||||||
|
return components, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ComponentRepository) GetByLotName(lotName string) (*models.LotMetadata, error) {
|
||||||
|
var component models.LotMetadata
|
||||||
|
err := r.db.
|
||||||
|
Preload("Lot").
|
||||||
|
Preload("Category").
|
||||||
|
Where("lot_name = ?", lotName).
|
||||||
|
First(&component).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &component, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ComponentRepository) GetMultiple(lotNames []string) ([]models.LotMetadata, error) {
|
||||||
|
var components []models.LotMetadata
|
||||||
|
err := r.db.
|
||||||
|
Preload("Lot").
|
||||||
|
Preload("Category").
|
||||||
|
Where("lot_name IN ?", lotNames).
|
||||||
|
Find(&components).Error
|
||||||
|
return components, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ComponentRepository) Update(component *models.LotMetadata) error {
|
||||||
|
return r.db.Save(component).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
||||||
|
return r.db.Create(component).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ComponentRepository) GetVendors(category string) ([]string, error) {
|
||||||
|
var vendors []string
|
||||||
|
query := r.db.Model(&models.LotMetadata{}).Distinct("vendor")
|
||||||
|
if category != "" {
|
||||||
|
query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id").
|
||||||
|
Where("qt_categories.code = ?", category)
|
||||||
|
}
|
||||||
|
err := query.Pluck("vendor", &vendors).Error
|
||||||
|
return vendors, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ComponentRepository) IncrementRequestCount(lotName string) error {
|
||||||
|
now := time.Now()
|
||||||
|
return r.db.Model(&models.LotMetadata{}).
|
||||||
|
Where("lot_name = ?", lotName).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"request_count": gorm.Expr("request_count + 1"),
|
||||||
|
"last_request_date": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllLots returns all lots from the existing lot table
|
||||||
|
func (r *ComponentRepository) GetAllLots() ([]models.Lot, error) {
|
||||||
|
var lots []models.Lot
|
||||||
|
err := r.db.Find(&lots).Error
|
||||||
|
return lots, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLotsWithoutMetadata returns lots that don't have qt_lot_metadata entries
|
||||||
|
func (r *ComponentRepository) GetLotsWithoutMetadata() ([]models.Lot, error) {
|
||||||
|
var lots []models.Lot
|
||||||
|
err := r.db.
|
||||||
|
Where("lot_name NOT IN (SELECT lot_name FROM qt_lot_metadata)").
|
||||||
|
Find(&lots).Error
|
||||||
|
return lots, err
|
||||||
|
}
|
||||||
75
internal/repository/configuration.go
Normal file
75
internal/repository/configuration.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigurationRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigurationRepository(db *gorm.DB) *ConfigurationRepository {
|
||||||
|
return &ConfigurationRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ConfigurationRepository) Create(config *models.Configuration) error {
|
||||||
|
return r.db.Create(config).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
||||||
|
var config models.Configuration
|
||||||
|
err := r.db.Preload("User").First(&config, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
|
||||||
|
var config models.Configuration
|
||||||
|
err := r.db.Preload("User").Where("uuid = ?", uuid).First(&config).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ConfigurationRepository) Update(config *models.Configuration) error {
|
||||||
|
return r.db.Save(config).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ConfigurationRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.Configuration{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ConfigurationRepository) ListByUser(userID uint, offset, limit int) ([]models.Configuration, int64, error) {
|
||||||
|
var configs []models.Configuration
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
r.db.Model(&models.Configuration{}).Where("user_id = ?", userID).Count(&total)
|
||||||
|
err := r.db.
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(limit).
|
||||||
|
Find(&configs).Error
|
||||||
|
|
||||||
|
return configs, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Configuration, int64, error) {
|
||||||
|
var configs []models.Configuration
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
r.db.Model(&models.Configuration{}).Where("is_template = ?", true).Count(&total)
|
||||||
|
err := r.db.
|
||||||
|
Preload("User").
|
||||||
|
Where("is_template = ?", true).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(limit).
|
||||||
|
Find(&configs).Error
|
||||||
|
|
||||||
|
return configs, total, err
|
||||||
|
}
|
||||||
99
internal/repository/price.go
Normal file
99
internal/repository/price.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PriceRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPriceRepository(db *gorm.DB) *PriceRepository {
|
||||||
|
return &PriceRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PricePoint struct {
|
||||||
|
Price float64
|
||||||
|
Date time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPriceHistory returns price history from lot_log for a component
|
||||||
|
func (r *PriceRepository) GetPriceHistory(lotName string, periodDays int) ([]PricePoint, error) {
|
||||||
|
var points []PricePoint
|
||||||
|
since := time.Now().AddDate(0, 0, -periodDays)
|
||||||
|
|
||||||
|
err := r.db.Model(&models.LotLog{}).
|
||||||
|
Select("price, date").
|
||||||
|
Where("lot = ? AND date >= ?", lotName, since).
|
||||||
|
Order("date DESC").
|
||||||
|
Scan(&points).Error
|
||||||
|
|
||||||
|
return points, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestPrice returns the most recent price for a component
|
||||||
|
func (r *PriceRepository) GetLatestPrice(lotName string) (*PricePoint, error) {
|
||||||
|
var point PricePoint
|
||||||
|
err := r.db.Model(&models.LotLog{}).
|
||||||
|
Select("price, date").
|
||||||
|
Where("lot = ?", lotName).
|
||||||
|
Order("date DESC").
|
||||||
|
First(&point).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &point, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPriceOverride returns active override for a component
|
||||||
|
func (r *PriceRepository) GetPriceOverride(lotName string) (*models.PriceOverride, error) {
|
||||||
|
var override models.PriceOverride
|
||||||
|
today := time.Now().Truncate(24 * time.Hour)
|
||||||
|
|
||||||
|
err := r.db.
|
||||||
|
Where("lot_name = ?", lotName).
|
||||||
|
Where("valid_from <= ?", today).
|
||||||
|
Where("valid_until IS NULL OR valid_until >= ?", today).
|
||||||
|
Order("valid_from DESC").
|
||||||
|
First(&override).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &override, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePriceOverride creates a new price override
|
||||||
|
func (r *PriceRepository) CreatePriceOverride(override *models.PriceOverride) error {
|
||||||
|
return r.db.Create(override).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPriceOverrides returns all overrides for a component
|
||||||
|
func (r *PriceRepository) GetPriceOverrides(lotName string) ([]models.PriceOverride, error) {
|
||||||
|
var overrides []models.PriceOverride
|
||||||
|
err := r.db.
|
||||||
|
Where("lot_name = ?", lotName).
|
||||||
|
Order("valid_from DESC").
|
||||||
|
Find(&overrides).Error
|
||||||
|
return overrides, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePriceOverride deletes an override
|
||||||
|
func (r *PriceRepository) DeletePriceOverride(id uint) error {
|
||||||
|
return r.db.Delete(&models.PriceOverride{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuoteCount returns the number of quotes in lot_log for a period
|
||||||
|
func (r *PriceRepository) GetQuoteCount(lotName string, periodDays int) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
since := time.Now().AddDate(0, 0, -periodDays)
|
||||||
|
|
||||||
|
err := r.db.Model(&models.LotLog{}).
|
||||||
|
Where("lot = ? AND date >= ?", lotName, since).
|
||||||
|
Count(&count).Error
|
||||||
|
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
92
internal/repository/stats.go
Normal file
92
internal/repository/stats.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatsRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStatsRepository(db *gorm.DB) *StatsRepository {
|
||||||
|
return &StatsRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StatsRepository) GetByLotName(lotName string) (*models.ComponentUsageStats, error) {
|
||||||
|
var stats models.ComponentUsageStats
|
||||||
|
err := r.db.Where("lot_name = ?", lotName).First(&stats).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StatsRepository) Upsert(stats *models.ComponentUsageStats) error {
|
||||||
|
return r.db.Save(stats).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StatsRepository) IncrementUsage(lotName string, quantity int, revenue float64) error {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
result := r.db.Model(&models.ComponentUsageStats{}).
|
||||||
|
Where("lot_name = ?", lotName).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"quotes_total": gorm.Expr("quotes_total + 1"),
|
||||||
|
"quotes_last_30d": gorm.Expr("quotes_last_30d + 1"),
|
||||||
|
"quotes_last_7d": gorm.Expr("quotes_last_7d + 1"),
|
||||||
|
"total_quantity": gorm.Expr("total_quantity + ?", quantity),
|
||||||
|
"total_revenue": gorm.Expr("total_revenue + ?", revenue),
|
||||||
|
"last_used_at": now,
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
stats := &models.ComponentUsageStats{
|
||||||
|
LotName: lotName,
|
||||||
|
QuotesTotal: 1,
|
||||||
|
QuotesLast30d: 1,
|
||||||
|
QuotesLast7d: 1,
|
||||||
|
TotalQuantity: quantity,
|
||||||
|
TotalRevenue: revenue,
|
||||||
|
LastUsedAt: &now,
|
||||||
|
}
|
||||||
|
return r.db.Create(stats).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StatsRepository) GetTopComponents(limit int) ([]models.ComponentUsageStats, error) {
|
||||||
|
var stats []models.ComponentUsageStats
|
||||||
|
err := r.db.
|
||||||
|
Order("quotes_last_30d DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&stats).Error
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StatsRepository) GetTrendingComponents(limit int) ([]models.ComponentUsageStats, error) {
|
||||||
|
var stats []models.ComponentUsageStats
|
||||||
|
err := r.db.
|
||||||
|
Where("trend_direction = ? AND trend_percent > ?", models.TrendUp, 20).
|
||||||
|
Order("trend_percent DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&stats).Error
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetWeeklyCounters resets quotes_last_7d (run weekly via cron)
|
||||||
|
func (r *StatsRepository) ResetWeeklyCounters() error {
|
||||||
|
return r.db.Model(&models.ComponentUsageStats{}).
|
||||||
|
Where("1 = 1").
|
||||||
|
Update("quotes_last_7d", 0).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetMonthlyCounters resets quotes_last_30d (run monthly via cron)
|
||||||
|
func (r *StatsRepository) ResetMonthlyCounters() error {
|
||||||
|
return r.db.Model(&models.ComponentUsageStats{}).
|
||||||
|
Where("1 = 1").
|
||||||
|
Update("quotes_last_30d", 0).Error
|
||||||
|
}
|
||||||
62
internal/repository/user.go
Normal file
62
internal/repository/user.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||||
|
return &UserRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Create(user *models.User) error {
|
||||||
|
return r.db.Create(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByID(id uint) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
err := r.db.First(&user, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByUsername(username string) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
err := r.db.Where("username = ?", username).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
err := r.db.Where("email = ?", email).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Update(user *models.User) error {
|
||||||
|
return r.db.Save(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.User{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) List(offset, limit int) ([]models.User, int64, error) {
|
||||||
|
var users []models.User
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
r.db.Model(&models.User{}).Count(&total)
|
||||||
|
err := r.db.Offset(offset).Limit(limit).Find(&users).Error
|
||||||
|
return users, total, err
|
||||||
|
}
|
||||||
199
internal/services/alerts/service.go
Normal file
199
internal/services/alerts/service.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mchus/quoteforge/internal/config"
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"github.com/mchus/quoteforge/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
alertRepo *repository.AlertRepository
|
||||||
|
componentRepo *repository.ComponentRepository
|
||||||
|
priceRepo *repository.PriceRepository
|
||||||
|
statsRepo *repository.StatsRepository
|
||||||
|
config config.AlertsConfig
|
||||||
|
pricingConfig config.PricingConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(
|
||||||
|
alertRepo *repository.AlertRepository,
|
||||||
|
componentRepo *repository.ComponentRepository,
|
||||||
|
priceRepo *repository.PriceRepository,
|
||||||
|
statsRepo *repository.StatsRepository,
|
||||||
|
alertCfg config.AlertsConfig,
|
||||||
|
pricingCfg config.PricingConfig,
|
||||||
|
) *Service {
|
||||||
|
return &Service{
|
||||||
|
alertRepo: alertRepo,
|
||||||
|
componentRepo: componentRepo,
|
||||||
|
priceRepo: priceRepo,
|
||||||
|
statsRepo: statsRepo,
|
||||||
|
config: alertCfg,
|
||||||
|
pricingConfig: pricingCfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(filter repository.AlertFilter, page, perPage int) ([]models.PricingAlert, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 || perPage > 100 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
return s.alertRepo.List(filter, offset, perPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Acknowledge(id uint) error {
|
||||||
|
return s.alertRepo.UpdateStatus(id, models.AlertStatusAcknowledged)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Resolve(id uint) error {
|
||||||
|
return s.alertRepo.UpdateStatus(id, models.AlertStatusResolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Ignore(id uint) error {
|
||||||
|
return s.alertRepo.UpdateStatus(id, models.AlertStatusIgnored)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetNewAlertsCount() (int64, error) {
|
||||||
|
return s.alertRepo.CountByStatus(models.AlertStatusNew)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAndGenerateAlerts scans components and creates alerts
|
||||||
|
func (s *Service) CheckAndGenerateAlerts() error {
|
||||||
|
if !s.config.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get top components by usage
|
||||||
|
topComponents, err := s.statsRepo.GetTopComponents(100)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stats := range topComponents {
|
||||||
|
component, err := s.componentRepo.GetByLotName(stats.LotName)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check high demand + stale price
|
||||||
|
if err := s.checkHighDemandStalePrice(component, &stats); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check trending without price
|
||||||
|
if err := s.checkTrendingNoPrice(component, &stats); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check no recent quotes
|
||||||
|
if err := s.checkNoRecentQuotes(component, &stats); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) checkHighDemandStalePrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
|
||||||
|
// high_demand_stale_price: >= 5 quotes/month AND price > 60 days old
|
||||||
|
if stats.QuotesLast30d < s.config.HighDemandThreshold {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if comp.PriceUpdatedAt == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
daysSinceUpdate := int(time.Since(*comp.PriceUpdatedAt).Hours() / 24)
|
||||||
|
if daysSinceUpdate <= s.pricingConfig.FreshnessYellowDays {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if alert already exists
|
||||||
|
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertHighDemandStalePrice)
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
alert := &models.PricingAlert{
|
||||||
|
LotName: comp.LotName,
|
||||||
|
AlertType: models.AlertHighDemandStalePrice,
|
||||||
|
Severity: models.SeverityCritical,
|
||||||
|
Message: fmt.Sprintf("Компонент %s: высокий спрос (%d КП/мес), но цена устарела (%d дней)", comp.LotName, stats.QuotesLast30d, daysSinceUpdate),
|
||||||
|
Details: models.AlertDetails{
|
||||||
|
"quotes_30d": stats.QuotesLast30d,
|
||||||
|
"days_since_update": daysSinceUpdate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.alertRepo.Create(alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) checkTrendingNoPrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
|
||||||
|
// trending_no_price: trend > 50% AND no price
|
||||||
|
if stats.TrendDirection != models.TrendUp || stats.TrendPercent < float64(s.config.TrendingThresholdPercent) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if comp.CurrentPrice != nil && *comp.CurrentPrice > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertTrendingNoPrice)
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
alert := &models.PricingAlert{
|
||||||
|
LotName: comp.LotName,
|
||||||
|
AlertType: models.AlertTrendingNoPrice,
|
||||||
|
Severity: models.SeverityHigh,
|
||||||
|
Message: fmt.Sprintf("Компонент %s: рост спроса +%.0f%%, но цена не установлена", comp.LotName, stats.TrendPercent),
|
||||||
|
Details: models.AlertDetails{
|
||||||
|
"trend_percent": stats.TrendPercent,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.alertRepo.Create(alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) checkNoRecentQuotes(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
|
||||||
|
// no_recent_quotes: popular component, no supplier quotes > 90 days
|
||||||
|
if stats.QuotesLast30d < 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteCount, err := s.priceRepo.GetQuoteCount(comp.LotName, s.pricingConfig.FreshnessRedDays)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if quoteCount > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertNoRecentQuotes)
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
alert := &models.PricingAlert{
|
||||||
|
LotName: comp.LotName,
|
||||||
|
AlertType: models.AlertNoRecentQuotes,
|
||||||
|
Severity: models.SeverityMedium,
|
||||||
|
Message: fmt.Sprintf("Компонент %s: популярный (%d КП), но нет новых котировок >%d дней", comp.LotName, stats.QuotesLast30d, s.pricingConfig.FreshnessRedDays),
|
||||||
|
Details: models.AlertDetails{
|
||||||
|
"quotes_30d": stats.QuotesLast30d,
|
||||||
|
"no_quotes_days": s.pricingConfig.FreshnessRedDays,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.alertRepo.Create(alert)
|
||||||
|
}
|
||||||
180
internal/services/auth.go
Normal file
180
internal/services/auth.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/mchus/quoteforge/internal/config"
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"github.com/mchus/quoteforge/internal/repository"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||||
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
ErrUserInactive = errors.New("user account is inactive")
|
||||||
|
ErrInvalidToken = errors.New("invalid token")
|
||||||
|
ErrTokenExpired = errors.New("token expired")
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
config config.AuthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthService(userRepo *repository.UserRepository, cfg config.AuthConfig) *AuthService {
|
||||||
|
return &AuthService{
|
||||||
|
userRepo: userRepo,
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role models.UserRole `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Login(username, password string) (*TokenPair, *models.User, error) {
|
||||||
|
user, err := s.userRepo.GetByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsActive {
|
||||||
|
return nil, nil, ErrUserInactive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
return nil, nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := s.generateTokenPair(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
|
||||||
|
claims, err := s.ValidateToken(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.GetByID(claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsActive {
|
||||||
|
return nil, ErrUserInactive
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.generateTokenPair(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(s.config.JWTSecret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||||
|
return nil, ErrTokenExpired
|
||||||
|
}
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) generateTokenPair(user *models.User) (*TokenPair, error) {
|
||||||
|
now := time.Now()
|
||||||
|
accessExpiry := now.Add(s.config.TokenExpiry)
|
||||||
|
refreshExpiry := now.Add(s.config.RefreshExpiry)
|
||||||
|
|
||||||
|
accessClaims := &Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(accessExpiry),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
Subject: user.Username,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||||
|
accessTokenString, err := accessToken.SignedString([]byte(s.config.JWTSecret))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshClaims := &Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(refreshExpiry),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
Subject: user.Username,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||||
|
refreshTokenString, err := refreshToken.SignedString([]byte(s.config.JWTSecret))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TokenPair{
|
||||||
|
AccessToken: accessTokenString,
|
||||||
|
RefreshToken: refreshTokenString,
|
||||||
|
ExpiresAt: accessExpiry.Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) HashPassword(password string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CreateUser(username, email, password string, role models.UserRole) (*models.User, error) {
|
||||||
|
hash, err := s.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Username: username,
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: hash,
|
||||||
|
Role: role,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userRepo.Create(user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
187
internal/services/component.go
Normal file
187
internal/services/component.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"github.com/mchus/quoteforge/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ComponentService struct {
|
||||||
|
componentRepo *repository.ComponentRepository
|
||||||
|
categoryRepo *repository.CategoryRepository
|
||||||
|
statsRepo *repository.StatsRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewComponentService(
|
||||||
|
componentRepo *repository.ComponentRepository,
|
||||||
|
categoryRepo *repository.CategoryRepository,
|
||||||
|
statsRepo *repository.StatsRepository,
|
||||||
|
) *ComponentService {
|
||||||
|
return &ComponentService{
|
||||||
|
componentRepo: componentRepo,
|
||||||
|
categoryRepo: categoryRepo,
|
||||||
|
statsRepo: statsRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePartNumber extracts category, vendor, model from lot_name
|
||||||
|
// "CPU_AMD_9654" → category="CPU", vendor="AMD", model="9654"
|
||||||
|
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", vendor="INTEL", model="4.Sapphire_2S_32xDDR5"
|
||||||
|
func ParsePartNumber(lotName string) (category, vendor, model string) {
|
||||||
|
parts := strings.SplitN(lotName, "_", 3)
|
||||||
|
if len(parts) >= 1 {
|
||||||
|
category = parts[0]
|
||||||
|
}
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
vendor = parts[1]
|
||||||
|
}
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
model = parts[2]
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentListResult struct {
|
||||||
|
Components []ComponentView `json:"components"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PerPage int `json:"per_page"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentView struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
Vendor string `json:"vendor"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
CurrentPrice *float64 `json:"current_price"`
|
||||||
|
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
||||||
|
PopularityScore float64 `json:"popularity_score"`
|
||||||
|
Specs models.Specs `json:"specs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 || perPage > 100 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
components, total, err := s.componentRepo.List(filter, offset, perPage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]ComponentView, len(components))
|
||||||
|
for i, c := range components {
|
||||||
|
view := ComponentView{
|
||||||
|
LotName: c.LotName,
|
||||||
|
Vendor: c.Vendor,
|
||||||
|
Model: c.Model,
|
||||||
|
CurrentPrice: c.CurrentPrice,
|
||||||
|
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||||
|
PopularityScore: c.PopularityScore,
|
||||||
|
Specs: c.Specs,
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Lot != nil {
|
||||||
|
view.Description = c.Lot.LotDescription
|
||||||
|
}
|
||||||
|
if c.Category != nil {
|
||||||
|
view.Category = c.Category.Code
|
||||||
|
view.CategoryName = c.Category.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
views[i] = view
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ComponentListResult{
|
||||||
|
Components: views,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PerPage: perPage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) {
|
||||||
|
c, err := s.componentRepo.GetByLotName(lotName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track usage
|
||||||
|
_ = s.componentRepo.IncrementRequestCount(lotName)
|
||||||
|
|
||||||
|
view := &ComponentView{
|
||||||
|
LotName: c.LotName,
|
||||||
|
Vendor: c.Vendor,
|
||||||
|
Model: c.Model,
|
||||||
|
CurrentPrice: c.CurrentPrice,
|
||||||
|
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||||
|
PopularityScore: c.PopularityScore,
|
||||||
|
Specs: c.Specs,
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Lot != nil {
|
||||||
|
view.Description = c.Lot.LotDescription
|
||||||
|
}
|
||||||
|
if c.Category != nil {
|
||||||
|
view.Category = c.Category.Code
|
||||||
|
view.CategoryName = c.Category.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ComponentService) GetCategories() ([]models.Category, error) {
|
||||||
|
return s.categoryRepo.GetAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ComponentService) GetVendors(category string) ([]string, error) {
|
||||||
|
return s.componentRepo.GetVendors(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportFromLot creates metadata entries for lots that don't have them
|
||||||
|
func (s *ComponentService) ImportFromLot() (int, error) {
|
||||||
|
lots, err := s.componentRepo.GetLotsWithoutMetadata()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
categories, err := s.categoryRepo.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryMap := make(map[string]uint)
|
||||||
|
for _, cat := range categories {
|
||||||
|
categoryMap[cat.Code] = cat.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
imported := 0
|
||||||
|
for _, lot := range lots {
|
||||||
|
category, vendor, model := ParsePartNumber(lot.LotName)
|
||||||
|
|
||||||
|
metadata := &models.LotMetadata{
|
||||||
|
LotName: lot.LotName,
|
||||||
|
Vendor: vendor,
|
||||||
|
Model: model,
|
||||||
|
Specs: make(models.Specs),
|
||||||
|
}
|
||||||
|
|
||||||
|
if catID, ok := categoryMap[category]; ok {
|
||||||
|
metadata.CategoryID = &catID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.componentRepo.Create(metadata); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
|
||||||
|
return imported, nil
|
||||||
|
}
|
||||||
176
internal/services/configuration.go
Normal file
176
internal/services/configuration.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"github.com/mchus/quoteforge/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrConfigNotFound = errors.New("configuration not found")
|
||||||
|
ErrConfigForbidden = errors.New("access to configuration forbidden")
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigurationService struct {
|
||||||
|
configRepo *repository.ConfigurationRepository
|
||||||
|
componentRepo *repository.ComponentRepository
|
||||||
|
quoteService *QuoteService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigurationService(
|
||||||
|
configRepo *repository.ConfigurationRepository,
|
||||||
|
componentRepo *repository.ComponentRepository,
|
||||||
|
quoteService *QuoteService,
|
||||||
|
) *ConfigurationService {
|
||||||
|
return &ConfigurationService{
|
||||||
|
configRepo: configRepo,
|
||||||
|
componentRepo: componentRepo,
|
||||||
|
quoteService: quoteService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateConfigRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Items models.ConfigItems `json:"items"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
IsTemplate bool `json:"is_template"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
|
total := req.Items.Total()
|
||||||
|
|
||||||
|
config := &models.Configuration{
|
||||||
|
UUID: uuid.New().String(),
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
Items: req.Items,
|
||||||
|
TotalPrice: &total,
|
||||||
|
Notes: req.Notes,
|
||||||
|
IsTemplate: req.IsTemplate,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.configRepo.Create(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record usage stats
|
||||||
|
_ = s.quoteService.RecordUsage(req.Items)
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConfigurationService) GetByUUID(uuid string, userID uint) (*models.Configuration, error) {
|
||||||
|
config, err := s.configRepo.GetByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow access if user owns config or it's a template
|
||||||
|
if config.UserID != userID && !config.IsTemplate {
|
||||||
|
return nil, ErrConfigForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
|
config, err := s.configRepo.GetByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.UserID != userID {
|
||||||
|
return nil, ErrConfigForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
total := req.Items.Total()
|
||||||
|
|
||||||
|
config.Name = req.Name
|
||||||
|
config.Items = req.Items
|
||||||
|
config.TotalPrice = &total
|
||||||
|
config.Notes = req.Notes
|
||||||
|
config.IsTemplate = req.IsTemplate
|
||||||
|
|
||||||
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConfigurationService) Delete(uuid string, userID uint) error {
|
||||||
|
config, err := s.configRepo.GetByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.UserID != userID {
|
||||||
|
return ErrConfigForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.configRepo.Delete(config.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConfigurationService) ListByUser(userID uint, 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.ListByUser(userID, offset, perPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConfigurationService) ListTemplates(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.ListTemplates(offset, perPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export configuration as JSON
|
||||||
|
type ConfigExport struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Items models.ConfigItems `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConfigurationService) ExportJSON(uuid string, userID uint) ([]byte, error) {
|
||||||
|
config, err := s.GetByUUID(uuid, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
export := ConfigExport{
|
||||||
|
Name: config.Name,
|
||||||
|
Notes: config.Notes,
|
||||||
|
Items: config.Items,
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.MarshalIndent(export, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConfigurationService) ImportJSON(userID uint, data []byte) (*models.Configuration, error) {
|
||||||
|
var export ConfigExport
|
||||||
|
if err := json.Unmarshal(data, &export); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &CreateConfigRequest{
|
||||||
|
Name: export.Name,
|
||||||
|
Notes: export.Notes,
|
||||||
|
Items: export.Items,
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.Create(userID, req)
|
||||||
|
}
|
||||||
175
internal/services/export.go
Normal file
175
internal/services/export.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mchus/quoteforge/internal/config"
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExportService struct {
|
||||||
|
config config.ExportConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExportService(cfg config.ExportConfig) *ExportService {
|
||||||
|
return &ExportService{config: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportData struct {
|
||||||
|
Name string
|
||||||
|
Items []ExportItem
|
||||||
|
Total float64
|
||||||
|
Notes string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportItem struct {
|
||||||
|
LotName string
|
||||||
|
Description string
|
||||||
|
Category string
|
||||||
|
Quantity int
|
||||||
|
UnitPrice float64
|
||||||
|
TotalPrice float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := csv.NewWriter(&buf)
|
||||||
|
|
||||||
|
// Header
|
||||||
|
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||||
|
if err := w.Write(headers); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items
|
||||||
|
for _, item := range data.Items {
|
||||||
|
row := []string{
|
||||||
|
item.LotName,
|
||||||
|
item.Description,
|
||||||
|
item.Category,
|
||||||
|
fmt.Sprintf("%d", item.Quantity),
|
||||||
|
fmt.Sprintf("%.2f", item.UnitPrice),
|
||||||
|
fmt.Sprintf("%.2f", item.TotalPrice),
|
||||||
|
}
|
||||||
|
if err := w.Write(row); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total row
|
||||||
|
if err := w.Write([]string{"", "", "", "", "ИТОГО:", fmt.Sprintf("%.2f", data.Total)}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Flush()
|
||||||
|
return buf.Bytes(), w.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExportService) ToXLSX(data *ExportData) ([]byte, error) {
|
||||||
|
f := excelize.NewFile()
|
||||||
|
sheet := "Конфигурация"
|
||||||
|
f.SetSheetName("Sheet1", sheet)
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
headerStyle, _ := f.NewStyle(&excelize.Style{
|
||||||
|
Font: &excelize.Font{Bold: true, Size: 12, Color: "#FFFFFF"},
|
||||||
|
Fill: excelize.Fill{Type: "pattern", Color: []string{"#4472C4"}, Pattern: 1},
|
||||||
|
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||||
|
Border: []excelize.Border{
|
||||||
|
{Type: "left", Color: "#000000", Style: 1},
|
||||||
|
{Type: "top", Color: "#000000", Style: 1},
|
||||||
|
{Type: "bottom", Color: "#000000", Style: 1},
|
||||||
|
{Type: "right", Color: "#000000", Style: 1},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
totalStyle, _ := f.NewStyle(&excelize.Style{
|
||||||
|
Font: &excelize.Font{Bold: true, Size: 12},
|
||||||
|
Fill: excelize.Fill{Type: "pattern", Color: []string{"#E2EFDA"}, Pattern: 1},
|
||||||
|
})
|
||||||
|
|
||||||
|
priceStyle, _ := f.NewStyle(&excelize.Style{
|
||||||
|
NumFmt: 4, // #,##0.00
|
||||||
|
})
|
||||||
|
|
||||||
|
// Title
|
||||||
|
f.SetCellValue(sheet, "A1", s.config.CompanyName)
|
||||||
|
f.SetCellValue(sheet, "A2", "Коммерческое предложение: "+data.Name)
|
||||||
|
f.SetCellValue(sheet, "A3", "Дата: "+data.CreatedAt.Format("02.01.2006"))
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
headers := []string{"Артикул", "Описание", "Категория", "Кол-во", "Цена", "Сумма"}
|
||||||
|
for i, h := range headers {
|
||||||
|
cell := fmt.Sprintf("%c5", 'A'+i)
|
||||||
|
f.SetCellValue(sheet, cell, h)
|
||||||
|
f.SetCellStyle(sheet, cell, cell, headerStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
row := 6
|
||||||
|
for _, item := range data.Items {
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), item.LotName)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("B%d", row), item.Description)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("C%d", row), item.Category)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("D%d", row), item.Quantity)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), item.UnitPrice)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), item.TotalPrice)
|
||||||
|
f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("F%d", row), priceStyle)
|
||||||
|
row++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total row
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), "ИТОГО:")
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), data.Total)
|
||||||
|
f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("F%d", row), totalStyle)
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
if data.Notes != "" {
|
||||||
|
row += 2
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Примечания: "+data.Notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column widths
|
||||||
|
f.SetColWidth(sheet, "A", "A", 25)
|
||||||
|
f.SetColWidth(sheet, "B", "B", 50)
|
||||||
|
f.SetColWidth(sheet, "C", "C", 15)
|
||||||
|
f.SetColWidth(sheet, "D", "D", 10)
|
||||||
|
f.SetColWidth(sheet, "E", "E", 15)
|
||||||
|
f.SetColWidth(sheet, "F", "F", 15)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := f.Write(&buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExportService) ConfigToExportData(config *models.Configuration) *ExportData {
|
||||||
|
items := make([]ExportItem, len(config.Items))
|
||||||
|
var total float64
|
||||||
|
|
||||||
|
for i, item := range config.Items {
|
||||||
|
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||||
|
items[i] = ExportItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: itemTotal,
|
||||||
|
}
|
||||||
|
total += itemTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ExportData{
|
||||||
|
Name: config.Name,
|
||||||
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
Notes: config.Notes,
|
||||||
|
CreatedAt: config.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
121
internal/services/pricing/calculator.go
Normal file
121
internal/services/pricing/calculator.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package pricing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mchus/quoteforge/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CalculateMedian returns the median of prices
|
||||||
|
func CalculateMedian(prices []float64) float64 {
|
||||||
|
if len(prices) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted := make([]float64, len(prices))
|
||||||
|
copy(sorted, prices)
|
||||||
|
sort.Float64s(sorted)
|
||||||
|
|
||||||
|
n := len(sorted)
|
||||||
|
if n%2 == 0 {
|
||||||
|
return (sorted[n/2-1] + sorted[n/2]) / 2
|
||||||
|
}
|
||||||
|
return sorted[n/2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateAverage returns the arithmetic mean of prices
|
||||||
|
func CalculateAverage(prices []float64) float64 {
|
||||||
|
if len(prices) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var sum float64
|
||||||
|
for _, p := range prices {
|
||||||
|
sum += p
|
||||||
|
}
|
||||||
|
return sum / float64(len(prices))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateWeightedMedian calculates median with exponential decay weights
|
||||||
|
// More recent prices have higher weight
|
||||||
|
func CalculateWeightedMedian(points []repository.PricePoint, decayDays int) float64 {
|
||||||
|
if len(points) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type weightedPrice struct {
|
||||||
|
price float64
|
||||||
|
weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
weighted := make([]weightedPrice, len(points))
|
||||||
|
var totalWeight float64
|
||||||
|
|
||||||
|
for i, p := range points {
|
||||||
|
daysSince := now.Sub(p.Date).Hours() / 24
|
||||||
|
// weight = e^(-days / decay_days)
|
||||||
|
weight := math.Exp(-daysSince / float64(decayDays))
|
||||||
|
weighted[i] = weightedPrice{price: p.Price, weight: weight}
|
||||||
|
totalWeight += weight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by price
|
||||||
|
sort.Slice(weighted, func(i, j int) bool {
|
||||||
|
return weighted[i].price < weighted[j].price
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find weighted median
|
||||||
|
targetWeight := totalWeight / 2
|
||||||
|
var cumulativeWeight float64
|
||||||
|
|
||||||
|
for _, wp := range weighted {
|
||||||
|
cumulativeWeight += wp.weight
|
||||||
|
if cumulativeWeight >= targetWeight {
|
||||||
|
return wp.price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return weighted[len(weighted)-1].price
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculatePercentile calculates the nth percentile of prices
|
||||||
|
func CalculatePercentile(prices []float64, percentile float64) float64 {
|
||||||
|
if len(prices) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted := make([]float64, len(prices))
|
||||||
|
copy(sorted, prices)
|
||||||
|
sort.Float64s(sorted)
|
||||||
|
|
||||||
|
index := (percentile / 100) * float64(len(sorted)-1)
|
||||||
|
lower := int(math.Floor(index))
|
||||||
|
upper := int(math.Ceil(index))
|
||||||
|
|
||||||
|
if lower == upper {
|
||||||
|
return sorted[lower]
|
||||||
|
}
|
||||||
|
|
||||||
|
fraction := index - float64(lower)
|
||||||
|
return sorted[lower]*(1-fraction) + sorted[upper]*fraction
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateStdDev calculates standard deviation
|
||||||
|
func CalculateStdDev(prices []float64) float64 {
|
||||||
|
if len(prices) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mean := CalculateAverage(prices)
|
||||||
|
var sumSquares float64
|
||||||
|
|
||||||
|
for _, p := range prices {
|
||||||
|
diff := p - mean
|
||||||
|
sumSquares += diff * diff
|
||||||
|
}
|
||||||
|
|
||||||
|
return math.Sqrt(sumSquares / float64(len(prices)-1))
|
||||||
|
}
|
||||||
178
internal/services/pricing/service.go
Normal file
178
internal/services/pricing/service.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package pricing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mchus/quoteforge/internal/config"
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"github.com/mchus/quoteforge/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
componentRepo *repository.ComponentRepository
|
||||||
|
priceRepo *repository.PriceRepository
|
||||||
|
config config.PricingConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(
|
||||||
|
componentRepo *repository.ComponentRepository,
|
||||||
|
priceRepo *repository.PriceRepository,
|
||||||
|
cfg config.PricingConfig,
|
||||||
|
) *Service {
|
||||||
|
return &Service{
|
||||||
|
componentRepo: componentRepo,
|
||||||
|
priceRepo: priceRepo,
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEffectivePrice returns the current effective price for a component
|
||||||
|
// Priority: active override > calculated price > nil
|
||||||
|
func (s *Service) GetEffectivePrice(lotName string) (*float64, error) {
|
||||||
|
// Check for active override first
|
||||||
|
override, err := s.priceRepo.GetPriceOverride(lotName)
|
||||||
|
if err == nil && override != nil {
|
||||||
|
return &override.Price, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get component metadata
|
||||||
|
component, err := s.componentRepo.GetByLotName(lotName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return component.CurrentPrice, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculatePrice calculates price using the specified method
|
||||||
|
func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, periodDays int) (float64, error) {
|
||||||
|
if periodDays == 0 {
|
||||||
|
periodDays = s.config.DefaultPeriodDays
|
||||||
|
}
|
||||||
|
|
||||||
|
points, err := s.priceRepo.GetPriceHistory(lotName, periodDays)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(points) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
prices := make([]float64, len(points))
|
||||||
|
for i, p := range points {
|
||||||
|
prices[i] = p.Price
|
||||||
|
}
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case models.PriceMethodAverage:
|
||||||
|
return CalculateAverage(prices), nil
|
||||||
|
case models.PriceMethodWeightedMedian:
|
||||||
|
return CalculateWeightedMedian(points, s.config.DefaultPeriodDays), nil
|
||||||
|
case models.PriceMethodMedian:
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
return CalculateMedian(prices), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateComponentPrice recalculates and updates the price for a component
|
||||||
|
func (s *Service) UpdateComponentPrice(lotName string) error {
|
||||||
|
component, err := s.componentRepo.GetByLotName(lotName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
price, err := s.CalculatePrice(lotName, component.PriceMethod, component.PricePeriodDays)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if price > 0 {
|
||||||
|
component.CurrentPrice = &price
|
||||||
|
component.PriceUpdatedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.componentRepo.Update(component)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetManualPrice sets a manual price override
|
||||||
|
func (s *Service) SetManualPrice(lotName string, price float64, reason string, userID uint) error {
|
||||||
|
override := &models.PriceOverride{
|
||||||
|
LotName: lotName,
|
||||||
|
Price: price,
|
||||||
|
ValidFrom: time.Now(),
|
||||||
|
Reason: reason,
|
||||||
|
CreatedBy: userID,
|
||||||
|
}
|
||||||
|
return s.priceRepo.CreatePriceOverride(override)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePriceMethod changes the pricing method for a component
|
||||||
|
func (s *Service) UpdatePriceMethod(lotName string, method models.PriceMethod, periodDays int) error {
|
||||||
|
component, err := s.componentRepo.GetByLotName(lotName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
component.PriceMethod = method
|
||||||
|
if periodDays > 0 {
|
||||||
|
component.PricePeriodDays = periodDays
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.componentRepo.Update(component); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.UpdateComponentPrice(lotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPriceStats returns statistics for a component's price history
|
||||||
|
func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, error) {
|
||||||
|
if periodDays == 0 {
|
||||||
|
periodDays = s.config.DefaultPeriodDays
|
||||||
|
}
|
||||||
|
|
||||||
|
points, err := s.priceRepo.GetPriceHistory(lotName, periodDays)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(points) == 0 {
|
||||||
|
return &PriceStats{QuoteCount: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
prices := make([]float64, len(points))
|
||||||
|
for i, p := range points {
|
||||||
|
prices[i] = p.Price
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PriceStats{
|
||||||
|
QuoteCount: len(points),
|
||||||
|
MinPrice: CalculatePercentile(prices, 0),
|
||||||
|
MaxPrice: CalculatePercentile(prices, 100),
|
||||||
|
MedianPrice: CalculateMedian(prices),
|
||||||
|
AveragePrice: CalculateAverage(prices),
|
||||||
|
StdDeviation: CalculateStdDev(prices),
|
||||||
|
LatestPrice: points[0].Price,
|
||||||
|
LatestDate: points[0].Date,
|
||||||
|
OldestDate: points[len(points)-1].Date,
|
||||||
|
Percentile25: CalculatePercentile(prices, 25),
|
||||||
|
Percentile75: CalculatePercentile(prices, 75),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type PriceStats struct {
|
||||||
|
QuoteCount int `json:"quote_count"`
|
||||||
|
MinPrice float64 `json:"min_price"`
|
||||||
|
MaxPrice float64 `json:"max_price"`
|
||||||
|
MedianPrice float64 `json:"median_price"`
|
||||||
|
AveragePrice float64 `json:"average_price"`
|
||||||
|
StdDeviation float64 `json:"std_deviation"`
|
||||||
|
LatestPrice float64 `json:"latest_price"`
|
||||||
|
LatestDate time.Time `json:"latest_date"`
|
||||||
|
OldestDate time.Time `json:"oldest_date"`
|
||||||
|
Percentile25 float64 `json:"percentile_25"`
|
||||||
|
Percentile75 float64 `json:"percentile_75"`
|
||||||
|
}
|
||||||
139
internal/services/quote.go
Normal file
139
internal/services/quote.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/mchus/quoteforge/internal/models"
|
||||||
|
"github.com/mchus/quoteforge/internal/repository"
|
||||||
|
"github.com/mchus/quoteforge/internal/services/pricing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrEmptyQuote = errors.New("quote cannot be empty")
|
||||||
|
ErrComponentNotFound = errors.New("component not found")
|
||||||
|
ErrNoPriceAvailable = errors.New("no price available for component")
|
||||||
|
)
|
||||||
|
|
||||||
|
type QuoteService struct {
|
||||||
|
componentRepo *repository.ComponentRepository
|
||||||
|
statsRepo *repository.StatsRepository
|
||||||
|
pricingService *pricing.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQuoteService(
|
||||||
|
componentRepo *repository.ComponentRepository,
|
||||||
|
statsRepo *repository.StatsRepository,
|
||||||
|
pricingService *pricing.Service,
|
||||||
|
) *QuoteService {
|
||||||
|
return &QuoteService{
|
||||||
|
componentRepo: componentRepo,
|
||||||
|
statsRepo: statsRepo,
|
||||||
|
pricingService: pricingService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuoteItem struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
TotalPrice float64 `json:"total_price"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
HasPrice bool `json:"has_price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuoteValidationResult struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Items []QuoteItem `json:"items"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuoteRequest struct {
|
||||||
|
Items []struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidationResult, error) {
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
return nil, ErrEmptyQuote
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &QuoteValidationResult{
|
||||||
|
Valid: true,
|
||||||
|
Items: make([]QuoteItem, 0, len(req.Items)),
|
||||||
|
Errors: make([]string, 0),
|
||||||
|
Warnings: make([]string, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
lotNames := make([]string, len(req.Items))
|
||||||
|
quantities := make(map[string]int)
|
||||||
|
for i, item := range req.Items {
|
||||||
|
lotNames[i] = item.LotName
|
||||||
|
quantities[item.LotName] = item.Quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
components, err := s.componentRepo.GetMultiple(lotNames)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
componentMap := make(map[string]*models.LotMetadata)
|
||||||
|
for i := range components {
|
||||||
|
componentMap[components[i].LotName] = &components[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
var total float64
|
||||||
|
|
||||||
|
for _, reqItem := range req.Items {
|
||||||
|
comp, exists := componentMap[reqItem.LotName]
|
||||||
|
if !exists {
|
||||||
|
result.Valid = false
|
||||||
|
result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
item := QuoteItem{
|
||||||
|
LotName: reqItem.LotName,
|
||||||
|
Quantity: reqItem.Quantity,
|
||||||
|
HasPrice: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if comp.Lot != nil {
|
||||||
|
item.Description = comp.Lot.LotDescription
|
||||||
|
}
|
||||||
|
if comp.Category != nil {
|
||||||
|
item.Category = comp.Category.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get effective price (override or calculated)
|
||||||
|
price, err := s.pricingService.GetEffectivePrice(reqItem.LotName)
|
||||||
|
if err == nil && price != nil && *price > 0 {
|
||||||
|
item.UnitPrice = *price
|
||||||
|
item.TotalPrice = *price * float64(reqItem.Quantity)
|
||||||
|
item.HasPrice = true
|
||||||
|
total += item.TotalPrice
|
||||||
|
} else {
|
||||||
|
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Total = total
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordUsage records that components were used in a quote
|
||||||
|
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
|
||||||
|
for _, item := range items {
|
||||||
|
revenue := item.UnitPrice * float64(item.Quantity)
|
||||||
|
if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user