Compare commits
10 Commits
44ccb01203
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3132ab2fa2 | |||
| 73acc5410f | |||
| 68d0e9a540 | |||
| 8309a5dc0e | |||
|
|
48921c699d | ||
|
|
d32b1c5d0c | ||
|
|
db37040399 | ||
|
|
7ded78f2c3 | ||
|
|
d7d6e9d62c | ||
|
|
a93644131c |
33
.dockerignore
Normal file
33
.dockerignore
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
server
|
||||||
|
*.exe
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# Config with secrets
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
.claude
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
*_test.go
|
||||||
|
test_*.csv
|
||||||
|
test_*.xlsx
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
99
CLAUDE.md
99
CLAUDE.md
@@ -20,7 +20,6 @@ QuoteForge — корпоративный инструмент для конфи
|
|||||||
quoteforge/
|
quoteforge/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ ├── server/main.go # Main HTTP server
|
│ ├── server/main.go # Main HTTP server
|
||||||
│ ├── priceupdater/main.go # Cron job for price updates & alerts
|
|
||||||
│ └── importer/main.go # Import metadata from lot table
|
│ └── importer/main.go # Import metadata from lot table
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/config.go # YAML config loading
|
│ ├── config/config.go # YAML config loading
|
||||||
@@ -89,7 +88,6 @@ CREATE TABLE qt_users (
|
|||||||
CREATE TABLE qt_lot_metadata (
|
CREATE TABLE qt_lot_metadata (
|
||||||
lot_name CHAR(255) PRIMARY KEY,
|
lot_name CHAR(255) PRIMARY KEY,
|
||||||
category_id INT,
|
category_id INT,
|
||||||
vendor VARCHAR(50), -- Parsed from lot_name: CPU_AMD_9654 → "AMD"
|
|
||||||
model VARCHAR(100), -- Parsed: CPU_AMD_9654 → "9654"
|
model VARCHAR(100), -- Parsed: CPU_AMD_9654 → "9654"
|
||||||
specs JSON,
|
specs JSON,
|
||||||
current_price DECIMAL(12,2),
|
current_price DECIMAL(12,2),
|
||||||
@@ -168,18 +166,21 @@ CREATE TABLE qt_component_usage_stats (
|
|||||||
|
|
||||||
### 1. Part Number Parsing
|
### 1. Part Number Parsing
|
||||||
|
|
||||||
Extract category, vendor, model from lot_name:
|
Extract category and model from lot_name:
|
||||||
```go
|
```go
|
||||||
// "CPU_AMD_9654" → category="CPU", vendor="AMD", model="9654"
|
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
|
||||||
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", vendor="INTEL", model="4.Sapphire_2S_32xDDR5"
|
// "MB_INTEL_4.Sapphire_2S" → category="MB", model="INTEL_4.Sapphire_2S"
|
||||||
// "MEM_DDR5_64G_5600" → category="MEM", vendor="DDR5", model="64G_5600"
|
// "MEM_DDR5_64G_5600" → category="MEM", model="DDR5_64G_5600"
|
||||||
// "GPU_NV_RTX_4090_PCIe" → category="GPU", vendor="NV", model="RTX_4090_PCIe"
|
// "GPU_NV_RTX_4090_PCIe" → category="GPU", model="NV_RTX_4090_PCIe"
|
||||||
|
|
||||||
func ParsePartNumber(lotName string) (category, vendor, model string) {
|
func ParsePartNumber(lotName string) (category, model string) {
|
||||||
parts := strings.SplitN(lotName, "_", 3)
|
parts := strings.SplitN(lotName, "_", 2)
|
||||||
if len(parts) >= 1 { category = parts[0] }
|
if len(parts) >= 1 {
|
||||||
if len(parts) >= 2 { vendor = parts[1] }
|
category = parts[0]
|
||||||
if len(parts) >= 3 { model = parts[2] }
|
}
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
model = parts[1]
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -233,7 +234,6 @@ Sort by: popularity + price freshness. Components without prices go to the botto
|
|||||||
Generate alerts when:
|
Generate alerts when:
|
||||||
- **high_demand_stale_price** (CRITICAL): >= 5 quotes/month AND price > 60 days old
|
- **high_demand_stale_price** (CRITICAL): >= 5 quotes/month AND price > 60 days old
|
||||||
- **trending_no_price** (HIGH): trend_percent > 50% AND no price set
|
- **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
|
- **no_recent_quotes** (MEDIUM): popular component, no supplier quotes > 90 days
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
@@ -274,7 +274,6 @@ GET /api/configs/:uuid → get by UUID
|
|||||||
PUT /api/configs/:uuid → update
|
PUT /api/configs/:uuid → update
|
||||||
DELETE /api/configs/:uuid → delete
|
DELETE /api/configs/:uuid → delete
|
||||||
GET /api/configs/:uuid/export → export as JSON
|
GET /api/configs/:uuid/export → export as JSON
|
||||||
POST /api/configs/import → import from JSON
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pricing Admin (requires role: pricing_admin or admin)
|
### Pricing Admin (requires role: pricing_admin or admin)
|
||||||
@@ -325,46 +324,60 @@ GET /partials/summary → price summary HTML
|
|||||||
# Run development server
|
# Run development server
|
||||||
go run ./cmd/server
|
go run ./cmd/server
|
||||||
|
|
||||||
# Run price updater (cron job)
|
|
||||||
go run ./cmd/priceupdater
|
|
||||||
|
|
||||||
# Run importer (one-time setup)
|
# Run importer (one-time setup)
|
||||||
go run ./cmd/importer
|
go run ./cmd/importer
|
||||||
|
|
||||||
|
# Run cron jobs manually
|
||||||
|
go run ./cmd/cron -job=alerts # Check and generate alerts
|
||||||
|
go run ./cmd/cron -job=update-prices # Recalculate all prices
|
||||||
|
go run ./cmd/cron -job=reset-counters # Reset usage counters
|
||||||
|
go run ./cmd/cron -job=update-popularity # Update popularity scores
|
||||||
|
|
||||||
# Build for production
|
# Build for production
|
||||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge-cron ./cmd/cron
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
go test ./...
|
go test ./...
|
||||||
|
````
|
||||||
|
|
||||||
|
## Cron Jobs
|
||||||
|
|
||||||
|
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
|
||||||
|
|
||||||
|
### Docker Compose Setup
|
||||||
|
|
||||||
|
The Docker setup includes a dedicated cron service that runs the following jobs:
|
||||||
|
|
||||||
|
- **Alerts check**: Every hour (0 * * * *)
|
||||||
|
- **Price updates**: Daily at 2 AM (0 2 * * *)
|
||||||
|
- **Usage counter reset**: Weekly on Sunday at 1 AM (0 1 * * 0)
|
||||||
|
- **Popularity score updates**: Daily at 3 AM (0 3 * * *)
|
||||||
|
|
||||||
|
### Manual Cron Job Execution
|
||||||
|
|
||||||
|
You can also run cron jobs manually using the quoteforge-cron binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check and generate alerts
|
||||||
|
go run ./cmd/cron -job=alerts
|
||||||
|
|
||||||
|
# Recalculate all prices
|
||||||
|
go run ./cmd/cron -job=update-prices
|
||||||
|
|
||||||
|
# Reset usage counters
|
||||||
|
go run ./cmd/cron -job=reset-counters
|
||||||
|
|
||||||
|
# Update popularity scores
|
||||||
|
go run ./cmd/cron -job=update-popularity
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dependencies (go.mod)
|
### Cron Job Details
|
||||||
|
|
||||||
```go
|
- **Alerts check**: Generates alerts for components with high demand and stale prices, trending components without prices, and components with no recent quotes
|
||||||
module github.com/mchus/quoteforge
|
- **Price updates**: Recalculates prices for all components using configured methods (median, weighted median, average)
|
||||||
|
- **Usage counter reset**: Resets weekly and monthly usage counters for components
|
||||||
go 1.22
|
- **Popularity score updates**: Recalculates popularity scores based on supplier quote activity
|
||||||
|
|
||||||
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
|
## Code Style
|
||||||
|
|
||||||
|
|||||||
68
Dockerfile
Normal file
68
Dockerfile
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates tzdata
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go mod files first for better caching
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the main binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||||
|
-ldflags="-s -w" \
|
||||||
|
-o /app/quoteforge \
|
||||||
|
./cmd/server
|
||||||
|
|
||||||
|
# Build the cron binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||||
|
-ldflags="-s -w" \
|
||||||
|
-o /app/quoteforge-cron \
|
||||||
|
./cmd/cron
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata cron
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN adduser -D -g '' appuser
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /app/quoteforge .
|
||||||
|
COPY --from=builder /app/quoteforge-cron .
|
||||||
|
|
||||||
|
# Copy cron job configuration
|
||||||
|
COPY crontab /etc/crontabs/appuser
|
||||||
|
RUN chmod 0600 /etc/crontabs/appuser
|
||||||
|
|
||||||
|
# Create log directory
|
||||||
|
RUN mkdir -p /var/log/cron
|
||||||
|
|
||||||
|
# Copy web templates and static files
|
||||||
|
COPY --from=builder /app/web ./web
|
||||||
|
|
||||||
|
# Copy migrations
|
||||||
|
COPY --from=builder /app/migrations ./migrations
|
||||||
|
|
||||||
|
# Copy example config (actual config should be mounted)
|
||||||
|
COPY --from=builder /app/config.example.yaml ./config.example.yaml
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
RUN chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/quoteforge"]
|
||||||
71
README.md
71
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Server Configuration & Quotation Tool**
|
**Server Configuration & Quotation Tool**
|
||||||
|
|
||||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений. Позволяет быстро собрать спецификацию сервера из каталога компонентов с автоматическим расчётом цен.
|
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -16,7 +16,6 @@ QuoteForge — корпоративный инструмент для конфи
|
|||||||
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
|
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
|
||||||
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
|
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
|
||||||
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
|
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
|
||||||
- 📤 **Импорт/экспорт JSON** — обмен конфигурациями между пользователями
|
|
||||||
|
|
||||||
### Для ценовых администраторов
|
### Для ценовых администраторов
|
||||||
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
|
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
|
||||||
@@ -83,23 +82,23 @@ auth:
|
|||||||
### 3. Миграции базы данных
|
### 3. Миграции базы данных
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make migrate
|
go run ./cmd/server -migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Импорт метаданных компонентов
|
### 4. Импорт метаданных компонентов
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make seed
|
go run ./cmd/importer
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Запуск
|
### 5. Запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Development
|
||||||
make run
|
go run ./cmd/server
|
||||||
|
|
||||||
# Production
|
# Production
|
||||||
make build
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||||
./bin/quoteforge
|
./bin/quoteforge
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -120,9 +119,8 @@ docker-compose up -d
|
|||||||
```
|
```
|
||||||
quoteforge/
|
quoteforge/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ ├── server/ # Основной сервер
|
│ ├── server/main.go # Main HTTP server
|
||||||
│ ├── priceupdater/ # Cron job обновления цен
|
│ └── importer/main.go # Import metadata from lot table
|
||||||
│ └── importer/ # Импорт данных
|
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/ # Конфигурация
|
│ ├── config/ # Конфигурация
|
||||||
│ ├── models/ # GORM модели
|
│ ├── models/ # GORM модели
|
||||||
@@ -137,7 +135,7 @@ quoteforge/
|
|||||||
├── config.yaml # Конфигурация
|
├── config.yaml # Конфигурация
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── Makefile
|
└── go.mod
|
||||||
```
|
```
|
||||||
|
|
||||||
## Роли пользователей
|
## Роли пользователей
|
||||||
@@ -165,30 +163,59 @@ GET /api/configs # Сохранённые конфигурации
|
|||||||
|
|
||||||
## Cron Jobs
|
## Cron Jobs
|
||||||
|
|
||||||
Добавьте в crontab:
|
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
|
||||||
|
|
||||||
|
### Docker Compose Setup
|
||||||
|
|
||||||
|
The Docker setup includes a dedicated cron service that runs the following jobs:
|
||||||
|
|
||||||
|
- **Alerts check**: Every hour (0 * * * *)
|
||||||
|
- **Price updates**: Daily at 2 AM (0 2 * * *)
|
||||||
|
- **Usage counter reset**: Weekly on Sunday at 1 AM (0 1 * * 0)
|
||||||
|
- **Popularity score updates**: Daily at 3 AM (0 3 * * *)
|
||||||
|
|
||||||
|
To enable cron jobs in Docker, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Обновление цен — каждую ночь в 2:00
|
docker-compose up -d
|
||||||
0 2 * * * /opt/quoteforge/bin/priceupdater
|
|
||||||
|
|
||||||
# Генерация алертов — каждый час
|
|
||||||
0 * * * * /opt/quoteforge/bin/priceupdater --alerts-only
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Manual Cron Job Execution
|
||||||
|
|
||||||
|
You can also run cron jobs manually using the quoteforge-cron binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check and generate alerts
|
||||||
|
go run ./cmd/cron -job=alerts
|
||||||
|
|
||||||
|
# Recalculate all prices
|
||||||
|
go run ./cmd/cron -job=update-prices
|
||||||
|
|
||||||
|
# Reset usage counters
|
||||||
|
go run ./cmd/cron -job=reset-counters
|
||||||
|
|
||||||
|
# Update popularity scores
|
||||||
|
go run ./cmd/cron -job=update-popularity
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Job Details
|
||||||
|
|
||||||
|
- **Alerts check**: Generates alerts for components with high demand and stale prices, trending components without prices, and components with no recent quotes
|
||||||
|
- **Price updates**: Recalculates prices for all components using configured methods (median, weighted median, average)
|
||||||
|
- **Usage counter reset**: Resets weekly and monthly usage counters for components
|
||||||
|
- **Popularity score updates**: Recalculates popularity scores based on supplier quote activity
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Запуск в режиме разработки (hot reload)
|
# Запуск в режиме разработки (hot reload)
|
||||||
make dev
|
go run ./cmd/server
|
||||||
|
|
||||||
# Запуск тестов
|
# Запуск тестов
|
||||||
make test
|
go test ./...
|
||||||
|
|
||||||
# Линтер
|
|
||||||
make lint
|
|
||||||
|
|
||||||
# Сборка для Linux
|
# Сборка для Linux
|
||||||
make build-linux
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||||
```
|
```
|
||||||
|
|
||||||
## Переменные окружения
|
## Переменные окружения
|
||||||
|
|||||||
85
cmd/cron/main.go
Normal file
85
cmd/cron/main.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||||
|
cronJob := flag.String("job", "", "type of cron job to run (alerts, update-prices)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure tables exist
|
||||||
|
if err := models.Migrate(db); err != nil {
|
||||||
|
log.Fatalf("Migration failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize repositories
|
||||||
|
statsRepo := repository.NewStatsRepository(db)
|
||||||
|
alertRepo := repository.NewAlertRepository(db)
|
||||||
|
componentRepo := repository.NewComponentRepository(db)
|
||||||
|
priceRepo := repository.NewPriceRepository(db)
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||||
|
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||||
|
|
||||||
|
switch *cronJob {
|
||||||
|
case "alerts":
|
||||||
|
log.Println("Running alerts check...")
|
||||||
|
if err := alertService.CheckAndGenerateAlerts(); err != nil {
|
||||||
|
log.Printf("Error running alerts check: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Alerts check completed successfully")
|
||||||
|
}
|
||||||
|
case "update-prices":
|
||||||
|
log.Println("Recalculating all prices...")
|
||||||
|
updated, errors := pricingService.RecalculateAllPrices()
|
||||||
|
log.Printf("Prices recalculated: %d updated, %d errors", updated, errors)
|
||||||
|
case "reset-counters":
|
||||||
|
log.Println("Resetting usage counters...")
|
||||||
|
if err := statsRepo.ResetWeeklyCounters(); err != nil {
|
||||||
|
log.Printf("Error resetting weekly counters: %v", err)
|
||||||
|
}
|
||||||
|
if err := statsRepo.ResetMonthlyCounters(); err != nil {
|
||||||
|
log.Printf("Error resetting monthly counters: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Usage counters reset completed")
|
||||||
|
case "update-popularity":
|
||||||
|
log.Println("Updating popularity scores...")
|
||||||
|
if err := statsRepo.UpdatePopularityScores(); err != nil {
|
||||||
|
log.Printf("Error updating popularity scores: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Popularity scores updated successfully")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Println("No valid cron job specified. Available jobs:")
|
||||||
|
log.Println(" - alerts: Check and generate alerts")
|
||||||
|
log.Println(" - update-prices: Recalculate all prices")
|
||||||
|
log.Println(" - reset-counters: Reset usage counters")
|
||||||
|
log.Println(" - update-popularity: Update popularity scores")
|
||||||
|
}
|
||||||
|
}
|
||||||
160
cmd/importer/main.go
Normal file
160
cmd/importer/main.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Connected to database")
|
||||||
|
|
||||||
|
// Ensure tables exist
|
||||||
|
if err := models.Migrate(db); err != nil {
|
||||||
|
log.Fatalf("Migration failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := models.SeedCategories(db); err != nil {
|
||||||
|
log.Fatalf("Seeding categories failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load categories for lookup
|
||||||
|
var categories []models.Category
|
||||||
|
db.Find(&categories)
|
||||||
|
categoryMap := make(map[string]uint)
|
||||||
|
for _, c := range categories {
|
||||||
|
categoryMap[c.Code] = c.ID
|
||||||
|
}
|
||||||
|
log.Printf("Loaded %d categories", len(categories))
|
||||||
|
|
||||||
|
// Get all lots
|
||||||
|
var lots []models.Lot
|
||||||
|
if err := db.Find(&lots).Error; err != nil {
|
||||||
|
log.Fatalf("Failed to load lots: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Found %d lots to import", len(lots))
|
||||||
|
|
||||||
|
// Import each lot
|
||||||
|
var imported, skipped, updated int
|
||||||
|
for _, lot := range lots {
|
||||||
|
category, model := ParsePartNumber(lot.LotName)
|
||||||
|
|
||||||
|
var categoryID *uint
|
||||||
|
if id, ok := categoryMap[category]; ok && id > 0 {
|
||||||
|
categoryID = &id
|
||||||
|
} else {
|
||||||
|
// Try to find by prefix match
|
||||||
|
for code, id := range categoryMap {
|
||||||
|
if strings.HasPrefix(category, code) {
|
||||||
|
categoryID = &id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
var existing models.LotMetadata
|
||||||
|
result := db.Where("lot_name = ?", lot.LotName).First(&existing)
|
||||||
|
|
||||||
|
if result.Error == gorm.ErrRecordNotFound {
|
||||||
|
// Check if there are prices in the last 90 days
|
||||||
|
var recentPriceCount int64
|
||||||
|
db.Model(&models.LotLog{}).
|
||||||
|
Where("lot = ? AND date >= DATE_SUB(NOW(), INTERVAL 90 DAY)", lot.LotName).
|
||||||
|
Count(&recentPriceCount)
|
||||||
|
|
||||||
|
// Default to 90 days, but use "all time" (0) if no recent prices
|
||||||
|
periodDays := 90
|
||||||
|
if recentPriceCount == 0 {
|
||||||
|
periodDays = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new
|
||||||
|
metadata := models.LotMetadata{
|
||||||
|
LotName: lot.LotName,
|
||||||
|
CategoryID: categoryID,
|
||||||
|
Model: model,
|
||||||
|
PricePeriodDays: periodDays,
|
||||||
|
}
|
||||||
|
if err := db.Create(&metadata).Error; err != nil {
|
||||||
|
log.Printf("Failed to create metadata for %s: %v", lot.LotName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
} else if result.Error == nil {
|
||||||
|
// Update if needed
|
||||||
|
needsUpdate := false
|
||||||
|
|
||||||
|
if existing.Model == "" {
|
||||||
|
existing.Model = model
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
if existing.CategoryID == nil {
|
||||||
|
existing.CategoryID = categoryID
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if using default period (90 days) but no recent prices
|
||||||
|
if existing.PricePeriodDays == 90 {
|
||||||
|
var recentPriceCount int64
|
||||||
|
db.Model(&models.LotLog{}).
|
||||||
|
Where("lot = ? AND date >= DATE_SUB(NOW(), INTERVAL 90 DAY)", lot.LotName).
|
||||||
|
Count(&recentPriceCount)
|
||||||
|
|
||||||
|
if recentPriceCount == 0 {
|
||||||
|
existing.PricePeriodDays = 0
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsUpdate {
|
||||||
|
db.Save(&existing)
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Import complete: %d imported, %d updated, %d skipped", imported, updated, skipped)
|
||||||
|
|
||||||
|
// Show final counts
|
||||||
|
var metadataCount int64
|
||||||
|
db.Model(&models.LotMetadata{}).Count(&metadataCount)
|
||||||
|
log.Printf("Total metadata records: %d", metadataCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePartNumber extracts category and model from lot_name
|
||||||
|
// Examples:
|
||||||
|
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
|
||||||
|
// "MB_INTEL_4.Sapphire_2S" → category="MB", model="INTEL_4.Sapphire_2S"
|
||||||
|
func ParsePartNumber(lotName string) (category, model string) {
|
||||||
|
parts := strings.SplitN(lotName, "_", 2)
|
||||||
|
if len(parts) >= 1 {
|
||||||
|
category = parts[0]
|
||||||
|
}
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
model = parts[1]
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -11,14 +11,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"github.com/mchus/quoteforge/internal/handlers"
|
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
||||||
"github.com/mchus/quoteforge/internal/middleware"
|
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"github.com/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
"github.com/mchus/quoteforge/internal/services/alerts"
|
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
||||||
"github.com/mchus/quoteforge/internal/services/pricing"
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
@@ -59,11 +60,21 @@ func main() {
|
|||||||
slog.Error("seeding categories failed", "error", err)
|
slog.Error("seeding categories failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
// Create default admin user (admin / admin123)
|
||||||
|
adminHash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||||
|
if err := models.SeedAdminUser(db, string(adminHash)); err != nil {
|
||||||
|
slog.Error("seeding admin user failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
slog.Info("migrations completed")
|
slog.Info("migrations completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
gin.SetMode(cfg.Server.Mode)
|
gin.SetMode(cfg.Server.Mode)
|
||||||
router := setupRouter(db, cfg)
|
router, err := setupRouter(db, cfg)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to setup router", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Address(),
|
Addr: cfg.Address(),
|
||||||
@@ -143,7 +154,7 @@ func setupDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
|
||||||
// Repositories
|
// Repositories
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
componentRepo := repository.NewComponentRepository(db)
|
componentRepo := repository.NewComponentRepository(db)
|
||||||
@@ -159,7 +170,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||||
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||||
configService := services.NewConfigurationService(configRepo, componentRepo, quoteService)
|
configService := services.NewConfigurationService(configRepo, componentRepo, quoteService)
|
||||||
exportService := services.NewExportService(cfg.Export)
|
exportService := services.NewExportService(cfg.Export, categoryRepo)
|
||||||
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
@@ -168,7 +179,13 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||||
configHandler := handlers.NewConfigurationHandler(configService, exportService)
|
configHandler := handlers.NewConfigurationHandler(configService, exportService)
|
||||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
||||||
pricingHandler := handlers.NewPricingHandler(pricingService, alertService, componentRepo, statsRepo)
|
pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
||||||
|
|
||||||
|
// Web handler (templates)
|
||||||
|
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Router
|
// Router
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
@@ -176,6 +193,9 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
router.Use(requestLogger())
|
router.Use(requestLogger())
|
||||||
router.Use(middleware.CORS())
|
router.Use(middleware.CORS())
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
router.Static("/static", "web/static")
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
router.GET("/health", func(c *gin.Context) {
|
router.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -184,6 +204,47 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// DB status endpoint
|
||||||
|
router.GET("/api/db-status", func(c *gin.Context) {
|
||||||
|
var lotCount, lotLogCount, metadataCount int64
|
||||||
|
var dbOK bool = true
|
||||||
|
var dbError string
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
dbOK = false
|
||||||
|
dbError = err.Error()
|
||||||
|
} else if err := sqlDB.Ping(); err != nil {
|
||||||
|
dbOK = false
|
||||||
|
dbError = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Table("lot").Count(&lotCount)
|
||||||
|
db.Table("lot_log").Count(&lotLogCount)
|
||||||
|
db.Table("qt_lot_metadata").Count(&metadataCount)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"connected": dbOK,
|
||||||
|
"error": dbError,
|
||||||
|
"lot_count": lotCount,
|
||||||
|
"lot_log_count": lotLogCount,
|
||||||
|
"metadata_count": metadataCount,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Web pages
|
||||||
|
router.GET("/", webHandler.Index)
|
||||||
|
router.GET("/login", webHandler.Login)
|
||||||
|
router.GET("/configs", webHandler.Configs)
|
||||||
|
router.GET("/configurator", webHandler.Configurator)
|
||||||
|
router.GET("/admin/pricing", webHandler.AdminPricing)
|
||||||
|
|
||||||
|
// htmx partials
|
||||||
|
partials := router.Group("/partials")
|
||||||
|
{
|
||||||
|
partials.GET("/components", webHandler.ComponentsPartial)
|
||||||
|
}
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
api := router.Group("/api")
|
api := router.Group("/api")
|
||||||
{
|
{
|
||||||
@@ -209,7 +270,6 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
|
|
||||||
// Categories (public)
|
// Categories (public)
|
||||||
api.GET("/categories", componentHandler.GetCategories)
|
api.GET("/categories", componentHandler.GetCategories)
|
||||||
api.GET("/vendors", componentHandler.GetVendors)
|
|
||||||
|
|
||||||
// Quote (public, for anonymous quote building)
|
// Quote (public, for anonymous quote building)
|
||||||
quote := api.Group("/quote")
|
quote := api.Group("/quote")
|
||||||
@@ -222,7 +282,6 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
export := api.Group("/export")
|
export := api.Group("/export")
|
||||||
{
|
{
|
||||||
export.POST("/csv", exportHandler.ExportCSV)
|
export.POST("/csv", exportHandler.ExportCSV)
|
||||||
export.POST("/xlsx", exportHandler.ExportXLSX)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configurations (requires auth)
|
// Configurations (requires auth)
|
||||||
@@ -234,11 +293,12 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
configs.POST("", configHandler.Create)
|
configs.POST("", configHandler.Create)
|
||||||
configs.GET("/:uuid", configHandler.Get)
|
configs.GET("/:uuid", configHandler.Get)
|
||||||
configs.PUT("/:uuid", configHandler.Update)
|
configs.PUT("/:uuid", configHandler.Update)
|
||||||
|
configs.PATCH("/:uuid/rename", configHandler.Rename)
|
||||||
|
configs.POST("/:uuid/clone", configHandler.Clone)
|
||||||
configs.DELETE("/:uuid", configHandler.Delete)
|
configs.DELETE("/:uuid", configHandler.Delete)
|
||||||
configs.GET("/:uuid/export", configHandler.ExportJSON)
|
// configs.GET("/:uuid/export", configHandler.ExportJSON)
|
||||||
configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV)
|
configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV)
|
||||||
configs.GET("/:uuid/xlsx", exportHandler.ExportConfigXLSX)
|
// configs.POST("/import", configHandler.ImportJSON)
|
||||||
configs.POST("/import", configHandler.ImportJSON)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +314,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
pricingAdmin.GET("/components", pricingHandler.ListComponents)
|
pricingAdmin.GET("/components", pricingHandler.ListComponents)
|
||||||
pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing)
|
pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing)
|
||||||
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
|
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
|
||||||
|
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
|
||||||
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
|
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
|
||||||
|
|
||||||
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
|
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
|
||||||
@@ -263,7 +324,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return router
|
return router, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestLogger() gin.HandlerFunc {
|
func requestLogger() gin.HandlerFunc {
|
||||||
|
|||||||
15
crontab
Normal file
15
crontab
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Cron jobs for QuoteForge
|
||||||
|
# Run alerts check every hour
|
||||||
|
0 * * * * /app/quoteforge-cron -job=alerts
|
||||||
|
|
||||||
|
# Run price updates daily at 2 AM
|
||||||
|
0 2 * * * /app/quoteforge-cron -job=update-prices
|
||||||
|
|
||||||
|
# Reset weekly counters every Sunday at 1 AM
|
||||||
|
0 1 * * 0 /app/quoteforge-cron -job=reset-counters
|
||||||
|
|
||||||
|
# Update popularity scores daily at 3 AM
|
||||||
|
0 3 * * * /app/quoteforge-cron -job=update-popularity
|
||||||
|
|
||||||
|
# Log rotation (optional)
|
||||||
|
# 0 0 * * * /usr/bin/logrotate /etc/logrotate.conf
|
||||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
quoteforge:
|
||||||
|
build: .
|
||||||
|
container_name: quoteforge
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/app/config.yaml:ro
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Moscow
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
quoteforge-cron:
|
||||||
|
build: .
|
||||||
|
container_name: quoteforge-cron
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/app/config.yaml:ro
|
||||||
|
- ./logs:/app/logs
|
||||||
|
command: /usr/sbin/crond -f -l 8
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Moscow
|
||||||
9
go.mod
9
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module github.com/mchus/quoteforge
|
module git.mchus.pro/mchus/quoteforge
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
@@ -6,7 +6,6 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/xuri/excelize/v2 v2.10.0
|
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.43.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/mysql v1.5.2
|
gorm.io/driver/mysql v1.5.2
|
||||||
@@ -32,13 +31,9 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
github.com/stretchr/testify v1.11.1 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
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/arch v0.3.0 // indirect
|
||||||
golang.org/x/net v0.46.0 // indirect
|
golang.org/x/net v0.46.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
|||||||
15
go.sum
15
go.sum
@@ -56,11 +56,6 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ
|
|||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/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.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -73,25 +68,15 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
|||||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
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.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 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
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 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
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 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
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.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mchus/quoteforge/internal/middleware"
|
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||||
"github.com/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"github.com/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"github.com/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ComponentHandler struct {
|
type ComponentHandler struct {
|
||||||
@@ -22,10 +22,10 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||||
|
|
||||||
filter := repository.ComponentFilter{
|
filter := repository.ComponentFilter{
|
||||||
Category: c.Query("category"),
|
Category: c.Query("category"),
|
||||||
Vendor: c.Query("vendor"),
|
Search: c.Query("search"),
|
||||||
Search: c.Query("search"),
|
HasPrice: c.Query("has_price") == "true",
|
||||||
HasPrice: c.Query("has_price") == "true",
|
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.componentService.List(filter, page, perPage)
|
result, err := h.componentService.List(filter, page, perPage)
|
||||||
@@ -58,15 +58,3 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, categories)
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mchus/quoteforge/internal/middleware"
|
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||||
"github.com/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigurationHandler struct {
|
type ConfigurationHandler struct {
|
||||||
@@ -123,34 +123,99 @@ func (h *ConfigurationHandler) Delete(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ConfigurationHandler) ExportJSON(c *gin.Context) {
|
type RenameConfigRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigurationHandler) Rename(c *gin.Context) {
|
||||||
userID := middleware.GetUserID(c)
|
userID := middleware.GetUserID(c)
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
data, err := h.configService.ExportJSON(uuid, userID)
|
var req RenameConfigRequest
|
||||||
if err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Header("Content-Disposition", "attachment; filename=config.json")
|
config, err := h.configService.Rename(uuid, userID, req.Name)
|
||||||
c.Data(http.StatusOK, "application/json", data)
|
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) ImportJSON(c *gin.Context) {
|
type CloneConfigRequest struct {
|
||||||
userID := middleware.GetUserID(c)
|
Name string `json:"name" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(c.Request.Body)
|
func (h *ConfigurationHandler) Clone(c *gin.Context) {
|
||||||
if err != nil {
|
userID := middleware.GetUserID(c)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
|
var req CloneConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := h.configService.ImportJSON(userID, data)
|
config, err := h.configService.Clone(uuid, userID, req.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, config)
|
c.JSON(http.StatusCreated, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// func (h *ConfigurationHandler) ExportJSON(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, err := h.configService.ExportJSON(uuid, userID)
|
||||||
|
// if err != nil {
|
||||||
|
// c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// filename := fmt.Sprintf("%s %s SPEC.json", config.CreatedAt.Format("2006-01-02"), config.Name)
|
||||||
|
// c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
// 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)
|
||||||
|
// }
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mchus/quoteforge/internal/middleware"
|
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
"github.com/mchus/quoteforge/internal/services"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExportHandler struct {
|
type ExportHandler struct {
|
||||||
@@ -54,42 +53,37 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%s_%s.csv", req.Name, time.Now().Format("20060102"))
|
filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name)
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
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 {
|
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
||||||
items := make([]services.ExportItem, len(req.Items))
|
items := make([]services.ExportItem, len(req.Items))
|
||||||
var total float64
|
var total float64
|
||||||
|
|
||||||
for i, item := range req.Items {
|
for i, item := range req.Items {
|
||||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||||
items[i] = services.ExportItem{
|
|
||||||
LotName: item.LotName,
|
// Получаем информацию о компоненте для заполнения категории и описания
|
||||||
Quantity: item.Quantity,
|
componentView, err := h.componentService.GetByLotName(item.LotName)
|
||||||
UnitPrice: item.UnitPrice,
|
if err != nil {
|
||||||
TotalPrice: itemTotal,
|
// Если не удалось получить информацию о компоненте, используем только основные данные
|
||||||
|
items[i] = services.ExportItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: itemTotal,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items[i] = services.ExportItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Description: componentView.Description,
|
||||||
|
Category: componentView.Category,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: itemTotal,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
total += itemTotal
|
total += itemTotal
|
||||||
}
|
}
|
||||||
@@ -113,7 +107,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := h.configToExportData(config)
|
data := h.exportService.ConfigToExportData(config, h.componentService)
|
||||||
|
|
||||||
csvData, err := h.exportService.ToCSV(data)
|
csvData, err := h.exportService.ToCSV(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -121,54 +115,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%s_%s.csv", config.Name, config.CreatedAt.Format("20060102"))
|
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name)
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,33 +2,67 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mchus/quoteforge/internal/middleware"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"github.com/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
||||||
"github.com/mchus/quoteforge/internal/services/alerts"
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||||
"github.com/mchus/quoteforge/internal/services/pricing"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// calculateMedian returns the median of a sorted slice of prices
|
||||||
|
func calculateMedian(prices []float64) float64 {
|
||||||
|
if len(prices) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
sort.Float64s(prices)
|
||||||
|
n := len(prices)
|
||||||
|
if n%2 == 0 {
|
||||||
|
return (prices[n/2-1] + prices[n/2]) / 2
|
||||||
|
}
|
||||||
|
return prices[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))
|
||||||
|
}
|
||||||
|
|
||||||
type PricingHandler struct {
|
type PricingHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
pricingService *pricing.Service
|
pricingService *pricing.Service
|
||||||
alertService *alerts.Service
|
alertService *alerts.Service
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
|
priceRepo *repository.PriceRepository
|
||||||
statsRepo *repository.StatsRepository
|
statsRepo *repository.StatsRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPricingHandler(
|
func NewPricingHandler(
|
||||||
|
db *gorm.DB,
|
||||||
pricingService *pricing.Service,
|
pricingService *pricing.Service,
|
||||||
alertService *alerts.Service,
|
alertService *alerts.Service,
|
||||||
componentRepo *repository.ComponentRepository,
|
componentRepo *repository.ComponentRepository,
|
||||||
|
priceRepo *repository.PriceRepository,
|
||||||
statsRepo *repository.StatsRepository,
|
statsRepo *repository.StatsRepository,
|
||||||
) *PricingHandler {
|
) *PricingHandler {
|
||||||
return &PricingHandler{
|
return &PricingHandler{
|
||||||
|
db: db,
|
||||||
pricingService: pricingService,
|
pricingService: pricingService,
|
||||||
alertService: alertService,
|
alertService: alertService,
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
|
priceRepo: priceRepo,
|
||||||
statsRepo: statsRepo,
|
statsRepo: statsRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,20 +73,27 @@ func (h *PricingHandler) GetStats(c *gin.Context) {
|
|||||||
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
|
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"new_alerts_count": newAlerts,
|
"new_alerts_count": newAlerts,
|
||||||
"top_components": topComponents,
|
"top_components": topComponents,
|
||||||
"trending_components": trendingComponents,
|
"trending_components": trendingComponents,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ComponentWithCount struct {
|
||||||
|
models.LotMetadata
|
||||||
|
QuoteCount int64 `json:"quote_count"`
|
||||||
|
UsedInMeta []string `json:"used_in_meta,omitempty"` // List of meta-articles that use this component
|
||||||
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) ListComponents(c *gin.Context) {
|
func (h *PricingHandler) ListComponents(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||||
|
|
||||||
filter := repository.ComponentFilter{
|
filter := repository.ComponentFilter{
|
||||||
Category: c.Query("category"),
|
Category: c.Query("category"),
|
||||||
Vendor: c.Query("vendor"),
|
Search: c.Query("search"),
|
||||||
Search: c.Query("search"),
|
SortField: c.Query("sort"),
|
||||||
|
SortDir: c.Query("dir"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -69,14 +110,108 @@ func (h *PricingHandler) ListComponents(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get quote counts
|
||||||
|
lotNames := make([]string, len(components))
|
||||||
|
for i, comp := range components {
|
||||||
|
lotNames[i] = comp.LotName
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, _ := h.priceRepo.GetQuoteCounts(lotNames)
|
||||||
|
|
||||||
|
// Get meta usage information
|
||||||
|
metaUsage := h.getMetaUsageMap(lotNames)
|
||||||
|
|
||||||
|
// Combine components with counts
|
||||||
|
result := make([]ComponentWithCount, len(components))
|
||||||
|
for i, comp := range components {
|
||||||
|
result[i] = ComponentWithCount{
|
||||||
|
LotMetadata: comp,
|
||||||
|
QuoteCount: counts[comp.LotName],
|
||||||
|
UsedInMeta: metaUsage[comp.LotName],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"components": components,
|
"components": result,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getMetaUsageMap returns a map of lot_name -> list of meta-articles that use this component
|
||||||
|
func (h *PricingHandler) getMetaUsageMap(lotNames []string) map[string][]string {
|
||||||
|
result := make(map[string][]string)
|
||||||
|
|
||||||
|
// Get all components with meta_prices
|
||||||
|
var metaComponents []models.LotMetadata
|
||||||
|
h.db.Where("meta_prices IS NOT NULL AND meta_prices != ''").Find(&metaComponents)
|
||||||
|
|
||||||
|
// Build reverse lookup: which components are used in which meta-articles
|
||||||
|
for _, meta := range metaComponents {
|
||||||
|
sources := strings.Split(meta.MetaPrices, ",")
|
||||||
|
for _, source := range sources {
|
||||||
|
source = strings.TrimSpace(source)
|
||||||
|
if source == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle wildcard patterns
|
||||||
|
if strings.HasSuffix(source, "*") {
|
||||||
|
prefix := strings.TrimSuffix(source, "*")
|
||||||
|
for _, lotName := range lotNames {
|
||||||
|
if strings.HasPrefix(lotName, prefix) && lotName != meta.LotName {
|
||||||
|
result[lotName] = append(result[lotName], meta.LotName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Direct match
|
||||||
|
for _, lotName := range lotNames {
|
||||||
|
if lotName == source && lotName != meta.LotName {
|
||||||
|
result[lotName] = append(result[lotName], meta.LotName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandMetaPrices expands meta_prices string to list of actual lot names
|
||||||
|
func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []string {
|
||||||
|
sources := strings.Split(metaPrices, ",")
|
||||||
|
var result []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, source := range sources {
|
||||||
|
source = strings.TrimSpace(source)
|
||||||
|
if source == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(source, "*") {
|
||||||
|
// Wildcard pattern - find matching lots
|
||||||
|
prefix := strings.TrimSuffix(source, "*")
|
||||||
|
var matchingLots []string
|
||||||
|
h.db.Model(&models.LotMetadata{}).
|
||||||
|
Where("lot_name LIKE ? AND lot_name != ?", prefix+"%", excludeLot).
|
||||||
|
Pluck("lot_name", &matchingLots)
|
||||||
|
for _, lot := range matchingLots {
|
||||||
|
if !seen[lot] {
|
||||||
|
result = append(result, lot)
|
||||||
|
seen[lot] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if source != excludeLot && !seen[source] {
|
||||||
|
result = append(result, source)
|
||||||
|
seen[source] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
|
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
|
||||||
lotName := c.Param("lot_name")
|
lotName := c.Param("lot_name")
|
||||||
|
|
||||||
@@ -102,41 +237,354 @@ type UpdatePriceRequest struct {
|
|||||||
LotName string `json:"lot_name" binding:"required"`
|
LotName string `json:"lot_name" binding:"required"`
|
||||||
Method models.PriceMethod `json:"method"`
|
Method models.PriceMethod `json:"method"`
|
||||||
PeriodDays int `json:"period_days"`
|
PeriodDays int `json:"period_days"`
|
||||||
|
Coefficient float64 `json:"coefficient"`
|
||||||
ManualPrice *float64 `json:"manual_price"`
|
ManualPrice *float64 `json:"manual_price"`
|
||||||
Reason string `json:"reason"`
|
ClearManual bool `json:"clear_manual"`
|
||||||
|
MetaEnabled bool `json:"meta_enabled"`
|
||||||
|
MetaPrices string `json:"meta_prices"`
|
||||||
|
MetaMethod string `json:"meta_method"`
|
||||||
|
MetaPeriod int `json:"meta_period"`
|
||||||
|
IsHidden bool `json:"is_hidden"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
||||||
userID := middleware.GetUserID(c)
|
|
||||||
|
|
||||||
var req UpdatePriceRequest
|
var req UpdatePriceRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ManualPrice != nil && *req.ManualPrice > 0 {
|
updates := map[string]interface{}{}
|
||||||
err := h.pricingService.SetManualPrice(req.LotName, *req.ManualPrice, req.Reason, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Update method if specified
|
||||||
if req.Method != "" {
|
if req.Method != "" {
|
||||||
err := h.pricingService.UpdatePriceMethod(req.LotName, req.Method, req.PeriodDays)
|
updates["price_method"] = req.Method
|
||||||
if err != nil {
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
// Update period days
|
||||||
|
if req.PeriodDays >= 0 {
|
||||||
|
updates["price_period_days"] = req.PeriodDays
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update coefficient
|
||||||
|
updates["price_coefficient"] = req.Coefficient
|
||||||
|
|
||||||
|
// Handle meta prices
|
||||||
|
if req.MetaEnabled && req.MetaPrices != "" {
|
||||||
|
updates["meta_prices"] = req.MetaPrices
|
||||||
|
} else {
|
||||||
|
updates["meta_prices"] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle hidden flag
|
||||||
|
updates["is_hidden"] = req.IsHidden
|
||||||
|
|
||||||
|
// Handle manual price
|
||||||
|
if req.ClearManual {
|
||||||
|
updates["manual_price"] = nil
|
||||||
|
} else if req.ManualPrice != nil {
|
||||||
|
updates["manual_price"] = *req.ManualPrice
|
||||||
|
// Also update current price immediately when setting manual
|
||||||
|
updates["current_price"] = *req.ManualPrice
|
||||||
|
updates["price_updated_at"] = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.db.Model(&models.LotMetadata{}).
|
||||||
|
Where("lot_name = ?", req.LotName).
|
||||||
|
Updates(updates).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate price if not using manual price
|
||||||
|
if req.ManualPrice == nil {
|
||||||
|
h.recalculateSinglePrice(req.LotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updated component to return new price
|
||||||
|
var comp models.LotMetadata
|
||||||
|
h.db.Where("lot_name = ?", req.LotName).First(&comp)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "price updated",
|
||||||
|
"current_price": comp.CurrentPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PricingHandler) recalculateSinglePrice(lotName string) {
|
||||||
|
var comp models.LotMetadata
|
||||||
|
if err := h.db.Where("lot_name = ?", lotName).First(&comp).Error; err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if manual price is set
|
||||||
|
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
periodDays := comp.PricePeriodDays
|
||||||
|
method := comp.PriceMethod
|
||||||
|
if method == "" {
|
||||||
|
method = models.PriceMethodMedian
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which lot names to use for price calculation
|
||||||
|
lotNames := []string{lotName}
|
||||||
|
if comp.MetaPrices != "" {
|
||||||
|
lotNames = h.expandMetaPrices(comp.MetaPrices, lotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get prices based on period from all relevant lots
|
||||||
|
var prices []float64
|
||||||
|
for _, ln := range lotNames {
|
||||||
|
var lotPrices []float64
|
||||||
|
if strings.HasSuffix(ln, "*") {
|
||||||
|
pattern := strings.TrimSuffix(ln, "*") + "%"
|
||||||
|
if periodDays > 0 {
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||||
|
pattern, periodDays).Pluck("price", &lotPrices)
|
||||||
|
} else {
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if periodDays > 0 {
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||||
|
ln, periodDays).Pluck("price", &lotPrices)
|
||||||
|
} else {
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prices = append(prices, lotPrices...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no prices in period, try all time
|
||||||
|
if len(prices) == 0 && periodDays > 0 {
|
||||||
|
for _, ln := range lotNames {
|
||||||
|
var lotPrices []float64
|
||||||
|
if strings.HasSuffix(ln, "*") {
|
||||||
|
pattern := strings.TrimSuffix(ln, "*") + "%"
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
|
||||||
|
} else {
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices)
|
||||||
|
}
|
||||||
|
prices = append(prices, lotPrices...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "price updated"})
|
if len(prices) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate price based on method
|
||||||
|
sortFloat64s(prices)
|
||||||
|
var finalPrice float64
|
||||||
|
switch method {
|
||||||
|
case models.PriceMethodMedian:
|
||||||
|
finalPrice = calculateMedian(prices)
|
||||||
|
case models.PriceMethodAverage:
|
||||||
|
finalPrice = calculateAverage(prices)
|
||||||
|
default:
|
||||||
|
finalPrice = calculateMedian(prices)
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalPrice <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply coefficient
|
||||||
|
if comp.PriceCoefficient != 0 {
|
||||||
|
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
// Only update price, preserve all user settings
|
||||||
|
h.db.Model(&models.LotMetadata{}).
|
||||||
|
Where("lot_name = ?", lotName).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"current_price": finalPrice,
|
||||||
|
"price_updated_at": now,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||||
// This would be better as a background job
|
// Set headers for SSE
|
||||||
c.JSON(http.StatusAccepted, gin.H{"message": "recalculation started"})
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
|
||||||
|
// Get all components with their settings
|
||||||
|
var components []models.LotMetadata
|
||||||
|
h.db.Find(&components)
|
||||||
|
total := int64(len(components))
|
||||||
|
|
||||||
|
// Pre-load all lot names for efficient wildcard matching
|
||||||
|
var allLotNames []string
|
||||||
|
h.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames)
|
||||||
|
lotNameSet := make(map[string]bool, len(allLotNames))
|
||||||
|
for _, ln := range allLotNames {
|
||||||
|
lotNameSet[ln] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-load latest quote dates for all lots (for checking updates)
|
||||||
|
type LotDate struct {
|
||||||
|
Lot string
|
||||||
|
Date time.Time
|
||||||
|
}
|
||||||
|
var latestDates []LotDate
|
||||||
|
h.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates)
|
||||||
|
lotLatestDate := make(map[string]time.Time, len(latestDates))
|
||||||
|
for _, ld := range latestDates {
|
||||||
|
lotLatestDate[ld.Lot] = ld.Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initial progress
|
||||||
|
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "starting"})
|
||||||
|
c.Writer.Flush()
|
||||||
|
|
||||||
|
// Process components individually to respect their settings
|
||||||
|
var updated, skipped, manual, unchanged, errors int
|
||||||
|
now := time.Now()
|
||||||
|
progressCounter := 0
|
||||||
|
|
||||||
|
for _, comp := range components {
|
||||||
|
progressCounter++
|
||||||
|
|
||||||
|
// If manual price is set, skip recalculation
|
||||||
|
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
||||||
|
manual++
|
||||||
|
goto sendProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate price based on component's individual settings
|
||||||
|
{
|
||||||
|
periodDays := comp.PricePeriodDays
|
||||||
|
method := comp.PriceMethod
|
||||||
|
if method == "" {
|
||||||
|
method = models.PriceMethodMedian
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine source lots for price calculation (using cached lot names)
|
||||||
|
var sourceLots []string
|
||||||
|
if comp.MetaPrices != "" {
|
||||||
|
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
|
||||||
|
} else {
|
||||||
|
sourceLots = []string{comp.LotName}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sourceLots) == 0 {
|
||||||
|
skipped++
|
||||||
|
goto sendProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are new quotes since last update (using cached dates)
|
||||||
|
if comp.PriceUpdatedAt != nil {
|
||||||
|
hasNewData := false
|
||||||
|
for _, lot := range sourceLots {
|
||||||
|
if latestDate, ok := lotLatestDate[lot]; ok {
|
||||||
|
if latestDate.After(*comp.PriceUpdatedAt) {
|
||||||
|
hasNewData = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasNewData {
|
||||||
|
unchanged++
|
||||||
|
goto sendProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get prices from source lots
|
||||||
|
var prices []float64
|
||||||
|
if periodDays > 0 {
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||||
|
sourceLots, periodDays).Pluck("price", &prices)
|
||||||
|
} else {
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
|
||||||
|
sourceLots).Pluck("price", &prices)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no prices in period, try all time
|
||||||
|
if len(prices) == 0 && periodDays > 0 {
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(prices) == 0 {
|
||||||
|
skipped++
|
||||||
|
goto sendProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate price based on method
|
||||||
|
var basePrice float64
|
||||||
|
switch method {
|
||||||
|
case models.PriceMethodMedian:
|
||||||
|
basePrice = calculateMedian(prices)
|
||||||
|
case models.PriceMethodAverage:
|
||||||
|
basePrice = calculateAverage(prices)
|
||||||
|
default:
|
||||||
|
basePrice = calculateMedian(prices)
|
||||||
|
}
|
||||||
|
|
||||||
|
if basePrice <= 0 {
|
||||||
|
skipped++
|
||||||
|
goto sendProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
finalPrice := basePrice
|
||||||
|
|
||||||
|
// Apply coefficient
|
||||||
|
if comp.PriceCoefficient != 0 {
|
||||||
|
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update only price fields
|
||||||
|
err := h.db.Model(&models.LotMetadata{}).
|
||||||
|
Where("lot_name = ?", comp.LotName).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"current_price": finalPrice,
|
||||||
|
"price_updated_at": now,
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
errors++
|
||||||
|
} else {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendProgress:
|
||||||
|
// Send progress update every 10 components to reduce overhead
|
||||||
|
if progressCounter%10 == 0 || progressCounter == int(total) {
|
||||||
|
c.SSEvent("progress", gin.H{
|
||||||
|
"current": updated + skipped + manual + unchanged + errors,
|
||||||
|
"total": total,
|
||||||
|
"updated": updated,
|
||||||
|
"skipped": skipped,
|
||||||
|
"manual": manual,
|
||||||
|
"unchanged": unchanged,
|
||||||
|
"errors": errors,
|
||||||
|
"status": "processing",
|
||||||
|
"lot_name": comp.LotName,
|
||||||
|
})
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update popularity scores
|
||||||
|
h.statsRepo.UpdatePopularityScores()
|
||||||
|
|
||||||
|
// Send completion
|
||||||
|
c.SSEvent("progress", gin.H{
|
||||||
|
"current": updated + skipped + manual + unchanged + errors,
|
||||||
|
"total": total,
|
||||||
|
"updated": updated,
|
||||||
|
"skipped": skipped,
|
||||||
|
"manual": manual,
|
||||||
|
"unchanged": unchanged,
|
||||||
|
"errors": errors,
|
||||||
|
"status": "completed",
|
||||||
|
})
|
||||||
|
c.Writer.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
||||||
@@ -208,3 +656,165 @@ func (h *PricingHandler) IgnoreAlert(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "ignored"})
|
c.JSON(http.StatusOK, gin.H{"message": "ignored"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PreviewPriceRequest struct {
|
||||||
|
LotName string `json:"lot_name" binding:"required"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
PeriodDays int `json:"period_days"`
|
||||||
|
Coefficient float64 `json:"coefficient"`
|
||||||
|
MetaEnabled bool `json:"meta_enabled"`
|
||||||
|
MetaPrices string `json:"meta_prices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||||
|
var req PreviewPriceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get component
|
||||||
|
var comp models.LotMetadata
|
||||||
|
if err := h.db.Where("lot_name = ?", req.LotName).First(&comp).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which lot names to use for price calculation
|
||||||
|
lotNames := []string{req.LotName}
|
||||||
|
if req.MetaEnabled && req.MetaPrices != "" {
|
||||||
|
lotNames = h.expandMetaPrices(req.MetaPrices, req.LotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all prices for calculations (from all relevant lots)
|
||||||
|
var allPrices []float64
|
||||||
|
for _, lotName := range lotNames {
|
||||||
|
var lotPrices []float64
|
||||||
|
if strings.HasSuffix(lotName, "*") {
|
||||||
|
// Wildcard pattern
|
||||||
|
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
|
||||||
|
} else {
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &lotPrices)
|
||||||
|
}
|
||||||
|
allPrices = append(allPrices, lotPrices...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate median for all time
|
||||||
|
var medianAllTime *float64
|
||||||
|
if len(allPrices) > 0 {
|
||||||
|
sortFloat64s(allPrices)
|
||||||
|
median := calculateMedian(allPrices)
|
||||||
|
medianAllTime = &median
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get quote count (from all relevant lots)
|
||||||
|
var quoteCount int64
|
||||||
|
for _, lotName := range lotNames {
|
||||||
|
var count int64
|
||||||
|
if strings.HasSuffix(lotName, "*") {
|
||||||
|
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
||||||
|
h.db.Model(&models.LotLog{}).Where("lot LIKE ?", pattern).Count(&count)
|
||||||
|
} else {
|
||||||
|
h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count)
|
||||||
|
}
|
||||||
|
quoteCount += count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last received price (from the main lot only)
|
||||||
|
var lastPrice struct {
|
||||||
|
Price *float64
|
||||||
|
Date *time.Time
|
||||||
|
}
|
||||||
|
h.db.Raw(`SELECT price, date FROM lot_log WHERE lot = ? ORDER BY date DESC, lot_log_id DESC LIMIT 1`, req.LotName).Scan(&lastPrice)
|
||||||
|
|
||||||
|
// Calculate new price based on parameters (method, period, coefficient)
|
||||||
|
method := req.Method
|
||||||
|
if method == "" {
|
||||||
|
method = "median"
|
||||||
|
}
|
||||||
|
|
||||||
|
var prices []float64
|
||||||
|
if req.PeriodDays > 0 {
|
||||||
|
for _, lotName := range lotNames {
|
||||||
|
var lotPrices []float64
|
||||||
|
if strings.HasSuffix(lotName, "*") {
|
||||||
|
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||||
|
pattern, req.PeriodDays).Pluck("price", &lotPrices)
|
||||||
|
} else {
|
||||||
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||||
|
lotName, req.PeriodDays).Pluck("price", &lotPrices)
|
||||||
|
}
|
||||||
|
prices = append(prices, lotPrices...)
|
||||||
|
}
|
||||||
|
// Fall back to all time if no prices in period
|
||||||
|
if len(prices) == 0 {
|
||||||
|
prices = allPrices
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
prices = allPrices
|
||||||
|
}
|
||||||
|
|
||||||
|
var newPrice *float64
|
||||||
|
if len(prices) > 0 {
|
||||||
|
sortFloat64s(prices)
|
||||||
|
var basePrice float64
|
||||||
|
if method == "average" {
|
||||||
|
basePrice = calculateAverage(prices)
|
||||||
|
} else {
|
||||||
|
basePrice = calculateMedian(prices)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Coefficient != 0 {
|
||||||
|
basePrice = basePrice * (1 + req.Coefficient/100)
|
||||||
|
}
|
||||||
|
newPrice = &basePrice
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"lot_name": req.LotName,
|
||||||
|
"current_price": comp.CurrentPrice,
|
||||||
|
"median_all_time": medianAllTime,
|
||||||
|
"new_price": newPrice,
|
||||||
|
"quote_count": quoteCount,
|
||||||
|
"manual_price": comp.ManualPrice,
|
||||||
|
"last_price": lastPrice.Price,
|
||||||
|
"last_price_date": lastPrice.Date,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortFloat64s sorts a slice of float64 in ascending order
|
||||||
|
func sortFloat64s(data []float64) {
|
||||||
|
sort.Float64s(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandMetaPricesWithCache expands meta_prices using pre-loaded lot names (no DB queries)
|
||||||
|
func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string {
|
||||||
|
sources := strings.Split(metaPrices, ",")
|
||||||
|
var result []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, source := range sources {
|
||||||
|
source = strings.TrimSpace(source)
|
||||||
|
if source == "" || source == excludeLot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(source, "*") {
|
||||||
|
// Wildcard pattern - find matching lots from cache
|
||||||
|
prefix := strings.TrimSuffix(source, "*")
|
||||||
|
for _, lot := range allLotNames {
|
||||||
|
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
|
||||||
|
result = append(result, lot)
|
||||||
|
seen[lot] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !seen[source] {
|
||||||
|
result = append(result, source)
|
||||||
|
seen[source] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QuoteHandler struct {
|
type QuoteHandler struct {
|
||||||
|
|||||||
186
internal/handlers/web.go
Normal file
186
internal/handlers/web.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebHandler struct {
|
||||||
|
templates map[string]*template.Template
|
||||||
|
componentService *services.ComponentService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) {
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
"sub": func(a, b int) int { return a - b },
|
||||||
|
"add": func(a, b int) int { return a + b },
|
||||||
|
"mul": func(a, b int) int { return a * b },
|
||||||
|
"div": func(a, b int) int {
|
||||||
|
if b == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (a + b - 1) / b
|
||||||
|
},
|
||||||
|
"deref": func(f *float64) float64 {
|
||||||
|
if f == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *f
|
||||||
|
},
|
||||||
|
"jsesc": func(s string) string {
|
||||||
|
// Escape string for safe use in JavaScript
|
||||||
|
result := ""
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '\\':
|
||||||
|
result += "\\\\"
|
||||||
|
case '\'':
|
||||||
|
result += "\\'"
|
||||||
|
case '"':
|
||||||
|
result += "\\\""
|
||||||
|
case '\n':
|
||||||
|
result += "\\n"
|
||||||
|
case '\r':
|
||||||
|
result += "\\r"
|
||||||
|
case '\t':
|
||||||
|
result += "\\t"
|
||||||
|
default:
|
||||||
|
result += string(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
templates := make(map[string]*template.Template)
|
||||||
|
basePath := filepath.Join(templatesPath, "base.html")
|
||||||
|
|
||||||
|
// Load each page template with base
|
||||||
|
simplePages := []string{"login.html", "configs.html", "admin_pricing.html"}
|
||||||
|
for _, page := range simplePages {
|
||||||
|
pagePath := filepath.Join(templatesPath, page)
|
||||||
|
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
templates[page] = tmpl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index page needs components_list.html as well
|
||||||
|
indexPath := filepath.Join(templatesPath, "index.html")
|
||||||
|
componentsListPath := filepath.Join(templatesPath, "components_list.html")
|
||||||
|
indexTmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
templates["index.html"] = indexTmpl
|
||||||
|
|
||||||
|
// Load partial templates (no base needed)
|
||||||
|
partials := []string{"components_list.html"}
|
||||||
|
for _, partial := range partials {
|
||||||
|
partialPath := filepath.Join(templatesPath, partial)
|
||||||
|
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
templates[partial] = tmpl
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WebHandler{
|
||||||
|
templates: templates,
|
||||||
|
componentService: componentService,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
tmpl, ok := h.templates[name]
|
||||||
|
if !ok {
|
||||||
|
c.String(500, "Template not found: %s", name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Execute the page template which will use base
|
||||||
|
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
|
||||||
|
c.String(500, "Template error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebHandler) Index(c *gin.Context) {
|
||||||
|
// Redirect to configs page - configurator is accessed via /configurator?uuid=...
|
||||||
|
c.Redirect(302, "/configs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebHandler) Configurator(c *gin.Context) {
|
||||||
|
categories, _ := h.componentService.GetCategories()
|
||||||
|
uuid := c.Query("uuid")
|
||||||
|
|
||||||
|
filter := repository.ComponentFilter{}
|
||||||
|
result, err := h.componentService.List(filter, 1, 20)
|
||||||
|
|
||||||
|
data := gin.H{
|
||||||
|
"ActivePage": "configurator",
|
||||||
|
"Categories": categories,
|
||||||
|
"Components": []interface{}{},
|
||||||
|
"Total": int64(0),
|
||||||
|
"Page": 1,
|
||||||
|
"PerPage": 20,
|
||||||
|
"ConfigUUID": uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil && result != nil {
|
||||||
|
data["Components"] = result.Components
|
||||||
|
data["Total"] = result.Total
|
||||||
|
data["Page"] = result.Page
|
||||||
|
data["PerPage"] = result.PerPage
|
||||||
|
}
|
||||||
|
|
||||||
|
h.render(c, "index.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebHandler) Login(c *gin.Context) {
|
||||||
|
h.render(c, "login.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebHandler) Configs(c *gin.Context) {
|
||||||
|
h.render(c, "configs.html", gin.H{"ActivePage": "configs"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebHandler) AdminPricing(c *gin.Context) {
|
||||||
|
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partials for htmx
|
||||||
|
|
||||||
|
func (h *WebHandler) ComponentsPartial(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
|
||||||
|
filter := repository.ComponentFilter{
|
||||||
|
Category: c.Query("category"),
|
||||||
|
Search: c.Query("search"),
|
||||||
|
}
|
||||||
|
|
||||||
|
data := gin.H{
|
||||||
|
"Components": []interface{}{},
|
||||||
|
"Total": int64(0),
|
||||||
|
"Page": page,
|
||||||
|
"PerPage": 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.componentService.List(filter, page, 20)
|
||||||
|
if err == nil && result != nil {
|
||||||
|
data["Components"] = result.Components
|
||||||
|
data["Total"] = result.Total
|
||||||
|
data["Page"] = result.Page
|
||||||
|
data["PerPage"] = result.PerPage
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if tmpl, ok := h.templates["components_list.html"]; ok {
|
||||||
|
tmpl.ExecuteTemplate(c.Writer, "components_list.html", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -13,17 +13,32 @@ func (Category) TableName() string {
|
|||||||
return "qt_categories"
|
return "qt_categories"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultCategories defines the standard categories with display order
|
||||||
|
// Order: BB, CPU, MEM, GPU, SSD, RAID, HBA, NIC, PSU, RISERS, ACC, and others
|
||||||
var DefaultCategories = []Category{
|
var DefaultCategories = []Category{
|
||||||
{Code: "MB", Name: "Motherboard", NameRu: "Материнская плата", DisplayOrder: 1, IsRequired: true},
|
{Code: "BB", Name: "Barebone", NameRu: "Шасси", DisplayOrder: 1, IsRequired: true},
|
||||||
{Code: "CPU", Name: "Processor", NameRu: "Процессор", DisplayOrder: 2, IsRequired: true},
|
{Code: "CPU", Name: "Processor", NameRu: "Процессор", DisplayOrder: 2, IsRequired: true},
|
||||||
{Code: "MEM", Name: "Memory", NameRu: "Оперативная память", DisplayOrder: 3, IsRequired: true},
|
{Code: "MEM", Name: "Memory", NameRu: "Оперативная память", DisplayOrder: 3, IsRequired: true},
|
||||||
{Code: "GPU", Name: "Graphics Card", NameRu: "Видеокарта", DisplayOrder: 4},
|
{Code: "GPU", Name: "Graphics Card", NameRu: "Видеокарта", DisplayOrder: 4},
|
||||||
{Code: "SSD", Name: "SSD Storage", NameRu: "SSD накопитель", DisplayOrder: 5},
|
{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: 6},
|
||||||
{Code: "RAID", Name: "RAID Controller", NameRu: "RAID контроллер", DisplayOrder: 7},
|
{Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 7},
|
||||||
{Code: "NIC", Name: "Network Card", NameRu: "Сетевая карта", DisplayOrder: 8},
|
{Code: "NIC", Name: "Network Card", NameRu: "Сетевая карта", DisplayOrder: 8},
|
||||||
{Code: "HCA", Name: "HCA Adapter", NameRu: "HCA адаптер", DisplayOrder: 9},
|
{Code: "PSU", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 9},
|
||||||
{Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 10},
|
{Code: "RISERS", Name: "Risers", NameRu: "Райзеры", DisplayOrder: 10},
|
||||||
{Code: "DPU", Name: "DPU", NameRu: "DPU", DisplayOrder: 11},
|
{Code: "ACC", Name: "Accessories", NameRu: "Аксессуары", DisplayOrder: 11},
|
||||||
{Code: "PS", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 12},
|
// Additional categories
|
||||||
|
{Code: "MB", Name: "Motherboard", NameRu: "Материнская плата", DisplayOrder: 12},
|
||||||
|
{Code: "HDD", Name: "HDD Storage", NameRu: "HDD накопитель", DisplayOrder: 13},
|
||||||
|
{Code: "HCA", Name: "HCA Adapter", NameRu: "HCA адаптер", DisplayOrder: 14},
|
||||||
|
{Code: "DPU", Name: "DPU", NameRu: "DPU", DisplayOrder: 15},
|
||||||
|
{Code: "M2", Name: "M.2 Storage", NameRu: "M.2 накопитель", DisplayOrder: 16},
|
||||||
|
{Code: "EDSFF", Name: "EDSFF Storage", NameRu: "EDSFF накопитель", DisplayOrder: 17},
|
||||||
|
{Code: "HHHL", Name: "HHHL Storage", NameRu: "HHHL накопитель", DisplayOrder: 18},
|
||||||
|
{Code: "PS", Name: "Power Supply (Legacy)", NameRu: "Блок питания", DisplayOrder: 19},
|
||||||
|
{Code: "CARD", Name: "Cards", NameRu: "Карты", DisplayOrder: 20},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MaxKnownDisplayOrder is the highest display order for known categories
|
||||||
|
// New categories will get display order starting from this + 1
|
||||||
|
const MaxKnownDisplayOrder = 100
|
||||||
|
|||||||
@@ -40,15 +40,17 @@ func (c ConfigItems) Total() float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||||
UserID uint `gorm:"not null" json:"user_id"`
|
UserID uint `gorm:"not null" json:"user_id"`
|
||||||
Name string `gorm:"size:200;not null" json:"name"`
|
Name string `gorm:"size:200;not null" json:"name"`
|
||||||
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||||
Notes string `gorm:"type:text" json:"notes"`
|
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
|
||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
|
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
|
||||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// Lot represents existing lot table (READ-ONLY)
|
// Lot represents existing lot table
|
||||||
type Lot struct {
|
type Lot struct {
|
||||||
LotName string `gorm:"column:lot_name;primaryKey;size:255"`
|
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||||
LotDescription string `gorm:"column:lot_description;size:10000"`
|
LotDescription string `gorm:"column:lot_description;size:10000" json:"lot_description"`
|
||||||
|
LotCategory *string `gorm:"column:lot_category;size:50" json:"lot_category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Lot) TableName() string {
|
func (Lot) TableName() string {
|
||||||
|
|||||||
@@ -35,18 +35,23 @@ func (s *Specs) Scan(value interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LotMetadata struct {
|
type LotMetadata struct {
|
||||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||||
CategoryID *uint `gorm:"column:category_id" json:"category_id"`
|
CategoryID *uint `gorm:"column:category_id" json:"category_id"`
|
||||||
Vendor string `gorm:"size:50" json:"vendor"`
|
Model string `gorm:"size:100" json:"model"`
|
||||||
Model string `gorm:"size:100" json:"model"`
|
Specs Specs `gorm:"type:json" json:"specs"`
|
||||||
Specs Specs `gorm:"type:json" json:"specs"`
|
CurrentPrice *float64 `gorm:"type:decimal(12,2)" json:"current_price"`
|
||||||
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"`
|
||||||
PriceMethod PriceMethod `gorm:"type:enum('manual','median','average','weighted_median');default:'median'" json:"price_method"`
|
PricePeriodDays int `gorm:"default:90" json:"price_period_days"`
|
||||||
PricePeriodDays int `gorm:"default:90" json:"price_period_days"`
|
PriceCoefficient float64 `gorm:"type:decimal(5,2);default:0" json:"price_coefficient"`
|
||||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
ManualPrice *float64 `gorm:"type:decimal(12,2)" json:"manual_price"`
|
||||||
RequestCount int `gorm:"default:0" json:"request_count"`
|
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||||
LastRequestDate *time.Time `gorm:"type:date" json:"last_request_date"`
|
RequestCount int `gorm:"default:0" json:"request_count"`
|
||||||
PopularityScore float64 `gorm:"type:decimal(10,4);default:0" json:"popularity_score"`
|
LastRequestDate *time.Time `gorm:"type:date" json:"last_request_date"`
|
||||||
|
PopularityScore float64 `gorm:"type:decimal(10,4);default:0" json:"popularity_score"`
|
||||||
|
MetaPrices string `gorm:"size:1000" json:"meta_prices"`
|
||||||
|
MetaMethod string `gorm:"size:20" json:"meta_method"`
|
||||||
|
MetaPeriodDays int `gorm:"default:90" json:"meta_period_days"`
|
||||||
|
IsHidden bool `gorm:"default:false" json:"is_hidden"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`
|
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`
|
||||||
|
|||||||
@@ -30,3 +30,22 @@ func SeedCategories(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeedAdminUser creates default admin user if not exists
|
||||||
|
// Default credentials: admin / admin123
|
||||||
|
func SeedAdminUser(db *gorm.DB, passwordHash string) error {
|
||||||
|
var count int64
|
||||||
|
db.Model(&User{}).Where("username = ?", "admin").Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := &User{
|
||||||
|
Username: "admin",
|
||||||
|
Email: "admin@example.com",
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
Role: RoleAdmin,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
return db.Create(admin).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,3 +36,41 @@ func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) {
|
|||||||
}
|
}
|
||||||
return &category, nil
|
return &category, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateIfNotExists creates a new category if it doesn't exist, returns existing one if it does
|
||||||
|
func (r *CategoryRepository) CreateIfNotExists(code string) (*models.Category, error) {
|
||||||
|
// Try to find existing
|
||||||
|
existing, err := r.GetByCode(code)
|
||||||
|
if err == nil {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get max display order to put new category at the end
|
||||||
|
var maxOrder int
|
||||||
|
r.db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder)
|
||||||
|
|
||||||
|
// Create new category
|
||||||
|
newCat := &models.Category{
|
||||||
|
Code: code,
|
||||||
|
Name: code, // Use code as name initially
|
||||||
|
NameRu: code,
|
||||||
|
DisplayOrder: maxOrder + 1,
|
||||||
|
IsRequired: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.db.Create(newCat).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new category
|
||||||
|
func (r *CategoryRepository) Create(category *models.Category) error {
|
||||||
|
return r.db.Create(category).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates an existing category
|
||||||
|
func (r *CategoryRepository) Update(category *models.Category) error {
|
||||||
|
return r.db.Save(category).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,10 +16,12 @@ func NewComponentRepository(db *gorm.DB) *ComponentRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ComponentFilter struct {
|
type ComponentFilter struct {
|
||||||
Category string
|
Category string
|
||||||
Vendor string
|
Search string
|
||||||
Search string
|
HasPrice bool
|
||||||
HasPrice bool
|
ExcludeHidden bool
|
||||||
|
SortField string
|
||||||
|
SortDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||||
@@ -34,9 +36,6 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
|
|||||||
query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id").
|
query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id").
|
||||||
Where("qt_categories.code = ?", filter.Category)
|
Where("qt_categories.code = ?", filter.Category)
|
||||||
}
|
}
|
||||||
if filter.Vendor != "" {
|
|
||||||
query = query.Where("vendor = ?", filter.Vendor)
|
|
||||||
}
|
|
||||||
if filter.Search != "" {
|
if filter.Search != "" {
|
||||||
search := "%" + filter.Search + "%"
|
search := "%" + filter.Search + "%"
|
||||||
query = query.Where("lot_name LIKE ? OR model LIKE ?", search, search)
|
query = query.Where("lot_name LIKE ? OR model LIKE ?", search, search)
|
||||||
@@ -44,13 +43,39 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
|
|||||||
if filter.HasPrice {
|
if filter.HasPrice {
|
||||||
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
||||||
}
|
}
|
||||||
|
if filter.ExcludeHidden {
|
||||||
|
query = query.Where("is_hidden = ? OR is_hidden IS NULL", false)
|
||||||
|
}
|
||||||
|
|
||||||
query.Count(&total)
|
query.Count(&total)
|
||||||
|
|
||||||
// Sort by popularity + freshness, no price goes last
|
// Apply sorting
|
||||||
|
sortDir := "ASC"
|
||||||
|
if filter.SortDir == "desc" {
|
||||||
|
sortDir = "DESC"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch filter.SortField {
|
||||||
|
case "popularity_score":
|
||||||
|
query = query.Order("popularity_score " + sortDir)
|
||||||
|
case "current_price":
|
||||||
|
query = query.Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
||||||
|
Order("current_price " + sortDir)
|
||||||
|
case "lot_name":
|
||||||
|
query = query.Order("lot_name " + sortDir)
|
||||||
|
case "quote_count":
|
||||||
|
// Sort by quote count from lot_log table
|
||||||
|
query = query.
|
||||||
|
Select("qt_lot_metadata.*, (SELECT COUNT(*) FROM lot_log WHERE lot_log.lot = qt_lot_metadata.lot_name) as quote_count_sort").
|
||||||
|
Order("quote_count_sort " + sortDir)
|
||||||
|
default:
|
||||||
|
// Default: sort by popularity, no price goes last
|
||||||
|
query = query.
|
||||||
|
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
||||||
|
Order("popularity_score DESC")
|
||||||
|
}
|
||||||
|
|
||||||
err := query.
|
err := query.
|
||||||
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
|
||||||
Order("popularity_score DESC").
|
|
||||||
Offset(offset).
|
Offset(offset).
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
Find(&components).Error
|
Find(&components).Error
|
||||||
@@ -89,17 +114,6 @@ func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
|||||||
return r.db.Create(component).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 {
|
func (r *ComponentRepository) IncrementRequestCount(lotName string) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return r.db.Model(&models.LotMetadata{}).
|
return r.db.Model(&models.LotMetadata{}).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,3 +97,28 @@ func (r *PriceRepository) GetQuoteCount(lotName string, periodDays int) (int64,
|
|||||||
|
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetQuoteCounts returns quote counts for multiple lot names
|
||||||
|
func (r *PriceRepository) GetQuoteCounts(lotNames []string) (map[string]int64, error) {
|
||||||
|
type Result struct {
|
||||||
|
Lot string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
var results []Result
|
||||||
|
|
||||||
|
err := r.db.Model(&models.LotLog{}).
|
||||||
|
Select("lot, COUNT(*) as count").
|
||||||
|
Where("lot IN ?", lotNames).
|
||||||
|
Group("lot").
|
||||||
|
Scan(&results).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := make(map[string]int64)
|
||||||
|
for _, r := range results {
|
||||||
|
counts[r.Lot] = r.Count
|
||||||
|
}
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,3 +90,26 @@ func (r *StatsRepository) ResetMonthlyCounters() error {
|
|||||||
Where("1 = 1").
|
Where("1 = 1").
|
||||||
Update("quotes_last_30d", 0).Error
|
Update("quotes_last_30d", 0).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatePopularityScores recalculates popularity_score in qt_lot_metadata
|
||||||
|
// based on supplier quotes from lot_log table
|
||||||
|
func (r *StatsRepository) UpdatePopularityScores() error {
|
||||||
|
// Formula: popularity_score = quotes_last_30d * 3 + quotes_last_90d * 1 + quotes_total * 0.1
|
||||||
|
// This gives more weight to recent supplier activity
|
||||||
|
return r.db.Exec(`
|
||||||
|
UPDATE qt_lot_metadata m
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
lot,
|
||||||
|
COUNT(*) as quotes_total,
|
||||||
|
SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as quotes_last_30d,
|
||||||
|
SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 90 DAY) THEN 1 ELSE 0 END) as quotes_last_90d
|
||||||
|
FROM lot_log
|
||||||
|
GROUP BY lot
|
||||||
|
) s ON m.lot_name = s.lot
|
||||||
|
SET m.popularity_score = COALESCE(
|
||||||
|
s.quotes_last_30d * 3 + s.quotes_last_90d * 1 + s.quotes_total * 0.1,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
`).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package services
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ComponentService struct {
|
type ComponentService struct {
|
||||||
@@ -25,19 +25,16 @@ func NewComponentService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParsePartNumber extracts category, vendor, model from lot_name
|
// ParsePartNumber extracts category and model from lot_name
|
||||||
// "CPU_AMD_9654" → category="CPU", vendor="AMD", model="9654"
|
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
|
||||||
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", vendor="INTEL", model="4.Sapphire_2S_32xDDR5"
|
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", model="INTEL_4.Sapphire_2S_32xDDR5"
|
||||||
func ParsePartNumber(lotName string) (category, vendor, model string) {
|
func ParsePartNumber(lotName string) (category, model string) {
|
||||||
parts := strings.SplitN(lotName, "_", 3)
|
parts := strings.SplitN(lotName, "_", 2)
|
||||||
if len(parts) >= 1 {
|
if len(parts) >= 1 {
|
||||||
category = parts[0]
|
category = parts[0]
|
||||||
}
|
}
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
vendor = parts[1]
|
model = parts[1]
|
||||||
}
|
|
||||||
if len(parts) >= 3 {
|
|
||||||
model = parts[2]
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -50,25 +47,27 @@ type ComponentListResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ComponentView struct {
|
type ComponentView struct {
|
||||||
LotName string `json:"lot_name"`
|
LotName string `json:"lot_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
CategoryName string `json:"category_name"`
|
CategoryName string `json:"category_name"`
|
||||||
Vendor string `json:"vendor"`
|
Model string `json:"model"`
|
||||||
Model string `json:"model"`
|
CurrentPrice *float64 `json:"current_price"`
|
||||||
CurrentPrice *float64 `json:"current_price"`
|
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
||||||
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
PopularityScore float64 `json:"popularity_score"`
|
||||||
PopularityScore float64 `json:"popularity_score"`
|
Specs models.Specs `json:"specs,omitempty"`
|
||||||
Specs models.Specs `json:"specs,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) {
|
func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
if perPage < 1 || perPage > 100 {
|
if perPage < 1 {
|
||||||
perPage = 20
|
perPage = 20
|
||||||
}
|
}
|
||||||
|
if perPage > 5000 {
|
||||||
|
perPage = 5000
|
||||||
|
}
|
||||||
offset := (page - 1) * perPage
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
components, total, err := s.componentRepo.List(filter, offset, perPage)
|
components, total, err := s.componentRepo.List(filter, offset, perPage)
|
||||||
@@ -80,7 +79,6 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
|||||||
for i, c := range components {
|
for i, c := range components {
|
||||||
view := ComponentView{
|
view := ComponentView{
|
||||||
LotName: c.LotName,
|
LotName: c.LotName,
|
||||||
Vendor: c.Vendor,
|
|
||||||
Model: c.Model,
|
Model: c.Model,
|
||||||
CurrentPrice: c.CurrentPrice,
|
CurrentPrice: c.CurrentPrice,
|
||||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||||
@@ -118,7 +116,6 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
|||||||
|
|
||||||
view := &ComponentView{
|
view := &ComponentView{
|
||||||
LotName: c.LotName,
|
LotName: c.LotName,
|
||||||
Vendor: c.Vendor,
|
|
||||||
Model: c.Model,
|
Model: c.Model,
|
||||||
CurrentPrice: c.CurrentPrice,
|
CurrentPrice: c.CurrentPrice,
|
||||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||||
@@ -141,10 +138,6 @@ func (s *ComponentService) GetCategories() ([]models.Category, error) {
|
|||||||
return s.categoryRepo.GetAll()
|
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
|
// ImportFromLot creates metadata entries for lots that don't have them
|
||||||
func (s *ComponentService) ImportFromLot() (int, error) {
|
func (s *ComponentService) ImportFromLot() (int, error) {
|
||||||
lots, err := s.componentRepo.GetLotsWithoutMetadata()
|
lots, err := s.componentRepo.GetLotsWithoutMetadata()
|
||||||
@@ -159,22 +152,36 @@ func (s *ComponentService) ImportFromLot() (int, error) {
|
|||||||
|
|
||||||
categoryMap := make(map[string]uint)
|
categoryMap := make(map[string]uint)
|
||||||
for _, cat := range categories {
|
for _, cat := range categories {
|
||||||
categoryMap[cat.Code] = cat.ID
|
categoryMap[strings.ToUpper(cat.Code)] = cat.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
imported := 0
|
imported := 0
|
||||||
for _, lot := range lots {
|
for _, lot := range lots {
|
||||||
category, vendor, model := ParsePartNumber(lot.LotName)
|
// Use lot_category from database if available, otherwise parse from lot_name
|
||||||
|
var category string
|
||||||
|
if lot.LotCategory != nil && *lot.LotCategory != "" {
|
||||||
|
category = strings.ToUpper(*lot.LotCategory)
|
||||||
|
} else {
|
||||||
|
category, _ = ParsePartNumber(lot.LotName)
|
||||||
|
category = strings.ToUpper(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, model := ParsePartNumber(lot.LotName)
|
||||||
|
|
||||||
metadata := &models.LotMetadata{
|
metadata := &models.LotMetadata{
|
||||||
LotName: lot.LotName,
|
LotName: lot.LotName,
|
||||||
Vendor: vendor,
|
|
||||||
Model: model,
|
Model: model,
|
||||||
Specs: make(models.Specs),
|
Specs: make(models.Specs),
|
||||||
}
|
}
|
||||||
|
|
||||||
if catID, ok := categoryMap[category]; ok {
|
if catID, ok := categoryMap[category]; ok {
|
||||||
metadata.CategoryID = &catID
|
metadata.CategoryID = &catID
|
||||||
|
} else {
|
||||||
|
// Create new category if it doesn't exist
|
||||||
|
newCat, err := s.categoryRepo.CreateIfNotExists(category)
|
||||||
|
if err == nil && newCat != nil {
|
||||||
|
metadata.CategoryID = &newCat.ID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.componentRepo.Create(metadata); err != nil {
|
if err := s.componentRepo.Create(metadata); err != nil {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -33,23 +32,32 @@ func NewConfigurationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateConfigRequest struct {
|
type CreateConfigRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Items models.ConfigItems `json:"items"`
|
Items models.ConfigItems `json:"items"`
|
||||||
Notes string `json:"notes"`
|
CustomPrice *float64 `json:"custom_price"`
|
||||||
IsTemplate bool `json:"is_template"`
|
Notes string `json:"notes"`
|
||||||
|
IsTemplate bool `json:"is_template"`
|
||||||
|
ServerCount int `json:"server_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
|
|
||||||
|
// If server count is greater than 1, multiply the total by server count
|
||||||
|
if req.ServerCount > 1 {
|
||||||
|
total *= float64(req.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
config := &models.Configuration{
|
config := &models.Configuration{
|
||||||
UUID: uuid.New().String(),
|
UUID: uuid.New().String(),
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Items: req.Items,
|
Items: req.Items,
|
||||||
TotalPrice: &total,
|
TotalPrice: &total,
|
||||||
Notes: req.Notes,
|
CustomPrice: req.CustomPrice,
|
||||||
IsTemplate: req.IsTemplate,
|
Notes: req.Notes,
|
||||||
|
IsTemplate: req.IsTemplate,
|
||||||
|
ServerCount: req.ServerCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(config); err != nil {
|
if err := s.configRepo.Create(config); err != nil {
|
||||||
@@ -88,11 +96,18 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
|
|||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
|
|
||||||
|
// If server count is greater than 1, multiply the total by server count
|
||||||
|
if req.ServerCount > 1 {
|
||||||
|
total *= float64(req.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
config.Name = req.Name
|
config.Name = req.Name
|
||||||
config.Items = req.Items
|
config.Items = req.Items
|
||||||
config.TotalPrice = &total
|
config.TotalPrice = &total
|
||||||
|
config.CustomPrice = req.CustomPrice
|
||||||
config.Notes = req.Notes
|
config.Notes = req.Notes
|
||||||
config.IsTemplate = req.IsTemplate
|
config.IsTemplate = req.IsTemplate
|
||||||
|
config.ServerCount = req.ServerCount
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -114,6 +129,58 @@ func (s *ConfigurationService) Delete(uuid string, userID uint) error {
|
|||||||
return s.configRepo.Delete(config.ID)
|
return s.configRepo.Delete(config.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) {
|
||||||
|
config, err := s.configRepo.GetByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.UserID != userID {
|
||||||
|
return nil, ErrConfigForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Name = newName
|
||||||
|
|
||||||
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) {
|
||||||
|
original, err := s.GetByUUID(configUUID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create copy with new UUID and name
|
||||||
|
total := original.Items.Total()
|
||||||
|
|
||||||
|
// If server count is greater than 1, multiply the total by server count
|
||||||
|
if original.ServerCount > 1 {
|
||||||
|
total *= float64(original.ServerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := &models.Configuration{
|
||||||
|
UUID: uuid.New().String(),
|
||||||
|
UserID: userID,
|
||||||
|
Name: newName,
|
||||||
|
Items: original.Items,
|
||||||
|
TotalPrice: &total,
|
||||||
|
CustomPrice: original.CustomPrice,
|
||||||
|
Notes: original.Notes,
|
||||||
|
IsTemplate: false, // Clone is never a template
|
||||||
|
ServerCount: original.ServerCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.configRepo.Create(clone); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
|
func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
@@ -138,39 +205,39 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config
|
|||||||
return s.configRepo.ListTemplates(offset, perPage)
|
return s.configRepo.ListTemplates(offset, perPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export configuration as JSON
|
// // Export configuration as JSON
|
||||||
type ConfigExport struct {
|
// type ConfigExport struct {
|
||||||
Name string `json:"name"`
|
// Name string `json:"name"`
|
||||||
Notes string `json:"notes"`
|
// Notes string `json:"notes"`
|
||||||
Items models.ConfigItems `json:"items"`
|
// Items models.ConfigItems `json:"items"`
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
func (s *ConfigurationService) ExportJSON(uuid string, userID uint) ([]byte, error) {
|
// func (s *ConfigurationService) ExportJSON(uuid string, userID uint) ([]byte, error) {
|
||||||
config, err := s.GetByUUID(uuid, userID)
|
// config, err := s.GetByUUID(uuid, userID)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
return nil, err
|
// return nil, err
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
export := ConfigExport{
|
// export := ConfigExport{
|
||||||
Name: config.Name,
|
// Name: config.Name,
|
||||||
Notes: config.Notes,
|
// Notes: config.Notes,
|
||||||
Items: config.Items,
|
// Items: config.Items,
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
return json.MarshalIndent(export, "", " ")
|
// return json.MarshalIndent(export, "", " ")
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
func (s *ConfigurationService) ImportJSON(userID uint, data []byte) (*models.Configuration, error) {
|
// func (s *ConfigurationService) ImportJSON(userID uint, data []byte) (*models.Configuration, error) {
|
||||||
var export ConfigExport
|
// var export ConfigExport
|
||||||
if err := json.Unmarshal(data, &export); err != nil {
|
// if err := json.Unmarshal(data, &export); err != nil {
|
||||||
return nil, err
|
// return nil, err
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
req := &CreateConfigRequest{
|
// req := &CreateConfigRequest{
|
||||||
Name: export.Name,
|
// Name: export.Name,
|
||||||
Notes: export.Notes,
|
// Notes: export.Notes,
|
||||||
Items: export.Items,
|
// Items: export.Items,
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
return s.Create(userID, req)
|
// return s.Create(userID, req)
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -6,25 +6,29 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/xuri/excelize/v2"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExportService struct {
|
type ExportService struct {
|
||||||
config config.ExportConfig
|
config config.ExportConfig
|
||||||
|
categoryRepo *repository.CategoryRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExportService(cfg config.ExportConfig) *ExportService {
|
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository) *ExportService {
|
||||||
return &ExportService{config: cfg}
|
return &ExportService{
|
||||||
|
config: cfg,
|
||||||
|
categoryRepo: categoryRepo,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportData struct {
|
type ExportData struct {
|
||||||
Name string
|
Name string
|
||||||
Items []ExportItem
|
Items []ExportItem
|
||||||
Total float64
|
Total float64
|
||||||
Notes string
|
Notes string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportItem struct {
|
type ExportItem struct {
|
||||||
@@ -46,8 +50,41 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get category hierarchy for sorting
|
||||||
|
categoryOrder := make(map[string]int)
|
||||||
|
if s.categoryRepo != nil {
|
||||||
|
categories, err := s.categoryRepo.GetAll()
|
||||||
|
if err == nil {
|
||||||
|
for _, cat := range categories {
|
||||||
|
categoryOrder[cat.Code] = cat.DisplayOrder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort items by category display order
|
||||||
|
sortedItems := make([]ExportItem, len(data.Items))
|
||||||
|
copy(sortedItems, data.Items)
|
||||||
|
|
||||||
|
// Sort using category display order (items without category go to the end)
|
||||||
|
for i := 0; i < len(sortedItems)-1; i++ {
|
||||||
|
for j := i + 1; j < len(sortedItems); j++ {
|
||||||
|
orderI, hasI := categoryOrder[sortedItems[i].Category]
|
||||||
|
orderJ, hasJ := categoryOrder[sortedItems[j].Category]
|
||||||
|
|
||||||
|
// Items without category go to the end
|
||||||
|
if !hasI && hasJ {
|
||||||
|
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
|
||||||
|
} else if hasI && hasJ {
|
||||||
|
// Both have categories, sort by display order
|
||||||
|
if orderI > orderJ {
|
||||||
|
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Items
|
// Items
|
||||||
for _, item := range data.Items {
|
for _, item := range sortedItems {
|
||||||
row := []string{
|
row := []string{
|
||||||
item.LotName,
|
item.LotName,
|
||||||
item.Description,
|
item.Description,
|
||||||
@@ -70,97 +107,32 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
|||||||
return buf.Bytes(), w.Error()
|
return buf.Bytes(), w.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) ToXLSX(data *ExportData) ([]byte, error) {
|
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
|
||||||
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))
|
items := make([]ExportItem, len(config.Items))
|
||||||
var total float64
|
var total float64
|
||||||
|
|
||||||
for i, item := range config.Items {
|
for i, item := range config.Items {
|
||||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||||
items[i] = ExportItem{
|
|
||||||
LotName: item.LotName,
|
// Получаем информацию о компоненте для заполнения категории
|
||||||
Quantity: item.Quantity,
|
componentView, err := componentService.GetByLotName(item.LotName)
|
||||||
UnitPrice: item.UnitPrice,
|
if err != nil {
|
||||||
TotalPrice: itemTotal,
|
// Если не удалось получить информацию о компоненте, используем только основные данные
|
||||||
|
items[i] = ExportItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: itemTotal,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items[i] = ExportItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Description: componentView.Description,
|
||||||
|
Category: componentView.Category,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: itemTotal,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
total += itemTotal
|
total += itemTotal
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CalculateMedian returns the median of prices
|
// CalculateMedian returns the median of prices
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package pricing
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -176,3 +176,30 @@ type PriceStats struct {
|
|||||||
Percentile25 float64 `json:"percentile_25"`
|
Percentile25 float64 `json:"percentile_25"`
|
||||||
Percentile75 float64 `json:"percentile_75"`
|
Percentile75 float64 `json:"percentile_75"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecalculateAllPrices recalculates prices for all components
|
||||||
|
func (s *Service) RecalculateAllPrices() (updated int, errors int) {
|
||||||
|
// Get all components
|
||||||
|
filter := repository.ComponentFilter{}
|
||||||
|
offset := 0
|
||||||
|
limit := 100
|
||||||
|
|
||||||
|
for {
|
||||||
|
components, _, err := s.componentRepo.List(filter, offset, limit)
|
||||||
|
if err != nil || len(components) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, comp := range components {
|
||||||
|
if err := s.UpdateComponentPrice(comp.LotName); err != nil {
|
||||||
|
errors++
|
||||||
|
} else {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated, errors
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package services
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"github.com/mchus/quoteforge/internal/services/pricing"
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
11
migrations/001_add_lot_category.sql
Normal file
11
migrations/001_add_lot_category.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Migration: Add lot_category column to lot table
|
||||||
|
-- Run this migration manually on the database
|
||||||
|
|
||||||
|
-- Add lot_category column to lot table
|
||||||
|
ALTER TABLE lot ADD COLUMN lot_category VARCHAR(50) DEFAULT NULL;
|
||||||
|
|
||||||
|
-- Create index for faster lookups
|
||||||
|
CREATE INDEX idx_lot_category ON lot(lot_category);
|
||||||
|
|
||||||
|
-- Update existing lots: extract category from lot_name (first part before underscore)
|
||||||
|
UPDATE lot SET lot_category = SUBSTRING_INDEX(lot_name, '_', 1) WHERE lot_category IS NULL;
|
||||||
2
migrations/002_add_custom_price.sql
Normal file
2
migrations/002_add_custom_price.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add custom_price column to qt_configurations table
|
||||||
|
ALTER TABLE qt_configurations ADD COLUMN custom_price DECIMAL(12,2) NULL COMMENT 'User-defined custom total price';
|
||||||
2
migrations/003_add_is_hidden.sql
Normal file
2
migrations/003_add_is_hidden.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add is_hidden column to qt_lot_metadata table
|
||||||
|
ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator';
|
||||||
9
web/static/app.css
Normal file
9
web/static/app.css
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/* QuoteForge custom styles */
|
||||||
|
/* Tailwind is loaded via CDN, this file is for any custom overrides */
|
||||||
|
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
856
web/templates/admin_pricing.html
Normal file
856
web/templates/admin_pricing.html
Normal file
@@ -0,0 +1,856 @@
|
|||||||
|
{{define "title"}}Цены - QuoteForge{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h1 class="text-2xl font-bold">Управление ценами</h1>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<div class="flex justify-between items-center border-b pb-4 mb-4">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
|
||||||
|
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
|
||||||
|
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
|
||||||
|
</div>
|
||||||
|
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
|
||||||
|
Пересчитать цены
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;">
|
||||||
|
<div class="flex justify-between text-sm text-gray-700 mb-2">
|
||||||
|
<span id="progress-text" class="font-medium">Пересчёт цен...</span>
|
||||||
|
<span id="progress-percent" class="font-bold">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-4">
|
||||||
|
<div id="progress-bar" class="bg-blue-600 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 mt-2">
|
||||||
|
<span id="progress-stats"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search and sort (only for components) -->
|
||||||
|
<div id="search-bar" class="mb-4 hidden">
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
<input type="text" id="search-input" placeholder="Поиск по артикулу..."
|
||||||
|
class="flex-1 px-3 py-2 border rounded"
|
||||||
|
onkeyup="debounceSearch()">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-500">Сортировка:</span>
|
||||||
|
<select id="sort-field" class="px-2 py-1 border rounded text-sm" onchange="changeSort()">
|
||||||
|
<option value="lot_name">Артикул</option>
|
||||||
|
<option value="popularity_score" selected>Популярность</option>
|
||||||
|
<option value="quote_count">Кол-во котировок</option>
|
||||||
|
<option value="current_price">Цена</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="toggleSortDir()" id="sort-dir-btn" class="px-2 py-1 border rounded text-sm">↓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-content">
|
||||||
|
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div id="pagination" class="flex justify-between items-center mt-4 pt-4 border-t hidden">
|
||||||
|
<span id="page-info" class="text-sm text-gray-600"></span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="prevPage()" id="btn-prev" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Назад</button>
|
||||||
|
<button onclick="nextPage()" id="btn-next" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Вперед</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Price Settings Modal -->
|
||||||
|
<div id="price-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
|
<div class="flex justify-between items-center p-4 border-b">
|
||||||
|
<h3 class="text-lg font-semibold">Настройка цены</h3>
|
||||||
|
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Артикул</label>
|
||||||
|
<input type="text" id="modal-lot-name" readonly class="w-full px-3 py-2 border rounded bg-gray-100">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<input type="checkbox" id="modal-meta-enabled" class="mr-2" onchange="toggleMetaPrice()">
|
||||||
|
<span class="text-sm font-medium text-gray-700">Мета артикул</span>
|
||||||
|
</div>
|
||||||
|
<div id="meta-price-fields" class="hidden mt-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Источники цен</label>
|
||||||
|
<input type="text" id="modal-meta-prices" class="w-full px-3 py-2 border rounded" placeholder="Артикулы через запятую (например: CPU_AMD_9654, MB_INTEL_4.Sapphire_2S)">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Артикулы, чьи цены будут использоваться в расчете. Для автоматического подбора используйте * в конце (например: CPU_AMD_9654*)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Метод расчёта</label>
|
||||||
|
<select id="modal-method" class="w-full px-3 py-2 border rounded" onchange="onMethodChange()">
|
||||||
|
<option value="median">Медиана</option>
|
||||||
|
<option value="average">Среднее</option>
|
||||||
|
<option value="manual">Установить цену вручную</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="manual-price-field" class="hidden">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Ручная цена (USD)</label>
|
||||||
|
<input type="number" id="modal-manual-price" step="0.01" class="w-full px-3 py-2 border rounded" placeholder="Цена USD">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Ручная цена сохраняется при пересчёте</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Период расчёта</label>
|
||||||
|
<select id="modal-period" class="w-full px-3 py-2 border rounded">
|
||||||
|
<option value="7">1 неделя</option>
|
||||||
|
<option value="30">1 месяц</option>
|
||||||
|
<option value="90">1 квартал</option>
|
||||||
|
<option value="365">1 год</option>
|
||||||
|
<option value="0">Всё время</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Коэффициент корректировки (%)</label>
|
||||||
|
<input type="number" id="modal-coefficient" step="1" class="w-full px-3 py-2 border rounded" placeholder="0">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Например: 30 для +30%, -10 для -10%</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center pt-2 border-t">
|
||||||
|
<input type="checkbox" id="modal-hidden" class="mr-2">
|
||||||
|
<span class="text-sm font-medium text-gray-700">Скрыть из конфигуратора</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 p-3 rounded space-y-2">
|
||||||
|
<div class="text-sm font-medium text-gray-700 mb-2">Расчёт цены</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div class="text-gray-600">Последняя цена:</div>
|
||||||
|
<div id="modal-last-price" class="font-medium text-right">—</div>
|
||||||
|
<div class="text-gray-600">Медиана (всё время):</div>
|
||||||
|
<div id="modal-median-all" class="font-medium text-right">—</div>
|
||||||
|
<div class="text-gray-600">Текущая цена:</div>
|
||||||
|
<div id="modal-current-price" class="font-medium text-right">—</div>
|
||||||
|
<div class="text-gray-600 font-medium text-blue-600">Новая цена:</div>
|
||||||
|
<div id="modal-new-price" class="font-bold text-right text-blue-600">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 pt-2 border-t">
|
||||||
|
Кол-во котировок: <span id="modal-quote-count">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 p-4 border-t">
|
||||||
|
<button onclick="closeModal()" class="px-4 py-2 border rounded hover:bg-gray-50">Отмена</button>
|
||||||
|
<button onclick="savePrice()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentTab = 'alerts';
|
||||||
|
let currentPage = 1;
|
||||||
|
let totalPages = 1;
|
||||||
|
let perPage = 50;
|
||||||
|
let searchTimeout = null;
|
||||||
|
let currentSearch = '';
|
||||||
|
let componentsCache = [];
|
||||||
|
let sortField = 'popularity_score';
|
||||||
|
let sortDir = 'desc';
|
||||||
|
|
||||||
|
async function loadTab(tab) {
|
||||||
|
currentTab = tab;
|
||||||
|
currentPage = 1;
|
||||||
|
currentSearch = '';
|
||||||
|
document.getElementById('search-input').value = '';
|
||||||
|
|
||||||
|
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||||
|
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||||
|
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
|
||||||
|
|
||||||
|
// Show/hide elements based on tab
|
||||||
|
if (tab === 'components') {
|
||||||
|
document.getElementById('search-bar').className = 'mb-4';
|
||||||
|
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
|
||||||
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
|
||||||
|
} else if (tab === 'all-configs') {
|
||||||
|
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
|
||||||
|
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
|
||||||
|
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
|
||||||
|
} else {
|
||||||
|
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||||
|
document.getElementById('pagination').className = 'hidden';
|
||||||
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentTab === 'alerts') {
|
||||||
|
const resp = await fetch('/admin/pricing/alerts?per_page=100', {
|
||||||
|
headers: {'Authorization': 'Bearer ' + token}
|
||||||
|
});
|
||||||
|
if (resp.status === 401) { logout(); return; }
|
||||||
|
if (resp.status === 403) { window.location.href = '/'; return; }
|
||||||
|
const data = await resp.json();
|
||||||
|
renderAlerts(data.alerts || []);
|
||||||
|
} else if (currentTab === 'all-configs') {
|
||||||
|
// Load all configurations for all users
|
||||||
|
let url = '/api/configs?page=' + currentPage + '&per_page=' + perPage;
|
||||||
|
if (currentSearch) {
|
||||||
|
url += '&search=' + encodeURIComponent(currentSearch);
|
||||||
|
}
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: {'Authorization': 'Bearer ' + token}
|
||||||
|
});
|
||||||
|
if (resp.status === 401) { logout(); return; }
|
||||||
|
if (resp.status === 403) { window.location.href = '/'; return; }
|
||||||
|
const data = await resp.json();
|
||||||
|
totalPages = Math.ceil(data.total / perPage);
|
||||||
|
renderAllConfigs(data.configurations || []);
|
||||||
|
updatePagination(data.total);
|
||||||
|
} else {
|
||||||
|
let url = '/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
|
||||||
|
if (currentSearch) {
|
||||||
|
url += '&search=' + encodeURIComponent(currentSearch);
|
||||||
|
}
|
||||||
|
if (sortField) {
|
||||||
|
url += '&sort=' + encodeURIComponent(sortField);
|
||||||
|
}
|
||||||
|
if (sortDir) {
|
||||||
|
url += '&dir=' + encodeURIComponent(sortDir);
|
||||||
|
}
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: {'Authorization': 'Bearer ' + token}
|
||||||
|
});
|
||||||
|
if (resp.status === 401) { logout(); return; }
|
||||||
|
const data = await resp.json();
|
||||||
|
totalPages = Math.ceil(data.total / perPage);
|
||||||
|
componentsCache = data.components || [];
|
||||||
|
renderComponents(componentsCache, data.total);
|
||||||
|
updatePagination(data.total);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-red-600">Ошибка загрузки</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function debounceSearch() {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
currentSearch = document.getElementById('search-input').value;
|
||||||
|
currentPage = 1;
|
||||||
|
loadData();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
currentPage--;
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
currentPage++;
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagination(total) {
|
||||||
|
document.getElementById('page-info').textContent =
|
||||||
|
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
|
||||||
|
document.getElementById('btn-prev').disabled = currentPage <= 1;
|
||||||
|
document.getElementById('btn-next').disabled = currentPage >= totalPages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlerts(alerts) {
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-green-600">Нет активных алертов</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="space-y-2">';
|
||||||
|
alerts.forEach(a => {
|
||||||
|
const colors = {critical: 'bg-red-100', high: 'bg-orange-100', medium: 'bg-yellow-100', low: 'bg-blue-100'};
|
||||||
|
html += '<div class="' + (colors[a.severity] || 'bg-gray-100') + ' p-3 rounded">';
|
||||||
|
html += '<div class="flex justify-between"><span class="font-medium">' + escapeHtml(a.lot_name) + '</span>';
|
||||||
|
html += '<span class="text-xs uppercase">' + a.severity + '</span></div>';
|
||||||
|
html += '<p class="text-sm text-gray-600">' + escapeHtml(a.message) + '</p></div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
document.getElementById('tab-content').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComponents(components, total) {
|
||||||
|
if (components.length === 0) {
|
||||||
|
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет данных</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="overflow-x-auto"><table class="w-full"><thead class="bg-gray-50"><tr>';
|
||||||
|
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Популярность</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во котировок</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Цена</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th>';
|
||||||
|
html += '</tr></thead><tbody class="divide-y">';
|
||||||
|
|
||||||
|
components.forEach((c, idx) => {
|
||||||
|
const price = c.current_price ? '$' + parseFloat(c.current_price).toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
|
||||||
|
const category = c.category ? c.category.code : '—';
|
||||||
|
const desc = c.lot && c.lot.lot_description ? c.lot.lot_description : '—';
|
||||||
|
const quoteCount = c.quote_count || 0;
|
||||||
|
const popularity = c.popularity_score ? c.popularity_score.toFixed(2) : '0.00';
|
||||||
|
const isHidden = c.is_hidden || quoteCount === 0;
|
||||||
|
const usedInMeta = c.used_in_meta && c.used_in_meta.length > 0;
|
||||||
|
|
||||||
|
// Determine status indicator (colored dot)
|
||||||
|
let dotColor, dotTitle;
|
||||||
|
if (usedInMeta) {
|
||||||
|
// Used as source for meta-articles - cyan
|
||||||
|
dotColor = 'bg-cyan-500';
|
||||||
|
dotTitle = 'Используется в мета: ' + c.used_in_meta.join(', ');
|
||||||
|
} else if (!isHidden) {
|
||||||
|
// Available in configurator - green
|
||||||
|
dotColor = 'bg-green-500';
|
||||||
|
dotTitle = 'Доступен в конфигураторе';
|
||||||
|
} else {
|
||||||
|
// Hidden and not used - gray
|
||||||
|
dotColor = 'bg-gray-400';
|
||||||
|
dotTitle = 'Скрыт из конфигуратора';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build settings summary
|
||||||
|
let settingsHtml = '';
|
||||||
|
|
||||||
|
if (isHidden) {
|
||||||
|
settingsHtml = '<span class="text-red-600 font-medium">СКРЫТ</span>';
|
||||||
|
} else {
|
||||||
|
let settings = [];
|
||||||
|
const method = c.price_method || 'median';
|
||||||
|
const hasManualPrice = c.manual_price && c.manual_price > 0;
|
||||||
|
const hasMeta = c.meta_prices && c.meta_prices.trim() !== '';
|
||||||
|
|
||||||
|
// Method indicator
|
||||||
|
if (hasManualPrice) {
|
||||||
|
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
|
||||||
|
} else if (method === 'average') {
|
||||||
|
settings.push('Сред');
|
||||||
|
} else {
|
||||||
|
settings.push('Мед');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Period (only if not manual price)
|
||||||
|
if (!hasManualPrice) {
|
||||||
|
const period = c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90;
|
||||||
|
if (period === 7) settings.push('1н');
|
||||||
|
else if (period === 30) settings.push('1м');
|
||||||
|
else if (period === 90) settings.push('3м');
|
||||||
|
else if (period === 365) settings.push('1г');
|
||||||
|
else if (period === 0) settings.push('все');
|
||||||
|
else settings.push(period + 'д');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coefficient
|
||||||
|
if (c.price_coefficient && c.price_coefficient !== 0) {
|
||||||
|
settings.push((c.price_coefficient > 0 ? '+' : '') + c.price_coefficient + '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta article indicator
|
||||||
|
if (hasMeta) {
|
||||||
|
settings.push('<span class="text-purple-600 font-medium">МЕТА</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsHtml = settings.join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + idx + ')">';
|
||||||
|
html += '<td class="px-3 py-2 text-sm font-mono"><span class="inline-flex items-center gap-2"><span class="w-2.5 h-2.5 rounded-full ' + dotColor + ' flex-shrink-0" title="' + escapeHtml(dotTitle) + '"></span>' + escapeHtml(c.lot_name) + '</span></td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm">' + escapeHtml(category) + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate">' + escapeHtml(desc) + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-right">' + popularity + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-right">' + quoteCount + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-right font-medium">' + price + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">' + settingsHtml + '</span></td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
document.getElementById('tab-content').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal functions
|
||||||
|
function openModal(idx) {
|
||||||
|
const c = componentsCache[idx];
|
||||||
|
if (!c) return;
|
||||||
|
|
||||||
|
document.getElementById('modal-lot-name').value = c.lot_name;
|
||||||
|
document.getElementById('modal-coefficient').value = c.price_coefficient || 0;
|
||||||
|
|
||||||
|
const hasManual = c.manual_price && c.manual_price > 0;
|
||||||
|
if (hasManual) {
|
||||||
|
document.getElementById('modal-method').value = 'manual';
|
||||||
|
document.getElementById('modal-manual-price').value = c.manual_price;
|
||||||
|
document.getElementById('manual-price-field').classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
document.getElementById('modal-method').value = c.price_method || 'median';
|
||||||
|
document.getElementById('modal-manual-price').value = '';
|
||||||
|
document.getElementById('manual-price-field').classList.add('hidden');
|
||||||
|
}
|
||||||
|
document.getElementById('modal-period').value = String(c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90);
|
||||||
|
|
||||||
|
// Load meta prices settings
|
||||||
|
const hasMeta = c.meta_prices && c.meta_prices.trim() !== '';
|
||||||
|
document.getElementById('modal-meta-enabled').checked = hasMeta;
|
||||||
|
document.getElementById('modal-meta-prices').value = c.meta_prices || '';
|
||||||
|
if (hasMeta) {
|
||||||
|
document.getElementById('meta-price-fields').classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
document.getElementById('meta-price-fields').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load hidden flag
|
||||||
|
const quoteCount = c.quote_count || 0;
|
||||||
|
const hiddenCheckbox = document.getElementById('modal-hidden');
|
||||||
|
if (quoteCount === 0) {
|
||||||
|
// Если нет котировок - чекбокс установлен и заблокирован
|
||||||
|
hiddenCheckbox.checked = true;
|
||||||
|
hiddenCheckbox.disabled = true;
|
||||||
|
} else {
|
||||||
|
hiddenCheckbox.checked = c.is_hidden || false;
|
||||||
|
hiddenCheckbox.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset price displays while loading
|
||||||
|
document.getElementById('modal-last-price').textContent = '...';
|
||||||
|
document.getElementById('modal-median-all').textContent = '...';
|
||||||
|
document.getElementById('modal-current-price').textContent = '...';
|
||||||
|
document.getElementById('modal-new-price').textContent = '...';
|
||||||
|
document.getElementById('modal-quote-count').textContent = '...';
|
||||||
|
|
||||||
|
document.getElementById('price-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('price-modal').classList.add('flex');
|
||||||
|
|
||||||
|
// Fetch price preview
|
||||||
|
fetchPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMethodChange() {
|
||||||
|
const method = document.getElementById('modal-method').value;
|
||||||
|
const manualField = document.getElementById('manual-price-field');
|
||||||
|
if (method === 'manual') {
|
||||||
|
manualField.classList.remove('hidden');
|
||||||
|
// При выборе "Установить цену вручную" снимаем галочку "Мета артикул"
|
||||||
|
document.getElementById('modal-meta-enabled').checked = false;
|
||||||
|
document.getElementById('meta-price-fields').classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
manualField.classList.add('hidden');
|
||||||
|
}
|
||||||
|
fetchPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPreview() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const lotName = document.getElementById('modal-lot-name').value;
|
||||||
|
const method = document.getElementById('modal-method').value;
|
||||||
|
const periodDays = parseInt(document.getElementById('modal-period').value) || 0;
|
||||||
|
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
|
||||||
|
const metaEnabled = document.getElementById('modal-meta-enabled').checked;
|
||||||
|
let metaPrices = '';
|
||||||
|
let metaMethod = '';
|
||||||
|
let metaPeriod = 0;
|
||||||
|
|
||||||
|
if (metaEnabled) {
|
||||||
|
metaPrices = document.getElementById('modal-meta-prices').value.trim();
|
||||||
|
metaMethod = method;
|
||||||
|
metaPeriod = periodDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/pricing/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
lot_name: lotName,
|
||||||
|
method: method,
|
||||||
|
period_days: periodDays,
|
||||||
|
coefficient: coefficient,
|
||||||
|
meta_enabled: metaEnabled,
|
||||||
|
meta_prices: metaPrices,
|
||||||
|
meta_method: metaMethod,
|
||||||
|
meta_period: metaPeriod
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401) { logout(); return; }
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
// Update last price with date
|
||||||
|
if (data.last_price) {
|
||||||
|
let lastPriceText = '$' + parseFloat(data.last_price).toFixed(2);
|
||||||
|
if (data.last_price_date) {
|
||||||
|
const date = new Date(data.last_price_date);
|
||||||
|
lastPriceText += ' (' + date.toLocaleDateString('ru-RU') + ')';
|
||||||
|
}
|
||||||
|
document.getElementById('modal-last-price').textContent = lastPriceText;
|
||||||
|
} else {
|
||||||
|
document.getElementById('modal-last-price').textContent = '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update median all time
|
||||||
|
document.getElementById('modal-median-all').textContent =
|
||||||
|
data.median_all_time ? '$' + parseFloat(data.median_all_time).toFixed(2) : '—';
|
||||||
|
|
||||||
|
// Update current price
|
||||||
|
document.getElementById('modal-current-price').textContent =
|
||||||
|
data.current_price ? '$' + parseFloat(data.current_price).toFixed(2) : '—';
|
||||||
|
|
||||||
|
// Update new calculated price
|
||||||
|
document.getElementById('modal-new-price').textContent =
|
||||||
|
data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—';
|
||||||
|
|
||||||
|
// Update quote count
|
||||||
|
document.getElementById('modal-quote-count').textContent = data.quote_count || 0;
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Preview fetch error:', e);
|
||||||
|
document.getElementById('modal-last-price').textContent = '—';
|
||||||
|
document.getElementById('modal-median-all').textContent = '—';
|
||||||
|
document.getElementById('modal-current-price').textContent = '—';
|
||||||
|
document.getElementById('modal-new-price').textContent = '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('price-modal').classList.add('hidden');
|
||||||
|
document.getElementById('price-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMetaPrice() {
|
||||||
|
const enabled = document.getElementById('modal-meta-enabled').checked;
|
||||||
|
const fields = document.getElementById('meta-price-fields');
|
||||||
|
fields.classList.toggle('hidden', !enabled);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// When enabling meta price, reset method to median if it was manual
|
||||||
|
const method = document.getElementById('modal-method').value;
|
||||||
|
if (method === 'manual') {
|
||||||
|
document.getElementById('modal-method').value = 'median';
|
||||||
|
document.getElementById('manual-price-field').classList.add('hidden');
|
||||||
|
document.getElementById('modal-manual-price').value = '';
|
||||||
|
}
|
||||||
|
// Auto-fill with wildcard pattern
|
||||||
|
const lotName = document.getElementById('modal-lot-name').value;
|
||||||
|
if (lotName) {
|
||||||
|
autoFillMetaPrices(lotName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce helper for preview updates
|
||||||
|
let previewTimeout = null;
|
||||||
|
function debounceFetchPreview() {
|
||||||
|
clearTimeout(previewTimeout);
|
||||||
|
previewTimeout = setTimeout(fetchPreview, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePrice() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lotName = document.getElementById('modal-lot-name').value;
|
||||||
|
const method = document.getElementById('modal-method').value;
|
||||||
|
const periodDaysStr = document.getElementById('modal-period').value;
|
||||||
|
const periodDays = periodDaysStr !== '' ? parseInt(periodDaysStr) : 90;
|
||||||
|
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
|
||||||
|
const manualEnabled = method === 'manual';
|
||||||
|
const manualPrice = manualEnabled ? parseFloat(document.getElementById('modal-manual-price').value) : null;
|
||||||
|
const metaEnabled = document.getElementById('modal-meta-enabled').checked;
|
||||||
|
const hiddenCheckbox = document.getElementById('modal-hidden');
|
||||||
|
// Если чекбокс заблокирован (нет котировок), всегда true
|
||||||
|
const isHidden = hiddenCheckbox.disabled ? true : hiddenCheckbox.checked;
|
||||||
|
|
||||||
|
let metaPrices = '';
|
||||||
|
let metaMethod = '';
|
||||||
|
let metaPeriod = 0;
|
||||||
|
|
||||||
|
if (metaEnabled) {
|
||||||
|
metaPrices = document.getElementById('modal-meta-prices').value.trim();
|
||||||
|
metaMethod = manualEnabled ? 'median' : method;
|
||||||
|
metaPeriod = periodDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
lot_name: lotName,
|
||||||
|
method: manualEnabled ? 'median' : method,
|
||||||
|
period_days: periodDays,
|
||||||
|
coefficient: coefficient,
|
||||||
|
clear_manual: !manualEnabled,
|
||||||
|
meta_enabled: metaEnabled,
|
||||||
|
meta_prices: metaPrices,
|
||||||
|
meta_method: metaMethod,
|
||||||
|
meta_period: metaPeriod,
|
||||||
|
is_hidden: isHidden
|
||||||
|
};
|
||||||
|
|
||||||
|
if (manualEnabled && manualPrice > 0) {
|
||||||
|
body.manual_price = manualPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/pricing/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401) { logout(); return; }
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
closeModal();
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
const data = await resp.json();
|
||||||
|
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Ошибка соединения');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to process meta prices and handle regex patterns
|
||||||
|
function processMetaPrices(metaPrices, originalLotName) {
|
||||||
|
if (!metaPrices) return [];
|
||||||
|
|
||||||
|
// Split by comma and clean up
|
||||||
|
let lots = metaPrices.split(',').map(lot => lot.trim()).filter(lot => lot.length > 0);
|
||||||
|
|
||||||
|
// Handle wildcard patterns (ending with *)
|
||||||
|
const processedLots = [];
|
||||||
|
const originalPrefix = originalLotName.split('_')[0] + '_'; // Get first part like "CPU_" from "CPU_AMD_9654"
|
||||||
|
|
||||||
|
lots.forEach(lot => {
|
||||||
|
if (lot.endsWith('*')) {
|
||||||
|
// Wildcard pattern - find all components that start with the prefix
|
||||||
|
const prefix = lot.slice(0, -1); // Remove the *
|
||||||
|
// In real implementation, this would be handled by backend
|
||||||
|
// For now, we'll just add the prefix as is to indicate it's a pattern
|
||||||
|
processedLots.push(prefix + '*');
|
||||||
|
} else {
|
||||||
|
// Regular component name
|
||||||
|
processedLots.push(lot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove duplicates and original lot name
|
||||||
|
const uniqueLots = [...new Set(processedLots)];
|
||||||
|
return uniqueLots.filter(lot => lot !== originalLotName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalculateAll() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('btn-recalc');
|
||||||
|
const progressContainer = document.getElementById('progress-container');
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
const progressText = document.getElementById('progress-text');
|
||||||
|
const progressPercent = document.getElementById('progress-percent');
|
||||||
|
const progressStats = document.getElementById('progress-stats');
|
||||||
|
|
||||||
|
// Show progress bar IMMEDIATELY
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Пересчёт...';
|
||||||
|
progressContainer.style.display = 'block';
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
|
||||||
|
progressText.textContent = 'Запуск пересчёта...';
|
||||||
|
progressPercent.textContent = '0%';
|
||||||
|
progressStats.textContent = 'Подготовка...';
|
||||||
|
|
||||||
|
// Use fetch with streaming for SSE
|
||||||
|
fetch('/admin/pricing/recalculate-all', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Authorization': 'Bearer ' + token}
|
||||||
|
}).then(response => {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
reader.read().then(({done, value}) => {
|
||||||
|
if (done) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Пересчитать цены';
|
||||||
|
progressText.textContent = 'Готово!';
|
||||||
|
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
||||||
|
setTimeout(() => {
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
if (currentTab === 'components') {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = decoder.decode(value);
|
||||||
|
const lines = text.split('\n');
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.substring(5).trim());
|
||||||
|
const percent = data.total > 0 ? Math.round((data.current / data.total) * 100) : 0;
|
||||||
|
|
||||||
|
progressBar.style.width = percent + '%';
|
||||||
|
progressPercent.textContent = percent + '%';
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
progressText.textContent = 'Пересчёт завершён!';
|
||||||
|
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
||||||
|
} else {
|
||||||
|
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
|
||||||
|
}
|
||||||
|
|
||||||
|
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Без изменений: ' + (data.unchanged || 0) + ' | Ручные: ' + (data.manual || 0) + ' | Нет данных: ' + (data.skipped || 0) + ' | Ошибок: ' + (data.errors || 0);
|
||||||
|
} catch(e) {
|
||||||
|
console.log('Parse error:', e, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
read();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
read();
|
||||||
|
}).catch(e => {
|
||||||
|
console.error('Fetch error:', e);
|
||||||
|
alert('Ошибка соединения');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Пересчитать цены';
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on click outside
|
||||||
|
document.getElementById('price-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function changeSort() {
|
||||||
|
sortField = document.getElementById('sort-field').value;
|
||||||
|
currentPage = 1;
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSortDir() {
|
||||||
|
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
document.getElementById('sort-dir-btn').textContent = sortDir === 'asc' ? '↑' : '↓';
|
||||||
|
currentPage = 1;
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render all configurations for admin view
|
||||||
|
function renderAllConfigs(configs) {
|
||||||
|
if (configs.length === 0) {
|
||||||
|
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет конфигураций</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="overflow-x-auto"><table class="w-full"><thead class="bg-gray-50"><tr>';
|
||||||
|
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Пользователь</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Серверы</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||||
|
html += '</tr></thead><tbody class="divide-y">';
|
||||||
|
|
||||||
|
configs.forEach(c => {
|
||||||
|
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
||||||
|
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
|
||||||
|
const serverCount = c.server_count ? c.server_count : 1;
|
||||||
|
const username = c.user ? c.user.username : '—';
|
||||||
|
|
||||||
|
html += '<tr class="hover:bg-gray-50">';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm font-medium">' + escapeHtml(c.name) + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-gray-500">' + username + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-gray-500">' + serverCount + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-right">' + total + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-right space-x-2">';
|
||||||
|
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||||
|
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>';
|
||||||
|
html += '</svg>';
|
||||||
|
html += '</button>';
|
||||||
|
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||||
|
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>';
|
||||||
|
html += '</svg>';
|
||||||
|
html += '</button>';
|
||||||
|
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="Удалить">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||||
|
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>';
|
||||||
|
html += '</svg>';
|
||||||
|
html += '</button>';
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
document.getElementById('tab-content').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadTab('alerts');
|
||||||
|
|
||||||
|
// Add event listeners for preview updates
|
||||||
|
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
||||||
|
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
|
||||||
|
document.getElementById('modal-manual-price').addEventListener('input', debounceFetchPreview);
|
||||||
|
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
113
web/templates/base.html
Normal file
113
web/templates/base.html
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{{define "base"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{template "title" .}}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
<style>
|
||||||
|
.htmx-request { opacity: 0.5; }
|
||||||
|
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen">
|
||||||
|
<nav class="bg-white shadow-sm sticky top-0 z-40">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-14">
|
||||||
|
<div class="flex items-center space-x-8">
|
||||||
|
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
|
||||||
|
<div class="hidden md:flex space-x-4">
|
||||||
|
<a href="/configs" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm" id="nav-configs" style="display:none;">Мои конфигурации</a>
|
||||||
|
<a href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm" id="nav-admin" style="display:none;">Цены</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div id="user-logged-out">
|
||||||
|
<a href="/login" class="text-blue-600 hover:text-blue-800 text-sm">Войти</a>
|
||||||
|
</div>
|
||||||
|
<div id="user-logged-in" class="hidden">
|
||||||
|
<span id="user-name" class="text-sm text-gray-700 mr-3"></span>
|
||||||
|
<button onclick="logout()" class="text-red-600 hover:text-red-800 text-sm">Выйти</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 pb-12">
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
||||||
|
|
||||||
|
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
|
||||||
|
<div class="max-w-7xl mx-auto flex justify-between">
|
||||||
|
<span id="db-status">БД: проверка...</span>
|
||||||
|
<span id="db-counts"></span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function initAuth() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const user = localStorage.getItem('user');
|
||||||
|
|
||||||
|
if (token && user) {
|
||||||
|
try {
|
||||||
|
const userData = JSON.parse(user);
|
||||||
|
document.getElementById('user-logged-out').classList.add('hidden');
|
||||||
|
document.getElementById('user-logged-in').classList.remove('hidden');
|
||||||
|
document.getElementById('user-name').textContent = userData.username;
|
||||||
|
document.getElementById('nav-configs').style.display = 'block';
|
||||||
|
if (userData.role === 'admin' || userData.role === 'pricing_admin') {
|
||||||
|
document.getElementById('nav-admin').style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, type) {
|
||||||
|
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
|
||||||
|
const el = document.getElementById('toast');
|
||||||
|
el.innerHTML = '<div class="' + (colors[type] || colors.info) + ' text-white px-4 py-2 rounded shadow">' + msg + '</div>';
|
||||||
|
setTimeout(() => el.innerHTML = '', 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkDbStatus() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/db-status');
|
||||||
|
const data = await resp.json();
|
||||||
|
const statusEl = document.getElementById('db-status');
|
||||||
|
const countsEl = document.getElementById('db-counts');
|
||||||
|
|
||||||
|
if (data.connected) {
|
||||||
|
statusEl.innerHTML = '<span class="text-green-400">БД: подключено</span>';
|
||||||
|
} else {
|
||||||
|
statusEl.innerHTML = '<span class="text-red-400">БД: ошибка - ' + data.error + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
countsEl.textContent = 'lot: ' + data.lot_count + ' | lot_log: ' + data.lot_log_count + ' | metadata: ' + data.metadata_count;
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('db-status').innerHTML = '<span class="text-red-400">БД: нет связи</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initAuth();
|
||||||
|
checkDbStatus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
52
web/templates/components_list.html
Normal file
52
web/templates/components_list.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{{define "components_list.html"}}
|
||||||
|
{{if .Components}}
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-24"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y">
|
||||||
|
{{range .Components}}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 text-sm font-medium font-mono">{{.LotName}}</td>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<span class="px-2 py-1 text-xs rounded bg-gray-100">{{.Category}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500 max-w-md truncate">{{.Description}}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-right font-medium">
|
||||||
|
{{if .CurrentPrice}}
|
||||||
|
${{printf "%.2f" (deref .CurrentPrice)}}
|
||||||
|
{{else}}
|
||||||
|
<span class="text-gray-400">—</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
{{if .CurrentPrice}}
|
||||||
|
<button onclick="addToCart('{{jsesc .LotName}}', {{deref .CurrentPrice}}, '{{jsesc .Description}}')"
|
||||||
|
class="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700">
|
||||||
|
+ Добавить
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<span class="text-gray-400 text-xs">Нет цены</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-gray-500 mt-4">Найдено: {{.Total}}</p>
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||||
|
Компоненты не найдены
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
491
web/templates/configs.html
Normal file
491
web/templates/configs.html
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
{{define "title"}}Мои конфигурации - QuoteForge{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button onclick="openCreateModal()" class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||||
|
+ Создать новую конфигурацию
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="configs-list">
|
||||||
|
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div id="pagination" class="flex justify-between items-center mt-4 pt-4 border-t hidden">
|
||||||
|
<span id="page-info" class="text-sm text-gray-600"></span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="prevPage()" id="btn-prev" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Назад</button>
|
||||||
|
<button onclick="nextPage()" id="btn-next" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Вперед</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for creating new configuration -->
|
||||||
|
<div id="create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Новая конфигурация</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Номер Opportunity</label>
|
||||||
|
<input type="text" id="opportunity-number" placeholder="Например: OPP-2024-001"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
|
<button onclick="closeCreateModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button onclick="createConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for renaming configuration -->
|
||||||
|
<div id="rename-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Переименовать конфигурацию</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Новое название</label>
|
||||||
|
<input type="text" id="rename-input" placeholder="Введите новое название"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<input type="hidden" id="rename-uuid">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
|
<button onclick="closeRenameModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button onclick="renameConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for cloning configuration -->
|
||||||
|
<div id="clone-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Копировать конфигурацию</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Название копии</label>
|
||||||
|
<input type="text" id="clone-input" placeholder="Введите название"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<input type="hidden" id="clone-uuid">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
|
<button onclick="closeCloneModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button onclick="cloneConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||||
|
Копировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function loadConfigs() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
document.getElementById('configs-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center"><a href="/login" class="text-blue-600">Войдите для просмотра</a></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs', {
|
||||||
|
headers: {'Authorization': 'Bearer ' + token}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401) {
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.status === 403) {
|
||||||
|
document.getElementById('configs-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Нет доступа</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
renderConfigs(data.configurations || []);
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('configs-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConfigs(configs) {
|
||||||
|
if (configs.length === 0) {
|
||||||
|
document.getElementById('configs-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет сохраненных конфигураций</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||||||
|
html += '<thead class="bg-gray-50"><tr>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||||
|
html += '</tr></thead><tbody class="divide-y">';
|
||||||
|
|
||||||
|
configs.forEach(c => {
|
||||||
|
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
||||||
|
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
|
||||||
|
const serverCount = c.server_count ? c.server_count : 1;
|
||||||
|
|
||||||
|
// Calculate price per unit (total / server count)
|
||||||
|
let pricePerUnit = '—';
|
||||||
|
if (c.total_price && serverCount > 0) {
|
||||||
|
const unitPrice = c.total_price / serverCount;
|
||||||
|
pricePerUnit = '$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<tr class="hover:bg-gray-50">';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||||
|
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||||
|
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>';
|
||||||
|
html += '</svg>';
|
||||||
|
html += '</button>';
|
||||||
|
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||||
|
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>';
|
||||||
|
html += '</svg>';
|
||||||
|
html += '</button>';
|
||||||
|
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="Удалить">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||||
|
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>';
|
||||||
|
html += '</svg>';
|
||||||
|
html += '</button>';
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
document.getElementById('configs-list').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteConfig(uuid) {
|
||||||
|
if (!confirm('Удалить?')) return;
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
await fetch('/api/configs/' + uuid, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {'Authorization': 'Bearer ' + token}
|
||||||
|
});
|
||||||
|
loadConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRenameModal(uuid, currentName) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('rename-uuid').value = uuid;
|
||||||
|
document.getElementById('rename-input').value = currentName;
|
||||||
|
document.getElementById('rename-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('rename-modal').classList.add('flex');
|
||||||
|
document.getElementById('rename-input').focus();
|
||||||
|
document.getElementById('rename-input').select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRenameModal() {
|
||||||
|
document.getElementById('rename-modal').classList.add('hidden');
|
||||||
|
document.getElementById('rename-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameConfig() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const uuid = document.getElementById('rename-uuid').value;
|
||||||
|
const name = document.getElementById('rename-input').value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
alert('Введите название');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/' + uuid + '/rename', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401) {
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
alert('Ошибка: ' + (err.error || 'Не удалось переименовать'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeRenameModal();
|
||||||
|
loadConfigs();
|
||||||
|
} catch(e) {
|
||||||
|
alert('Ошибка переименования');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCloneModal(uuid, currentName) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('clone-uuid').value = uuid;
|
||||||
|
document.getElementById('clone-input').value = currentName + ' (копия)';
|
||||||
|
document.getElementById('clone-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('clone-modal').classList.add('flex');
|
||||||
|
document.getElementById('clone-input').focus();
|
||||||
|
document.getElementById('clone-input').select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCloneModal() {
|
||||||
|
document.getElementById('clone-modal').classList.add('hidden');
|
||||||
|
document.getElementById('clone-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneConfig() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const uuid = document.getElementById('clone-uuid').value;
|
||||||
|
const name = document.getElementById('clone-input').value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
alert('Введите название');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/' + uuid + '/clone', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401) {
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
alert('Ошибка: ' + (err.error || 'Не удалось скопировать'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCloneModal();
|
||||||
|
loadConfigs();
|
||||||
|
} catch(e) {
|
||||||
|
alert('Ошибка копирования');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('opportunity-number').value = '';
|
||||||
|
document.getElementById('create-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('create-modal').classList.add('flex');
|
||||||
|
document.getElementById('opportunity-number').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
document.getElementById('create-modal').classList.add('hidden');
|
||||||
|
document.getElementById('create-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createConfig() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const name = document.getElementById('opportunity-number').value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
alert('Введите номер Opportunity');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name,
|
||||||
|
items: [],
|
||||||
|
notes: '',
|
||||||
|
server_count: 1
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401) {
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
alert('Ошибка: ' + (err.error || 'Не удалось создать'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await resp.json();
|
||||||
|
window.location.href = '/configurator?uuid=' + config.uuid;
|
||||||
|
} catch(e) {
|
||||||
|
alert('Ошибка создания конфигурации');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on outside click
|
||||||
|
document.getElementById('create-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeCreateModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('rename-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeRenameModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('clone-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeCloneModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on Escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeCreateModal();
|
||||||
|
closeRenameModal();
|
||||||
|
closeCloneModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit rename on Enter key
|
||||||
|
document.getElementById('rename-input').addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
renameConfig();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit clone on Enter key
|
||||||
|
document.getElementById('clone-input').addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
cloneConfig();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination functions
|
||||||
|
let currentPage = 1;
|
||||||
|
let totalPages = 1;
|
||||||
|
let perPage = 20;
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
currentPage--;
|
||||||
|
loadConfigs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
currentPage++;
|
||||||
|
loadConfigs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagination(total) {
|
||||||
|
totalPages = Math.ceil(total / perPage);
|
||||||
|
document.getElementById('page-info').textContent =
|
||||||
|
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
|
||||||
|
document.getElementById('btn-prev').disabled = currentPage <= 1;
|
||||||
|
document.getElementById('btn-next').disabled = currentPage >= totalPages;
|
||||||
|
document.getElementById('pagination').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load configs with pagination
|
||||||
|
async function loadConfigs() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
document.getElementById('configs-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center"><a href="/login" class="text-blue-600">Войдите для просмотра</a></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage, {
|
||||||
|
headers: {'Authorization': 'Bearer ' + token}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401) {
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.status === 403) {
|
||||||
|
document.getElementById('configs-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Нет доступа</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
renderConfigs(data.configurations || []);
|
||||||
|
updatePagination(data.total);
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('configs-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadConfigs);
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
1304
web/templates/index.html
Normal file
1304
web/templates/index.html
Normal file
File diff suppressed because it is too large
Load Diff
82
web/templates/login.html
Normal file
82
web/templates/login.html
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{{define "title"}}Вход - QuoteForge{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="max-w-sm mx-auto mt-16">
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<h1 class="text-xl font-bold text-center mb-6">Вход в систему</h1>
|
||||||
|
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Логин</label>
|
||||||
|
<input type="text" name="username" id="username" required
|
||||||
|
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value="admin">
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||||
|
<input type="password" name="password" id="password" required
|
||||||
|
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value="admin123">
|
||||||
|
</div>
|
||||||
|
<div id="error" class="text-red-600 text-sm mb-4 hidden"></div>
|
||||||
|
<button type="submit" id="submit-btn"
|
||||||
|
class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700">
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-gray-500 mt-4">
|
||||||
|
<a href="/" class="text-blue-600">← На главную</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('login-form');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const errorEl = document.getElementById('error');
|
||||||
|
const btn = document.getElementById('submit-btn');
|
||||||
|
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Вход...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({username, password})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (resp.ok && data.access_token) {
|
||||||
|
localStorage.setItem('token', data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
window.location.href = '/configs';
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = data.error || 'Неверный логин или пароль';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Войти';
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
errorEl.textContent = 'Ошибка соединения с сервером';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Войти';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
Reference in New Issue
Block a user