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:
co-authored by
Claude Opus 4.5
parent
a4ab83d4d9
commit
190a9aa0a3
@@ -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"
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user