Compare commits
168 Commits
44ccb01203
...
v1.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06397a6bd1 | ||
|
|
4e977737ee | ||
|
|
7c3752f110 | ||
|
|
08ecfd0826 | ||
|
|
42458455f7 | ||
|
|
8663a87d28 | ||
| 2f0957ae4e | |||
| 65db9b37ea | |||
| ed0ef04d10 | |||
| 2e0faf4aec | |||
| 4b0879779a | |||
| 2b175a3d1e | |||
| 5732c75b85 | |||
| eb7c3739ce | |||
|
|
6e0335af7c | ||
|
|
a42a80beb8 | ||
|
|
586114c79c | ||
|
|
e9230c0e58 | ||
|
|
aa65fc8156 | ||
|
|
b22e961656 | ||
|
|
af83818564 | ||
|
|
8a138327a3 | ||
|
|
d1f65f6684 | ||
|
|
7b371add10 | ||
|
|
8d7fab39b4 | ||
|
|
1906a74759 | ||
| d0400b18a3 | |||
| d3f1a838eb | |||
| c6086ac03a | |||
| a127ebea82 | |||
| 347599e06b | |||
| 4a44d48366 | |||
| 23882637b5 | |||
| 5e56f386cc | |||
| e5b6902c9e | |||
|
|
3c46cd7bf0 | ||
|
|
7f8491d197 | ||
|
|
3fd7a2231a | ||
|
|
c295b60dd8 | ||
| cc9b846c31 | |||
| 87cb12906d | |||
| 075fc709dd | |||
| cbaeafa9c8 | |||
| 71f73e2f1d | |||
| 2e973b6d78 | |||
| 8508ee2921 | |||
| b153afbf51 | |||
|
|
9b5d57902d | ||
|
|
4e1a46bd71 | ||
|
|
857ec7a0e5 | ||
|
|
01f21fa5ac | ||
|
|
a1edca3be9 | ||
|
|
7fbf813952 | ||
|
|
e58fd35ee4 | ||
|
|
e3559035f7 | ||
|
|
5edffe822b | ||
|
|
99fd80bca7 | ||
|
|
d8edd5d5f0 | ||
|
|
9cb17ee03f | ||
|
|
8f596cec68 | ||
|
|
8fd27d11a7 | ||
|
|
600f842b82 | ||
|
|
acf7c8a4da | ||
|
|
5984a57a8b | ||
|
|
84dda8cf0a | ||
|
|
abeb26d82d | ||
|
|
29edd73744 | ||
|
|
e8d0e28415 | ||
|
|
08feda9af6 | ||
|
|
af79b6f3bf | ||
|
|
bca82f9dc0 | ||
| 17969277e6 | |||
| 0dbfe45353 | |||
| f609d2ce35 | |||
| 593280de99 | |||
| eb8555c11a | |||
| 7523a7d887 | |||
| 95b5f8bf65 | |||
| b629af9742 | |||
| 72ff842f5d | |||
|
|
5f2969a85a | ||
|
|
eb8ac34d83 | ||
|
|
104a26d907 | ||
|
|
b965c6bb95 | ||
|
|
29035ddc5a | ||
|
|
2f0ac2f6d2 | ||
|
|
8a8ea10dc2 | ||
|
|
51e2d1fc83 | ||
|
|
3d5ab63970 | ||
|
|
c02a7eac73 | ||
|
|
651427e0dd | ||
|
|
f665e9b08c | ||
|
|
994eec53e7 | ||
|
|
2f3c20fea6 | ||
|
|
80ec7bc6b8 | ||
|
|
8e5c4f5a7c | ||
|
|
1744e6a3b8 | ||
|
|
726dccb07c | ||
|
|
38d7332a38 | ||
|
|
c0beed021c | ||
|
|
08b95c293c | ||
|
|
c418d6cfc3 | ||
|
|
548a256d04 | ||
|
|
77c00de97a | ||
|
|
0c190efda4 | ||
|
|
41c0a47f54 | ||
|
|
f4f92dea66 | ||
|
|
f42b850734 | ||
|
|
d094d39427 | ||
|
|
4509e93864 | ||
|
|
e2800b06f9 | ||
|
|
7c606af2bb | ||
|
|
fabd30650d | ||
|
|
40ade651b0 | ||
|
|
1b87c53609 | ||
| a3dc264efd | |||
| 20056f3593 | |||
|
|
8a37542929 | ||
|
|
0eb6730a55 | ||
|
|
e2d056e7cb | ||
|
|
1bce8086d6 | ||
|
|
0bdd163728 | ||
|
|
fa0f5e321d | ||
|
|
502832ac9a | ||
|
|
8d84484412 | ||
| 2510d9e36e | |||
| d7285fc730 | |||
| e33a3f2c88 | |||
| 4735e2b9bb | |||
| cdf5cef2cf | |||
| 7f030e7db7 | |||
| 3d222b7f14 | |||
| c024b96de7 | |||
| 2c75a7ccb8 | |||
|
|
f25477a25e | ||
|
|
0bde12a39d | ||
|
|
e0404186ad | ||
|
|
eda0e7cb47 | ||
|
|
693c1d05d7 | ||
|
|
7fb9dd0267 | ||
|
|
61646bea46 | ||
|
|
9495f929aa | ||
|
|
b80bde7dac | ||
|
|
e307a2765d | ||
|
|
6f1feb942a | ||
|
|
236e37376e | ||
|
|
ded6e09b5e | ||
|
|
96bbe0a510 | ||
|
|
b672cbf27d | ||
|
|
e206531364 | ||
|
|
9bd2acd4f7 | ||
| ec3c16f3fc | |||
| 1f739a3ab2 | |||
| be77256d4e | |||
| 143d217397 | |||
| 8b8d2f18f9 | |||
| 8c1c8ccace | |||
| f31ae69233 | |||
| 3132ab2fa2 | |||
| 73acc5410f | |||
| 68d0e9a540 | |||
| 8309a5dc0e | |||
|
|
48921c699d | ||
|
|
d32b1c5d0c | ||
|
|
db37040399 | ||
|
|
7ded78f2c3 | ||
|
|
d7d6e9d62c | ||
|
|
a93644131c |
5
.githooks/pre-commit
Executable file
5
.githooks/pre-commit
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
"$repo_root/scripts/check-secrets.sh"
|
||||
52
.gitignore
vendored
52
.gitignore
vendored
@@ -1,5 +1,51 @@
|
||||
# QuoteForge
|
||||
config.yaml
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
*.crt
|
||||
id_rsa
|
||||
id_rsa.*
|
||||
secrets.yaml
|
||||
secrets.yml
|
||||
|
||||
# Local SQLite database (contains encrypted credentials)
|
||||
/data/*.db
|
||||
/data/*.db-journal
|
||||
/data/*.db-shm
|
||||
/data/*.db-wal
|
||||
|
||||
# Binaries
|
||||
/server
|
||||
/importer
|
||||
/cron
|
||||
/bin/
|
||||
qfs
|
||||
|
||||
# Local Go build cache used in sandboxed runs
|
||||
.gocache/
|
||||
|
||||
# Local tooling state
|
||||
.claude/
|
||||
|
||||
# Editor settings
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Temp and logs
|
||||
*.tmp
|
||||
*.temp
|
||||
*.log
|
||||
|
||||
# Go test/build artifacts
|
||||
*.out
|
||||
*.test
|
||||
coverage/
|
||||
|
||||
# ---> macOS
|
||||
# General
|
||||
@@ -8,7 +54,7 @@ config.yaml
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
@@ -29,3 +75,7 @@ Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Release artifacts (binaries, archives, checksums), but DO track releases/memory/ for changelog
|
||||
releases/*
|
||||
!releases/memory/
|
||||
!releases/memory/**
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "bible"]
|
||||
path = bible
|
||||
url = https://git.mchus.pro/mchus/bible.git
|
||||
11
AGENTS.md
Normal file
11
AGENTS.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# QuoteForge — Instructions for Codex
|
||||
|
||||
## Shared Engineering Rules
|
||||
Read `bible/` — shared rules for all projects (CSV, logging, DB, tables, background tasks, code style).
|
||||
Start with `bible/rules/patterns/` for specific contracts.
|
||||
|
||||
## Project Architecture
|
||||
Read `bible-local/` — QuoteForge specific architecture.
|
||||
Read order: `bible-local/README.md` → relevant files for the task.
|
||||
|
||||
Every architectural decision specific to this project must be recorded in `bible-local/`.
|
||||
380
CLAUDE.md
380
CLAUDE.md
@@ -1,375 +1,17 @@
|
||||
# QuoteForge - Claude Code Instructions
|
||||
# QuoteForge — Instructions for Claude
|
||||
|
||||
## Project Overview
|
||||
## Shared Engineering Rules
|
||||
Read `bible/` — shared rules for all projects (CSV, logging, DB, tables, background tasks, code style).
|
||||
Start with `bible/rules/patterns/` for specific contracts.
|
||||
|
||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG.
|
||||
## Project Architecture
|
||||
Read `bible-local/` — QuoteForge specific architecture.
|
||||
Read order: `bible-local/README.md` → relevant files for the task.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Language:** Go 1.22+
|
||||
- **Web Framework:** Gin (github.com/gin-gonic/gin)
|
||||
- **ORM:** GORM (gorm.io/gorm)
|
||||
- **Database:** MariaDB 11 (existing database RFQ_LOG)
|
||||
- **Frontend:** HTML templates + htmx + Tailwind CSS (CDN)
|
||||
- **Excel Export:** excelize (github.com/xuri/excelize/v2)
|
||||
- **Auth:** JWT (github.com/golang-jwt/jwt/v5)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
quoteforge/
|
||||
├── cmd/
|
||||
│ ├── server/main.go # Main HTTP server
|
||||
│ ├── priceupdater/main.go # Cron job for price updates & alerts
|
||||
│ └── importer/main.go # Import metadata from lot table
|
||||
├── internal/
|
||||
│ ├── config/config.go # YAML config loading
|
||||
│ ├── models/ # GORM models
|
||||
│ ├── handlers/ # Gin HTTP handlers
|
||||
│ ├── services/ # Business logic
|
||||
│ ├── middleware/ # Auth, CORS, roles
|
||||
│ └── repository/ # Database queries
|
||||
├── web/
|
||||
│ ├── templates/ # Go HTML templates
|
||||
│ └── static/ # CSS, JS
|
||||
├── migrations/ # SQL migration files
|
||||
├── config.yaml
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
## Existing Database Tables (READ-ONLY - DO NOT MODIFY)
|
||||
|
||||
These tables are used by other systems. Our app only reads from them:
|
||||
|
||||
```sql
|
||||
-- Component catalog
|
||||
CREATE TABLE lot (
|
||||
lot_name CHAR(255) PRIMARY KEY, -- e.g., "CPU_AMD_9654", "MB_INTEL_4.Sapphire_2S"
|
||||
lot_description VARCHAR(10000)
|
||||
);
|
||||
|
||||
-- Price history from suppliers
|
||||
CREATE TABLE lot_log (
|
||||
lot_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
lot CHAR(255) NOT NULL, -- FK → lot.lot_name
|
||||
supplier CHAR(255) NOT NULL, -- FK → supplier.supplier_name
|
||||
date DATE NOT NULL,
|
||||
price DOUBLE NOT NULL,
|
||||
quality CHAR(255),
|
||||
comments VARCHAR(15000),
|
||||
FOREIGN KEY (lot) REFERENCES lot(lot_name),
|
||||
FOREIGN KEY (supplier) REFERENCES supplier(supplier_name)
|
||||
);
|
||||
|
||||
-- Supplier catalog
|
||||
CREATE TABLE supplier (
|
||||
supplier_name CHAR(255) PRIMARY KEY,
|
||||
supplier_comment VARCHAR(10000)
|
||||
);
|
||||
```
|
||||
|
||||
## New Tables (prefix qt_)
|
||||
|
||||
QuoteForge creates these tables:
|
||||
|
||||
```sql
|
||||
-- Users
|
||||
CREATE TABLE qt_users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role ENUM('viewer', 'editor', 'pricing_admin', 'admin') DEFAULT 'viewer',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Component metadata (extends lot table)
|
||||
CREATE TABLE qt_lot_metadata (
|
||||
lot_name CHAR(255) PRIMARY KEY,
|
||||
category_id INT,
|
||||
vendor VARCHAR(50), -- Parsed from lot_name: CPU_AMD_9654 → "AMD"
|
||||
model VARCHAR(100), -- Parsed: CPU_AMD_9654 → "9654"
|
||||
specs JSON,
|
||||
current_price DECIMAL(12,2),
|
||||
price_method ENUM('manual', 'median', 'average', 'weighted_median') DEFAULT 'median',
|
||||
price_period_days INT DEFAULT 90,
|
||||
price_updated_at TIMESTAMP,
|
||||
request_count INT DEFAULT 0,
|
||||
last_request_date DATE,
|
||||
popularity_score DECIMAL(10,4),
|
||||
FOREIGN KEY (lot_name) REFERENCES lot(lot_name)
|
||||
);
|
||||
|
||||
-- Categories
|
||||
CREATE TABLE qt_categories (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
code VARCHAR(20) UNIQUE NOT NULL, -- MB, CPU, MEM, GPU, SSD, HDD, RAID, NIC, HCA, HBA, DPU, PS
|
||||
name VARCHAR(100) NOT NULL,
|
||||
name_ru VARCHAR(100),
|
||||
display_order INT DEFAULT 0,
|
||||
is_required BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Saved configurations
|
||||
CREATE TABLE qt_configurations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid VARCHAR(36) UNIQUE NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
items JSON NOT NULL, -- [{"lot_name": "CPU_AMD_9654", "quantity": 2, "unit_price": 11500}]
|
||||
total_price DECIMAL(12,2),
|
||||
notes TEXT,
|
||||
is_template BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES qt_users(id)
|
||||
);
|
||||
|
||||
-- Price overrides
|
||||
CREATE TABLE qt_price_overrides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
lot_name CHAR(255) NOT NULL,
|
||||
price DECIMAL(12,2) NOT NULL,
|
||||
valid_from DATE NOT NULL,
|
||||
valid_until DATE,
|
||||
reason TEXT,
|
||||
created_by INT NOT NULL,
|
||||
FOREIGN KEY (lot_name) REFERENCES lot(lot_name)
|
||||
);
|
||||
|
||||
-- Alerts for pricing admins
|
||||
CREATE TABLE qt_pricing_alerts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
lot_name CHAR(255) NOT NULL,
|
||||
alert_type ENUM('high_demand_stale_price', 'price_spike', 'price_drop', 'no_recent_quotes', 'trending_no_price') NOT NULL,
|
||||
severity ENUM('low', 'medium', 'high', 'critical') DEFAULT 'medium',
|
||||
message TEXT NOT NULL,
|
||||
details JSON,
|
||||
status ENUM('new', 'acknowledged', 'resolved', 'ignored') DEFAULT 'new',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Usage statistics
|
||||
CREATE TABLE qt_component_usage_stats (
|
||||
lot_name CHAR(255) PRIMARY KEY,
|
||||
quotes_total INT DEFAULT 0,
|
||||
quotes_last_30d INT DEFAULT 0,
|
||||
quotes_last_7d INT DEFAULT 0,
|
||||
total_quantity INT DEFAULT 0,
|
||||
total_revenue DECIMAL(14,2) DEFAULT 0,
|
||||
trend_direction ENUM('up', 'stable', 'down') DEFAULT 'stable',
|
||||
trend_percent DECIMAL(5,2) DEFAULT 0,
|
||||
last_used_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
## Key Business Logic
|
||||
|
||||
### 1. Part Number Parsing
|
||||
|
||||
Extract category, vendor, model from lot_name:
|
||||
```go
|
||||
// "CPU_AMD_9654" → category="CPU", vendor="AMD", model="9654"
|
||||
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", vendor="INTEL", model="4.Sapphire_2S_32xDDR5"
|
||||
// "MEM_DDR5_64G_5600" → category="MEM", vendor="DDR5", model="64G_5600"
|
||||
// "GPU_NV_RTX_4090_PCIe" → category="GPU", vendor="NV", model="RTX_4090_PCIe"
|
||||
|
||||
func ParsePartNumber(lotName string) (category, vendor, model string) {
|
||||
parts := strings.SplitN(lotName, "_", 3)
|
||||
if len(parts) >= 1 { category = parts[0] }
|
||||
if len(parts) >= 2 { vendor = parts[1] }
|
||||
if len(parts) >= 3 { model = parts[2] }
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Price Calculation Methods
|
||||
|
||||
```go
|
||||
// Median - simple median of prices in period
|
||||
func CalculateMedian(prices []float64) float64
|
||||
|
||||
// Average - arithmetic mean
|
||||
func CalculateAverage(prices []float64) float64
|
||||
|
||||
// Weighted Median - recent prices have higher weight (exponential decay)
|
||||
// weight = e^(-days_since_quote / decay_days)
|
||||
func CalculateWeightedMedian(prices []PricePoint, decayDays int) float64
|
||||
```
|
||||
|
||||
### 3. Price Freshness (color coding)
|
||||
|
||||
```go
|
||||
// Green: < 30 days AND >= 3 quotes
|
||||
// Yellow: 30-60 days OR 1-2 quotes
|
||||
// Orange: 60-90 days
|
||||
// Red: > 90 days OR no price
|
||||
|
||||
func GetPriceFreshness(daysSinceUpdate int, quoteCount int) string {
|
||||
if daysSinceUpdate < 30 && quoteCount >= 3 {
|
||||
return "fresh" // green
|
||||
} else if daysSinceUpdate < 60 {
|
||||
return "normal" // yellow
|
||||
} else if daysSinceUpdate < 90 {
|
||||
return "stale" // orange
|
||||
}
|
||||
return "critical" // red
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Component Sorting
|
||||
|
||||
Sort by: popularity + price freshness. Components without prices go to the bottom.
|
||||
|
||||
```go
|
||||
// Sort score = popularity_score * 10 + freshness_bonus - no_price_penalty
|
||||
// freshness_bonus: fresh=100, normal=50, stale=10, critical=0
|
||||
// no_price_penalty: -1000 if current_price is NULL or 0
|
||||
```
|
||||
|
||||
### 5. Alert Generation
|
||||
|
||||
Generate alerts when:
|
||||
- **high_demand_stale_price** (CRITICAL): >= 5 quotes/month AND price > 60 days old
|
||||
- **trending_no_price** (HIGH): trend_percent > 50% AND no price set
|
||||
- **price_spike** (MEDIUM): price increased > 20% from previous period
|
||||
- **no_recent_quotes** (MEDIUM): popular component, no supplier quotes > 90 days
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth
|
||||
```
|
||||
POST /api/auth/login → {"username", "password"} → {"token", "refresh_token"}
|
||||
POST /api/auth/logout
|
||||
POST /api/auth/refresh
|
||||
GET /api/auth/me → current user info
|
||||
```
|
||||
|
||||
### Components
|
||||
```
|
||||
GET /api/components → list with pagination
|
||||
GET /api/components?category=CPU&vendor=AMD → filtered
|
||||
GET /api/components/:lot_name → single component details
|
||||
GET /api/categories → category list
|
||||
```
|
||||
|
||||
### Quote Builder
|
||||
```
|
||||
POST /api/quote/validate → {"items": [...]} → {"valid": bool, "errors": [], "warnings": []}
|
||||
POST /api/quote/calculate → {"items": [...]} → {"items": [...], "total": 45000.00}
|
||||
```
|
||||
|
||||
### Export
|
||||
```
|
||||
POST /api/export/csv → {"items": [...], "name": "Config 1"} → CSV file
|
||||
POST /api/export/xlsx → {"items": [...], "name": "Config 1"} → XLSX file
|
||||
```
|
||||
|
||||
### Configurations
|
||||
```
|
||||
GET /api/configs → list user's configurations
|
||||
POST /api/configs → save new configuration
|
||||
GET /api/configs/:uuid → get by UUID
|
||||
PUT /api/configs/:uuid → update
|
||||
DELETE /api/configs/:uuid → delete
|
||||
GET /api/configs/:uuid/export → export as JSON
|
||||
POST /api/configs/import → import from JSON
|
||||
```
|
||||
|
||||
### Pricing Admin (requires role: pricing_admin or admin)
|
||||
```
|
||||
GET /admin/pricing/stats → dashboard stats
|
||||
GET /admin/pricing/components → components with pricing info
|
||||
GET /admin/pricing/components/:lot_name → component pricing details
|
||||
POST /admin/pricing/update → update price method/value
|
||||
POST /admin/pricing/recalculate-all → recalculate all prices
|
||||
|
||||
GET /admin/pricing/alerts → list alerts
|
||||
POST /admin/pricing/alerts/:id/acknowledge → mark as seen
|
||||
POST /admin/pricing/alerts/:id/resolve → mark as resolved
|
||||
POST /admin/pricing/alerts/:id/ignore → dismiss alert
|
||||
```
|
||||
|
||||
### htmx Partials
|
||||
```
|
||||
GET /partials/components?category=CPU&vendor=AMD → HTML fragment
|
||||
GET /partials/cart → cart HTML
|
||||
GET /partials/summary → price summary HTML
|
||||
```
|
||||
|
||||
## User Roles
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| viewer | View components, create quotes, export |
|
||||
| editor | + save/load configurations |
|
||||
| pricing_admin | + manage prices, view alerts |
|
||||
| admin | + manage users |
|
||||
|
||||
## Frontend Guidelines
|
||||
|
||||
- **Mobile-first** design
|
||||
- Use **htmx** for interactivity (hx-get, hx-post, hx-target, hx-swap)
|
||||
- Use **Tailwind CSS** via CDN
|
||||
- Minimal custom JavaScript
|
||||
- Color scheme for price freshness:
|
||||
- `text-green-600 bg-green-50` - fresh
|
||||
- `text-yellow-600 bg-yellow-50` - normal
|
||||
- `text-orange-600 bg-orange-50` - stale
|
||||
- `text-red-600 bg-red-50` - critical
|
||||
|
||||
## Commands
|
||||
Every architectural decision specific to this project must be recorded in `bible-local/`.
|
||||
|
||||
```bash
|
||||
# Run development server
|
||||
go run ./cmd/server
|
||||
|
||||
# Run price updater (cron job)
|
||||
go run ./cmd/priceupdater
|
||||
|
||||
# Run importer (one-time setup)
|
||||
go run ./cmd/importer
|
||||
|
||||
# Build for production
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
go build ./cmd/qfs && go vet ./... # verify
|
||||
go run ./cmd/qfs # run
|
||||
make build-release # release build
|
||||
```
|
||||
|
||||
## Dependencies (go.mod)
|
||||
|
||||
```go
|
||||
module github.com/mchus/quoteforge
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
gorm.io/gorm v1.25.5
|
||||
gorm.io/driver/mysql v1.5.2
|
||||
github.com/xuri/excelize/v2 v2.8.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/google/uuid v1.5.0
|
||||
golang.org/x/crypto v0.17.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
```
|
||||
|
||||
## Development Priorities
|
||||
|
||||
1. **Phase 1 (MVP):** Project setup, models, component API, basic UI, CSV export
|
||||
2. **Phase 2:** JWT auth with roles, pricing admin UI, all price methods
|
||||
3. **Phase 3:** Save/load configs, JSON import/export, XLSX export, cron jobs
|
||||
4. **Phase 4:** Usage stats, alerts system, dashboard
|
||||
5. **Phase 5:** Polish, tests, Docker, documentation
|
||||
|
||||
## Code Style
|
||||
|
||||
- Use standard Go formatting (gofmt)
|
||||
- Error handling: always check errors, wrap with context
|
||||
- Logging: use structured logging (slog or zerolog)
|
||||
- Comments: in Russian or English, be consistent
|
||||
- File naming: snake_case for files, PascalCase for types
|
||||
|
||||
104
Makefile
Normal file
104
Makefile
Normal file
@@ -0,0 +1,104 @@
|
||||
.PHONY: build build-release clean test run version install-hooks
|
||||
|
||||
# Get version from git
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||
LDFLAGS := -s -w -X main.Version=$(VERSION)
|
||||
|
||||
# Binary name
|
||||
BINARY := qfs
|
||||
|
||||
# Build for development (with debug info)
|
||||
build:
|
||||
go build -o bin/$(BINARY) ./cmd/qfs
|
||||
|
||||
# Build for release (optimized, with version)
|
||||
build-release:
|
||||
@echo "Building $(BINARY) version $(VERSION)..."
|
||||
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY) ./cmd/qfs
|
||||
@echo "✓ Built: bin/$(BINARY)"
|
||||
@./bin/$(BINARY) -version
|
||||
|
||||
# Build release for Linux (cross-compile)
|
||||
build-linux:
|
||||
@echo "Building $(BINARY) for Linux..."
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-linux-amd64 ./cmd/qfs
|
||||
@echo "✓ Built: bin/$(BINARY)-linux-amd64"
|
||||
|
||||
# Build release for macOS (cross-compile)
|
||||
build-macos:
|
||||
@echo "Building $(BINARY) for macOS..."
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-darwin-amd64 ./cmd/qfs
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-darwin-arm64 ./cmd/qfs
|
||||
@echo "✓ Built: bin/$(BINARY)-darwin-amd64"
|
||||
@echo "✓ Built: bin/$(BINARY)-darwin-arm64"
|
||||
|
||||
# Build release for Windows (cross-compile)
|
||||
build-windows:
|
||||
@echo "Building $(BINARY) for Windows..."
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-windows-amd64.exe ./cmd/qfs
|
||||
@echo "✓ Built: bin/$(BINARY)-windows-amd64.exe"
|
||||
|
||||
# Build all platforms
|
||||
build-all: build-release build-linux build-macos build-windows
|
||||
|
||||
# Create release packages for all platforms
|
||||
release:
|
||||
@./scripts/release.sh
|
||||
|
||||
# Show version
|
||||
version:
|
||||
@echo "Version: $(VERSION)"
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/
|
||||
rm -f $(BINARY)
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
# Run development server
|
||||
run:
|
||||
go run ./cmd/qfs
|
||||
|
||||
# Run with auto-restart (requires entr: brew install entr)
|
||||
watch:
|
||||
find . -name '*.go' | entr -r go run ./cmd/qfs
|
||||
|
||||
# Install dependencies
|
||||
deps:
|
||||
go mod download
|
||||
go mod tidy
|
||||
|
||||
# Install local git hooks
|
||||
install-hooks:
|
||||
git config core.hooksPath .githooks
|
||||
chmod +x .githooks/pre-commit scripts/check-secrets.sh
|
||||
@echo "Installed git hooks from .githooks/"
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "QuoteForge Server (qfs) - Build Commands"
|
||||
@echo ""
|
||||
@echo "Usage: make [target]"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " build Build for development (with debug info)"
|
||||
@echo " build-release Build optimized release (default)"
|
||||
@echo " build-linux Cross-compile for Linux"
|
||||
@echo " build-macos Cross-compile for macOS (Intel + Apple Silicon)"
|
||||
@echo " build-windows Cross-compile for Windows"
|
||||
@echo " build-all Build for all platforms"
|
||||
@echo " release Create release packages for all platforms"
|
||||
@echo " version Show current version"
|
||||
@echo " clean Remove build artifacts"
|
||||
@echo " test Run tests"
|
||||
@echo " run Run development server"
|
||||
@echo " watch Run with auto-restart (requires entr)"
|
||||
@echo " deps Install/update dependencies"
|
||||
@echo " install-hooks Install local git hooks (secret scan on commit)"
|
||||
@echo " help Show this help"
|
||||
@echo ""
|
||||
@echo "Current version: $(VERSION)"
|
||||
227
README.md
227
README.md
@@ -1,231 +1,66 @@
|
||||
# QuoteForge
|
||||
|
||||
**Server Configuration & Quotation Tool**
|
||||
**Корпоративный конфигуратор серверов и расчёт КП**
|
||||
|
||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений. Позволяет быстро собрать спецификацию сервера из каталога компонентов с автоматическим расчётом цен.
|
||||
Offline-first архитектура: пользовательские операции через локальную SQLite, MariaDB только для синхронизации.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Возможности
|
||||
---
|
||||
|
||||
### Для пользователей
|
||||
- 📱 **Mobile-first интерфейс** — удобная работа с телефона и планшета
|
||||
- 🖥️ **Конфигуратор серверов** — пошаговый выбор компонентов с проверкой совместимости
|
||||
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
|
||||
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
|
||||
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
|
||||
- 📤 **Импорт/экспорт JSON** — обмен конфигурациями между пользователями
|
||||
## Документация
|
||||
|
||||
### Для ценовых администраторов
|
||||
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
|
||||
- 🎯 **Система алертов** — уведомления о популярных компонентах с устаревшими ценами
|
||||
- 📉 **Аналитика использования** — какие компоненты востребованы в КП
|
||||
- ⚙️ **Гибкие настройки** — периоды расчёта, методы, ручные переопределения
|
||||
Полная архитектурная документация хранится в **[bible/](bible/README.md)**:
|
||||
|
||||
### Индикация актуальности цен
|
||||
| Цвет | Статус | Условие |
|
||||
|------|--------|---------|
|
||||
| 🟢 Зелёный | Свежая | < 30 дней, ≥ 3 источника |
|
||||
| 🟡 Жёлтый | Нормальная | 30-60 дней |
|
||||
| 🟠 Оранжевый | Устаревающая | 60-90 дней |
|
||||
| 🔴 Красный | Устаревшая | > 90 дней или нет данных |
|
||||
| Файл | Тема |
|
||||
|------|------|
|
||||
| [bible/01-overview.md](bible/01-overview.md) | Продукт, возможности, технологии, структура репо |
|
||||
| [bible/02-architecture.md](bible/02-architecture.md) | Local-first, sync, ценообразование, версионность |
|
||||
| [bible/03-database.md](bible/03-database.md) | SQLite и MariaDB схемы, права, миграции |
|
||||
| [bible/04-api.md](bible/04-api.md) | Все API endpoints и web-маршруты |
|
||||
| [bible/05-config.md](bible/05-config.md) | Конфигурация, env vars, установка |
|
||||
| [bible/06-backup.md](bible/06-backup.md) | Резервное копирование |
|
||||
| [bible/07-dev.md](bible/07-dev.md) | Команды разработки, стиль кода, guardrails |
|
||||
|
||||
## Технологии
|
||||
---
|
||||
|
||||
- **Backend:** Go 1.22+, Gin, GORM
|
||||
- **Frontend:** HTML, Tailwind CSS, htmx
|
||||
- **Database:** MariaDB 11+
|
||||
- **Export:** excelize (XLSX), encoding/csv
|
||||
|
||||
## Требования
|
||||
|
||||
- Go 1.22 или выше
|
||||
- MariaDB 11.x (или MySQL 8.x)
|
||||
- ~50 MB дискового пространства
|
||||
|
||||
## Установка
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-company/quoteforge.git
|
||||
cd quoteforge
|
||||
```
|
||||
# Применить миграции
|
||||
go run ./cmd/qfs -migrate
|
||||
|
||||
### 2. Настройка конфигурации
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
Отредактируйте `config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
mode: "release"
|
||||
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 3306
|
||||
name: "RFQ_LOG"
|
||||
user: "quoteforge"
|
||||
password: "your-secure-password"
|
||||
|
||||
auth:
|
||||
jwt_secret: "your-jwt-secret-min-32-chars"
|
||||
token_expiry: "24h"
|
||||
```
|
||||
|
||||
### 3. Миграции базы данных
|
||||
|
||||
```bash
|
||||
make migrate
|
||||
```
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
|
||||
```bash
|
||||
make seed
|
||||
```
|
||||
|
||||
### 5. Запуск
|
||||
|
||||
```bash
|
||||
# Development
|
||||
# Запустить
|
||||
go run ./cmd/qfs
|
||||
# или
|
||||
make run
|
||||
|
||||
# Production
|
||||
make build
|
||||
./bin/quoteforge
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу: http://localhost:8080
|
||||
|
||||
## Docker
|
||||
Приложение: http://localhost:8080 → откроется `/setup` для настройки подключения к MariaDB.
|
||||
|
||||
```bash
|
||||
# Сборка образа
|
||||
docker build -t quoteforge .
|
||||
# Сборка
|
||||
make build-release
|
||||
|
||||
# Запуск с docker-compose
|
||||
docker-compose up -d
|
||||
# Проверка
|
||||
go build ./cmd/qfs && go vet ./...
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
---
|
||||
|
||||
```
|
||||
quoteforge/
|
||||
├── cmd/
|
||||
│ ├── server/ # Основной сервер
|
||||
│ ├── priceupdater/ # Cron job обновления цен
|
||||
│ └── importer/ # Импорт данных
|
||||
├── internal/
|
||||
│ ├── config/ # Конфигурация
|
||||
│ ├── models/ # GORM модели
|
||||
│ ├── handlers/ # HTTP handlers
|
||||
│ ├── services/ # Бизнес-логика
|
||||
│ ├── middleware/ # Auth, CORS, etc.
|
||||
│ └── repository/ # Работа с БД
|
||||
├── web/
|
||||
│ ├── templates/ # HTML шаблоны
|
||||
│ └── static/ # CSS, JS, изображения
|
||||
├── migrations/ # SQL миграции
|
||||
├── config.yaml # Конфигурация
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── Makefile
|
||||
```
|
||||
## Releases & Changelog
|
||||
|
||||
## Роли пользователей
|
||||
Changelog между версиями: `releases/memory/v{major}.{minor}.{patch}.md`
|
||||
|
||||
| Роль | Описание |
|
||||
|------|----------|
|
||||
| `viewer` | Просмотр, создание квот, экспорт |
|
||||
| `editor` | + сохранение конфигураций |
|
||||
| `pricing_admin` | + управление ценами и алертами |
|
||||
| `admin` | Полный доступ, управление пользователями |
|
||||
|
||||
## API
|
||||
|
||||
Документация API доступна по адресу `/api/docs` (в разработке).
|
||||
|
||||
Основные endpoints:
|
||||
|
||||
```
|
||||
POST /api/auth/login # Авторизация
|
||||
GET /api/components # Список компонентов
|
||||
POST /api/quote/calculate # Расчёт цены
|
||||
POST /api/export/xlsx # Экспорт в Excel
|
||||
GET /api/configs # Сохранённые конфигурации
|
||||
```
|
||||
|
||||
## Cron Jobs
|
||||
|
||||
Добавьте в crontab:
|
||||
|
||||
```bash
|
||||
# Обновление цен — каждую ночь в 2:00
|
||||
0 2 * * * /opt/quoteforge/bin/priceupdater
|
||||
|
||||
# Генерация алертов — каждый час
|
||||
0 * * * * /opt/quoteforge/bin/priceupdater --alerts-only
|
||||
```
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
# Запуск в режиме разработки (hot reload)
|
||||
make dev
|
||||
|
||||
# Запуск тестов
|
||||
make test
|
||||
|
||||
# Линтер
|
||||
make lint
|
||||
|
||||
# Сборка для Linux
|
||||
make build-linux
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|------------|----------|--------------|
|
||||
| `QF_DB_HOST` | Хост базы данных | localhost |
|
||||
| `QF_DB_PORT` | Порт базы данных | 3306 |
|
||||
| `QF_DB_NAME` | Имя базы данных | RFQ_LOG |
|
||||
| `QF_DB_USER` | Пользователь БД | — |
|
||||
| `QF_DB_PASSWORD` | Пароль БД | — |
|
||||
| `QF_JWT_SECRET` | Секрет для JWT | — |
|
||||
| `QF_SERVER_PORT` | Порт сервера | 8080 |
|
||||
|
||||
## Интеграция с существующей БД
|
||||
|
||||
QuoteForge интегрируется с существующей базой RFQ_LOG:
|
||||
|
||||
- `lot` — справочник компонентов (только чтение)
|
||||
- `lot_log` — история цен от поставщиков (только чтение)
|
||||
- `supplier` — справочник поставщиков (только чтение)
|
||||
|
||||
Новые таблицы QuoteForge имеют префикс `qt_`:
|
||||
|
||||
- `qt_users` — пользователи приложения
|
||||
- `qt_lot_metadata` — расширенные данные компонентов
|
||||
- `qt_configurations` — сохранённые конфигурации
|
||||
- `qt_pricing_alerts` — алерты для администраторов
|
||||
---
|
||||
|
||||
## Поддержка
|
||||
|
||||
По вопросам работы приложения обращайтесь:
|
||||
- Email: mike@mchus.pro
|
||||
- Internal: @mchus
|
||||
|
||||
## Лицензия
|
||||
|
||||
Данное программное обеспечение является собственностью компании и предназначено исключительно для внутреннего использования. Распространение, копирование или модификация без письменного разрешения запрещены.
|
||||
|
||||
См. файл [LICENSE](LICENSE) для подробностей.
|
||||
Собственность компании, только для внутреннего использования. См. [LICENSE](LICENSE).
|
||||
|
||||
33
acc_lot_log_import.sql
Normal file
33
acc_lot_log_import.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Generated from /Users/mchusavitin/Downloads/acc.csv
|
||||
-- Unambiguous rows only. Rows from headers without a date were skipped.
|
||||
INSERT INTO lot_log (`lot`, `supplier`, `date`, `price`, `quality`, `comments`) VALUES
|
||||
('ACC_RMK_L_Type', '', '2024-04-01', 19, NULL, 'header supplier missing in source (45383)'),
|
||||
('ACC_RMK_SLIDE', '', '2024-04-01', 31, NULL, 'header supplier missing in source (45383)'),
|
||||
('NVLINK_2S_Bridge', '', '2023-01-01', 431, NULL, 'header supplier missing in source (44927)'),
|
||||
('NVLINK_2S_Bridge', 'Jevy Yang', '2025-01-15', 139, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Wendy', '2025-01-15', 143, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'HONCH (Darian)', '2025-05-06', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'HONCH (Sunny)', '2025-06-17', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Wendy', '2025-07-02', 145, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Honch (Sunny)', '2025-07-10', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Honch (Yan)', '2025-08-07', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Jevy', '2025-09-09', 155, NULL, NULL),
|
||||
('NVLINK_2S_Bridge', 'Honch (Darian)', '2025-11-17', 102, NULL, NULL),
|
||||
('NVLINK_2W_Bridge(H200)', '', '2023-01-01', 405, NULL, 'header supplier missing in source (44927)'),
|
||||
('NVLINK_2W_Bridge(H200)', 'network logic / Stephen', '2025-02-10', 305, NULL, NULL),
|
||||
('NVLINK_2W_Bridge(H200)', 'JEVY', '2025-02-18', 411, NULL, NULL),
|
||||
('NVLINK_4W_Bridge(H200)', '', '2023-01-01', 820, NULL, 'header supplier missing in source (44927)'),
|
||||
('NVLINK_4W_Bridge(H200)', 'network logic / Stephen', '2025-02-10', 610, NULL, NULL),
|
||||
('NVLINK_4W_Bridge(H200)', 'JEVY', '2025-02-18', 754, NULL, NULL),
|
||||
('25G_SFP28_MMA2P00-AS', 'HONCH (Doris)', '2025-02-19', 65, NULL, NULL),
|
||||
('ACC_SuperCap', '', '2024-04-01', 59, NULL, 'header supplier missing in source (45383)'),
|
||||
('ACC_SuperCap', 'Chiphome', '2025-02-28', 48, NULL, NULL);
|
||||
|
||||
-- Skipped source values due to missing date in header:
|
||||
-- lot=ACC_RMK_L_Type; header=FOB; price=19; reason=header has supplier but no date
|
||||
-- lot=ACC_RMK_SLIDE; header=FOB; price=31; reason=header has supplier but no date
|
||||
-- lot=NVLINK_2S_Bridge; header=FOB; price=155; reason=header has supplier but no date
|
||||
-- lot=NVLINK_2W_Bridge(H200); header=FOB; price=405; reason=header has supplier but no date
|
||||
-- lot=NVLINK_4W_Bridge(H200); header=FOB; price=754; reason=header has supplier but no date
|
||||
-- lot=25G_SFP28_MMA2P00-AS; header=FOB; price=65; reason=header has supplier but no date
|
||||
-- lot=ACC_SuperCap; header=FOB; price=48; reason=header has supplier but no date
|
||||
21
assets_embed.go
Normal file
21
assets_embed.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package quoteforge
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// TemplatesFS contains HTML templates embedded into the binary.
|
||||
//
|
||||
//go:embed web/templates/*.html web/templates/partials/*.html
|
||||
var TemplatesFS embed.FS
|
||||
|
||||
// StaticFiles contains static assets (CSS, JS, etc.) embedded into the binary.
|
||||
//
|
||||
//go:embed web/static/*
|
||||
var StaticFiles embed.FS
|
||||
|
||||
// StaticFS returns a filesystem rooted at web/static for serving static assets.
|
||||
func StaticFS() (fs.FS, error) {
|
||||
return fs.Sub(StaticFiles, "web/static")
|
||||
}
|
||||
1
bible
Submodule
1
bible
Submodule
Submodule bible added at 5a69e0bba8
130
bible-local/01-overview.md
Normal file
130
bible-local/01-overview.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# 01 — Product Overview
|
||||
|
||||
## What is QuoteForge
|
||||
|
||||
A corporate server configuration and quotation tool.
|
||||
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only by synchronization and dedicated setup/migration tooling.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### For Users
|
||||
- Mobile-first interface — works comfortably on phones and tablets
|
||||
- Server configurator — step-by-step component selection
|
||||
- Automatic price calculation — based on pricelists from local cache
|
||||
- CSV export — ready-to-use specifications for clients
|
||||
- Configuration history — versioned snapshots with rollback support
|
||||
- Full offline operation — continue working without network, sync later
|
||||
- Guarded synchronization — sync is blocked by preflight check if local schema is not ready
|
||||
|
||||
### Local Client Security Model
|
||||
|
||||
QuoteForge is currently a **single-user thick client** bound to `localhost`.
|
||||
|
||||
- The local HTTP/UI layer is not treated as a multi-user security boundary.
|
||||
- RBAC is not part of the active product contract for the local client.
|
||||
- The authoritative authentication boundary is the remote sync server and its DB credentials captured during setup.
|
||||
- If the app is ever exposed beyond `localhost`, auth/RBAC must be reintroduced as an enforced perimeter before release.
|
||||
|
||||
### Price Freshness Indicators
|
||||
|
||||
| Color | Status | Condition |
|
||||
|-------|--------|-----------|
|
||||
| Green | Fresh | < 30 days, ≥ 3 sources |
|
||||
| Yellow | Normal | 30–60 days |
|
||||
| Orange | Aging | 60–90 days |
|
||||
| Red | Stale | > 90 days or no data |
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Stack |
|
||||
|-------|-------|
|
||||
| Backend | Go 1.22+, Gin, GORM |
|
||||
| Frontend | HTML, Tailwind CSS, htmx |
|
||||
| Local DB | SQLite (`qfs.db`) |
|
||||
| Server DB | MariaDB 11+ (sync transport only for app runtime) |
|
||||
| Export | encoding/csv, excelize (XLSX) |
|
||||
|
||||
---
|
||||
|
||||
## Product Scope
|
||||
|
||||
**In scope:**
|
||||
- Component configurator and quotation calculation
|
||||
- Projects and configurations
|
||||
- Read-only pricelist viewing from local cache
|
||||
- Sync (pull components/pricelists, push local changes)
|
||||
|
||||
**Out of scope (removed intentionally — do not restore):**
|
||||
- Admin pricing UI/API
|
||||
- Stock import
|
||||
- Alerts
|
||||
- Cron/importer utilities
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
quoteforge/
|
||||
├── cmd/
|
||||
│ ├── qfs/main.go # HTTP server entry point
|
||||
│ ├── migrate/ # Migration tool
|
||||
│ └── migrate_ops_projects/ # OPS project migrator
|
||||
├── internal/
|
||||
│ ├── appmeta/ # App version metadata
|
||||
│ ├── appstate/ # State management, backup
|
||||
│ ├── article/ # Article generation
|
||||
│ ├── config/ # Config parsing
|
||||
│ ├── db/ # DB initialization
|
||||
│ ├── handlers/ # HTTP handlers
|
||||
│ ├── localdb/ # SQLite layer
|
||||
│ ├── middleware/ # Auth, CORS, etc.
|
||||
│ ├── models/ # GORM models
|
||||
│ ├── repository/ # Repository layer
|
||||
│ └── services/ # Business logic
|
||||
├── web/
|
||||
│ ├── templates/ # HTML templates + partials
|
||||
│ └── static/ # CSS, JS, assets
|
||||
├── migrations/ # SQL migration files (30+)
|
||||
├── bible/ # Architectural documentation (this section)
|
||||
├── releases/memory/ # Per-version changelogs
|
||||
├── config.example.yaml # Config template (the only one in repo)
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with Existing DB
|
||||
|
||||
QuoteForge integrates with the existing `RFQ_LOG` database.
|
||||
|
||||
Hard boundary:
|
||||
|
||||
- normal runtime HTTP handlers, UI flows, pricing, export, BOM resolution, and project/config CRUD must use SQLite only;
|
||||
- MariaDB access is allowed only inside `internal/services/sync/*` and dedicated setup/migration tools under `cmd/`;
|
||||
- any new direct MariaDB query in non-sync runtime code is an architectural violation.
|
||||
|
||||
**Read-only:**
|
||||
- `lot` — component catalog
|
||||
- `qt_lot_metadata` — extended component data
|
||||
- `qt_categories` — categories
|
||||
- `qt_pricelists`, `qt_pricelist_items` — pricelists
|
||||
- `stock_log` — stock quantities consumed during sync enrichment
|
||||
- `qt_partnumber_books`, `qt_partnumber_book_items` — partnumber book snapshots consumed during sync pull
|
||||
|
||||
**Read + Write:**
|
||||
- `qt_configurations` — configurations
|
||||
- `qt_projects` — projects
|
||||
|
||||
**Sync service tables:**
|
||||
- `qt_client_schema_state` — applied migrations state and operational client status per device (`username + hostname`)
|
||||
Fields written by QuoteForge:
|
||||
`app_version`, `last_sync_at`, `last_sync_status`,
|
||||
`pending_changes_count`, `pending_errors_count`, `configurations_count`, `projects_count`,
|
||||
`estimate_pricelist_version`, `warehouse_pricelist_version`, `competitor_pricelist_version`,
|
||||
`last_sync_error_code`, `last_sync_error_text`, `last_checked_at`, `updated_at`
|
||||
- `qt_pricelist_sync_status` — pricelist sync status
|
||||
251
bible-local/02-architecture.md
Normal file
251
bible-local/02-architecture.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# 02 — Architecture
|
||||
|
||||
## Local-First Principle
|
||||
|
||||
**SQLite** is the single source of truth for the user.
|
||||
**MariaDB** is a sync server only — it never blocks local operations.
|
||||
|
||||
```
|
||||
User
|
||||
│
|
||||
▼
|
||||
SQLite (qfs.db) ← all CRUD operations go here
|
||||
│
|
||||
│ background sync (every 5 min)
|
||||
▼
|
||||
MariaDB (RFQ_LOG) ← pull/push only
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- All CRUD operations go through SQLite only
|
||||
- If MariaDB is unavailable → local work continues without restrictions
|
||||
- Changes are queued in `pending_changes` and pushed on next sync
|
||||
|
||||
## MariaDB Boundary
|
||||
|
||||
MariaDB is not part of the runtime read/write path for user features.
|
||||
|
||||
Hard rules:
|
||||
|
||||
- HTTP handlers, web pages, quote calculation, export, vendor BOM resolution, pricelist browsing, project browsing, and configuration CRUD must read/write SQLite only.
|
||||
- MariaDB access from the app runtime is allowed only inside the sync subsystem (`internal/services/sync/*`) for explicit pull/push work.
|
||||
- Dedicated tooling under `cmd/migrate` and `cmd/migrate_ops_projects` may access MariaDB for operator-run schema/data migration tasks.
|
||||
- Setup may test/store connection settings, but after setup the application must treat MariaDB as sync transport only.
|
||||
- Any new repository/service/handler that issues MariaDB queries outside sync is a regression and must be rejected in review.
|
||||
- Local SQLite migrations are code-defined only (`AutoMigrate` + `runLocalMigrations`); there is no server-driven client migration registry.
|
||||
- Read-only local sync caches are disposable. If a local cache table cannot be migrated safely at startup, the client may quarantine/reset that cache and continue booting.
|
||||
|
||||
Forbidden patterns:
|
||||
|
||||
- calling `connMgr.GetDB()` from non-sync runtime business code;
|
||||
- constructing MariaDB-backed repositories in handlers for normal user requests;
|
||||
- using MariaDB as online fallback for reads when local SQLite already contains the synced dataset;
|
||||
- adding UI/API features that depend on live MariaDB availability.
|
||||
|
||||
## Local Client Boundary
|
||||
|
||||
The running app is a localhost-only thick client.
|
||||
|
||||
- Browser/UI requests on the local machine are treated as part of the same trusted user session.
|
||||
- Local routes are not modeled as a hardened multi-user API perimeter.
|
||||
- Authorization to the central server happens through the saved MariaDB connection configured during setup.
|
||||
- Any future deployment that binds beyond `127.0.0.1` must add enforced auth/RBAC before exposure.
|
||||
|
||||
---
|
||||
|
||||
## Synchronization
|
||||
|
||||
### Data Flow Diagram
|
||||
|
||||
```
|
||||
[ SERVER / MariaDB ]
|
||||
┌───────────────────────────┐
|
||||
│ qt_projects │
|
||||
│ qt_configurations │
|
||||
│ qt_pricelists │
|
||||
│ qt_pricelist_items │
|
||||
│ qt_pricelist_sync_status │
|
||||
└─────────────┬─────────────┘
|
||||
│
|
||||
pull (projects/configs/pricelists)
|
||||
│
|
||||
┌────────────────────┴────────────────────┐
|
||||
│ │
|
||||
[ CLIENT A / SQLite ] [ CLIENT B / SQLite ]
|
||||
local_projects local_projects
|
||||
local_configurations local_configurations
|
||||
local_pricelists local_pricelists
|
||||
local_pricelist_items local_pricelist_items
|
||||
pending_changes pending_changes
|
||||
│ │
|
||||
└────── push (projects/configs only) ─────┘
|
||||
│
|
||||
[ SERVER / MariaDB ]
|
||||
```
|
||||
|
||||
### Sync Direction by Entity
|
||||
|
||||
| Entity | Direction |
|
||||
|--------|-----------|
|
||||
| Configurations | Client ↔ Server ↔ Other Clients |
|
||||
| Projects | Client ↔ Server ↔ Other Clients |
|
||||
| Pricelists | Server → Clients only (no push) |
|
||||
| Components | Server → Clients only |
|
||||
| Partnumber books | Server → Clients only |
|
||||
|
||||
Local pricelists not present on the server and not referenced by active configurations are deleted automatically on sync.
|
||||
|
||||
### Soft Deletes (Archive Pattern)
|
||||
|
||||
Configurations and projects are **never hard-deleted**. Deletion is archive via `is_active = false`.
|
||||
|
||||
- `DELETE /api/configs/:uuid` → sets `is_active = false` (archived); can be restored via `reactivate`
|
||||
- `DELETE /api/projects/:uuid` → archives a project **variant** only (`variant` field must be non-empty); main projects cannot be deleted via this endpoint
|
||||
|
||||
## Sync Readiness Guard
|
||||
|
||||
Before every push/pull, a preflight check runs:
|
||||
1. Is the server (MariaDB) reachable?
|
||||
2. Is the local client schema initialized and writable?
|
||||
|
||||
**If the check fails:**
|
||||
- Local CRUD continues without restriction
|
||||
- Sync API returns `423 Locked` with `reason_code` and `reason_text`
|
||||
- UI shows a red indicator with the block reason
|
||||
|
||||
---
|
||||
|
||||
## Pricing
|
||||
|
||||
### Principle
|
||||
|
||||
**Prices come only from `local_pricelist_items`.**
|
||||
Components (`local_components`) are metadata-only — they contain no pricing information.
|
||||
Stock enrichment for pricelist rows is persisted into `local_pricelist_items` during sync; UI/runtime must not resolve it live from MariaDB.
|
||||
|
||||
### Lookup Pattern
|
||||
|
||||
```go
|
||||
// Look up a price for a line item
|
||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
||||
if found && price > 0 {
|
||||
// use price
|
||||
}
|
||||
|
||||
// Inside lookupPriceByPricelistID:
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
|
||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
||||
```
|
||||
|
||||
### Multi-Level Pricelists
|
||||
|
||||
A configuration can reference up to three pricelists simultaneously:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `pricelist_id` | Primary (estimate) |
|
||||
| `warehouse_pricelist_id` | Warehouse pricing |
|
||||
| `competitor_pricelist_id` | Competitor pricing |
|
||||
|
||||
Pricelist sources: `estimate` | `warehouse` | `competitor`
|
||||
|
||||
### "Auto" Pricelist Selection
|
||||
|
||||
Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`):
|
||||
|
||||
- **Explicit mode:** concrete `pricelist_id` is set by user in settings.
|
||||
- **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist.
|
||||
|
||||
`auto` must stay `auto` after price-level refresh and after manual "refresh prices":
|
||||
- resolved IDs are runtime-only and must not overwrite user's mode;
|
||||
- switching to explicit selection must clear runtime auto resolution for that source.
|
||||
|
||||
### Latest Pricelist Resolution Rules
|
||||
|
||||
For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with:
|
||||
|
||||
1. only pricelists that have at least one item (`EXISTS ...pricelist_items`);
|
||||
2. deterministic sort: `created_at DESC, id DESC`.
|
||||
|
||||
This prevents selecting empty/incomplete snapshots and removes nondeterministic ties.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Versioning
|
||||
|
||||
### Principle
|
||||
|
||||
Append-only for **spec+price** changes: immutable snapshots are stored in `local_configuration_versions`.
|
||||
|
||||
```
|
||||
local_configurations
|
||||
└── current_version_id ──► local_configuration_versions (v3) ← active
|
||||
local_configuration_versions (v2)
|
||||
local_configuration_versions (v1)
|
||||
```
|
||||
|
||||
- `version_no = max + 1` when configuration **spec+price** changes
|
||||
- Old versions are never modified or deleted in normal flow
|
||||
- Rollback does **not** rewind history — it creates a **new** version from the snapshot
|
||||
- Operational updates (`line_no` reorder, server count, project move, rename)
|
||||
are synced via `pending_changes` but do **not** create a new revision snapshot
|
||||
|
||||
### Rollback
|
||||
|
||||
```bash
|
||||
POST /api/configs/:uuid/rollback
|
||||
{
|
||||
"target_version": 3,
|
||||
"note": "optional comment"
|
||||
}
|
||||
```
|
||||
|
||||
Result:
|
||||
- A new version `vN` is created with `data` from the target version
|
||||
- `change_note = "rollback to v{target_version}"` (+ note if provided)
|
||||
- `current_version_id` is switched to the new version
|
||||
- Configuration moves to `sync_status = pending`
|
||||
|
||||
### Sync Status Flow
|
||||
|
||||
```
|
||||
local → pending → synced
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Specification Ordering (`Line`)
|
||||
|
||||
- Each project configuration has persistent `line_no` (`10,20,30...`) in both SQLite and MariaDB.
|
||||
- Project list ordering is deterministic:
|
||||
`line_no ASC`, then `created_at DESC`, then `id DESC`.
|
||||
- Drag-and-drop reorder in project UI updates `line_no` for active project configurations.
|
||||
- Reorder writes are queued as configuration `update` events in `pending_changes`
|
||||
without creating new configuration versions.
|
||||
- Backward compatibility: if remote MariaDB schema does not yet include `line_no`,
|
||||
sync falls back to create/update without `line_no` instead of failing.
|
||||
|
||||
---
|
||||
|
||||
## Sync Payload for Versioning
|
||||
|
||||
Events in `pending_changes` for configurations contain:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `configuration_uuid` | Identifier |
|
||||
| `operation` | `create` / `update` / `rollback` |
|
||||
| `current_version_id` | Active version ID |
|
||||
| `current_version_no` | Version number |
|
||||
| `snapshot` | Current configuration state |
|
||||
| `idempotency_key` | For idempotent push |
|
||||
| `conflict_policy` | `last_write_wins` |
|
||||
|
||||
---
|
||||
|
||||
## Background Processes
|
||||
|
||||
| Process | Interval | What it does |
|
||||
|---------|----------|--------------|
|
||||
| Sync worker | 5 min | push pending + pull all |
|
||||
| Backup scheduler | configurable (`backup.time`) | creates ZIP archives |
|
||||
267
bible-local/03-database.md
Normal file
267
bible-local/03-database.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# 03 — Database
|
||||
|
||||
## SQLite (local, client-side)
|
||||
|
||||
File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
||||
|
||||
### Tables
|
||||
|
||||
#### Components and Reference Data
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_components` | Component metadata (NO prices) | `lot_name` (PK), `lot_description`, `category`, `model` |
|
||||
| `connection_settings` | MariaDB connection settings | key-value store |
|
||||
| `app_settings` | Application settings | `key` (PK), `value`, `updated_at` |
|
||||
|
||||
Read-only cache contract:
|
||||
|
||||
- `local_components`, `local_pricelists`, `local_pricelist_items`, `local_partnumber_books`, and `local_partnumber_book_items` are synchronized caches, not user-authored data.
|
||||
- Startup must prefer application availability over preserving a broken cache schema.
|
||||
- If one of these tables cannot be migrated safely, the client may quarantine or drop it and recreate it empty; the next sync repopulates it.
|
||||
|
||||
#### Pricelists
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` |
|
||||
| `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` |
|
||||
|
||||
#### Partnumber Books (PN → LOT mapping, pull-only from PriceForge)
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_partnumber_books` | Version snapshots of PN→LOT mappings | `id`, `server_id` (unique), `version`, `created_at`, `is_active` |
|
||||
| `local_partnumber_book_items` | Canonical PN catalog rows | `id`, `partnumber`, `lots_json`, `description` |
|
||||
|
||||
Active book: `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
|
||||
|
||||
#### Configurations and Projects
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `vendor_spec` (JSON: PN/qty/description + canonical `lot_mappings[]`), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
|
||||
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
|
||||
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
|
||||
|
||||
#### Sync
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `pending_changes` | Queue of changes to push to MariaDB |
|
||||
| `local_schema_migrations` | Applied migrations (idempotency guard) |
|
||||
|
||||
---
|
||||
|
||||
### Key SQLite Indexes
|
||||
|
||||
```sql
|
||||
-- Pricelists
|
||||
INDEX local_pricelist_items(pricelist_id)
|
||||
UNIQUE INDEX local_pricelists(server_id)
|
||||
INDEX local_pricelists(source, created_at) -- used for "latest by source" queries
|
||||
-- latest-by-source runtime query also applies deterministic tie-break by id DESC
|
||||
|
||||
-- Configurations
|
||||
INDEX local_configurations(pricelist_id)
|
||||
INDEX local_configurations(warehouse_pricelist_id)
|
||||
INDEX local_configurations(competitor_pricelist_id)
|
||||
INDEX local_configurations(project_uuid, line_no) -- project ordering (Line column)
|
||||
UNIQUE INDEX local_configurations(uuid)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `items` JSON Structure in Configurations
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"lot_name": "CPU_AMD_9654",
|
||||
"quantity": 2,
|
||||
"unit_price": 123456.78,
|
||||
"section": "Processors"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Prices are stored inside the `items` JSON field and refreshed from the pricelist on configuration refresh.
|
||||
|
||||
---
|
||||
|
||||
## MariaDB (server-side, sync-only)
|
||||
|
||||
Database: `RFQ_LOG`
|
||||
|
||||
### Tables and Permissions
|
||||
|
||||
| Table | Purpose | Permissions |
|
||||
|-------|---------|-------------|
|
||||
| `lot` | Component catalog | SELECT |
|
||||
| `qt_lot_metadata` | Extended component data | SELECT |
|
||||
| `qt_categories` | Component categories | SELECT |
|
||||
| `qt_pricelists` | Pricelists | SELECT |
|
||||
| `qt_pricelist_items` | Pricelist line items | SELECT |
|
||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment during sync only) | SELECT |
|
||||
| `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE |
|
||||
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
||||
| `qt_client_schema_state` | Applied migrations state + client operational status per `username + hostname` | SELECT, INSERT, UPDATE |
|
||||
| `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE |
|
||||
| `qt_partnumber_books` | Partnumber book headers with snapshot membership in `partnumbers_json` (written by PriceForge) | SELECT |
|
||||
| `qt_partnumber_book_items` | Canonical PN catalog with `lots_json` composition (written by PriceForge) | SELECT |
|
||||
| `qt_vendor_partnumber_seen` | Vendor PN tracking for unresolved/ignored BOM rows (`is_ignored`) | INSERT only for new `partnumber`; existing rows must not be modified |
|
||||
|
||||
Legacy server tables not used by QuoteForge runtime anymore:
|
||||
|
||||
- `qt_bom`
|
||||
- `qt_lot_bundles`
|
||||
- `qt_lot_bundle_items`
|
||||
|
||||
QuoteForge canonical BOM storage is:
|
||||
|
||||
- `qt_configurations.vendor_spec`
|
||||
- row-level PN -> multiple LOT decomposition in `vendor_spec[].lot_mappings[]`
|
||||
|
||||
Partnumber book server read contract:
|
||||
|
||||
1. Read active or target book from `qt_partnumber_books`.
|
||||
2. Parse `partnumbers_json`.
|
||||
3. Load payloads from `qt_partnumber_book_items WHERE partnumber IN (...)`.
|
||||
|
||||
Pricelist stock enrichment contract:
|
||||
|
||||
1. Sync pulls base pricelist rows from `qt_pricelist_items`.
|
||||
2. Sync reads latest stock quantities from `stock_log`.
|
||||
3. Sync resolves `partnumber -> lot` through the local mirror of `qt_partnumber_book_items` (`local_partnumber_book_items.lots_json`).
|
||||
4. Sync stores enriched `available_qty` and `partnumbers` into `local_pricelist_items`.
|
||||
|
||||
Runtime rule:
|
||||
|
||||
- pricelist UI and quote logic read only `local_pricelist_items`;
|
||||
- runtime code must not query `stock_log`, `qt_pricelist_items`, or `qt_partnumber_book_items` directly outside sync.
|
||||
|
||||
`qt_partnumber_book_items` no longer contains `book_id` or `lot_name`.
|
||||
It stores one row per `partnumber` with:
|
||||
|
||||
- `partnumber`
|
||||
- `lots_json` as `[{"lot_name":"CPU_X","qty":2}, ...]`
|
||||
- `description`
|
||||
|
||||
`qt_client_schema_state` current contract:
|
||||
|
||||
- identity key: `username + hostname`
|
||||
- client/runtime state:
|
||||
`app_version`, `last_checked_at`, `updated_at`
|
||||
- operational state:
|
||||
`last_sync_at`, `last_sync_status`
|
||||
- queue health:
|
||||
`pending_changes_count`, `pending_errors_count`
|
||||
- local dataset size:
|
||||
`configurations_count`, `projects_count`
|
||||
- price context:
|
||||
`estimate_pricelist_version`, `warehouse_pricelist_version`, `competitor_pricelist_version`
|
||||
- last known sync problem:
|
||||
`last_sync_error_code`, `last_sync_error_text`
|
||||
|
||||
`last_sync_error_*` source priority:
|
||||
|
||||
1. blocked readiness state from `local_sync_guard_state`
|
||||
2. latest non-empty `pending_changes.last_error`
|
||||
3. `NULL` when no known sync problem exists
|
||||
|
||||
### Grant Permissions to Existing User
|
||||
|
||||
```sql
|
||||
GRANT SELECT ON RFQ_LOG.lot TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.stock_log TO '<DB_USER>'@'%';
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
|
||||
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
|
||||
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO '<DB_USER>'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### Create a New User
|
||||
|
||||
```sql
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
|
||||
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.stock_log TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'quote_user'@'%';
|
||||
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'quote_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
SHOW GRANTS FOR 'quote_user'@'%';
|
||||
```
|
||||
|
||||
**Note:** If pricelists sync but stock enrichment is empty, verify `SELECT` on `qt_pricelist_items`, `qt_partnumber_books`, `qt_partnumber_book_items`, and `stock_log`.
|
||||
|
||||
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
### SQLite Migrations (local) — два уровня, выполняются при каждом старте
|
||||
|
||||
**1. GORM AutoMigrate** (`internal/localdb/localdb.go`) — первый и основной уровень.
|
||||
Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**.
|
||||
|
||||
Local SQLite partnumber book cache contract:
|
||||
|
||||
- `local_partnumber_books.partnumbers_json` stores PN membership for a pulled book.
|
||||
- `local_partnumber_book_items` is a deduplicated local catalog by `partnumber`.
|
||||
- `local_partnumber_book_items.lots_json` mirrors the server `lots_json` payload.
|
||||
- SQLite migration `2026_03_07_local_partnumber_book_catalog` rebuilds old `book_id + lot_name` rows into the new local cache shape.
|
||||
→ Для добавления новой таблицы или колонки достаточно добавить модель/поле и включить модель в AutoMigrate.
|
||||
|
||||
**2. `runLocalMigrations`** (`internal/localdb/migrations.go`) — второй уровень, для операций которые AutoMigrate не умеет: backfill данных, пересоздание таблиц, создание индексов.
|
||||
Каждая функция выполняется один раз — идемпотентность через запись `id` в `local_schema_migrations`.
|
||||
|
||||
QuoteForge does not use centralized server-driven SQLite migrations.
|
||||
All local SQLite schema/data migrations live in the client codebase.
|
||||
|
||||
### MariaDB Migrations (server-side)
|
||||
|
||||
- Stored in `migrations/` (SQL files)
|
||||
- Applied via `-migrate` flag
|
||||
- `min_app_version` — minimum app version required for the migration
|
||||
|
||||
---
|
||||
|
||||
## DB Debugging
|
||||
|
||||
```bash
|
||||
# Inspect schema
|
||||
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_components"
|
||||
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_configurations"
|
||||
|
||||
# Check pricelist item count
|
||||
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM local_pricelist_items"
|
||||
|
||||
# Check pending sync queue
|
||||
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM pending_changes"
|
||||
```
|
||||
170
bible-local/04-api.md
Normal file
170
bible-local/04-api.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 04 — API and Web Routes
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Setup
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/setup` | Initial setup page |
|
||||
| POST | `/setup` | Save connection settings |
|
||||
| POST | `/setup/test` | Test MariaDB connection |
|
||||
| GET | `/setup/status` | Setup status |
|
||||
|
||||
### Components
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/components` | List components (metadata only) |
|
||||
| GET | `/api/components/:lot_name` | Component by lot_name |
|
||||
| GET | `/api/categories` | List categories |
|
||||
|
||||
### Quote
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| POST | `/api/quote/validate` | Validate line items |
|
||||
| POST | `/api/quote/calculate` | Calculate quote (prices from pricelist) |
|
||||
| POST | `/api/quote/price-levels` | Prices by level (estimate/warehouse/competitor) |
|
||||
|
||||
### Pricelists (read-only)
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/pricelists` | List pricelists (`source`, `active_only`, pagination) |
|
||||
| GET | `/api/pricelists/latest` | Latest pricelist by source |
|
||||
| GET | `/api/pricelists/:id` | Pricelist by ID |
|
||||
| GET | `/api/pricelists/:id/items` | Pricelist line items |
|
||||
| GET | `/api/pricelists/:id/lots` | Lot names in pricelist |
|
||||
|
||||
`GET /api/pricelists?active_only=true` returns only pricelists that have synced items (`item_count > 0`).
|
||||
|
||||
### Configurations
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/configs` | List configurations |
|
||||
| POST | `/api/configs` | Create configuration |
|
||||
| GET | `/api/configs/:uuid` | Get configuration |
|
||||
| PUT | `/api/configs/:uuid` | Update configuration |
|
||||
| DELETE | `/api/configs/:uuid` | Archive configuration |
|
||||
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
|
||||
| POST | `/api/configs/:uuid/clone` | Clone configuration |
|
||||
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
|
||||
| POST | `/api/configs/:uuid/rename` | Rename configuration |
|
||||
| POST | `/api/configs/preview-article` | Preview generated article for a configuration |
|
||||
| POST | `/api/configs/:uuid/rollback` | Roll back to a version |
|
||||
| GET | `/api/configs/:uuid/versions` | List versions |
|
||||
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
|
||||
|
||||
`line` field in configuration payloads is backed by persistent `line_no` in DB.
|
||||
|
||||
### Projects
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/projects` | List projects |
|
||||
| POST | `/api/projects` | Create project |
|
||||
| GET | `/api/projects/:uuid` | Get project |
|
||||
| PUT | `/api/projects/:uuid` | Update project |
|
||||
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
|
||||
| GET | `/api/projects/:uuid/configs` | Project configurations |
|
||||
| PATCH | `/api/projects/:uuid/configs/reorder` | Reorder active project configurations (`ordered_uuids`) and persist `line_no` |
|
||||
| POST | `/api/projects/:uuid/vendor-import` | Import a vendor `CFXML` workspace into the existing project |
|
||||
|
||||
`GET /api/projects/:uuid/configs` ordering:
|
||||
`line ASC`, then `created_at DESC`, then `id DESC`.
|
||||
|
||||
`POST /api/projects/:uuid/vendor-import` accepts `multipart/form-data` with one required file field:
|
||||
|
||||
- `file` — vendor configurator export in `CFXML` format
|
||||
|
||||
### Sync
|
||||
|
||||
| Method | Endpoint | Purpose | Flow |
|
||||
|--------|----------|---------|------|
|
||||
| GET | `/api/sync/status` | Overall sync status | read-only |
|
||||
| GET | `/api/sync/readiness` | Preflight status (ready/blocked/unknown) | read-only |
|
||||
| GET | `/api/sync/info` | Data for sync modal | read-only |
|
||||
| GET | `/api/sync/users-status` | Users status | read-only |
|
||||
| GET | `/api/sync/pending` | List pending changes | read-only |
|
||||
| GET | `/api/sync/pending/count` | Count of pending changes | read-only |
|
||||
| POST | `/api/sync/push` | Push pending → MariaDB | SQLite → MariaDB |
|
||||
| POST | `/api/sync/components` | Pull components | MariaDB → SQLite |
|
||||
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
|
||||
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
|
||||
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
|
||||
| POST | `/api/sync/partnumber-seen` | Report unresolved/ignored vendor PNs for server-side tracking | QuoteForge → MariaDB |
|
||||
|
||||
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
|
||||
|
||||
### Vendor Spec (BOM)
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored vendor BOM |
|
||||
| PUT | `/api/configs/:uuid/vendor-spec` | Replace vendor BOM (full update) |
|
||||
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (read-only) |
|
||||
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
|
||||
|
||||
Notes:
|
||||
- `GET` / `PUT /api/configs/:uuid/vendor-spec` exchange normalized BOM rows (`vendor_spec`), not raw pasted Excel layout.
|
||||
- BOM row contract stores canonical LOT mapping list as seen in BOM UI:
|
||||
- `lot_mappings[]`
|
||||
- each mapping contains `lot_name` + `quantity_per_pn`
|
||||
- `POST /api/configs/:uuid/vendor-spec/apply` rebuilds cart items from explicit BOM mappings:
|
||||
- all LOTs from `lot_mappings[]`
|
||||
|
||||
### Partnumber Books (read-only)
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/partnumber-books` | List local book snapshots |
|
||||
| GET | `/api/partnumber-books/:id` | Items for a book by `server_id` (`page`, `per_page`, `search`) |
|
||||
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
|
||||
|
||||
See [09-vendor-spec.md](09-vendor-spec.md) for schema and pull logic.
|
||||
See [09-vendor-spec.md](09-vendor-spec.md) for `vendor_spec` JSON schema and BOM UI mapping contract.
|
||||
|
||||
### Export
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| POST | `/api/export/csv` | Export configuration to CSV |
|
||||
| GET | `/api/projects/:uuid/export` | Legacy project CSV export in block BOM format |
|
||||
| POST | `/api/projects/:uuid/export` | Project CSV export in pricing-tab format with selectable columns (`include_lot`, `include_bom`, `include_estimate`, `include_stock`, `include_competitor`) |
|
||||
|
||||
**Export filename format:** `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
||||
(uses `project.Code`, not `project.Name`)
|
||||
|
||||
---
|
||||
|
||||
## Web Routes
|
||||
|
||||
| Route | Page |
|
||||
|-------|------|
|
||||
| `/configs` | Configuration list |
|
||||
| `/configurator` | Configurator |
|
||||
| `/configs/:uuid/revisions` | Configuration revision history |
|
||||
| `/projects` | Project list |
|
||||
| `/projects/:uuid` | Project details |
|
||||
| `/pricelists` | Pricelist list |
|
||||
| `/pricelists/:id` | Pricelist details |
|
||||
| `/partnumber-books` | Partnumber books (active book summary + snapshot history) |
|
||||
| `/setup` | Connection settings |
|
||||
|
||||
---
|
||||
|
||||
## Rollback API (details)
|
||||
|
||||
```bash
|
||||
POST /api/configs/:uuid/rollback
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"target_version": 3,
|
||||
"note": "optional comment"
|
||||
}
|
||||
```
|
||||
|
||||
Response: updated configuration with the new version.
|
||||
141
bible-local/05-config.md
Normal file
141
bible-local/05-config.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 05 — Configuration and Environment
|
||||
|
||||
## File Paths
|
||||
|
||||
### SQLite database (`qfs.db`)
|
||||
|
||||
| OS | Default path |
|
||||
|----|-------------|
|
||||
| macOS | `~/Library/Application Support/QuoteForge/qfs.db` |
|
||||
| Linux | `$XDG_STATE_HOME/quoteforge/qfs.db` or `~/.local/state/quoteforge/qfs.db` |
|
||||
| Windows | `%LOCALAPPDATA%\QuoteForge\qfs.db` |
|
||||
|
||||
Override: `-localdb <path>` or `QFS_DB_PATH`.
|
||||
|
||||
### config.yaml
|
||||
|
||||
Searched in the same user-state directory as `qfs.db` by default.
|
||||
If the file does not exist, it is created automatically.
|
||||
If the format is outdated, it is automatically migrated to the runtime format (`server` + `logging` sections only).
|
||||
|
||||
Override: `-config <path>` or `QFS_CONFIG_PATH`.
|
||||
|
||||
**Important:** `config.yaml` is a runtime user file — it is **not stored in the repository**.
|
||||
`config.example.yaml` is the only config template in the repo.
|
||||
|
||||
### Local encryption key
|
||||
|
||||
Saved MariaDB credentials in SQLite are encrypted with:
|
||||
|
||||
1. `QUOTEFORGE_ENCRYPTION_KEY` if explicitly provided, otherwise
|
||||
2. an application-managed random key file stored at `<state dir>/local_encryption.key`.
|
||||
|
||||
Rules:
|
||||
- The key file is created automatically with mode `0600`.
|
||||
- The key file is not committed and is not included in normal backups.
|
||||
- Restoring `qfs.db` on another machine requires re-entering DB credentials unless the key file is migrated separately.
|
||||
|
||||
---
|
||||
|
||||
## config.yaml Structure
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
mode: "release" # release | debug
|
||||
|
||||
logging:
|
||||
level: "info" # debug | info | warn | error
|
||||
format: "json" # json | text
|
||||
output: "stdout" # stdout | stderr | /path/to/file
|
||||
|
||||
backup:
|
||||
time: "00:00" # HH:MM in local time
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `QFS_DB_PATH` | Full path to SQLite DB | OS-specific user state dir |
|
||||
| `QFS_STATE_DIR` | State directory (if `QFS_DB_PATH` is not set) | OS-specific user state dir |
|
||||
| `QFS_CONFIG_PATH` | Full path to `config.yaml` | OS-specific user state dir |
|
||||
| `QFS_BACKUP_DIR` | Root directory for rotating backups | `<db dir>/backups` |
|
||||
| `QFS_BACKUP_DISABLE` | Disable automatic backups | — |
|
||||
| `QUOTEFORGE_ENCRYPTION_KEY` | Explicit override for local credential encryption key | app-managed key file |
|
||||
| `QF_DB_HOST` | MariaDB host | localhost |
|
||||
| `QF_DB_PORT` | MariaDB port | 3306 |
|
||||
| `QF_DB_NAME` | Database name | RFQ_LOG |
|
||||
| `QF_DB_USER` | DB user | — |
|
||||
| `QF_DB_PASSWORD` | DB password | — |
|
||||
| `QF_SERVER_PORT` | HTTP server port | 8080 |
|
||||
|
||||
`QFS_BACKUP_DISABLE` accepts: `1`, `true`, `yes`.
|
||||
|
||||
---
|
||||
|
||||
## CLI Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-config <path>` | Path to config.yaml |
|
||||
| `-localdb <path>` | Path to SQLite DB |
|
||||
| `-reset-localdb` | Reset local DB (destructive!) |
|
||||
| `-migrate` | Apply pending migrations and exit |
|
||||
| `-version` | Print version and exit |
|
||||
|
||||
---
|
||||
|
||||
## Installation and First Run
|
||||
|
||||
### Requirements
|
||||
- Go 1.22 or higher
|
||||
- MariaDB 11.x (or MySQL 8.x)
|
||||
- ~50 MB disk space
|
||||
|
||||
### Steps
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone <repo-url>
|
||||
cd quoteforge
|
||||
|
||||
# 2. Apply migrations
|
||||
go run ./cmd/qfs -migrate
|
||||
|
||||
# 3. Start
|
||||
go run ./cmd/qfs
|
||||
# or
|
||||
make run
|
||||
```
|
||||
|
||||
Application is available at: http://localhost:8080
|
||||
|
||||
On first run, `/setup` opens for configuring the MariaDB connection.
|
||||
|
||||
### OPS Project Migrator
|
||||
|
||||
Migrates quotes whose names start with `OPS-xxxx` (where `x` is a digit) into a project named `OPS-xxxx`.
|
||||
|
||||
```bash
|
||||
# Preview first (always)
|
||||
go run ./cmd/migrate_ops_projects
|
||||
|
||||
# Apply
|
||||
go run ./cmd/migrate_ops_projects -apply
|
||||
|
||||
# Apply without interactive confirmation
|
||||
go run ./cmd/migrate_ops_projects -apply -yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t quoteforge .
|
||||
docker-compose up -d
|
||||
```
|
||||
227
bible-local/06-backup.md
Normal file
227
bible-local/06-backup.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 06 — Backup
|
||||
|
||||
## Overview
|
||||
|
||||
Automatic rotating ZIP backup system for local data.
|
||||
|
||||
**What is included in each archive:**
|
||||
- SQLite DB (`qfs.db`)
|
||||
- SQLite sidecars (`qfs.db-wal`, `qfs.db-shm`) if present
|
||||
- `config.yaml` if present
|
||||
|
||||
**Archive name format:** `qfs-backp-YYYY-MM-DD.zip`
|
||||
|
||||
**Retention policy:**
|
||||
| Period | Keep |
|
||||
|--------|------|
|
||||
| Daily | 7 archives |
|
||||
| Weekly | 4 archives |
|
||||
| Monthly | 12 archives |
|
||||
| Yearly | 10 archives |
|
||||
|
||||
**Directories:** `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
backup:
|
||||
time: "00:00" # Trigger time in local time (HH:MM format)
|
||||
```
|
||||
|
||||
**Environment variables:**
|
||||
- `QFS_BACKUP_DIR` — backup root directory (default: `<db dir>/backups`)
|
||||
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`)
|
||||
|
||||
**Safety rules:**
|
||||
- Backup root must resolve outside any git worktree.
|
||||
- If `qfs.db` is placed inside a repository checkout, default backups are rejected until `QFS_BACKUP_DIR` points outside the repo.
|
||||
- Backup archives intentionally do **not** include `local_encryption.key`; restored installations on another machine must re-enter DB credentials.
|
||||
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
- **At startup:** if no backup exists for the current period, one is created immediately
|
||||
- **Daily:** at the configured time, a new backup is created
|
||||
- **Deduplication:** prevented via a `.period.json` marker file in each period directory
|
||||
- **Rotation:** excess old archives are deleted automatically
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
Module: `internal/appstate/backup.go`
|
||||
|
||||
Main function:
|
||||
```go
|
||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error)
|
||||
```
|
||||
|
||||
Scheduler (in `main.go`):
|
||||
```go
|
||||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string)
|
||||
```
|
||||
|
||||
### Config struct
|
||||
|
||||
```go
|
||||
type BackupConfig struct {
|
||||
Time string `yaml:"time"`
|
||||
}
|
||||
// Default: "00:00"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `backup.time` is in **local time** without timezone offset parsing
|
||||
- `.period.json` is the marker that prevents duplicate backups within the same period
|
||||
- Archive filenames contain only the date; uniqueness is ensured by per-period directories + the period marker
|
||||
- When changing naming or retention: update both the filename logic and the prune logic together
|
||||
- Git worktree detection is path-based (`.git` ancestor check) and blocks backup creation inside the repo tree
|
||||
|
||||
---
|
||||
|
||||
## Full Listing: `internal/appstate/backup.go`
|
||||
|
||||
```go
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type backupPeriod struct {
|
||||
name string
|
||||
retention int
|
||||
key func(time.Time) string
|
||||
date func(time.Time) string
|
||||
}
|
||||
|
||||
var backupPeriods = []backupPeriod{
|
||||
{
|
||||
name: "daily",
|
||||
retention: 7,
|
||||
key: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
},
|
||||
{
|
||||
name: "weekly",
|
||||
retention: 4,
|
||||
key: func(t time.Time) string {
|
||||
y, w := t.ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", y, w)
|
||||
},
|
||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
},
|
||||
{
|
||||
name: "monthly",
|
||||
retention: 12,
|
||||
key: func(t time.Time) string { return t.Format("2006-01") },
|
||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
},
|
||||
{
|
||||
name: "yearly",
|
||||
retention: 10,
|
||||
key: func(t time.Time) string { return t.Format("2006") },
|
||||
date: func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
},
|
||||
}
|
||||
|
||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
||||
if isBackupDisabled() || dbPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
root := resolveBackupRoot(dbPath)
|
||||
now := time.Now()
|
||||
created := make([]string, 0)
|
||||
for _, period := range backupPeriods {
|
||||
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
created = append(created, newFiles...)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full Listing: Scheduler Hook (`main.go`)
|
||||
|
||||
```go
|
||||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
hour, minute, err := parseBackupTime(cfg.Backup.Time)
|
||||
if err != nil {
|
||||
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
|
||||
hour, minute = 0, 0
|
||||
}
|
||||
|
||||
// Startup check: create backup immediately if none exists for current periods
|
||||
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
|
||||
slog.Error("local backup failed", "error", backupErr)
|
||||
} else {
|
||||
for _, path := range created {
|
||||
slog.Info("local backup completed", "archive", path)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
next := nextBackupTime(time.Now(), hour, minute)
|
||||
timer := time.NewTimer(time.Until(next))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
start := time.Now()
|
||||
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
|
||||
duration := time.Since(start)
|
||||
if backupErr != nil {
|
||||
slog.Error("local backup failed", "error", backupErr, "duration", duration)
|
||||
} else {
|
||||
for _, path := range created {
|
||||
slog.Info("local backup completed", "archive", path, "duration", duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseBackupTime(value string) (int, int, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return 0, 0, fmt.Errorf("empty backup time")
|
||||
}
|
||||
parsed, err := time.Parse("15:04", value)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return parsed.Hour(), parsed.Minute(), nil
|
||||
}
|
||||
|
||||
func nextBackupTime(now time.Time, hour, minute int) time.Time {
|
||||
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
||||
if !now.Before(target) {
|
||||
target = target.Add(24 * time.Hour)
|
||||
}
|
||||
return target
|
||||
}
|
||||
```
|
||||
141
bible-local/07-dev.md
Normal file
141
bible-local/07-dev.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 07 — Development
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Run (dev)
|
||||
go run ./cmd/qfs
|
||||
make run
|
||||
|
||||
# Build
|
||||
make build-release # Optimized build with version info
|
||||
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
|
||||
|
||||
# Cross-platform build
|
||||
make build-all # Linux, macOS, Windows
|
||||
make build-windows # Windows only
|
||||
|
||||
# Verification
|
||||
go build ./cmd/qfs # Must compile without errors
|
||||
go vet ./... # Linter
|
||||
|
||||
# Tests
|
||||
go test ./...
|
||||
make test
|
||||
|
||||
# Utilities
|
||||
make install-hooks # Git hooks (block committing secrets)
|
||||
make clean # Clean bin/
|
||||
make help # All available commands
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Formatting:** `gofmt` (mandatory)
|
||||
- **Logging:** `slog` only (structured logging to the binary's stdout/stderr). No `console.log` or any other logging in browser-side JS — the browser console is never used for diagnostics.
|
||||
- **Errors:** explicit wrapping with context (`fmt.Errorf("context: %w", err)`)
|
||||
- **Style:** no unnecessary abstractions; minimum code for the task
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
### What Must Never Be Restored
|
||||
|
||||
The following components were **intentionally removed** and must not be brought back:
|
||||
- cron jobs
|
||||
- importer utility
|
||||
- admin pricing UI/API
|
||||
- alerts
|
||||
- stock import
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `config.yaml` — runtime user file, **not stored in the repository**
|
||||
- `config.example.yaml` — the only config template in the repo
|
||||
|
||||
### Sync and Local-First
|
||||
|
||||
- Any sync changes must preserve local-first behavior
|
||||
- Local CRUD must not be blocked when MariaDB is unavailable
|
||||
- Runtime business code must not query MariaDB directly; all normal reads/writes go through SQLite snapshots
|
||||
- Direct MariaDB access is allowed only in `internal/services/sync/*` and dedicated setup/migration tools under `cmd/`
|
||||
- `connMgr.GetDB()` in handlers/services outside sync is a code review failure unless the code is strictly setup or operator tooling
|
||||
- Local SQLite migrations must be implemented in code; do not add a server-side registry of client SQLite SQL patches
|
||||
- Read-only local cache tables may be reset during startup recovery if migration fails; do not apply that strategy to user-authored tables like configurations, projects, pending changes, or connection settings
|
||||
|
||||
### Formats and UI
|
||||
|
||||
- **CSV export:** filename must use **project code** (`project.Code`), not project name
|
||||
Format: `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
||||
- **Breadcrumbs UI:** names longer than 16 characters must be truncated with an ellipsis
|
||||
|
||||
### Architecture Documentation
|
||||
|
||||
- **Every architectural decision must be recorded in `bible/`**
|
||||
- The corresponding Bible file must be updated **in the same commit** as the code change
|
||||
- On every user-requested commit, review and update the Bible in that same commit
|
||||
|
||||
---
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Add a Field to Configuration
|
||||
|
||||
1. Add the field to `LocalConfiguration` struct (`internal/models/`)
|
||||
2. Add GORM tags for the DB column
|
||||
3. Write a SQL migration (`migrations/`)
|
||||
4. Update `ConfigurationToLocal` / `LocalToConfiguration` converters
|
||||
5. Update API handlers and services
|
||||
|
||||
### Add a Field to Component
|
||||
|
||||
1. Add the field to `LocalComponent` struct (`internal/models/`)
|
||||
2. Update the SQL query in `SyncComponents()`
|
||||
3. Update the `componentRow` struct to match
|
||||
4. Update converter functions
|
||||
|
||||
### Add a Pricelist Price Lookup
|
||||
|
||||
```go
|
||||
// Modern pattern
|
||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
||||
if found && price > 0 {
|
||||
// use price
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
1. **`CurrentPrice` removed from components** — any code using it will fail to compile
|
||||
2. **`HasPrice` filter removed** — `component.go ListComponents` no longer supports this filter
|
||||
3. **Quote calculation:** always SQLite-only; do not add a live MariaDB fallback
|
||||
4. **Items JSON:** prices are stored in the `items` field of the configuration, not fetched from components
|
||||
5. **Migrations are additive:** already-applied migrations are skipped (checked by `id` in `local_schema_migrations`)
|
||||
6. **`SyncedAt` removed:** last component sync time is now in `app_settings` (key=`last_component_sync`)
|
||||
|
||||
---
|
||||
|
||||
## Debugging Price Issues
|
||||
|
||||
**Problem: quote returns no prices**
|
||||
1. Check that `pricelist_id` is set on the configuration
|
||||
2. Check that pricelist items exist: `SELECT COUNT(*) FROM local_pricelist_items`
|
||||
3. Check `lookupPriceByPricelistID()` in `quote.go`
|
||||
4. Verify the correct source is used (estimate/warehouse/competitor)
|
||||
|
||||
**Problem: component sync not working**
|
||||
1. Components sync as metadata only — no prices
|
||||
2. Prices come via a separate pricelist sync
|
||||
3. Check `SyncComponents()` and the MariaDB query
|
||||
|
||||
**Problem: configuration refresh does not update prices**
|
||||
1. Refresh uses the latest estimate pricelist by default
|
||||
2. Latest resolution ignores pricelists without items (`EXISTS local_pricelist_items`)
|
||||
3. Old prices in `config.items` are preserved if a line item is not found in the pricelist
|
||||
4. To force a pricelist update: set `configuration.pricelist_id`
|
||||
5. In configurator, `Авто` must remain auto-mode (runtime resolved ID must not be persisted as explicit selection)
|
||||
568
bible-local/09-vendor-spec.md
Normal file
568
bible-local/09-vendor-spec.md
Normal file
@@ -0,0 +1,568 @@
|
||||
# 09 — Vendor Spec (BOM Import)
|
||||
|
||||
## Overview
|
||||
|
||||
The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a configuration. It maps vendor part numbers (PN) to internal LOT names using an active partnumber book (snapshot pulled from PriceForge), then aggregates quantities to populate the Estimate (cart).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Storage
|
||||
|
||||
| Data | Storage | Sync direction |
|
||||
|------|---------|---------------|
|
||||
| `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT, JSON-encoded) | Two-way via `pending_changes` |
|
||||
| Partnumber book snapshots | `local_partnumber_books` + `local_partnumber_book_items` | Pull-only from PriceForge |
|
||||
|
||||
`vendor_spec` is a JSON array of `VendorSpecItem` objects stored inside the configuration row.
|
||||
It syncs to MariaDB `qt_configurations.vendor_spec` via the existing pending_changes mechanism.
|
||||
|
||||
Legacy storage note:
|
||||
|
||||
- QuoteForge does not use `qt_bom`
|
||||
- QuoteForge does not use `qt_lot_bundles`
|
||||
- QuoteForge does not use `qt_lot_bundle_items`
|
||||
|
||||
The only canonical persisted BOM contract for QuoteForge is `qt_configurations.vendor_spec`.
|
||||
|
||||
### `vendor_spec` JSON Schema
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"sort_order": 10,
|
||||
"vendor_partnumber": "ABC-123",
|
||||
"quantity": 2,
|
||||
"description": "...",
|
||||
"unit_price": 4500.00,
|
||||
"total_price": 9000.00,
|
||||
"lot_mappings": [
|
||||
{ "lot_name": "LOT_A", "quantity_per_pn": 1 },
|
||||
{ "lot_name": "LOT_B", "quantity_per_pn": 2 }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`lot_mappings[]` is the canonical persisted LOT mapping list for a BOM row.
|
||||
Each mapping entry stores:
|
||||
|
||||
- `lot_name`
|
||||
- `quantity_per_pn` (how many units of this LOT are included in **one vendor PN**)
|
||||
|
||||
### PN → LOT Mapping Contract (single LOT, multiplier, bundle)
|
||||
|
||||
QuoteForge expects the server to return/store BOM rows (`vendor_spec`) using a single canonical mapping list:
|
||||
|
||||
- `lot_mappings[]` contains **all** LOT mappings for the PN row (single LOT and bundle cases alike)
|
||||
- the list stores exactly what the user sees in BOM (LOT + "LOT в 1 PN")
|
||||
- the DB contract does **not** split mappings into "base LOT" vs "bundle LOTs"
|
||||
|
||||
#### Final quantity contribution to Estimate
|
||||
|
||||
For one BOM row with vendor PN quantity `pn_qty`:
|
||||
|
||||
- each mapping contribution:
|
||||
- `lot_qty = pn_qty * lot_mappings[i].quantity_per_pn`
|
||||
|
||||
#### Example: one PN maps to multiple LOTs
|
||||
|
||||
```json
|
||||
{
|
||||
"vendor_partnumber": "SYS-821GE-TNHR",
|
||||
"quantity": 3,
|
||||
"lot_mappings": [
|
||||
{ "lot_name": "CHASSIS_X13_8GPU", "quantity_per_pn": 1 },
|
||||
{ "lot_name": "PS_3000W_Titanium", "quantity_per_pn": 2 },
|
||||
{ "lot_name": "RAILKIT_X13", "quantity_per_pn": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This row contributes to Estimate:
|
||||
|
||||
- `CHASSIS_X13_8GPU` → `3 * 1 = 3`
|
||||
- `PS_3000W_Titanium` → `3 * 2 = 6`
|
||||
- `RAILKIT_X13` → `3 * 1 = 3`
|
||||
|
||||
---
|
||||
|
||||
## Partnumber Books (Snapshots)
|
||||
|
||||
Partnumber books are immutable versioned snapshots of the global PN→LOT mapping table, analogous to pricelists. PriceForge creates new snapshots; QuoteForge only pulls and reads them.
|
||||
|
||||
### SQLite (local mirror)
|
||||
|
||||
```sql
|
||||
CREATE TABLE local_partnumber_books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER UNIQUE NOT NULL, -- id from qt_partnumber_books
|
||||
version TEXT NOT NULL, -- format YYYY-MM-DD-NNN
|
||||
created_at DATETIME NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partnumber TEXT NOT NULL,
|
||||
lots_json TEXT NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_local_book_pn ON local_partnumber_book_items(partnumber);
|
||||
```
|
||||
|
||||
**Active book query:** `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
|
||||
|
||||
**Schema creation:** GORM AutoMigrate (not `runLocalMigrations`).
|
||||
|
||||
### MariaDB (managed exclusively by PriceForge)
|
||||
|
||||
```sql
|
||||
CREATE TABLE qt_partnumber_books (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
version VARCHAR(50) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
partnumbers_json LONGTEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE qt_partnumber_book_items (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
partnumber VARCHAR(255) NOT NULL,
|
||||
lots_json LONGTEXT NOT NULL,
|
||||
description VARCHAR(10000) NULL,
|
||||
UNIQUE KEY uq_qt_partnumber_book_items_partnumber (partnumber)
|
||||
);
|
||||
|
||||
ALTER TABLE qt_configurations ADD COLUMN vendor_spec JSON NULL;
|
||||
```
|
||||
|
||||
QuoteForge has `SELECT` permission only on `qt_partnumber_books` and `qt_partnumber_book_items`. All writes are managed by PriceForge.
|
||||
|
||||
**Grant (add to existing user setup):**
|
||||
```sql
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resolution Algorithm (3-step)
|
||||
|
||||
For each `vendor_partnumber` in the BOM, QuoteForge builds/updates UI-visible LOT mappings:
|
||||
|
||||
1. **Active book lookup** — read active `local_partnumber_books`, verify PN membership in `partnumbers_json`, then query `local_partnumber_book_items WHERE partnumber = ?`.
|
||||
2. **Populate BOM UI** — if a match exists, BOM row gets `lot_mappings[]` from `lots_json` (user can still edit it).
|
||||
3. **Unresolved** — red row + inline LOT input with strict autocomplete.
|
||||
|
||||
Persistence note: the application stores the final user-visible mappings in `lot_mappings[]` (not separate "resolved/manual" persisted fields).
|
||||
|
||||
---
|
||||
|
||||
## CFXML Workspace Import Contract
|
||||
|
||||
QuoteForge may import a vendor configurator workspace in `CFXML` format as an existing project update path.
|
||||
This import path must convert one external workspace into one QuoteForge project containing multiple configurations.
|
||||
|
||||
### Import Unit Boundaries
|
||||
|
||||
- One `CFXML` workspace file = one QuoteForge project import session.
|
||||
- One top-level configuration group inside the workspace = one QuoteForge configuration.
|
||||
- Software rows are **not** imported as standalone configurations.
|
||||
- All software rows must be attached to the configuration group they belong to.
|
||||
|
||||
### Configuration Grouping
|
||||
|
||||
Top-level `ProductLineItem` rows are grouped by:
|
||||
|
||||
- `ProprietaryGroupIdentifier`
|
||||
|
||||
This field is the canonical boundary of one imported configuration.
|
||||
|
||||
Rules:
|
||||
|
||||
1. Read all top-level `ProductLineItem` rows in document order.
|
||||
2. Group them by `ProprietaryGroupIdentifier`.
|
||||
3. Preserve document order of groups by the first encountered `ProductLineNumber`.
|
||||
4. Import each group as exactly one QuoteForge configuration.
|
||||
|
||||
`ConfigurationGroupLineNumberReference` is not sufficient for grouping imported configurations because
|
||||
multiple independent configuration groups may share the same value in one workspace.
|
||||
|
||||
### Primary Row Selection (no SKU hardcode)
|
||||
|
||||
The importer must not hardcode vendor, model, or server SKU values.
|
||||
|
||||
Within each `ProprietaryGroupIdentifier` group, the importer selects one primary top-level row using
|
||||
structural rules only:
|
||||
|
||||
1. Prefer rows with `ProductTypeCode = Hardware`.
|
||||
2. If multiple rows match, prefer the row with the largest number of `ProductSubLineItem` children.
|
||||
3. If there is still a tie, prefer the first row by `ProductLineNumber`.
|
||||
|
||||
The primary row provides configuration-level metadata such as:
|
||||
|
||||
- configuration name
|
||||
- server count
|
||||
- server model / description
|
||||
- article / support code candidate
|
||||
|
||||
### Software Inclusion Rule
|
||||
|
||||
All top-level rows belonging to the same `ProprietaryGroupIdentifier` must be imported into the same
|
||||
QuoteForge configuration, including:
|
||||
|
||||
- `Hardware`
|
||||
- `Software`
|
||||
- instruction / service rows represented as software-like items
|
||||
|
||||
Effects:
|
||||
|
||||
- a workspace never creates a separate configuration made only of software;
|
||||
- `software1`, `software2`, license rows, and instruction rows stay inside the related configuration;
|
||||
- the user sees one complete configuration instead of fragmented partial imports.
|
||||
|
||||
### Mapping to QuoteForge Project / Configuration
|
||||
|
||||
For one imported configuration group:
|
||||
|
||||
- QuoteForge configuration `name` <- primary row `ProductName`
|
||||
- QuoteForge configuration `server_count` <- primary row `Quantity`
|
||||
- QuoteForge configuration `server_model` <- primary row `ProductDescription`
|
||||
- QuoteForge configuration `article` or `support_code` <- primary row `ProprietaryProductIdentifier`
|
||||
- QuoteForge configuration `line` <- stable order by group appearance in the workspace
|
||||
|
||||
Project-level fields such as QuoteForge `code`, `name`, and `variant` are not reliably defined by `CFXML`
|
||||
itself and should come from the existing target project context or explicit user input.
|
||||
|
||||
### Mapping to `vendor_spec`
|
||||
|
||||
The importer must build one combined `vendor_spec` array per configuration group.
|
||||
|
||||
Source rows:
|
||||
|
||||
- all `ProductSubLineItem` rows from the primary top-level row;
|
||||
- all `ProductSubLineItem` rows from every non-primary top-level row in the same group;
|
||||
- if a top-level row has no `ProductSubLineItem`, the top-level row itself may be converted into one
|
||||
`vendor_spec` row so that software-only content is not lost.
|
||||
|
||||
Each imported row maps into one `VendorSpecItem`:
|
||||
|
||||
- `sort_order` <- stable sequence within the group
|
||||
- `vendor_partnumber` <- `ProprietaryProductIdentifier`
|
||||
- `quantity` <- `Quantity`
|
||||
- `description` <- `ProductDescription`
|
||||
- `unit_price` <- `UnitListPrice.FinancialAmount.MonetaryAmount` when present
|
||||
- `total_price` <- `quantity * unit_price` when unit price is present
|
||||
- `lot_mappings` <- resolved immediately from the active partnumber book using `lots_json`
|
||||
|
||||
The importer stores vendor-native rows in `vendor_spec`, then immediately runs the same logical flow as BOM
|
||||
Resolve + Apply:
|
||||
|
||||
- resolve vendor PN rows through the active partnumber book
|
||||
- persist canonical `lot_mappings[]`
|
||||
- build normalized configuration `items` from `row.quantity * quantity_per_pn`
|
||||
- fill `items.unit_price` from the latest local `estimate` pricelist
|
||||
- recalculate configuration `total_price`
|
||||
|
||||
### Import Pipeline
|
||||
|
||||
Recommended parser pipeline:
|
||||
|
||||
1. Parse XML into top-level `ProductLineItem` rows.
|
||||
2. Group rows by `ProprietaryGroupIdentifier`.
|
||||
3. Select one primary row per group using structural rules.
|
||||
4. Build one QuoteForge configuration DTO per group.
|
||||
5. Merge all hardware/software rows of the group into one `vendor_spec`.
|
||||
6. Resolve imported PN rows into canonical `lot_mappings[]` using the active partnumber book.
|
||||
7. Build configuration `items` from resolved `lot_mappings[]`.
|
||||
8. Price those `items` from the latest local `estimate` pricelist.
|
||||
9. Save or update the QuoteForge configuration inside the existing project.
|
||||
|
||||
### Recommended Internal DTO
|
||||
|
||||
```go
|
||||
type ImportedProject struct {
|
||||
SourceFormat string
|
||||
SourceFilePath string
|
||||
SourceDocID string
|
||||
|
||||
Code string
|
||||
Name string
|
||||
Variant string
|
||||
|
||||
Configurations []ImportedConfiguration
|
||||
}
|
||||
|
||||
type ImportedConfiguration struct {
|
||||
GroupID string
|
||||
|
||||
Name string
|
||||
Line int
|
||||
ServerCount int
|
||||
|
||||
ServerModel string
|
||||
Article string
|
||||
SupportCode string
|
||||
CurrencyCode string
|
||||
|
||||
TopLevelRows []ImportedTopLevelRow
|
||||
VendorSpec []ImportedVendorRow
|
||||
}
|
||||
|
||||
type ImportedTopLevelRow struct {
|
||||
ProductLineNumber string
|
||||
ItemNo string
|
||||
GroupID string
|
||||
|
||||
ProductType string
|
||||
ProductCode string
|
||||
ProductName string
|
||||
Description string
|
||||
Quantity int
|
||||
UnitPrice *float64
|
||||
IsPrimary bool
|
||||
|
||||
SubRows []ImportedVendorRow
|
||||
}
|
||||
|
||||
type ImportedVendorRow struct {
|
||||
SortOrder int
|
||||
|
||||
SourceLineNumber string
|
||||
SourceParentLine string
|
||||
SourceProductType string
|
||||
|
||||
VendorPartnumber string
|
||||
Description string
|
||||
Quantity int
|
||||
UnitPrice *float64
|
||||
TotalPrice *float64
|
||||
|
||||
ProductCharacter string
|
||||
ProductCharPath string
|
||||
}
|
||||
```
|
||||
|
||||
### Current Product Assumption
|
||||
|
||||
For QuoteForge product behavior, the correct user-facing interpretation is:
|
||||
|
||||
- one external project/workspace contains several configurations;
|
||||
- each configuration contains both hardware and software rows that belong to it;
|
||||
- the importer must preserve that grouping exactly.
|
||||
|
||||
---
|
||||
|
||||
## Qty Aggregation Logic
|
||||
|
||||
After resolution, qty per LOT is computed from the BOM row quantity multiplied by the matched `lots_json.qty`:
|
||||
|
||||
```
|
||||
qty(lot) = SUM(quantity_of_pn_row * quantity_of_lot_inside_lots_json)
|
||||
```
|
||||
|
||||
Examples (book: PN_X → `[{LOT_A, qty:2}, {LOT_B, qty:1}]`):
|
||||
- BOM: PN_X ×3 → `LOT_A ×6`, `LOT_B ×3`
|
||||
- BOM: PN_X ×1 and PN_X ×2 → `LOT_A ×6`, `LOT_B ×3`
|
||||
|
||||
---
|
||||
|
||||
## UI: Three Top-Level Tabs
|
||||
|
||||
The configurator (`/configurator`) has three tabs:
|
||||
|
||||
1. **Estimate** — existing cart/component configurator (unchanged).
|
||||
2. **BOM** — paste/import vendor BOM, manual column mapping, LOT matching, bundle decomposition (`1 PN -> multiple LOTs`), "Пересчитать эстимейт", "Очистить".
|
||||
3. **Ценообразование** — pricing summary table + custom price input.
|
||||
|
||||
BOM data is shared between tabs 2 and 3.
|
||||
|
||||
### BOM Import UI (raw table, manual column mapping)
|
||||
|
||||
After paste (`Ctrl+V`) QuoteForge renders an editable raw table (not auto-detected parsing).
|
||||
|
||||
- The pasted rows are shown **as-is** (including header rows, if present).
|
||||
- The user selects a type for each column manually:
|
||||
- `P/N`
|
||||
- `Кол-во`
|
||||
- `Цена`
|
||||
- `Описание`
|
||||
- `Не использовать`
|
||||
- Required mapping:
|
||||
- exactly one `P/N`
|
||||
- exactly one `Кол-во`
|
||||
- Optional mapping:
|
||||
- `Цена` (0..1)
|
||||
- `Описание` (0..1)
|
||||
- Rows can be:
|
||||
- ignored (UI-only, excluded from `vendor_spec`)
|
||||
- deleted
|
||||
- Raw cells are editable inline after paste.
|
||||
|
||||
Notes:
|
||||
- There is **no auto column detection**.
|
||||
- There is **no auto header-row skip**.
|
||||
- Raw import layout itself is not stored on server; only normalized `vendor_spec` is stored.
|
||||
|
||||
### LOT matching in BOM table
|
||||
|
||||
The BOM table adds service columns on the right:
|
||||
|
||||
- `LOT`
|
||||
- `LOT в 1 PN`
|
||||
- actions (`+`, ignore, delete)
|
||||
|
||||
`LOT` behavior:
|
||||
- The first LOT row shown in the BOM UI is the primary LOT mapping for the PN row.
|
||||
- Additional LOT rows are added via the `+` action.
|
||||
- inline LOT input is strict:
|
||||
- autocomplete source = full local components list (`/api/components?per_page=5000`)
|
||||
- free text that does not match an existing LOT is rejected
|
||||
|
||||
`LOT в 1 PN` behavior:
|
||||
- quantity multiplier for each visible LOT row in BOM (`quantity_per_pn` in persisted `lot_mappings[]`)
|
||||
- default = `1`
|
||||
- editable inline
|
||||
|
||||
### Bundle mode (`1 PN -> multiple LOTs`)
|
||||
|
||||
The `+` action in BOM rows adds an extra LOT mapping row for the same vendor PN row.
|
||||
|
||||
- All visible LOT rows (first + added rows) are persisted uniformly in `lot_mappings[]`
|
||||
- Each mapping row has:
|
||||
- LOT
|
||||
- qty (`LOT in 1 PN` = `quantity_per_pn`)
|
||||
|
||||
### BOM restore on config open
|
||||
|
||||
On config open, QuoteForge loads `vendor_spec` from server and reconstructs the editable BOM table in normalized form:
|
||||
|
||||
- columns restored as: `Qty | P/N | Description | Price`
|
||||
- column mapping restored as:
|
||||
- `qty`, `pn`, `description`, `price`
|
||||
- LOT / `LOT в 1 PN` rows are restored from `vendor_spec.lot_mappings[]`
|
||||
|
||||
This restores the BOM editing state, but not the original raw Excel layout (extra columns, ignored rows, original headers).
|
||||
|
||||
### Pricing Tab: column order
|
||||
|
||||
```
|
||||
LOT | PN вендора | Описание | Кол-во | Estimate | Цена вендора | Склад | Конк.
|
||||
```
|
||||
|
||||
**If BOM is empty** — the pricing tab still renders, using cart items as rows (PN вендора = "—", Цена вендора = "—").
|
||||
|
||||
**Description source priority:** BOM row description → LOT description from `local_components`.
|
||||
|
||||
### Pricing Tab: BOM + Estimate merge behavior
|
||||
|
||||
When BOM exists, the pricing tab renders:
|
||||
|
||||
- BOM-based rows (including rows resolved via manual LOT and bundle mappings)
|
||||
- plus **Estimate-only LOTs** (rows currently in cart but not covered by BOM mappings)
|
||||
|
||||
Estimate-only rows are shown as separate rows with:
|
||||
- `PN вендора = "—"`
|
||||
- vendor price = `—`
|
||||
- description from local components
|
||||
|
||||
### Pricing Tab: "Своя цена" input
|
||||
|
||||
- Manual entry → proportionally redistributes custom price into "Цена вендора" cells (proportional to each row's Estimate share). Last row absorbs rounding remainder.
|
||||
- "Проставить цены BOM" button → restores per-row original BOM prices directly (no proportional redistribution). Sets "Своя цена" to their sum.
|
||||
- Both paths show "Скидка от Estimate: X%" info.
|
||||
- "Экспорт CSV" button → downloads `pricing_<uuid>.csv` with UTF-8 BOM, same column order as table, plus Итого row.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | URL | Description |
|
||||
|--------|-----|-------------|
|
||||
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored BOM |
|
||||
| PUT | `/api/configs/:uuid/vendor-spec` | Replace BOM (full update) |
|
||||
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (no cart mutation) |
|
||||
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
|
||||
| POST | `/api/projects/:uuid/vendor-import` | Import `CFXML` workspace into an existing project and create grouped configurations |
|
||||
| GET | `/api/partnumber-books` | List local book snapshots |
|
||||
| GET | `/api/partnumber-books/:id` | Items for a book by server_id |
|
||||
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
|
||||
| POST | `/api/sync/partnumber-seen` | Push unresolved PNs to `qt_vendor_partnumber_seen` on MariaDB |
|
||||
|
||||
## Unresolved PN Tracking (`qt_vendor_partnumber_seen`)
|
||||
|
||||
After each `resolveBOM()` call, QuoteForge pushes PN rows to `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored):
|
||||
|
||||
- unresolved BOM rows (`ignored = false`)
|
||||
- raw BOM rows explicitly marked as ignored in UI (`ignored = true`) — these rows are **not** saved to `vendor_spec`, but are reported for server-side tracking
|
||||
|
||||
The handler calls `sync.PushPartnumberSeen()` which inserts into `qt_vendor_partnumber_seen`.
|
||||
If a row with the same `partnumber` already exists, QuoteForge must leave it untouched:
|
||||
|
||||
- do not update `last_seen_at`
|
||||
- do not update `is_ignored`
|
||||
- do not update `description`
|
||||
|
||||
Canonical insert behavior:
|
||||
|
||||
```sql
|
||||
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
||||
VALUES ('manual', '', ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
partnumber = partnumber
|
||||
```
|
||||
|
||||
Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge uses this table for populating the partnumber book.
|
||||
|
||||
Partnumber book sync contract:
|
||||
|
||||
- PriceForge writes membership snapshots to `qt_partnumber_books.partnumbers_json`.
|
||||
- PriceForge writes canonical PN payloads to `qt_partnumber_book_items`.
|
||||
- QuoteForge syncs book headers first, then pulls PN payloads with:
|
||||
`SELECT partnumber, lots_json, description FROM qt_partnumber_book_items WHERE partnumber IN (...)`
|
||||
|
||||
## BOM Persistence
|
||||
|
||||
- `vendor_spec` is saved to server via `PUT /api/configs/:uuid/vendor-spec`.
|
||||
- `GET` / `PUT` `vendor_spec` must preserve row-level mapping fields used by the UI:
|
||||
- `lot_mappings[]`
|
||||
- each item: `lot_name`, `quantity_per_pn`
|
||||
- `description` is persisted in each BOM row and is used by the Pricing tab when available.
|
||||
- Ignored raw rows are **not** persisted into `vendor_spec`.
|
||||
- The PUT handler explicitly marshals `VendorSpec` to JSON string before passing to GORM `Update` (GORM does not reliably call `driver.Valuer` for custom types in `Update(column, value)`).
|
||||
- BOM is autosaved (debounced) after BOM-changing actions, including:
|
||||
- `resolveBOM()`
|
||||
- LOT row qty (`LOT в 1 PN`) changes
|
||||
- LOT row add/remove (`+` / delete in bundle context)
|
||||
- "Сохранить BOM" button triggers explicit save.
|
||||
|
||||
## Pricing Tab: Estimate Price Source
|
||||
|
||||
`renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with LOTs collected from:
|
||||
|
||||
- `lot_mappings[]` from BOM rows
|
||||
- current Estimate/cart LOTs not covered by BOM mappings (to show estimate-only rows)
|
||||
|
||||
This ensures Estimate prices appear for:
|
||||
|
||||
- manually matched LOTs in the BOM tab
|
||||
- bundle LOTs
|
||||
- LOTs already present in Estimate but not mapped from BOM
|
||||
|
||||
### Apply to Estimate (`Пересчитать эстимейт`)
|
||||
|
||||
When applying BOM to Estimate, QuoteForge builds cart rows from explicit UI mappings stored in `lot_mappings[]`.
|
||||
|
||||
For a BOM row with PN qty = `Q`:
|
||||
|
||||
- each mapped LOT contributes `Q * quantity_per_pn`
|
||||
|
||||
Rows without any valid LOT mapping are skipped.
|
||||
|
||||
## Web Route
|
||||
|
||||
| Route | Page |
|
||||
|-------|------|
|
||||
| `/partnumber-books` | Partnumber books — active book summary (unique LOTs, total PN, primary PN count), searchable items table, collapsible snapshot history |
|
||||
55
bible-local/README.md
Normal file
55
bible-local/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# QuoteForge Bible — Architectural Documentation
|
||||
|
||||
The single source of truth for architecture, schemas, and patterns.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
| File | Topic |
|
||||
|------|-------|
|
||||
| [01-overview.md](01-overview.md) | Product: purpose, features, tech stack, repository structure |
|
||||
| [02-architecture.md](02-architecture.md) | Architecture: local-first, sync, pricing, versioning |
|
||||
| [03-database.md](03-database.md) | DB schemas: SQLite + MariaDB, permissions, indexes |
|
||||
| [04-api.md](04-api.md) | API endpoints and web routes |
|
||||
| [05-config.md](05-config.md) | Configuration, environment variables, paths, installation |
|
||||
| [06-backup.md](06-backup.md) | Backup: implementation, rotation policy |
|
||||
| [07-dev.md](07-dev.md) | Development: commands, code style, guardrails |
|
||||
|
||||
---
|
||||
|
||||
## Bible Rules
|
||||
|
||||
> **Every architectural decision must be recorded in the Bible.**
|
||||
>
|
||||
> Any change to DB schema, data access patterns, sync behavior, API contracts,
|
||||
> configuration format, or any other system-level aspect — the corresponding `bible/` file
|
||||
> **must be updated in the same commit** as the code.
|
||||
>
|
||||
> On every user-requested commit, the Bible must be reviewed and updated in that commit.
|
||||
>
|
||||
> The Bible is the single source of truth for architecture. Outdated documentation is worse than none.
|
||||
|
||||
> **Documentation language: English.**
|
||||
>
|
||||
> All files in `bible/` are written and updated **in English only**.
|
||||
> Mixing languages is not allowed.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Where is user data stored?**
|
||||
SQLite → `~/Library/Application Support/QuoteForge/qfs.db` (macOS). MariaDB is sync-only.
|
||||
|
||||
**How to look up a price for a line item?**
|
||||
`local_pricelist_items` → by `pricelist_id` from config + `lot_name`. Prices are **never** taken from `local_components`.
|
||||
|
||||
**Pre-commit check?**
|
||||
`go build ./cmd/qfs && go vet ./...`
|
||||
|
||||
**What must never be restored?**
|
||||
cron jobs, admin pricing, alerts, stock import, importer utility — all removed intentionally.
|
||||
|
||||
**Where is the release changelog?**
|
||||
`releases/memory/v{major}.{minor}.{patch}.md`
|
||||
164
cmd/migrate/main.go
Normal file
164
cmd/migrate/main.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
defaultLocalDBPath, err := appstate.ResolveDBPath("")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to resolve default local SQLite path: %v", err)
|
||||
}
|
||||
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||
dryRun := flag.Bool("dry-run", false, "show what would be migrated without actually doing it")
|
||||
flag.Parse()
|
||||
|
||||
log.Println("QuoteForge Configuration Migration Tool")
|
||||
log.Println("========================================")
|
||||
|
||||
// Initialize local SQLite
|
||||
log.Printf("Opening local SQLite at %s...", *localDBPath)
|
||||
local, err := localdb.New(*localDBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize local database: %v", err)
|
||||
}
|
||||
log.Println("Local SQLite initialized")
|
||||
if !local.HasSettings() {
|
||||
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
|
||||
}
|
||||
|
||||
settings, err := local.GetSettings()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load SQLite connection settings: %v", err)
|
||||
}
|
||||
dsn, err := local.GetDSN()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to build DSN from SQLite settings: %v", err)
|
||||
}
|
||||
|
||||
// Connect to MariaDB
|
||||
log.Printf("Connecting to MariaDB at %s:%d...", settings.Host, settings.Port)
|
||||
mariaDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to MariaDB: %v", err)
|
||||
}
|
||||
log.Println("Connected to MariaDB")
|
||||
|
||||
// Count configurations in MariaDB
|
||||
var serverCount int64
|
||||
if err := mariaDB.Model(&models.Configuration{}).Count(&serverCount).Error; err != nil {
|
||||
log.Fatalf("Failed to count configurations: %v", err)
|
||||
}
|
||||
log.Printf("Found %d configurations in MariaDB", serverCount)
|
||||
|
||||
if serverCount == 0 {
|
||||
log.Println("No configurations to migrate")
|
||||
return
|
||||
}
|
||||
|
||||
// Get all configurations from MariaDB
|
||||
var configs []models.Configuration
|
||||
if err := mariaDB.Find(&configs).Error; err != nil {
|
||||
log.Fatalf("Failed to fetch configurations: %v", err)
|
||||
}
|
||||
|
||||
// Check existing local configurations
|
||||
localCount := local.CountConfigurations()
|
||||
log.Printf("Found %d configurations in local SQLite", localCount)
|
||||
|
||||
if *dryRun {
|
||||
log.Println("\n[DRY RUN] Would migrate the following configurations:")
|
||||
for _, c := range configs {
|
||||
userName := c.OwnerUsername
|
||||
if userName == "" {
|
||||
userName = "unknown"
|
||||
}
|
||||
log.Printf(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items))
|
||||
}
|
||||
log.Printf("\nTotal: %d configurations", len(configs))
|
||||
return
|
||||
}
|
||||
|
||||
// Migrate configurations
|
||||
log.Println("\nMigrating configurations...")
|
||||
migrated := 0
|
||||
skipped := 0
|
||||
errors := 0
|
||||
|
||||
for _, c := range configs {
|
||||
// Check if already exists
|
||||
existing, err := local.GetConfigurationByUUID(c.UUID)
|
||||
if err == nil && existing.ID > 0 {
|
||||
log.Printf(" SKIP: %s (already exists)", c.Name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert items
|
||||
localItems := make(localdb.LocalConfigItems, len(c.Items))
|
||||
for i, item := range c.Items {
|
||||
localItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
}
|
||||
|
||||
// Create local configuration
|
||||
now := time.Now()
|
||||
localConfig := &localdb.LocalConfiguration{
|
||||
UUID: c.UUID,
|
||||
ServerID: &c.ID,
|
||||
ProjectUUID: c.ProjectUUID,
|
||||
Name: c.Name,
|
||||
Items: localItems,
|
||||
TotalPrice: c.TotalPrice,
|
||||
CustomPrice: c.CustomPrice,
|
||||
Notes: c.Notes,
|
||||
IsTemplate: c.IsTemplate,
|
||||
ServerCount: c.ServerCount,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: now,
|
||||
SyncedAt: &now,
|
||||
SyncStatus: "synced",
|
||||
OriginalUserID: derefUint(c.UserID),
|
||||
OriginalUsername: c.OwnerUsername,
|
||||
}
|
||||
|
||||
if err := local.SaveConfiguration(localConfig); err != nil {
|
||||
log.Printf(" ERROR: %s - %v", c.Name, err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf(" OK: %s (%d items)", c.Name, len(c.Items))
|
||||
migrated++
|
||||
}
|
||||
|
||||
log.Println("\n========================================")
|
||||
log.Printf("Migration complete!")
|
||||
log.Printf(" Migrated: %d", migrated)
|
||||
log.Printf(" Skipped: %d", skipped)
|
||||
log.Printf(" Errors: %d", errors)
|
||||
|
||||
fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs")
|
||||
}
|
||||
|
||||
func derefUint(v *uint) uint {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
308
cmd/migrate_ops_projects/main.go
Normal file
308
cmd/migrate_ops_projects/main.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type configRow struct {
|
||||
ID uint
|
||||
UUID string
|
||||
OwnerUsername string
|
||||
Name string
|
||||
ProjectUUID *string
|
||||
}
|
||||
|
||||
type migrationAction struct {
|
||||
ConfigID uint
|
||||
ConfigUUID string
|
||||
ConfigName string
|
||||
OwnerUsername string
|
||||
TargetProjectName string
|
||||
CurrentProject string
|
||||
NeedCreateProject bool
|
||||
NeedReactivate bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
defaultLocalDBPath, err := appstate.ResolveDBPath("")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to resolve default local SQLite path: %v", err)
|
||||
}
|
||||
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||
apply := flag.Bool("apply", false, "apply migration (default is preview only)")
|
||||
yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)")
|
||||
flag.Parse()
|
||||
|
||||
local, err := localdb.New(*localDBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize local database: %v", err)
|
||||
}
|
||||
if !local.HasSettings() {
|
||||
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
|
||||
}
|
||||
dsn, err := local.GetDSN()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to build DSN from SQLite settings: %v", err)
|
||||
}
|
||||
dbUser := strings.TrimSpace(local.GetDBUser())
|
||||
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect database: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureProjectsTable(db); err != nil {
|
||||
log.Fatalf("precheck failed: %v", err)
|
||||
}
|
||||
|
||||
actions, existingProjects, err := buildPlan(db, dbUser)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to build migration plan: %v", err)
|
||||
}
|
||||
|
||||
printPlan(actions)
|
||||
if len(actions) == 0 {
|
||||
fmt.Println("Nothing to migrate.")
|
||||
return
|
||||
}
|
||||
|
||||
if !*apply {
|
||||
fmt.Println("\nPreview complete. Re-run with -apply to execute.")
|
||||
return
|
||||
}
|
||||
|
||||
if !*yes {
|
||||
ok, confirmErr := askForConfirmation()
|
||||
if confirmErr != nil {
|
||||
log.Fatalf("confirmation failed: %v", confirmErr)
|
||||
}
|
||||
if !ok {
|
||||
fmt.Println("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := executePlan(db, actions, existingProjects); err != nil {
|
||||
log.Fatalf("migration failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Migration completed successfully.")
|
||||
}
|
||||
|
||||
func ensureProjectsTable(db *gorm.DB) error {
|
||||
var count int64
|
||||
if err := db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'qt_projects'").Scan(&count).Error; err != nil {
|
||||
return fmt.Errorf("checking qt_projects table: %w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
return fmt.Errorf("table qt_projects does not exist; run migration 009_add_projects.sql first")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string]*models.Project, error) {
|
||||
var configs []configRow
|
||||
if err := db.Table("qt_configurations").
|
||||
Select("id, uuid, owner_username, name, project_uuid").
|
||||
Find(&configs).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("load configurations: %w", err)
|
||||
}
|
||||
|
||||
codeRegex := regexp.MustCompile(`^(OPS-[0-9]{4})`)
|
||||
owners := make(map[string]struct{})
|
||||
projectNames := make(map[string]struct{})
|
||||
type candidate struct {
|
||||
config configRow
|
||||
code string
|
||||
owner string
|
||||
}
|
||||
candidates := make([]candidate, 0)
|
||||
|
||||
for _, cfg := range configs {
|
||||
match := codeRegex.FindStringSubmatch(strings.TrimSpace(cfg.Name))
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
owner := strings.TrimSpace(cfg.OwnerUsername)
|
||||
if owner == "" {
|
||||
owner = strings.TrimSpace(fallbackOwner)
|
||||
}
|
||||
if owner == "" {
|
||||
continue
|
||||
}
|
||||
code := match[1]
|
||||
owners[owner] = struct{}{}
|
||||
projectNames[code] = struct{}{}
|
||||
candidates = append(candidates, candidate{config: cfg, code: code, owner: owner})
|
||||
}
|
||||
|
||||
ownerList := setKeys(owners)
|
||||
nameList := setKeys(projectNames)
|
||||
existingProjects := make(map[string]*models.Project)
|
||||
if len(ownerList) > 0 && len(nameList) > 0 {
|
||||
var projects []models.Project
|
||||
if err := db.Where("owner_username IN ? AND name IN ?", ownerList, nameList).Find(&projects).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("load existing projects: %w", err)
|
||||
}
|
||||
for i := range projects {
|
||||
p := projects[i]
|
||||
existingProjects[projectKey(p.OwnerUsername, derefString(p.Name))] = &p
|
||||
}
|
||||
}
|
||||
|
||||
actions := make([]migrationAction, 0)
|
||||
for _, c := range candidates {
|
||||
key := projectKey(c.owner, c.code)
|
||||
existing := existingProjects[key]
|
||||
|
||||
currentProject := ""
|
||||
if c.config.ProjectUUID != nil {
|
||||
currentProject = *c.config.ProjectUUID
|
||||
}
|
||||
|
||||
if existing != nil && currentProject == existing.UUID {
|
||||
continue
|
||||
}
|
||||
|
||||
action := migrationAction{
|
||||
ConfigID: c.config.ID,
|
||||
ConfigUUID: c.config.UUID,
|
||||
ConfigName: c.config.Name,
|
||||
OwnerUsername: c.owner,
|
||||
TargetProjectName: c.code,
|
||||
CurrentProject: currentProject,
|
||||
}
|
||||
if existing == nil {
|
||||
action.NeedCreateProject = true
|
||||
} else if !existing.IsActive {
|
||||
action.NeedReactivate = true
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
|
||||
return actions, existingProjects, nil
|
||||
}
|
||||
|
||||
func printPlan(actions []migrationAction) {
|
||||
createCount := 0
|
||||
reactivateCount := 0
|
||||
for _, a := range actions {
|
||||
if a.NeedCreateProject {
|
||||
createCount++
|
||||
}
|
||||
if a.NeedReactivate {
|
||||
reactivateCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Planned actions: %d\n", len(actions))
|
||||
fmt.Printf("Projects to create: %d\n", createCount)
|
||||
fmt.Printf("Projects to reactivate: %d\n", reactivateCount)
|
||||
fmt.Println("\nDetails:")
|
||||
|
||||
for _, a := range actions {
|
||||
extra := ""
|
||||
if a.NeedCreateProject {
|
||||
extra = " [create project]"
|
||||
} else if a.NeedReactivate {
|
||||
extra = " [reactivate project]"
|
||||
}
|
||||
current := a.CurrentProject
|
||||
if current == "" {
|
||||
current = "NULL"
|
||||
}
|
||||
fmt.Printf("- %s | owner=%s | \"%s\" | project: %s -> %s%s\n",
|
||||
a.ConfigUUID, a.OwnerUsername, a.ConfigName, current, a.TargetProjectName, extra)
|
||||
}
|
||||
}
|
||||
|
||||
func askForConfirmation() (bool, error) {
|
||||
fmt.Print("\nApply these changes? type 'yes' to continue: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(line), "yes"), nil
|
||||
}
|
||||
|
||||
func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[string]*models.Project) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
projectCache := make(map[string]*models.Project, len(existingProjects))
|
||||
for k, v := range existingProjects {
|
||||
cp := *v
|
||||
projectCache[k] = &cp
|
||||
}
|
||||
|
||||
for _, action := range actions {
|
||||
key := projectKey(action.OwnerUsername, action.TargetProjectName)
|
||||
project := projectCache[key]
|
||||
if project == nil {
|
||||
project = &models.Project{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: action.OwnerUsername,
|
||||
Code: action.TargetProjectName,
|
||||
Name: ptrString(action.TargetProjectName),
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
}
|
||||
if err := tx.Create(project).Error; err != nil {
|
||||
return fmt.Errorf("create project %s for owner %s: %w", action.TargetProjectName, action.OwnerUsername, err)
|
||||
}
|
||||
projectCache[key] = project
|
||||
} else if !project.IsActive {
|
||||
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
|
||||
return fmt.Errorf("reactivate project %s (%s): %w", derefString(project.Name), project.UUID, err)
|
||||
}
|
||||
project.IsActive = true
|
||||
}
|
||||
|
||||
if err := tx.Table("qt_configurations").Where("id = ?", action.ConfigID).Update("project_uuid", project.UUID).Error; err != nil {
|
||||
return fmt.Errorf("move configuration %s to project %s: %w", action.ConfigUUID, project.UUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func setKeys(set map[string]struct{}) []string {
|
||||
keys := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func projectKey(owner, name string) string {
|
||||
return owner + "||" + name
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
66
cmd/qfs/config_migration_test.go
Normal file
66
cmd/qfs/config_migration_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
)
|
||||
|
||||
func TestMigrateConfigFileToRuntimeShapeDropsDeprecatedSections(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
legacy := `server:
|
||||
host: "0.0.0.0"
|
||||
port: 9191
|
||||
database:
|
||||
host: "legacy-db"
|
||||
port: 3306
|
||||
name: "RFQ_LOG"
|
||||
user: "old"
|
||||
password: "REDACTED_TEST_PASSWORD"
|
||||
pricing:
|
||||
default_method: "median"
|
||||
logging:
|
||||
level: "debug"
|
||||
format: "text"
|
||||
output: "stdout"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
|
||||
t.Fatalf("write legacy config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load legacy config: %v", err)
|
||||
}
|
||||
setConfigDefaults(cfg)
|
||||
if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil {
|
||||
t.Fatalf("migrate config: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read migrated config: %v", err)
|
||||
}
|
||||
text := string(got)
|
||||
if strings.Contains(text, "database:") {
|
||||
t.Fatalf("migrated config still contains deprecated database section:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "pricing:") {
|
||||
t.Fatalf("migrated config still contains deprecated pricing section:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "server:") || !strings.Contains(text, "logging:") {
|
||||
t.Fatalf("migrated config missing required sections:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "port: 9191") {
|
||||
t.Fatalf("migrated config did not preserve server port:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "level: debug") {
|
||||
t.Fatalf("migrated config did not preserve logging level:\n%s", text)
|
||||
}
|
||||
}
|
||||
1831
cmd/qfs/main.go
Normal file
1831
cmd/qfs/main.go
Normal file
File diff suppressed because it is too large
Load Diff
327
cmd/qfs/versioning_api_test.go
Normal file
327
cmd/qfs/versioning_api_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
)
|
||||
|
||||
func TestConfigurationVersioningAPI(t *testing.T) {
|
||||
moveToRepoRoot(t)
|
||||
|
||||
local, connMgr, configService := newAPITestStack(t)
|
||||
_ = local
|
||||
|
||||
created, err := configService.Create("tester", &services.CreateConfigRequest{
|
||||
Name: "api-v1",
|
||||
Items: models.ConfigItems{{LotName: "CPU_API", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if _, err := configService.RenameNoAuth(created.UUID, "api-v2"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{}
|
||||
setConfigDefaults(cfg)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
|
||||
// list versions happy path
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions?limit=10&offset=0", nil)
|
||||
listRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRec, listReq)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list versions status=%d body=%s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
|
||||
// get version happy path
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions/1", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("get version status=%d body=%s", getRec.Code, getRec.Body.String())
|
||||
}
|
||||
|
||||
// rollback happy path
|
||||
body := []byte(`{"target_version":1,"note":"api rollback"}`)
|
||||
rbReq := httptest.NewRequest(http.MethodPost, "/api/configs/"+created.UUID+"/rollback", bytes.NewReader(body))
|
||||
rbReq.Header.Set("Content-Type", "application/json")
|
||||
rbRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rbRec, rbReq)
|
||||
if rbRec.Code != http.StatusOK {
|
||||
t.Fatalf("rollback status=%d body=%s", rbRec.Code, rbRec.Body.String())
|
||||
}
|
||||
|
||||
var rbResp struct {
|
||||
Message string `json:"message"`
|
||||
CurrentVersion struct {
|
||||
VersionNo int `json:"version_no"`
|
||||
} `json:"current_version"`
|
||||
}
|
||||
if err := json.Unmarshal(rbRec.Body.Bytes(), &rbResp); err != nil {
|
||||
t.Fatalf("unmarshal rollback response: %v", err)
|
||||
}
|
||||
if rbResp.Message == "" || rbResp.CurrentVersion.VersionNo != 2 {
|
||||
t.Fatalf("unexpected rollback response: %+v", rbResp)
|
||||
}
|
||||
|
||||
// 404: version missing
|
||||
notFoundReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions/999", nil)
|
||||
notFoundRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(notFoundRec, notFoundReq)
|
||||
if notFoundRec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for missing version, got %d", notFoundRec.Code)
|
||||
}
|
||||
|
||||
// 400: invalid version number
|
||||
invalidReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions/abc", nil)
|
||||
invalidRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(invalidRec, invalidReq)
|
||||
if invalidRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid version, got %d", invalidRec.Code)
|
||||
}
|
||||
|
||||
// 400: rollback invalid target_version
|
||||
badRollbackReq := httptest.NewRequest(http.MethodPost, "/api/configs/"+created.UUID+"/rollback", bytes.NewReader([]byte(`{"target_version":0}`)))
|
||||
badRollbackReq.Header.Set("Content-Type", "application/json")
|
||||
badRollbackRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(badRollbackRec, badRollbackReq)
|
||||
if badRollbackRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid rollback target, got %d", badRollbackRec.Code)
|
||||
}
|
||||
|
||||
// archive + reactivate flow
|
||||
delReq := httptest.NewRequest(http.MethodDelete, "/api/configs/"+created.UUID, nil)
|
||||
delRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(delRec, delReq)
|
||||
if delRec.Code != http.StatusOK {
|
||||
t.Fatalf("archive status=%d body=%s", delRec.Code, delRec.Body.String())
|
||||
}
|
||||
|
||||
archivedListReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=archived&page=1&per_page=20", nil)
|
||||
archivedListRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(archivedListRec, archivedListReq)
|
||||
if archivedListRec.Code != http.StatusOK {
|
||||
t.Fatalf("archived list status=%d body=%s", archivedListRec.Code, archivedListRec.Body.String())
|
||||
}
|
||||
|
||||
reactivateReq := httptest.NewRequest(http.MethodPost, "/api/configs/"+created.UUID+"/reactivate", nil)
|
||||
reactivateRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(reactivateRec, reactivateReq)
|
||||
if reactivateRec.Code != http.StatusOK {
|
||||
t.Fatalf("reactivate status=%d body=%s", reactivateRec.Code, reactivateRec.Body.String())
|
||||
}
|
||||
|
||||
activeListReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=active&page=1&per_page=20", nil)
|
||||
activeListRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(activeListRec, activeListReq)
|
||||
if activeListRec.Code != http.StatusOK {
|
||||
t.Fatalf("active list status=%d body=%s", activeListRec.Code, activeListRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
|
||||
moveToRepoRoot(t)
|
||||
|
||||
local, connMgr, configService := newAPITestStack(t)
|
||||
_ = configService
|
||||
|
||||
cfg := &config.Config{}
|
||||
setConfigDefaults(cfg)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
|
||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1","code":"P1"}`)))
|
||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||
createProjectRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||
if createProjectRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String())
|
||||
}
|
||||
var project models.Project
|
||||
if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil {
|
||||
t.Fatalf("unmarshal project: %v", err)
|
||||
}
|
||||
|
||||
createCfgBody := []byte(`{"name":"Cfg A","items":[{"lot_name":"CPU","quantity":1,"unit_price":100}],"server_count":1}`)
|
||||
createCfgReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/configs", bytes.NewReader(createCfgBody))
|
||||
createCfgReq.Header.Set("Content-Type", "application/json")
|
||||
createCfgRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createCfgRec, createCfgReq)
|
||||
if createCfgRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create project config status=%d body=%s", createCfgRec.Code, createCfgRec.Body.String())
|
||||
}
|
||||
var createdCfg models.Configuration
|
||||
if err := json.Unmarshal(createCfgRec.Body.Bytes(), &createdCfg); err != nil {
|
||||
t.Fatalf("unmarshal project config: %v", err)
|
||||
}
|
||||
if createdCfg.ProjectUUID == nil || *createdCfg.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected config project_uuid=%s got=%v", project.UUID, createdCfg.ProjectUUID)
|
||||
}
|
||||
|
||||
cloneReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/configs/"+createdCfg.UUID+"/clone", bytes.NewReader([]byte(`{"name":"Cfg A Clone"}`)))
|
||||
cloneReq.Header.Set("Content-Type", "application/json")
|
||||
cloneRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(cloneRec, cloneReq)
|
||||
if cloneRec.Code != http.StatusCreated {
|
||||
t.Fatalf("clone in project status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
|
||||
}
|
||||
var cloneCfg models.Configuration
|
||||
if err := json.Unmarshal(cloneRec.Body.Bytes(), &cloneCfg); err != nil {
|
||||
t.Fatalf("unmarshal clone config: %v", err)
|
||||
}
|
||||
if cloneCfg.ProjectUUID == nil || *cloneCfg.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected clone project_uuid=%s got=%v", project.UUID, cloneCfg.ProjectUUID)
|
||||
}
|
||||
|
||||
projectConfigsReq := httptest.NewRequest(http.MethodGet, "/api/projects/"+project.UUID+"/configs", nil)
|
||||
projectConfigsRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(projectConfigsRec, projectConfigsReq)
|
||||
if projectConfigsRec.Code != http.StatusOK {
|
||||
t.Fatalf("project configs status=%d body=%s", projectConfigsRec.Code, projectConfigsRec.Body.String())
|
||||
}
|
||||
var projectConfigsResp struct {
|
||||
Configurations []models.Configuration `json:"configurations"`
|
||||
}
|
||||
if err := json.Unmarshal(projectConfigsRec.Body.Bytes(), &projectConfigsResp); err != nil {
|
||||
t.Fatalf("unmarshal project configs response: %v", err)
|
||||
}
|
||||
if len(projectConfigsResp.Configurations) != 2 {
|
||||
t.Fatalf("expected 2 project configs after clone, got %d", len(projectConfigsResp.Configurations))
|
||||
}
|
||||
|
||||
archiveReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/archive", nil)
|
||||
archiveRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(archiveRec, archiveReq)
|
||||
if archiveRec.Code != http.StatusOK {
|
||||
t.Fatalf("archive project status=%d body=%s", archiveRec.Code, archiveRec.Body.String())
|
||||
}
|
||||
|
||||
activeReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=active&page=1&per_page=20", nil)
|
||||
activeRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(activeRec, activeReq)
|
||||
if activeRec.Code != http.StatusOK {
|
||||
t.Fatalf("active configs status=%d body=%s", activeRec.Code, activeRec.Body.String())
|
||||
}
|
||||
var activeResp struct {
|
||||
Configurations []models.Configuration `json:"configurations"`
|
||||
}
|
||||
if err := json.Unmarshal(activeRec.Body.Bytes(), &activeResp); err != nil {
|
||||
t.Fatalf("unmarshal active configs response: %v", err)
|
||||
}
|
||||
if len(activeResp.Configurations) != 0 {
|
||||
t.Fatalf("expected no active configs after project archive, got %d", len(activeResp.Configurations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigMoveToProjectEndpoint(t *testing.T) {
|
||||
moveToRepoRoot(t)
|
||||
|
||||
local, connMgr, _ := newAPITestStack(t)
|
||||
cfg := &config.Config{}
|
||||
setConfigDefaults(cfg)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
|
||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project","code":"MOVE"}`)))
|
||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||
createProjectRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||
if createProjectRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String())
|
||||
}
|
||||
var project models.Project
|
||||
if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil {
|
||||
t.Fatalf("unmarshal project: %v", err)
|
||||
}
|
||||
|
||||
createConfigReq := httptest.NewRequest(http.MethodPost, "/api/configs", bytes.NewReader([]byte(`{"name":"Move Me","items":[],"notes":"","server_count":1}`)))
|
||||
createConfigReq.Header.Set("Content-Type", "application/json")
|
||||
createConfigRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createConfigRec, createConfigReq)
|
||||
if createConfigRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create config status=%d body=%s", createConfigRec.Code, createConfigRec.Body.String())
|
||||
}
|
||||
var created models.Configuration
|
||||
if err := json.Unmarshal(createConfigRec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("unmarshal config: %v", err)
|
||||
}
|
||||
|
||||
moveReq := httptest.NewRequest(http.MethodPatch, "/api/configs/"+created.UUID+"/project", bytes.NewReader([]byte(`{"project_uuid":"`+project.UUID+`"}`)))
|
||||
moveReq.Header.Set("Content-Type", "application/json")
|
||||
moveRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(moveRec, moveReq)
|
||||
if moveRec.Code != http.StatusOK {
|
||||
t.Fatalf("move config status=%d body=%s", moveRec.Code, moveRec.Body.String())
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID, nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("get config status=%d body=%s", getRec.Code, getRec.Body.String())
|
||||
}
|
||||
var updated models.Configuration
|
||||
if err := json.Unmarshal(getRec.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatalf("unmarshal updated config: %v", err)
|
||||
}
|
||||
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected moved project_uuid=%s, got %v", project.UUID, updated.ProjectUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) {
|
||||
t.Helper()
|
||||
|
||||
localPath := filepath.Join(t.TempDir(), "api.db")
|
||||
local, err := localdb.New(localPath)
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
connMgr := db.NewConnectionManager(local)
|
||||
syncService := syncsvc.NewService(connMgr, local)
|
||||
configService := services.NewLocalConfigurationService(
|
||||
local,
|
||||
syncService,
|
||||
&services.QuoteService{},
|
||||
func() bool { return false },
|
||||
)
|
||||
return local, connMgr, configService
|
||||
}
|
||||
|
||||
func moveToRepoRoot(t *testing.T) {
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatalf("chdir repo root: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(wd)
|
||||
})
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mchus/quoteforge/internal/config"
|
||||
"github.com/mchus/quoteforge/internal/handlers"
|
||||
"github.com/mchus/quoteforge/internal/middleware"
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"github.com/mchus/quoteforge/internal/repository"
|
||||
"github.com/mchus/quoteforge/internal/services"
|
||||
"github.com/mchus/quoteforge/internal/services/alerts"
|
||||
"github.com/mchus/quoteforge/internal/services/pricing"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||
migrate := flag.Bool("migrate", false, "run database migrations")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to load config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
setupLogger(cfg.Logging)
|
||||
|
||||
slog.Info("starting QuoteForge server",
|
||||
"host", cfg.Server.Host,
|
||||
"port", cfg.Server.Port,
|
||||
"mode", cfg.Server.Mode,
|
||||
)
|
||||
|
||||
db, err := setupDatabase(cfg.Database)
|
||||
if err != nil {
|
||||
slog.Error("failed to connect to database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *migrate {
|
||||
slog.Info("running database migrations...")
|
||||
if err := models.Migrate(db); err != nil {
|
||||
slog.Error("migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := models.SeedCategories(db); err != nil {
|
||||
slog.Error("seeding categories failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("migrations completed")
|
||||
}
|
||||
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
router := setupRouter(db, cfg)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Address(),
|
||||
Handler: router,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("server listening", "address", cfg.Address())
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
slog.Info("shutting down server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
slog.Error("server forced to shutdown", "error", err)
|
||||
}
|
||||
|
||||
slog.Info("server stopped")
|
||||
}
|
||||
|
||||
func setupLogger(cfg config.LoggingConfig) {
|
||||
var level slog.Level
|
||||
switch cfg.Level {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
default:
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
|
||||
var handler slog.Handler
|
||||
if cfg.Format == "json" {
|
||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||
} else {
|
||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||
}
|
||||
|
||||
slog.SetDefault(slog.New(handler))
|
||||
}
|
||||
|
||||
func setupDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
gormLogger := logger.Default.LogMode(logger.Silent)
|
||||
|
||||
db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
||||
// Repositories
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
componentRepo := repository.NewComponentRepository(db)
|
||||
categoryRepo := repository.NewCategoryRepository(db)
|
||||
priceRepo := repository.NewPriceRepository(db)
|
||||
configRepo := repository.NewConfigurationRepository(db)
|
||||
alertRepo := repository.NewAlertRepository(db)
|
||||
statsRepo := repository.NewStatsRepository(db)
|
||||
|
||||
// Services
|
||||
authService := services.NewAuthService(userRepo, cfg.Auth)
|
||||
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||
configService := services.NewConfigurationService(configRepo, componentRepo, quoteService)
|
||||
exportService := services.NewExportService(cfg.Export)
|
||||
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||
|
||||
// Handlers
|
||||
authHandler := handlers.NewAuthHandler(authService, userRepo)
|
||||
componentHandler := handlers.NewComponentHandler(componentService)
|
||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||
configHandler := handlers.NewConfigurationHandler(configService, exportService)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
||||
pricingHandler := handlers.NewPricingHandler(pricingService, alertService, componentRepo, statsRepo)
|
||||
|
||||
// Router
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(requestLogger())
|
||||
router.Use(middleware.CORS())
|
||||
|
||||
// Health check
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
|
||||
// API routes
|
||||
api := router.Group("/api")
|
||||
{
|
||||
api.GET("/ping", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
||||
})
|
||||
|
||||
// Auth (public)
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", authHandler.Login)
|
||||
auth.POST("/refresh", authHandler.Refresh)
|
||||
auth.POST("/logout", authHandler.Logout)
|
||||
auth.GET("/me", middleware.Auth(authService), authHandler.Me)
|
||||
}
|
||||
|
||||
// Components (public read, for quote builder)
|
||||
components := api.Group("/components")
|
||||
{
|
||||
components.GET("", componentHandler.List)
|
||||
components.GET("/:lot_name", componentHandler.Get)
|
||||
}
|
||||
|
||||
// Categories (public)
|
||||
api.GET("/categories", componentHandler.GetCategories)
|
||||
api.GET("/vendors", componentHandler.GetVendors)
|
||||
|
||||
// Quote (public, for anonymous quote building)
|
||||
quote := api.Group("/quote")
|
||||
{
|
||||
quote.POST("/validate", quoteHandler.Validate)
|
||||
quote.POST("/calculate", quoteHandler.Calculate)
|
||||
}
|
||||
|
||||
// Export (public, for anonymous exports)
|
||||
export := api.Group("/export")
|
||||
{
|
||||
export.POST("/csv", exportHandler.ExportCSV)
|
||||
export.POST("/xlsx", exportHandler.ExportXLSX)
|
||||
}
|
||||
|
||||
// Configurations (requires auth)
|
||||
configs := api.Group("/configs")
|
||||
configs.Use(middleware.Auth(authService))
|
||||
configs.Use(middleware.RequireEditor())
|
||||
{
|
||||
configs.GET("", configHandler.List)
|
||||
configs.POST("", configHandler.Create)
|
||||
configs.GET("/:uuid", configHandler.Get)
|
||||
configs.PUT("/:uuid", configHandler.Update)
|
||||
configs.DELETE("/:uuid", configHandler.Delete)
|
||||
configs.GET("/:uuid/export", configHandler.ExportJSON)
|
||||
configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV)
|
||||
configs.GET("/:uuid/xlsx", exportHandler.ExportConfigXLSX)
|
||||
configs.POST("/import", configHandler.ImportJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// Admin routes
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(middleware.Auth(authService))
|
||||
{
|
||||
// Pricing admin
|
||||
pricingAdmin := admin.Group("/pricing")
|
||||
pricingAdmin.Use(middleware.RequirePricingAdmin())
|
||||
{
|
||||
pricingAdmin.GET("/stats", pricingHandler.GetStats)
|
||||
pricingAdmin.GET("/components", pricingHandler.ListComponents)
|
||||
pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing)
|
||||
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
|
||||
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
|
||||
|
||||
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
|
||||
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)
|
||||
pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert)
|
||||
pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert)
|
||||
}
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func requestLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
|
||||
slog.Info("request",
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
"query", query,
|
||||
"status", status,
|
||||
"latency", latency,
|
||||
"ip", c.ClientIP(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
# Copy this file to config.yaml and update values
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
host: "127.0.0.1" # Use 0.0.0.0 to listen on all interfaces
|
||||
port: 8080
|
||||
mode: "release" # debug | release
|
||||
read_timeout: "30s"
|
||||
@@ -18,11 +18,6 @@ database:
|
||||
max_idle_conns: 5
|
||||
conn_max_lifetime: "5m"
|
||||
|
||||
auth:
|
||||
jwt_secret: "CHANGE_ME_MIN_32_CHARACTERS_LONG"
|
||||
token_expiry: "24h"
|
||||
refresh_expiry: "168h" # 7 days
|
||||
|
||||
pricing:
|
||||
default_method: "weighted_median" # median | average | weighted_median
|
||||
default_period_days: 90
|
||||
@@ -37,6 +32,9 @@ export:
|
||||
max_file_age: "1h"
|
||||
company_name: "Your Company Name"
|
||||
|
||||
backup:
|
||||
time: "00:00"
|
||||
|
||||
alerts:
|
||||
enabled: true
|
||||
check_interval: "1h"
|
||||
|
||||
BIN
dist/qfs-darwin-amd64
vendored
Executable file
BIN
dist/qfs-darwin-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/qfs-darwin-arm64
vendored
Executable file
BIN
dist/qfs-darwin-arm64
vendored
Executable file
Binary file not shown.
BIN
dist/qfs-linux-amd64
vendored
Executable file
BIN
dist/qfs-linux-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/qfs-windows-amd64.exe
vendored
Executable file
BIN
dist/qfs-windows-amd64.exe
vendored
Executable file
Binary file not shown.
24
go.mod
24
go.mod
@@ -1,27 +1,27 @@
|
||||
module github.com/mchus/quoteforge
|
||||
module git.mchus.pro/mchus/quoteforge
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.5.2
|
||||
gorm.io/gorm v1.25.5
|
||||
gorm.io/gorm v1.25.7
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -32,16 +32,18 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
|
||||
43
go.sum
43
go.sum
@@ -7,12 +7,18 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -26,12 +32,12 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -56,11 +62,9 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -73,25 +77,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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
||||
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -100,8 +94,9 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
@@ -113,6 +108,14 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
|
||||
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
|
||||
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
26
internal/appmeta/version.go
Normal file
26
internal/appmeta/version.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package appmeta
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
var appVersion atomic.Value
|
||||
|
||||
func init() {
|
||||
appVersion.Store("dev")
|
||||
}
|
||||
|
||||
// SetVersion configures the running application version string.
|
||||
func SetVersion(v string) {
|
||||
if v == "" {
|
||||
v = "dev"
|
||||
}
|
||||
appVersion.Store(v)
|
||||
}
|
||||
|
||||
// Version returns the running application version string.
|
||||
func Version() string {
|
||||
if v, ok := appVersion.Load().(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return "dev"
|
||||
}
|
||||
|
||||
310
internal/appstate/backup.go
Normal file
310
internal/appstate/backup.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type backupPeriod struct {
|
||||
name string
|
||||
retention int
|
||||
key func(time.Time) string
|
||||
date func(time.Time) string
|
||||
}
|
||||
|
||||
var backupPeriods = []backupPeriod{
|
||||
{
|
||||
name: "daily",
|
||||
retention: 7,
|
||||
key: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
date: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "weekly",
|
||||
retention: 4,
|
||||
key: func(t time.Time) string {
|
||||
y, w := t.ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", y, w)
|
||||
},
|
||||
date: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "monthly",
|
||||
retention: 12,
|
||||
key: func(t time.Time) string {
|
||||
return t.Format("2006-01")
|
||||
},
|
||||
date: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "yearly",
|
||||
retention: 10,
|
||||
key: func(t time.Time) string {
|
||||
return t.Format("2006")
|
||||
},
|
||||
date: func(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const (
|
||||
envBackupDisable = "QFS_BACKUP_DISABLE"
|
||||
envBackupDir = "QFS_BACKUP_DIR"
|
||||
)
|
||||
|
||||
var backupNow = time.Now
|
||||
|
||||
// EnsureRotatingLocalBackup creates or refreshes daily/weekly/monthly/yearly backups
|
||||
// for the local database and config. It keeps a limited number per period.
|
||||
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
|
||||
if isBackupDisabled() {
|
||||
return nil, nil
|
||||
}
|
||||
if dbPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("stat db: %w", err)
|
||||
}
|
||||
|
||||
root := resolveBackupRoot(dbPath)
|
||||
if err := validateBackupRoot(root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := backupNow()
|
||||
|
||||
created := make([]string, 0)
|
||||
for _, period := range backupPeriods {
|
||||
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
if len(newFiles) > 0 {
|
||||
created = append(created, newFiles...)
|
||||
}
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func resolveBackupRoot(dbPath string) string {
|
||||
if fromEnv := strings.TrimSpace(os.Getenv(envBackupDir)); fromEnv != "" {
|
||||
return filepath.Clean(fromEnv)
|
||||
}
|
||||
return filepath.Join(filepath.Dir(dbPath), "backups")
|
||||
}
|
||||
|
||||
func validateBackupRoot(root string) error {
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve backup root: %w", err)
|
||||
}
|
||||
|
||||
if gitRoot, ok := findGitWorktreeRoot(absRoot); ok {
|
||||
return fmt.Errorf("backup root must stay outside git worktree: %s is inside %s", absRoot, gitRoot)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func findGitWorktreeRoot(path string) (string, bool) {
|
||||
current := filepath.Clean(path)
|
||||
info, err := os.Stat(current)
|
||||
if err == nil && !info.IsDir() {
|
||||
current = filepath.Dir(current)
|
||||
}
|
||||
|
||||
for {
|
||||
gitPath := filepath.Join(current, ".git")
|
||||
if _, err := os.Stat(gitPath); err == nil {
|
||||
return current, true
|
||||
}
|
||||
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return "", false
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func isBackupDisabled() bool {
|
||||
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
|
||||
return val == "1" || val == "true" || val == "yes"
|
||||
}
|
||||
|
||||
func ensurePeriodBackup(root string, period backupPeriod, now time.Time, dbPath, configPath string) ([]string, error) {
|
||||
key := period.key(now)
|
||||
periodDir := filepath.Join(root, period.name)
|
||||
if err := os.MkdirAll(periodDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create %s backup dir: %w", period.name, err)
|
||||
}
|
||||
|
||||
if hasBackupForKey(periodDir, key) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
archiveName := fmt.Sprintf("qfs-backp-%s.zip", period.date(now))
|
||||
archivePath := filepath.Join(periodDir, archiveName)
|
||||
|
||||
if err := createBackupArchive(archivePath, dbPath, configPath); err != nil {
|
||||
return nil, fmt.Errorf("create %s backup archive: %w", period.name, err)
|
||||
}
|
||||
|
||||
if err := writePeriodMarker(periodDir, key); err != nil {
|
||||
return []string{archivePath}, err
|
||||
}
|
||||
|
||||
if err := pruneOldBackups(periodDir, period.retention); err != nil {
|
||||
return []string{archivePath}, err
|
||||
}
|
||||
|
||||
return []string{archivePath}, nil
|
||||
}
|
||||
|
||||
func hasBackupForKey(periodDir, key string) bool {
|
||||
marker := periodMarker{Key: ""}
|
||||
data, err := os.ReadFile(periodMarkerPath(periodDir))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(data, &marker); err != nil {
|
||||
return false
|
||||
}
|
||||
return marker.Key == key
|
||||
}
|
||||
|
||||
type periodMarker struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func periodMarkerPath(periodDir string) string {
|
||||
return filepath.Join(periodDir, ".period.json")
|
||||
}
|
||||
|
||||
func writePeriodMarker(periodDir, key string) error {
|
||||
data, err := json.MarshalIndent(periodMarker{Key: key}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(periodMarkerPath(periodDir), data, 0644)
|
||||
}
|
||||
|
||||
func pruneOldBackups(periodDir string, keep int) error {
|
||||
entries, err := os.ReadDir(periodDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read backups dir: %w", err)
|
||||
}
|
||||
|
||||
files := make([]os.DirEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(entry.Name(), ".zip") {
|
||||
files = append(files, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) <= keep {
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
infoI, errI := files[i].Info()
|
||||
infoJ, errJ := files[j].Info()
|
||||
if errI != nil || errJ != nil {
|
||||
return files[i].Name() < files[j].Name()
|
||||
}
|
||||
return infoI.ModTime().Before(infoJ.ModTime())
|
||||
})
|
||||
|
||||
for i := 0; i < len(files)-keep; i++ {
|
||||
path := filepath.Join(periodDir, files[i].Name())
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("remove old backup %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createBackupArchive(destPath, dbPath, configPath string) error {
|
||||
file, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(file)
|
||||
if err := addZipFile(zipWriter, dbPath); err != nil {
|
||||
_ = zipWriter.Close()
|
||||
return err
|
||||
}
|
||||
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
|
||||
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
|
||||
|
||||
if strings.TrimSpace(configPath) != "" {
|
||||
_ = addZipOptionalFile(zipWriter, configPath)
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func addZipOptionalFile(writer *zip.Writer, path string) error {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil
|
||||
}
|
||||
return addZipFile(writer, path)
|
||||
}
|
||||
|
||||
func addZipFile(writer *zip.Writer, path string) error {
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
info, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = filepath.Base(path)
|
||||
header.Method = zip.Deflate
|
||||
|
||||
out, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
112
internal/appstate/backup_test.go
Normal file
112
internal/appstate/backup_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
dbPath := filepath.Join(temp, "qfs.db")
|
||||
cfgPath := filepath.Join(temp, "config.yaml")
|
||||
|
||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
||||
t.Fatalf("write db: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
prevNow := backupNow
|
||||
defer func() { backupNow = prevNow }()
|
||||
backupNow = func() time.Time { return time.Date(2026, 2, 11, 10, 0, 0, 0, time.UTC) }
|
||||
|
||||
created, err := EnsureRotatingLocalBackup(dbPath, cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatalf("expected backup to be created")
|
||||
}
|
||||
|
||||
dailyArchive := filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-11.zip")
|
||||
if _, err := os.Stat(dailyArchive); err != nil {
|
||||
t.Fatalf("daily archive missing: %v", err)
|
||||
}
|
||||
|
||||
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
|
||||
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("backup rotate: %v", err)
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatalf("expected backup to be created for new day")
|
||||
}
|
||||
|
||||
dailyArchive = filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-12.zip")
|
||||
if _, err := os.Stat(dailyArchive); err != nil {
|
||||
t.Fatalf("daily archive missing after rotate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
dbPath := filepath.Join(temp, "qfs.db")
|
||||
cfgPath := filepath.Join(temp, "config.yaml")
|
||||
|
||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
||||
t.Fatalf("write db: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
backupRoot := filepath.Join(temp, "custom_backups")
|
||||
t.Setenv(envBackupDir, backupRoot)
|
||||
|
||||
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||
t.Fatalf("backup with env: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(backupRoot, "daily", ".period.json")); err != nil {
|
||||
t.Fatalf("expected backup in custom dir: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(envBackupDisable, "1")
|
||||
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
|
||||
t.Fatalf("backup disabled: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(backupRoot, "daily", ".period.json")); err != nil {
|
||||
t.Fatalf("backup should remain from previous run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
repoRoot := filepath.Join(temp, "repo")
|
||||
if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0755); err != nil {
|
||||
t.Fatalf("mkdir git dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(repoRoot, "data", "qfs.db")
|
||||
cfgPath := filepath.Join(repoRoot, "data", "config.yaml")
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||
t.Fatalf("mkdir data dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
|
||||
t.Fatalf("write db: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
|
||||
t.Fatalf("write cfg: %v", err)
|
||||
}
|
||||
|
||||
_, err := EnsureRotatingLocalBackup(dbPath, cfgPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected git worktree backup root to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outside git worktree") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
217
internal/appstate/path.go
Normal file
217
internal/appstate/path.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
appDirName = "QuoteForge"
|
||||
defaultDB = "qfs.db"
|
||||
defaultCfg = "config.yaml"
|
||||
envDBPath = "QFS_DB_PATH"
|
||||
envStateDir = "QFS_STATE_DIR"
|
||||
envCfgPath = "QFS_CONFIG_PATH"
|
||||
)
|
||||
|
||||
// ResolveDBPath returns the local SQLite path using priority:
|
||||
// explicit CLI path > QFS_DB_PATH > OS-specific user state directory.
|
||||
func ResolveDBPath(explicitPath string) (string, error) {
|
||||
if explicitPath != "" {
|
||||
return filepath.Clean(explicitPath), nil
|
||||
}
|
||||
|
||||
if fromEnv := os.Getenv(envDBPath); fromEnv != "" {
|
||||
return filepath.Clean(fromEnv), nil
|
||||
}
|
||||
|
||||
dir, err := defaultStateDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(dir, defaultDB), nil
|
||||
}
|
||||
|
||||
// ResolveConfigPath returns the config path using priority:
|
||||
// explicit CLI path > QFS_CONFIG_PATH > OS-specific user state directory.
|
||||
func ResolveConfigPath(explicitPath string) (string, error) {
|
||||
if explicitPath != "" {
|
||||
return filepath.Clean(explicitPath), nil
|
||||
}
|
||||
|
||||
if fromEnv := os.Getenv(envCfgPath); fromEnv != "" {
|
||||
return filepath.Clean(fromEnv), nil
|
||||
}
|
||||
|
||||
dir, err := defaultStateDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(dir, defaultCfg), nil
|
||||
}
|
||||
|
||||
// ResolveConfigPathNearDB returns config path using priority:
|
||||
// explicit CLI path > QFS_CONFIG_PATH > directory of resolved local DB path.
|
||||
// Falls back to ResolveConfigPath when dbPath is empty.
|
||||
func ResolveConfigPathNearDB(explicitPath, dbPath string) (string, error) {
|
||||
if explicitPath != "" {
|
||||
return filepath.Clean(explicitPath), nil
|
||||
}
|
||||
|
||||
if fromEnv := os.Getenv(envCfgPath); fromEnv != "" {
|
||||
return filepath.Clean(fromEnv), nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(dbPath) != "" {
|
||||
return filepath.Join(filepath.Dir(filepath.Clean(dbPath)), defaultCfg), nil
|
||||
}
|
||||
|
||||
return ResolveConfigPath("")
|
||||
}
|
||||
|
||||
// MigrateLegacyDB copies an existing legacy DB (and optional SQLite sidecars)
|
||||
// to targetPath if targetPath does not already exist.
|
||||
// Returns source path if migration happened.
|
||||
func MigrateLegacyDB(targetPath string, legacyPaths []string) (string, error) {
|
||||
if targetPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if exists(targetPath) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("creating target db directory: %w", err)
|
||||
}
|
||||
|
||||
for _, src := range legacyPaths {
|
||||
if src == "" {
|
||||
continue
|
||||
}
|
||||
src = filepath.Clean(src)
|
||||
if src == targetPath || !exists(src) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := copyFile(src, targetPath); err != nil {
|
||||
return "", fmt.Errorf("migrating legacy db from %s: %w", src, err)
|
||||
}
|
||||
|
||||
// Optional SQLite sidecar files.
|
||||
_ = copyIfExists(src+"-wal", targetPath+"-wal")
|
||||
_ = copyIfExists(src+"-shm", targetPath+"-shm")
|
||||
|
||||
return src, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// MigrateLegacyFile copies an existing legacy file to targetPath
|
||||
// if targetPath does not already exist.
|
||||
func MigrateLegacyFile(targetPath string, legacyPaths []string) (string, error) {
|
||||
if targetPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if exists(targetPath) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("creating target directory: %w", err)
|
||||
}
|
||||
|
||||
for _, src := range legacyPaths {
|
||||
if src == "" {
|
||||
continue
|
||||
}
|
||||
src = filepath.Clean(src)
|
||||
if src == targetPath || !exists(src) {
|
||||
continue
|
||||
}
|
||||
if err := copyFile(src, targetPath); err != nil {
|
||||
return "", fmt.Errorf("migrating legacy file from %s: %w", src, err)
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func defaultStateDir() (string, error) {
|
||||
if override := os.Getenv(envStateDir); override != "" {
|
||||
return filepath.Clean(override), nil
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
base, err := os.UserConfigDir() // ~/Library/Application Support
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(base, appDirName), nil
|
||||
case "windows":
|
||||
if local := os.Getenv("LOCALAPPDATA"); local != "" {
|
||||
return filepath.Join(local, appDirName), nil
|
||||
}
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(base, appDirName), nil
|
||||
default:
|
||||
if xdgState := os.Getenv("XDG_STATE_HOME"); xdgState != "" {
|
||||
return filepath.Join(xdgState, "quoteforge"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving user home dir: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".local", "state", "quoteforge"), nil
|
||||
}
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func copyIfExists(src, dst string) error {
|
||||
if !exists(src) {
|
||||
return nil
|
||||
}
|
||||
return copyFile(src, dst)
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
info, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return out.Sync()
|
||||
}
|
||||
124
internal/article/categories.go
Normal file
124
internal/article/categories.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
)
|
||||
|
||||
// ErrMissingCategoryForLot is returned when a lot has no category in local_pricelist_items.lot_category.
|
||||
var ErrMissingCategoryForLot = errors.New("missing_category_for_lot")
|
||||
|
||||
type MissingCategoryForLotError struct {
|
||||
LotName string
|
||||
}
|
||||
|
||||
func (e *MissingCategoryForLotError) Error() string {
|
||||
if e == nil || strings.TrimSpace(e.LotName) == "" {
|
||||
return ErrMissingCategoryForLot.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", ErrMissingCategoryForLot.Error(), e.LotName)
|
||||
}
|
||||
|
||||
func (e *MissingCategoryForLotError) Unwrap() error {
|
||||
return ErrMissingCategoryForLot
|
||||
}
|
||||
|
||||
type Group string
|
||||
|
||||
const (
|
||||
GroupCPU Group = "CPU"
|
||||
GroupMEM Group = "MEM"
|
||||
GroupGPU Group = "GPU"
|
||||
GroupDISK Group = "DISK"
|
||||
GroupNET Group = "NET"
|
||||
GroupPSU Group = "PSU"
|
||||
)
|
||||
|
||||
// GroupForLotCategory maps pricelist lot_category codes into article groups.
|
||||
// Unknown/unrelated categories return ok=false.
|
||||
func GroupForLotCategory(cat string) (group Group, ok bool) {
|
||||
c := strings.ToUpper(strings.TrimSpace(cat))
|
||||
switch c {
|
||||
case "CPU":
|
||||
return GroupCPU, true
|
||||
case "MEM":
|
||||
return GroupMEM, true
|
||||
case "GPU":
|
||||
return GroupGPU, true
|
||||
case "M2", "SSD", "HDD", "EDSFF", "HHHL":
|
||||
return GroupDISK, true
|
||||
case "NIC", "HCA", "DPU":
|
||||
return GroupNET, true
|
||||
case "HBA":
|
||||
return GroupNET, true
|
||||
case "PSU", "PS":
|
||||
return GroupPSU, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveLotCategoriesStrict resolves categories for lotNames using local_pricelist_items.lot_category
|
||||
// for a given server pricelist id. If any lot is missing or has empty category, returns an error.
|
||||
func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||
if local == nil {
|
||||
return nil, fmt.Errorf("local db is nil")
|
||||
}
|
||||
cats, err := local.GetLocalLotCategoriesByServerPricelistID(serverPricelistID, lotNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
missing := make([]string, 0)
|
||||
for _, lot := range lotNames {
|
||||
cat := strings.TrimSpace(cats[lot])
|
||||
if cat == "" {
|
||||
missing = append(missing, lot)
|
||||
continue
|
||||
}
|
||||
cats[lot] = cat
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
fallback, err := local.GetLocalComponentCategoriesByLotNames(missing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, lot := range missing {
|
||||
if cat := strings.TrimSpace(fallback[lot]); cat != "" {
|
||||
cats[lot] = cat
|
||||
}
|
||||
}
|
||||
for _, lot := range missing {
|
||||
if strings.TrimSpace(cats[lot]) == "" {
|
||||
return nil, &MissingCategoryForLotError{LotName: lot}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cats, nil
|
||||
}
|
||||
|
||||
// NormalizeServerModel produces a stable article segment for the server model.
|
||||
func NormalizeServerModel(model string) string {
|
||||
trimmed := strings.TrimSpace(model)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
upper := strings.ToUpper(trimmed)
|
||||
var b strings.Builder
|
||||
for _, r := range upper {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if r == '.' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
98
internal/article/categories_test.go
Normal file
98
internal/article/categories_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
)
|
||||
|
||||
func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 1,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-001",
|
||||
Name: "test",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: localPL.ID, LotName: "CPU_A", LotCategory: "", Price: 10},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local items: %v", err)
|
||||
}
|
||||
|
||||
_, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrMissingCategoryForLot) {
|
||||
t.Fatalf("expected ErrMissingCategoryForLot, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) {
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 2,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-002",
|
||||
Name: "test",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(2)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: localPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local items: %v", err)
|
||||
}
|
||||
if err := local.DB().Create(&localdb.LocalComponent{
|
||||
LotName: "CPU_B",
|
||||
Category: "CPU",
|
||||
LotDescription: "cpu",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("save local components: %v", err)
|
||||
}
|
||||
|
||||
cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected fallback, got error: %v", err)
|
||||
}
|
||||
if cats["CPU_B"] != "CPU" {
|
||||
t.Fatalf("expected CPU, got %q", cats["CPU_B"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupForLotCategory(t *testing.T) {
|
||||
if g, ok := GroupForLotCategory("cpu"); !ok || g != GroupCPU {
|
||||
t.Fatalf("expected cpu -> GroupCPU")
|
||||
}
|
||||
if g, ok := GroupForLotCategory("SFP"); ok || g != "" {
|
||||
t.Fatalf("expected SFP to be excluded")
|
||||
}
|
||||
}
|
||||
605
internal/article/generator.go
Normal file
605
internal/article/generator.go
Normal file
@@ -0,0 +1,605 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
type BuildOptions struct {
|
||||
ServerModel string
|
||||
SupportCode string
|
||||
ServerPricelist *uint
|
||||
}
|
||||
|
||||
type BuildResult struct {
|
||||
Article string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
var (
|
||||
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
|
||||
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
|
||||
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
|
||||
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
|
||||
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
|
||||
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
|
||||
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
|
||||
)
|
||||
|
||||
func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) {
|
||||
segments := make([]string, 0, 8)
|
||||
warnings := make([]string, 0)
|
||||
|
||||
model := NormalizeServerModel(opts.ServerModel)
|
||||
if model == "" {
|
||||
return BuildResult{}, fmt.Errorf("server_model required")
|
||||
}
|
||||
segments = append(segments, model)
|
||||
|
||||
lotNames := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
lotNames = append(lotNames, it.LotName)
|
||||
}
|
||||
|
||||
if opts.ServerPricelist == nil || *opts.ServerPricelist == 0 {
|
||||
return BuildResult{}, fmt.Errorf("pricelist_id required for article")
|
||||
}
|
||||
|
||||
cats, err := ResolveLotCategoriesStrict(local, *opts.ServerPricelist, lotNames)
|
||||
if err != nil {
|
||||
return BuildResult{}, err
|
||||
}
|
||||
|
||||
cpuSeg := buildCPUSegment(items, cats)
|
||||
if cpuSeg != "" {
|
||||
segments = append(segments, cpuSeg)
|
||||
}
|
||||
memSeg, memWarn := buildMemSegment(items, cats)
|
||||
if memWarn != "" {
|
||||
warnings = append(warnings, memWarn)
|
||||
}
|
||||
if memSeg != "" {
|
||||
segments = append(segments, memSeg)
|
||||
}
|
||||
gpuSeg := buildGPUSegment(items, cats)
|
||||
if gpuSeg != "" {
|
||||
segments = append(segments, gpuSeg)
|
||||
}
|
||||
diskSeg, diskWarn := buildDiskSegment(items, cats)
|
||||
if diskWarn != "" {
|
||||
warnings = append(warnings, diskWarn)
|
||||
}
|
||||
if diskSeg != "" {
|
||||
segments = append(segments, diskSeg)
|
||||
}
|
||||
netSeg, netWarn := buildNetSegment(items, cats)
|
||||
if netWarn != "" {
|
||||
warnings = append(warnings, netWarn)
|
||||
}
|
||||
if netSeg != "" {
|
||||
segments = append(segments, netSeg)
|
||||
}
|
||||
psuSeg, psuWarn := buildPSUSegment(items, cats)
|
||||
if psuWarn != "" {
|
||||
warnings = append(warnings, psuWarn)
|
||||
}
|
||||
if psuSeg != "" {
|
||||
segments = append(segments, psuSeg)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(opts.SupportCode) != "" {
|
||||
code := strings.TrimSpace(opts.SupportCode)
|
||||
if !isSupportCodeValid(code) {
|
||||
return BuildResult{}, fmt.Errorf("invalid_support_code")
|
||||
}
|
||||
segments = append(segments, code)
|
||||
}
|
||||
|
||||
article := strings.Join(segments, "-")
|
||||
if len([]rune(article)) > 80 {
|
||||
article = compressArticle(segments)
|
||||
warnings = append(warnings, "compressed")
|
||||
}
|
||||
if len([]rune(article)) > 80 {
|
||||
return BuildResult{}, fmt.Errorf("article_overflow")
|
||||
}
|
||||
|
||||
return BuildResult{Article: article, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func isSupportCodeValid(code string) bool {
|
||||
if len(code) < 3 {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(code, "y") {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(code, "y")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range parts[0] {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch parts[1] {
|
||||
case "W", "B", "S", "P":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
||||
type agg struct {
|
||||
qty int
|
||||
}
|
||||
models := map[string]*agg{}
|
||||
for _, it := range items {
|
||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||
if !ok || group != GroupCPU {
|
||||
continue
|
||||
}
|
||||
model := parseCPUModel(it.LotName)
|
||||
if model == "" {
|
||||
model = "UNK"
|
||||
}
|
||||
if _, ok := models[model]; !ok {
|
||||
models[model] = &agg{}
|
||||
}
|
||||
models[model].qty += it.Quantity
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(models))
|
||||
for model, a := range models {
|
||||
parts = append(parts, fmt.Sprintf("%dx%s", a.qty, model))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func buildMemSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||
totalGiB := 0
|
||||
for _, it := range items {
|
||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||
if !ok || group != GroupMEM {
|
||||
continue
|
||||
}
|
||||
per := parseMemGiB(it.LotName)
|
||||
if per <= 0 {
|
||||
return "", "mem_unknown"
|
||||
}
|
||||
totalGiB += per * it.Quantity
|
||||
}
|
||||
if totalGiB == 0 {
|
||||
return "", ""
|
||||
}
|
||||
if totalGiB%1024 == 0 {
|
||||
return fmt.Sprintf("%dT", totalGiB/1024), ""
|
||||
}
|
||||
return fmt.Sprintf("%dG", totalGiB), ""
|
||||
}
|
||||
|
||||
func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
||||
models := map[string]int{}
|
||||
for _, it := range items {
|
||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||
if !ok || group != GroupGPU {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.ToUpper(it.LotName), "MB_") {
|
||||
continue
|
||||
}
|
||||
model := parseGPUModel(it.LotName)
|
||||
if model == "" {
|
||||
model = "UNK"
|
||||
}
|
||||
models[model] += it.Quantity
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(models))
|
||||
for model, qty := range models {
|
||||
parts = append(parts, fmt.Sprintf("%dx%s", qty, model))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||
type key struct {
|
||||
t string
|
||||
c string
|
||||
}
|
||||
groupQty := map[key]int{}
|
||||
warn := ""
|
||||
for _, it := range items {
|
||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||
if !ok || group != GroupDISK {
|
||||
continue
|
||||
}
|
||||
capToken := parseCapacity(it.LotName)
|
||||
if capToken == "" {
|
||||
warn = "disk_unknown"
|
||||
}
|
||||
typeCode := diskTypeCode(cats[it.LotName], it.LotName)
|
||||
k := key{t: typeCode, c: capToken}
|
||||
groupQty[k] += it.Quantity
|
||||
}
|
||||
if len(groupQty) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
parts := make([]string, 0, len(groupQty))
|
||||
for k, qty := range groupQty {
|
||||
if k.c == "" {
|
||||
parts = append(parts, fmt.Sprintf("%dx%s", qty, k.t))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("%dx%s%s", qty, k.c, k.t))
|
||||
}
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "+"), warn
|
||||
}
|
||||
|
||||
func buildNetSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||
groupQty := map[string]int{}
|
||||
warn := ""
|
||||
for _, it := range items {
|
||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||
if !ok || group != GroupNET {
|
||||
continue
|
||||
}
|
||||
profile := parsePortSpeed(it.LotName)
|
||||
if profile == "" {
|
||||
profile = "UNKNET"
|
||||
warn = "net_unknown"
|
||||
}
|
||||
groupQty[profile] += it.Quantity
|
||||
}
|
||||
if len(groupQty) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
parts := make([]string, 0, len(groupQty))
|
||||
for profile, qty := range groupQty {
|
||||
parts = append(parts, fmt.Sprintf("%dx%s", qty, profile))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "+"), warn
|
||||
}
|
||||
|
||||
func buildPSUSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
|
||||
groupQty := map[string]int{}
|
||||
warn := ""
|
||||
for _, it := range items {
|
||||
group, ok := GroupForLotCategory(cats[it.LotName])
|
||||
if !ok || group != GroupPSU {
|
||||
continue
|
||||
}
|
||||
rating := parseWatts(it.LotName)
|
||||
if rating == "" {
|
||||
rating = "UNKPSU"
|
||||
warn = "psu_unknown"
|
||||
}
|
||||
groupQty[rating] += it.Quantity
|
||||
}
|
||||
if len(groupQty) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
parts := make([]string, 0, len(groupQty))
|
||||
for rating, qty := range groupQty {
|
||||
parts = append(parts, fmt.Sprintf("%dx%s", qty, rating))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "+"), warn
|
||||
}
|
||||
|
||||
func normalizeModelToken(lotName string) string {
|
||||
if idx := strings.Index(lotName, "_"); idx >= 0 && idx+1 < len(lotName) {
|
||||
lotName = lotName[idx+1:]
|
||||
}
|
||||
parts := strings.Split(lotName, "_")
|
||||
token := parts[len(parts)-1]
|
||||
return strings.ToUpper(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
func parseCPUModel(lotName string) string {
|
||||
parts := strings.Split(lotName, "_")
|
||||
if len(parts) >= 2 {
|
||||
last := strings.ToUpper(strings.TrimSpace(parts[len(parts)-1]))
|
||||
if last != "" {
|
||||
return last
|
||||
}
|
||||
}
|
||||
return normalizeModelToken(lotName)
|
||||
}
|
||||
|
||||
func parseGPUModel(lotName string) string {
|
||||
upper := strings.ToUpper(lotName)
|
||||
if idx := strings.Index(upper, "GPU_"); idx >= 0 {
|
||||
upper = upper[idx+4:]
|
||||
}
|
||||
parts := strings.Split(upper, "_")
|
||||
model := ""
|
||||
mem := ""
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
switch p {
|
||||
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
||||
continue
|
||||
default:
|
||||
if strings.Contains(p, "GB") {
|
||||
mem = p
|
||||
continue
|
||||
}
|
||||
if model == "" && (i > 0) {
|
||||
model = p
|
||||
}
|
||||
}
|
||||
}
|
||||
if model != "" && mem != "" {
|
||||
return model + "_" + mem
|
||||
}
|
||||
if model != "" {
|
||||
return model
|
||||
}
|
||||
return normalizeModelToken(lotName)
|
||||
}
|
||||
|
||||
func parseMemGiB(lotName string) int {
|
||||
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
|
||||
return atoi(m[1]) * 1024
|
||||
}
|
||||
if m := reMemGiB.FindStringSubmatch(lotName); len(m) == 3 {
|
||||
return atoi(m[1])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseCapacity(lotName string) string {
|
||||
if m := reCapacityT.FindStringSubmatch(lotName); len(m) == 2 {
|
||||
return normalizeTToken(strings.ReplaceAll(m[1], ",", ".")) + "T"
|
||||
}
|
||||
if m := reCapacityG.FindStringSubmatch(lotName); len(m) == 2 {
|
||||
return normalizeNumberToken(strings.ReplaceAll(m[1], ",", ".")) + "G"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func diskTypeCode(cat string, lotName string) string {
|
||||
c := strings.ToUpper(strings.TrimSpace(cat))
|
||||
if c == "M2" {
|
||||
return "M2"
|
||||
}
|
||||
upper := strings.ToUpper(lotName)
|
||||
if strings.Contains(upper, "NVME") {
|
||||
return "NV"
|
||||
}
|
||||
if strings.Contains(upper, "SAS") {
|
||||
return "SAS"
|
||||
}
|
||||
if strings.Contains(upper, "SATA") {
|
||||
return "SAT"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func parsePortSpeed(lotName string) string {
|
||||
if m := rePortSpeed.FindStringSubmatch(lotName); len(m) == 4 {
|
||||
return fmt.Sprintf("%sp%sG", m[1], m[2])
|
||||
}
|
||||
if m := rePortFC.FindStringSubmatch(lotName); len(m) == 3 {
|
||||
return fmt.Sprintf("%spFC%s", m[1], m[2])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseWatts(lotName string) string {
|
||||
if m := reWatts.FindStringSubmatch(lotName); len(m) == 2 {
|
||||
w := atoi(m[1])
|
||||
if w >= 1000 {
|
||||
kw := fmt.Sprintf("%.1f", float64(w)/1000.0)
|
||||
kw = strings.TrimSuffix(kw, ".0")
|
||||
return fmt.Sprintf("%skW", kw)
|
||||
}
|
||||
return fmt.Sprintf("%dW", w)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeNumberToken(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimLeft(raw, "0")
|
||||
if raw == "" || raw[0] == '.' {
|
||||
raw = "0" + raw
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func normalizeTToken(raw string) string {
|
||||
raw = normalizeNumberToken(raw)
|
||||
parts := strings.SplitN(raw, ".", 2)
|
||||
intPart := parts[0]
|
||||
frac := ""
|
||||
if len(parts) == 2 {
|
||||
frac = parts[1]
|
||||
}
|
||||
if frac == "" {
|
||||
frac = "0"
|
||||
}
|
||||
if len(intPart) >= 2 {
|
||||
return intPart + "." + frac
|
||||
}
|
||||
if len(frac) > 1 {
|
||||
frac = frac[:1]
|
||||
}
|
||||
return intPart + "." + frac
|
||||
}
|
||||
|
||||
func atoi(v string) int {
|
||||
n := 0
|
||||
for _, r := range v {
|
||||
if r < '0' || r > '9' {
|
||||
continue
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func compressArticle(segments []string) string {
|
||||
if len(segments) == 0 {
|
||||
return ""
|
||||
}
|
||||
normalized := make([]string, 0, len(segments))
|
||||
for _, s := range segments {
|
||||
normalized = append(normalized, strings.ReplaceAll(s, "GbE", "G"))
|
||||
}
|
||||
segments = normalized
|
||||
article := strings.Join(segments, "-")
|
||||
if len([]rune(article)) <= 80 {
|
||||
return article
|
||||
}
|
||||
|
||||
// segment order: model, cpu, mem, gpu, disk, net, psu, support
|
||||
index := func(i int) (int, bool) {
|
||||
if i >= 0 && i < len(segments) {
|
||||
return i, true
|
||||
}
|
||||
return -1, false
|
||||
}
|
||||
|
||||
// 1) remove PSU
|
||||
if i, ok := index(6); ok {
|
||||
segments = append(segments[:i], segments[i+1:]...)
|
||||
article = strings.Join(segments, "-")
|
||||
if len([]rune(article)) <= 80 {
|
||||
return article
|
||||
}
|
||||
}
|
||||
|
||||
// 2) compress NET/HBA/HCA
|
||||
if i, ok := index(5); ok {
|
||||
segments[i] = compressNetSegment(segments[i])
|
||||
article = strings.Join(segments, "-")
|
||||
if len([]rune(article)) <= 80 {
|
||||
return article
|
||||
}
|
||||
}
|
||||
|
||||
// 3) compress DISK
|
||||
if i, ok := index(4); ok {
|
||||
segments[i] = compressDiskSegment(segments[i])
|
||||
article = strings.Join(segments, "-")
|
||||
if len([]rune(article)) <= 80 {
|
||||
return article
|
||||
}
|
||||
}
|
||||
|
||||
// 4) compress GPU to vendor only (GPU_NV)
|
||||
if i, ok := index(3); ok {
|
||||
segments[i] = compressGPUSegment(segments[i])
|
||||
}
|
||||
return strings.Join(segments, "-")
|
||||
}
|
||||
|
||||
func compressNetSegment(seg string) string {
|
||||
if seg == "" {
|
||||
return seg
|
||||
}
|
||||
parts := strings.Split(seg, "+")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
qty := "1"
|
||||
profile := p
|
||||
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
||||
qty = x[0]
|
||||
profile = x[1]
|
||||
}
|
||||
upper := strings.ToUpper(profile)
|
||||
label := "NIC"
|
||||
if strings.Contains(upper, "FC") {
|
||||
label = "HBA"
|
||||
} else if strings.Contains(upper, "HCA") || strings.Contains(upper, "IB") {
|
||||
label = "HCA"
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%sx%s", qty, label))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return seg
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, "+")
|
||||
}
|
||||
|
||||
func compressDiskSegment(seg string) string {
|
||||
if seg == "" {
|
||||
return seg
|
||||
}
|
||||
parts := strings.Split(seg, "+")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
qty := "1"
|
||||
spec := p
|
||||
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
||||
qty = x[0]
|
||||
spec = x[1]
|
||||
}
|
||||
upper := strings.ToUpper(spec)
|
||||
label := "DSK"
|
||||
for _, t := range []string{"M2", "NV", "SAS", "SAT", "SSD", "HDD", "EDS", "HHH"} {
|
||||
if strings.Contains(upper, t) {
|
||||
label = t
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%sx%s", qty, label))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return seg
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, "+")
|
||||
}
|
||||
|
||||
func compressGPUSegment(seg string) string {
|
||||
if seg == "" {
|
||||
return seg
|
||||
}
|
||||
parts := strings.Split(seg, "+")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
qty := "1"
|
||||
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
|
||||
qty = x[0]
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%sxGPU_NV", qty))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return seg
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, "+")
|
||||
}
|
||||
66
internal/article/generator_test.go
Normal file
66
internal/article/generator_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
func TestBuild_ParsesNetAndPSU(t *testing.T) {
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 1,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-001",
|
||||
Name: "test",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{PricelistID: localPL.ID, LotName: "NIC_2p25G_MCX512A-AC", LotCategory: "NIC", Price: 1},
|
||||
{PricelistID: localPL.ID, LotName: "HBA_2pFC32_Gen6", LotCategory: "HBA", Price: 1},
|
||||
{PricelistID: localPL.ID, LotName: "PS_1000W_Platinum", LotCategory: "PS", Price: 1},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local items: %v", err)
|
||||
}
|
||||
|
||||
items := models.ConfigItems{
|
||||
{LotName: "NIC_2p25G_MCX512A-AC", Quantity: 1},
|
||||
{LotName: "HBA_2pFC32_Gen6", Quantity: 1},
|
||||
{LotName: "PS_1000W_Platinum", Quantity: 2},
|
||||
}
|
||||
result, err := Build(local, items, BuildOptions{
|
||||
ServerModel: "DL380GEN11",
|
||||
SupportCode: "1yW",
|
||||
ServerPricelist: &localPL.ServerID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build article: %v", err)
|
||||
}
|
||||
if result.Article == "" {
|
||||
t.Fatalf("expected article to be non-empty")
|
||||
}
|
||||
if contains(result.Article, "UNKNET") || contains(result.Article, "UNKPSU") {
|
||||
t.Fatalf("unexpected UNK in article: %s", result.Article)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return strings.Contains(s, sub)
|
||||
}
|
||||
@@ -2,21 +2,24 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Pricing PricingConfig `yaml:"pricing"`
|
||||
Export ExportConfig `yaml:"export"`
|
||||
Alerts AlertsConfig `yaml:"alerts"`
|
||||
Notifications NotificationsConfig `yaml:"notifications"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Backup BackupConfig `yaml:"backup"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -39,14 +42,18 @@ type DatabaseConfig struct {
|
||||
}
|
||||
|
||||
func (d *DatabaseConfig) DSN() string {
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
d.User, d.Password, d.Host, d.Port, d.Name)
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
JWTSecret string `yaml:"jwt_secret"`
|
||||
TokenExpiry time.Duration `yaml:"token_expiry"`
|
||||
RefreshExpiry time.Duration `yaml:"refresh_expiry"`
|
||||
cfg := mysqlDriver.NewConfig()
|
||||
cfg.User = d.User
|
||||
cfg.Passwd = d.Password
|
||||
cfg.Net = "tcp"
|
||||
cfg.Addr = net.JoinHostPort(d.Host, strconv.Itoa(d.Port))
|
||||
cfg.DBName = d.Name
|
||||
cfg.ParseTime = true
|
||||
cfg.Loc = time.Local
|
||||
cfg.Params = map[string]string{
|
||||
"charset": "utf8mb4",
|
||||
}
|
||||
return cfg.FormatDSN()
|
||||
}
|
||||
|
||||
type PricingConfig struct {
|
||||
@@ -88,6 +95,10 @@ type LoggingConfig struct {
|
||||
FilePath string `yaml:"file_path"`
|
||||
}
|
||||
|
||||
type BackupConfig struct {
|
||||
Time string `yaml:"time"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -106,7 +117,7 @@ func Load(path string) (*Config, error) {
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.Server.Host == "" {
|
||||
c.Server.Host = "0.0.0.0"
|
||||
c.Server.Host = "127.0.0.1"
|
||||
}
|
||||
if c.Server.Port == 0 {
|
||||
c.Server.Port = 8080
|
||||
@@ -134,13 +145,6 @@ func (c *Config) setDefaults() {
|
||||
c.Database.ConnMaxLifetime = 5 * time.Minute
|
||||
}
|
||||
|
||||
if c.Auth.TokenExpiry == 0 {
|
||||
c.Auth.TokenExpiry = 24 * time.Hour
|
||||
}
|
||||
if c.Auth.RefreshExpiry == 0 {
|
||||
c.Auth.RefreshExpiry = 7 * 24 * time.Hour
|
||||
}
|
||||
|
||||
if c.Pricing.DefaultMethod == "" {
|
||||
c.Pricing.DefaultMethod = "weighted_median"
|
||||
}
|
||||
@@ -169,6 +173,10 @@ func (c *Config) setDefaults() {
|
||||
if c.Logging.Output == "" {
|
||||
c.Logging.Output = "stdout"
|
||||
}
|
||||
|
||||
if c.Backup.Time == "" {
|
||||
c.Backup.Time = "00:00"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Address() string {
|
||||
|
||||
334
internal/db/connection.go
Normal file
334
internal/db/connection.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConnectTimeout = 5 * time.Second
|
||||
defaultPingInterval = 30 * time.Second
|
||||
defaultReconnectCooldown = 10 * time.Second
|
||||
|
||||
maxOpenConns = 10
|
||||
maxIdleConns = 2
|
||||
connMaxLifetime = 5 * time.Minute
|
||||
)
|
||||
|
||||
// ConnectionStatus represents the current status of the database connection
|
||||
type ConnectionStatus struct {
|
||||
IsConnected bool
|
||||
LastCheck time.Time
|
||||
LastError string // empty if no error
|
||||
DSNHost string // host:port for display (without password!)
|
||||
}
|
||||
|
||||
// ConnectionManager manages database connections with thread-safety and connection pooling
|
||||
type ConnectionManager struct {
|
||||
localDB *localdb.LocalDB // for getting DSN from settings
|
||||
mu sync.RWMutex // protects db and state
|
||||
db *gorm.DB // current connection (nil if not connected)
|
||||
lastError error // last connection error
|
||||
lastCheck time.Time // time of last check/attempt
|
||||
connectTimeout time.Duration // timeout for connection (default: 5s)
|
||||
pingInterval time.Duration // minimum interval between pings (default: 30s)
|
||||
reconnectCooldown time.Duration // pause after failed attempt (default: 10s)
|
||||
}
|
||||
|
||||
// NewConnectionManager creates a new ConnectionManager instance
|
||||
func NewConnectionManager(localDB *localdb.LocalDB) *ConnectionManager {
|
||||
return &ConnectionManager{
|
||||
localDB: localDB,
|
||||
connectTimeout: defaultConnectTimeout,
|
||||
pingInterval: defaultPingInterval,
|
||||
reconnectCooldown: defaultReconnectCooldown,
|
||||
db: nil,
|
||||
lastError: nil,
|
||||
lastCheck: time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDB returns the current database connection, establishing it if needed
|
||||
// Thread-safe and respects connection cooldowns
|
||||
func (cm *ConnectionManager) GetDB() (*gorm.DB, error) {
|
||||
// Handle case where localDB is nil
|
||||
if cm.localDB == nil {
|
||||
return nil, fmt.Errorf("local database not initialized")
|
||||
}
|
||||
|
||||
// First check if we already have a valid connection
|
||||
cm.mu.RLock()
|
||||
if cm.db != nil {
|
||||
// Check if connection is still valid and within ping interval
|
||||
if time.Since(cm.lastCheck) < cm.pingInterval {
|
||||
cm.mu.RUnlock()
|
||||
return cm.db, nil
|
||||
}
|
||||
}
|
||||
cm.mu.RUnlock()
|
||||
|
||||
// Upgrade to write lock
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
// Double-check: someone else might have connected while we were waiting for the write lock
|
||||
if cm.db != nil {
|
||||
// Check if connection is still valid and within ping interval
|
||||
if time.Since(cm.lastCheck) < cm.pingInterval {
|
||||
return cm.db, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're in cooldown period after a failed attempt
|
||||
if cm.lastError != nil && time.Since(cm.lastCheck) < cm.reconnectCooldown {
|
||||
return nil, cm.lastError
|
||||
}
|
||||
|
||||
// Attempt to connect
|
||||
err := cm.connect()
|
||||
if err != nil {
|
||||
// Drop stale handle so callers don't treat it as an active connection.
|
||||
cm.db = nil
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update last check time and return success
|
||||
cm.lastCheck = time.Now()
|
||||
cm.lastError = nil
|
||||
return cm.db, nil
|
||||
}
|
||||
|
||||
// connect establishes a new database connection
|
||||
func (cm *ConnectionManager) connect() error {
|
||||
// Get DSN from local settings
|
||||
dsn, err := cm.localDB.GetDSN()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting DSN: %w", err)
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Open database connection
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening database connection: %w", err)
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting sql.DB: %w", err)
|
||||
}
|
||||
|
||||
// Ping with timeout
|
||||
if err = sqlDB.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("pinging database: %w", err)
|
||||
}
|
||||
|
||||
// Set connection pool settings
|
||||
sqlDB.SetMaxOpenConns(maxOpenConns)
|
||||
sqlDB.SetMaxIdleConns(maxIdleConns)
|
||||
sqlDB.SetConnMaxLifetime(connMaxLifetime)
|
||||
|
||||
// Store the connection
|
||||
cm.db = db
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsOnline checks if the database is currently connected and responsive.
|
||||
// If disconnected, it tries to reconnect (respecting cooldowns in GetDB).
|
||||
func (cm *ConnectionManager) IsOnline() bool {
|
||||
cm.mu.RLock()
|
||||
isDisconnected := cm.db == nil
|
||||
lastErr := cm.lastError
|
||||
checkedRecently := time.Since(cm.lastCheck) < cm.pingInterval
|
||||
cm.mu.RUnlock()
|
||||
|
||||
// Try reconnect in disconnected state.
|
||||
if isDisconnected {
|
||||
_, err := cm.GetDB()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// If we've checked recently, return cached result.
|
||||
if checkedRecently {
|
||||
return lastErr == nil
|
||||
}
|
||||
|
||||
// Need to perform actual ping.
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if cm.db == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Perform ping with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout)
|
||||
defer cancel()
|
||||
|
||||
sqlDB, err := cm.db.DB()
|
||||
if err != nil {
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
cm.db = nil
|
||||
return false
|
||||
}
|
||||
|
||||
if err = sqlDB.PingContext(ctx); err != nil {
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
cm.db = nil
|
||||
return false
|
||||
}
|
||||
|
||||
// Update last check time and return success
|
||||
cm.lastCheck = time.Now()
|
||||
cm.lastError = nil
|
||||
return true
|
||||
}
|
||||
|
||||
// TryConnect forces a new connection attempt (for UI "Reconnect" button)
|
||||
// Ignores cooldown period
|
||||
func (cm *ConnectionManager) TryConnect() error {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
// Attempt to connect
|
||||
err := cm.connect()
|
||||
if err != nil {
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
return err
|
||||
}
|
||||
|
||||
// Update last check time and clear error
|
||||
cm.lastCheck = time.Now()
|
||||
cm.lastError = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect closes the current database connection
|
||||
func (cm *ConnectionManager) Disconnect() {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
if cm.db != nil {
|
||||
sqlDB, err := cm.db.DB()
|
||||
if err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
cm.db = nil
|
||||
cm.lastError = nil
|
||||
}
|
||||
|
||||
// GetLastError returns the last connection error (thread-safe)
|
||||
func (cm *ConnectionManager) GetLastError() error {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.lastError
|
||||
}
|
||||
|
||||
// GetStatus returns the current connection status
|
||||
func (cm *ConnectionManager) GetStatus() ConnectionStatus {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
|
||||
status := ConnectionStatus{
|
||||
IsConnected: cm.db != nil,
|
||||
LastCheck: cm.lastCheck,
|
||||
LastError: "",
|
||||
DSNHost: "",
|
||||
}
|
||||
|
||||
if cm.lastError != nil {
|
||||
status.LastError = cm.lastError.Error()
|
||||
}
|
||||
|
||||
// Extract host from DSN for display
|
||||
if cm.localDB != nil {
|
||||
if dsn, err := cm.localDB.GetDSN(); err == nil {
|
||||
// Parse DSN to extract host:port
|
||||
// Format: user:password@tcp(host:port)/database?...
|
||||
status.DSNHost = extractHostFromDSN(dsn)
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// extractHostFromDSN extracts the host:port part from a DSN string
|
||||
func extractHostFromDSN(dsn string) string {
|
||||
// Find the tcp( part
|
||||
tcpStart := 0
|
||||
if tcpStart = len("tcp("); tcpStart < len(dsn) && dsn[tcpStart] == '(' {
|
||||
// Look for the closing parenthesis
|
||||
parenEnd := -1
|
||||
for i := tcpStart + 1; i < len(dsn); i++ {
|
||||
if dsn[i] == ')' {
|
||||
parenEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if parenEnd != -1 {
|
||||
// Extract host:port part between tcp( and )
|
||||
hostPort := dsn[tcpStart+1 : parenEnd]
|
||||
return hostPort
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to find host:port by looking for @tcp( pattern
|
||||
atIndex := -1
|
||||
for i := 0; i < len(dsn)-4; i++ {
|
||||
if dsn[i:i+4] == "@tcp" {
|
||||
atIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if atIndex != -1 {
|
||||
// Look for the opening parenthesis after @tcp
|
||||
parenStart := -1
|
||||
for i := atIndex + 4; i < len(dsn); i++ {
|
||||
if dsn[i] == '(' {
|
||||
parenStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if parenStart != -1 {
|
||||
// Look for the closing parenthesis
|
||||
parenEnd := -1
|
||||
for i := parenStart + 1; i < len(dsn); i++ {
|
||||
if dsn[i] == ')' {
|
||||
parenEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if parenEnd != -1 {
|
||||
hostPort := dsn[parenStart+1 : parenEnd]
|
||||
return hostPort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't parse it, return empty string
|
||||
return ""
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mchus/quoteforge/internal/middleware"
|
||||
"github.com/mchus/quoteforge/internal/repository"
|
||||
"github.com/mchus/quoteforge/internal/services"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *services.AuthService
|
||||
userRepo *repository.UserRepository
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService *services.AuthService, userRepo *repository.UserRepository) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
User UserResponse `json:"user"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, user, err := h.authService.Login(req.Username, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, LoginResponse{
|
||||
AccessToken: tokens.AccessToken,
|
||||
RefreshToken: tokens.RefreshToken,
|
||||
ExpiresAt: tokens.ExpiresAt,
|
||||
User: UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: string(user.Role),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
var req RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.authService.RefreshTokens(req.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
claims := middleware.GetClaims(c)
|
||||
if claims == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userRepo.GetByID(claims.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: string(user.Role),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
// JWT is stateless, logout is handled on client by discarding tokens
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
@@ -3,70 +3,106 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mchus/quoteforge/internal/repository"
|
||||
"github.com/mchus/quoteforge/internal/services"
|
||||
)
|
||||
|
||||
type ComponentHandler struct {
|
||||
componentService *services.ComponentService
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewComponentHandler(componentService *services.ComponentService) *ComponentHandler {
|
||||
return &ComponentHandler{componentService: componentService}
|
||||
func NewComponentHandler(componentService *services.ComponentService, localDB *localdb.LocalDB) *ComponentHandler {
|
||||
return &ComponentHandler{
|
||||
componentService: componentService,
|
||||
localDB: localDB,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
filter := repository.ComponentFilter{
|
||||
Category: c.Query("category"),
|
||||
Vendor: c.Query("vendor"),
|
||||
Search: c.Query("search"),
|
||||
HasPrice: c.Query("has_price") == "true",
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
|
||||
result, err := h.componentService.List(filter, page, perPage)
|
||||
filter := repository.ComponentFilter{
|
||||
Category: c.Query("category"),
|
||||
Search: c.Query("search"),
|
||||
HasPrice: c.Query("has_price") == "true",
|
||||
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
|
||||
}
|
||||
|
||||
localFilter := localdb.ComponentFilter{
|
||||
Category: filter.Category,
|
||||
Search: filter.Search,
|
||||
HasPrice: filter.HasPrice,
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
components := make([]services.ComponentView, len(localComps))
|
||||
for i, lc := range localComps {
|
||||
components[i] = services.ComponentView{
|
||||
LotName: lc.LotName,
|
||||
Description: lc.LotDescription,
|
||||
Category: lc.Category,
|
||||
CategoryName: lc.Category,
|
||||
Model: lc.Model,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, &services.ComponentListResult{
|
||||
Components: components,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) Get(c *gin.Context) {
|
||||
lotName := c.Param("lot_name")
|
||||
|
||||
component, err := h.componentService.GetByLotName(lotName)
|
||||
component, err := h.localDB.GetLocalComponent(lotName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, component)
|
||||
c.JSON(http.StatusOK, services.ComponentView{
|
||||
LotName: component.LotName,
|
||||
Description: component.LotDescription,
|
||||
Category: component.Category,
|
||||
CategoryName: component.Category,
|
||||
Model: component.Model,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
||||
categories, err := h.componentService.GetCategories()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
codes, err := h.localDB.GetLocalComponentCategories()
|
||||
if err == nil && len(codes) > 0 {
|
||||
categories := make([]models.Category, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
trimmed := strings.TrimSpace(code)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
categories = append(categories, models.Category{Code: trimmed, Name: trimmed})
|
||||
}
|
||||
c.JSON(http.StatusOK, categories)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, categories)
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) GetVendors(c *gin.Context) {
|
||||
category := c.Query("category")
|
||||
|
||||
vendors, err := h.componentService.GetVendors(category)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, vendors)
|
||||
c.JSON(http.StatusOK, models.DefaultCategories)
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mchus/quoteforge/internal/middleware"
|
||||
"github.com/mchus/quoteforge/internal/services"
|
||||
)
|
||||
|
||||
type ConfigurationHandler struct {
|
||||
configService *services.ConfigurationService
|
||||
exportService *services.ExportService
|
||||
}
|
||||
|
||||
func NewConfigurationHandler(
|
||||
configService *services.ConfigurationService,
|
||||
exportService *services.ExportService,
|
||||
) *ConfigurationHandler {
|
||||
return &ConfigurationHandler{
|
||||
configService: configService,
|
||||
exportService: exportService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) List(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
configs, total, err := h.configService.ListByUser(userID, page, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"configurations": configs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Create(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var req services.CreateConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.Create(userID, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, config)
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Get(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
config, err := h.configService.GetByUUID(uuid, userID)
|
||||
if err != nil {
|
||||
status := http.StatusNotFound
|
||||
if err == services.ErrConfigForbidden {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Update(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
var req services.CreateConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.Update(uuid, userID, &req)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
status = http.StatusNotFound
|
||||
} else if err == services.ErrConfigForbidden {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Delete(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
err := h.configService.Delete(uuid, userID)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
status = http.StatusNotFound
|
||||
} else if err == services.ErrConfigForbidden {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) ExportJSON(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
data, err := h.configService.ExportJSON(uuid, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", "attachment; filename=config.json")
|
||||
c.Data(http.StatusOK, "application/json", data)
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) ImportJSON(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
data, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.ImportJSON(userID, data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, config)
|
||||
}
|
||||
@@ -3,35 +3,43 @@ package handlers
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mchus/quoteforge/internal/middleware"
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"github.com/mchus/quoteforge/internal/services"
|
||||
)
|
||||
|
||||
type ExportHandler struct {
|
||||
exportService *services.ExportService
|
||||
configService *services.ConfigurationService
|
||||
componentService *services.ComponentService
|
||||
exportService *services.ExportService
|
||||
configService services.ConfigurationGetter
|
||||
projectService *services.ProjectService
|
||||
dbUsername string
|
||||
}
|
||||
|
||||
func NewExportHandler(
|
||||
exportService *services.ExportService,
|
||||
configService *services.ConfigurationService,
|
||||
componentService *services.ComponentService,
|
||||
configService services.ConfigurationGetter,
|
||||
projectService *services.ProjectService,
|
||||
dbUsername string,
|
||||
) *ExportHandler {
|
||||
return &ExportHandler{
|
||||
exportService: exportService,
|
||||
configService: configService,
|
||||
componentService: componentService,
|
||||
exportService: exportService,
|
||||
configService: configService,
|
||||
projectService: projectService,
|
||||
dbUsername: dbUsername,
|
||||
}
|
||||
}
|
||||
|
||||
type ExportRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Items []struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ProjectName string `json:"project_name"`
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
Article string `json:"article"`
|
||||
ServerCount int `json:"server_count"`
|
||||
PricelistID *uint `json:"pricelist_id"`
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name" binding:"required"`
|
||||
Quantity int `json:"quantity" binding:"required,min=1"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
@@ -39,6 +47,14 @@ type ExportRequest struct {
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type ProjectExportOptionsRequest struct {
|
||||
IncludeLOT bool `json:"include_lot"`
|
||||
IncludeBOM bool `json:"include_bom"`
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
var req ExportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -48,127 +64,208 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
|
||||
data := h.buildExportData(&req)
|
||||
|
||||
csvData, err := h.exportService.ToCSV(data)
|
||||
// Validate before streaming (can return JSON error)
|
||||
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get project code for filename
|
||||
projectCode := req.ProjectName // legacy field: may contain code from frontend
|
||||
if projectCode == "" && req.ProjectUUID != "" {
|
||||
if project, err := h.projectService.GetByUUID(req.ProjectUUID, h.dbUsername); err == nil && project != nil {
|
||||
projectCode = project.Code
|
||||
}
|
||||
}
|
||||
if projectCode == "" {
|
||||
projectCode = req.Name
|
||||
}
|
||||
|
||||
// Set headers before streaming
|
||||
exportDate := data.CreatedAt
|
||||
articleSegment := sanitizeFilenameSegment(req.Article)
|
||||
if articleSegment == "" {
|
||||
articleSegment = "BOM"
|
||||
}
|
||||
filename := fmt.Sprintf("%s (%s) %s %s.csv", exportDate.Format("2006-01-02"), projectCode, req.Name, articleSegment)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
// Stream CSV (cannot return JSON after this point)
|
||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||
c.Error(err) // Log only
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// buildExportData converts an ExportRequest into a ProjectExportData using a temporary Configuration model
|
||||
// so that ExportService.ConfigToExportData can resolve categories via localDB.
|
||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ProjectExportData {
|
||||
configItems := make(models.ConfigItems, len(req.Items))
|
||||
for i, item := range req.Items {
|
||||
configItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
}
|
||||
|
||||
serverCount := req.ServerCount
|
||||
if serverCount < 1 {
|
||||
serverCount = 1
|
||||
}
|
||||
|
||||
cfg := &models.Configuration{
|
||||
Article: req.Article,
|
||||
ServerCount: serverCount,
|
||||
PricelistID: req.PricelistID,
|
||||
Items: configItems,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
return h.exportService.ConfigToExportData(cfg)
|
||||
}
|
||||
|
||||
func sanitizeFilenameSegment(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return ""
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
"/", "_",
|
||||
"\\", "_",
|
||||
":", "_",
|
||||
"*", "_",
|
||||
"?", "_",
|
||||
"\"", "_",
|
||||
"<", "_",
|
||||
">", "_",
|
||||
"|", "_",
|
||||
)
|
||||
return strings.TrimSpace(replacer.Replace(value))
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
// Get config before streaming (can return JSON error)
|
||||
config, err := h.configService.GetByUUID(uuid, h.dbUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
data := h.exportService.ConfigToExportData(config)
|
||||
|
||||
// Validate before streaming (can return JSON error)
|
||||
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get project code for filename
|
||||
projectCode := config.Name // fallback: use config name if no project
|
||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, h.dbUsername); err == nil && project != nil {
|
||||
projectCode = project.Code
|
||||
}
|
||||
}
|
||||
|
||||
// Set headers before streaming
|
||||
// Use price update time if available, otherwise creation time
|
||||
exportDate := config.CreatedAt
|
||||
if config.PriceUpdatedAt != nil {
|
||||
exportDate = *config.PriceUpdatedAt
|
||||
}
|
||||
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectCode, config.Name)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
// Stream CSV (cannot return JSON after this point)
|
||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||
c.Error(err) // Log only
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ExportProjectCSV exports all active configurations of a project as a single CSV file.
|
||||
func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
||||
projectUUID := c.Param("uuid")
|
||||
|
||||
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s_%s.csv", req.Name, time.Now().Format("20060102"))
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
if len(result.Configs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
||||
return
|
||||
}
|
||||
|
||||
data := h.exportService.ProjectToExportData(result.Configs)
|
||||
|
||||
// Filename: YYYY-MM-DD (ProjectCode) BOM.csv
|
||||
filename := fmt.Sprintf("%s (%s) BOM.csv", time.Now().Format("2006-01-02"), project.Code)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportXLSX(c *gin.Context) {
|
||||
var req ExportRequest
|
||||
func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||
projectUUID := c.Param("uuid")
|
||||
|
||||
var req ProjectExportOptionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
data := h.buildExportData(&req)
|
||||
|
||||
xlsxData, err := h.exportService.ToXLSX(data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s_%s.xlsx", req.Name, time.Now().Format("20060102"))
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsxData)
|
||||
}
|
||||
|
||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
||||
items := make([]services.ExportItem, len(req.Items))
|
||||
var total float64
|
||||
|
||||
for i, item := range req.Items {
|
||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||
items[i] = services.ExportItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: itemTotal,
|
||||
}
|
||||
total += itemTotal
|
||||
}
|
||||
|
||||
return &services.ExportData{
|
||||
Name: req.Name,
|
||||
Items: items,
|
||||
Total: total,
|
||||
Notes: req.Notes,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
config, err := h.configService.GetByUUID(uuid, userID)
|
||||
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
data := h.configToExportData(config)
|
||||
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(result.Configs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
||||
return
|
||||
}
|
||||
|
||||
csvData, err := h.exportService.ToCSV(data)
|
||||
opts := services.ProjectPricingExportOptions{
|
||||
IncludeLOT: req.IncludeLOT,
|
||||
IncludeBOM: req.IncludeBOM,
|
||||
IncludeEstimate: req.IncludeEstimate,
|
||||
IncludeStock: req.IncludeStock,
|
||||
IncludeCompetitor: req.IncludeCompetitor,
|
||||
}
|
||||
|
||||
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s_%s.csv", config.Name, config.CreatedAt.Format("20060102"))
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
}
|
||||
filename := fmt.Sprintf("%s (%s) pricing.csv", time.Now().Format("2006-01-02"), project.Code)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
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()})
|
||||
if err := h.exportService.ToPricingCSV(c.Writer, data, opts); err != nil {
|
||||
c.Error(err)
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
303
internal/handlers/export_test.go
Normal file
303
internal/handlers/export_test.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Mock services for testing
|
||||
type mockConfigService struct {
|
||||
config *models.Configuration
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
return m.config, m.err
|
||||
}
|
||||
|
||||
func TestExportCSV_Success(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Create handler with mocks
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
nil,
|
||||
"testuser",
|
||||
)
|
||||
|
||||
// Create JSON request body
|
||||
jsonBody := `{
|
||||
"name": "Test Export",
|
||||
"items": [
|
||||
{
|
||||
"lot_name": "LOT-001",
|
||||
"quantity": 2,
|
||||
"unit_price": 100.50
|
||||
}
|
||||
],
|
||||
"notes": "Test notes"
|
||||
}`
|
||||
|
||||
// Create HTTP request
|
||||
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Create response recorder
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Create Gin context
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
// Call handler
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Check status code
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check Content-Type header
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "text/csv; charset=utf-8" {
|
||||
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
|
||||
}
|
||||
|
||||
// Check for BOM
|
||||
responseBody := w.Body.Bytes()
|
||||
if len(responseBody) < 3 {
|
||||
t.Fatalf("Response too short to contain BOM")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := responseBody[:3]
|
||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||
}
|
||||
|
||||
// Check semicolon delimiter in CSV
|
||||
reader := csv.NewReader(bytes.NewReader(responseBody[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to parse CSV header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 8 {
|
||||
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
nil,
|
||||
"testuser",
|
||||
)
|
||||
|
||||
// Create invalid request (missing required field)
|
||||
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name": "Test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
var errResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||
if _, hasError := errResp["error"]; !hasError {
|
||||
t.Errorf("Expected error in JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportCSV_EmptyItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{},
|
||||
nil,
|
||||
"testuser",
|
||||
)
|
||||
|
||||
// Create request with empty items array - should fail binding validation
|
||||
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name":"Empty Export","items":[],"notes":""}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
handler.ExportCSV(c)
|
||||
|
||||
// Should return 400 Bad Request (validation error from gin binding)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_Success(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Mock configuration
|
||||
mockConfig := &models.Configuration{
|
||||
UUID: "test-uuid",
|
||||
Name: "Test Config",
|
||||
OwnerUsername: "testuser",
|
||||
Items: models.ConfigItems{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
nil,
|
||||
"testuser",
|
||||
)
|
||||
|
||||
// Create HTTP request
|
||||
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "test-uuid"},
|
||||
}
|
||||
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Check status code
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check Content-Type header
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "text/csv; charset=utf-8" {
|
||||
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
|
||||
}
|
||||
|
||||
// Check for BOM
|
||||
responseBody := w.Body.Bytes()
|
||||
if len(responseBody) < 3 {
|
||||
t.Fatalf("Response too short to contain BOM")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := responseBody[:3]
|
||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||
t.Errorf("UTF-8 BOM mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{err: errors.New("config not found")},
|
||||
nil,
|
||||
"testuser",
|
||||
)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "nonexistent-uuid"},
|
||||
}
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Should return 404 Not Found
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
var errResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||
if _, hasError := errResp["error"]; !hasError {
|
||||
t.Errorf("Expected error in JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Mock configuration with empty items
|
||||
mockConfig := &models.Configuration{
|
||||
UUID: "test-uuid",
|
||||
Name: "Empty Config",
|
||||
OwnerUsername: "testuser",
|
||||
Items: models.ConfigItems{},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||
handler := NewExportHandler(
|
||||
exportSvc,
|
||||
&mockConfigService{config: mockConfig},
|
||||
nil,
|
||||
"testuser",
|
||||
)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{
|
||||
{Key: "uuid", Value: "test-uuid"},
|
||||
}
|
||||
handler.ExportConfigCSV(c)
|
||||
|
||||
// Should return 400 Bad Request
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Should return JSON error
|
||||
var errResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &errResp)
|
||||
if _, hasError := errResp["error"]; !hasError {
|
||||
t.Errorf("Expected error in JSON response")
|
||||
}
|
||||
}
|
||||
106
internal/handlers/partnumber_books.go
Normal file
106
internal/handlers/partnumber_books.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PartnumberBooksHandler provides read-only access to local partnumber book snapshots.
|
||||
type PartnumberBooksHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewPartnumberBooksHandler(localDB *localdb.LocalDB) *PartnumberBooksHandler {
|
||||
return &PartnumberBooksHandler{localDB: localDB}
|
||||
}
|
||||
|
||||
// List returns all local partnumber book snapshots.
|
||||
// GET /api/partnumber-books
|
||||
func (h *PartnumberBooksHandler) List(c *gin.Context) {
|
||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||
books, err := bookRepo.ListBooks()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type bookSummary struct {
|
||||
ID uint `json:"id"`
|
||||
ServerID int `json:"server_id"`
|
||||
Version string `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
summaries := make([]bookSummary, 0, len(books))
|
||||
for _, b := range books {
|
||||
summaries = append(summaries, bookSummary{
|
||||
ID: b.ID,
|
||||
ServerID: b.ServerID,
|
||||
Version: b.Version,
|
||||
CreatedAt: b.CreatedAt.Format("2006-01-02"),
|
||||
IsActive: b.IsActive,
|
||||
ItemCount: bookRepo.CountBookItems(b.ID),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"books": summaries,
|
||||
"total": len(summaries),
|
||||
})
|
||||
}
|
||||
|
||||
// GetItems returns items for a partnumber book by server ID.
|
||||
// GET /api/partnumber-books/:id
|
||||
func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"})
|
||||
return
|
||||
}
|
||||
|
||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "100"))
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 500 {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
// Find local book by server_id
|
||||
var book localdb.LocalPartnumberBook
|
||||
if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
|
||||
return
|
||||
}
|
||||
|
||||
items, total, err := bookRepo.GetBookItemsPage(book.ID, search, page, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"book_id": book.ServerID,
|
||||
"version": book.Version,
|
||||
"is_active": book.IsActive,
|
||||
"partnumbers": book.PartnumbersJSON,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"search": search,
|
||||
"book_total": bookRepo.CountBookItems(book.ID),
|
||||
"lot_count": bookRepo.CountDistinctLots(book.ID),
|
||||
})
|
||||
}
|
||||
255
internal/handlers/pricelist.go
Normal file
255
internal/handlers/pricelist.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type PricelistHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewPricelistHandler(localDB *localdb.LocalDB) *PricelistHandler {
|
||||
return &PricelistHandler{localDB: localDB}
|
||||
}
|
||||
|
||||
// List returns all pricelists with pagination.
|
||||
func (h *PricelistHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
source := c.Query("source")
|
||||
activeOnly := c.DefaultQuery("active_only", "false") == "true"
|
||||
|
||||
localPLs, err := h.localDB.GetLocalPricelists()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if source != "" {
|
||||
filtered := localPLs[:0]
|
||||
for _, lpl := range localPLs {
|
||||
if strings.EqualFold(lpl.Source, source) {
|
||||
filtered = append(filtered, lpl)
|
||||
}
|
||||
}
|
||||
localPLs = filtered
|
||||
}
|
||||
type pricelistWithCount struct {
|
||||
pricelist localdb.LocalPricelist
|
||||
itemCount int64
|
||||
usageCount int
|
||||
}
|
||||
withCounts := make([]pricelistWithCount, 0, len(localPLs))
|
||||
for _, lpl := range localPLs {
|
||||
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
|
||||
if activeOnly && itemCount == 0 {
|
||||
continue
|
||||
}
|
||||
usageCount := 0
|
||||
if lpl.IsUsed {
|
||||
usageCount = 1
|
||||
}
|
||||
withCounts = append(withCounts, pricelistWithCount{
|
||||
pricelist: lpl,
|
||||
itemCount: itemCount,
|
||||
usageCount: usageCount,
|
||||
})
|
||||
}
|
||||
localPLs = localPLs[:0]
|
||||
for _, row := range withCounts {
|
||||
localPLs = append(localPLs, row.pricelist)
|
||||
}
|
||||
sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) })
|
||||
total := len(localPLs)
|
||||
start := (page - 1) * perPage
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + perPage
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
pageSlice := localPLs[start:end]
|
||||
summaries := make([]map[string]interface{}, 0, len(pageSlice))
|
||||
for _, lpl := range pageSlice {
|
||||
itemCount := int64(0)
|
||||
usageCount := 0
|
||||
for _, row := range withCounts {
|
||||
if row.pricelist.ID == lpl.ID {
|
||||
itemCount = row.itemCount
|
||||
usageCount = row.usageCount
|
||||
break
|
||||
}
|
||||
}
|
||||
summaries = append(summaries, map[string]interface{}{
|
||||
"id": lpl.ServerID,
|
||||
"source": lpl.Source,
|
||||
"version": lpl.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": itemCount,
|
||||
"usage_count": usageCount,
|
||||
"is_active": true,
|
||||
"created_at": lpl.CreatedAt,
|
||||
"synced_from": "local",
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pricelists": summaries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
// Get returns a single pricelist by ID.
|
||||
func (h *PricelistHandler) Get(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": localPL.ServerID,
|
||||
"source": localPL.Source,
|
||||
"version": localPL.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": h.localDB.CountLocalPricelistItems(localPL.ID),
|
||||
"is_active": true,
|
||||
"created_at": localPL.CreatedAt,
|
||||
"synced_from": "local",
|
||||
})
|
||||
}
|
||||
|
||||
// GetItems returns items for a pricelist with pagination.
|
||||
func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
||||
search := c.Query("search")
|
||||
|
||||
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
|
||||
return
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
var items []localdb.LocalPricelistItem
|
||||
dbq := h.localDB.DB().Model(&localdb.LocalPricelistItem{}).Where("pricelist_id = ?", localPL.ID)
|
||||
if strings.TrimSpace(search) != "" {
|
||||
dbq = dbq.Where("lot_name LIKE ?", "%"+strings.TrimSpace(search)+"%")
|
||||
}
|
||||
var total int64
|
||||
if err := dbq.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
resultItems := make([]gin.H, 0, len(items))
|
||||
for _, item := range items {
|
||||
resultItems = append(resultItems, gin.H{
|
||||
"id": item.ID,
|
||||
"lot_name": item.LotName,
|
||||
"price": item.Price,
|
||||
"category": item.LotCategory,
|
||||
"available_qty": item.AvailableQty,
|
||||
"partnumbers": []string(item.Partnumbers),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"source": localPL.Source,
|
||||
"items": resultItems,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PricelistHandler) GetLotNames(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
|
||||
return
|
||||
}
|
||||
items, err := h.localDB.GetLocalPricelistItems(localPL.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lotNames := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
lotNames = append(lotNames, item.LotName)
|
||||
}
|
||||
sort.Strings(lotNames)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"lot_names": lotNames,
|
||||
"total": len(lotNames),
|
||||
})
|
||||
}
|
||||
|
||||
// GetLatest returns the most recent active pricelist.
|
||||
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||||
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
|
||||
source = string(models.NormalizePricelistSource(source))
|
||||
|
||||
localPL, err := h.localDB.GetLatestLocalPricelistBySource(source)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no pricelists available"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": localPL.ServerID,
|
||||
"source": localPL.Source,
|
||||
"version": localPL.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": h.localDB.CountLocalPricelistItems(localPL.ID),
|
||||
"is_active": true,
|
||||
"created_at": localPL.CreatedAt,
|
||||
"synced_from": "local",
|
||||
})
|
||||
}
|
||||
161
internal/handlers/pricelist_test.go
Normal file
161
internal/handlers/pricelist_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestPricelistGetItems_ReturnsLotCategoryFromLocalPricelistItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 1,
|
||||
Source: "estimate",
|
||||
Version: "S-2026-02-11-001",
|
||||
Name: "test",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
IsUsed: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{
|
||||
PricelistID: localPL.ID,
|
||||
LotName: "NO_UNDERSCORE_NAME",
|
||||
LotCategory: "CPU",
|
||||
Price: 10,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save local pricelist items: %v", err)
|
||||
}
|
||||
|
||||
h := NewPricelistHandler(local)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/pricelists/1/items?page=1&per_page=50", nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "id", Value: "1"}}
|
||||
|
||||
h.GetItems(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Category string `json:"category"`
|
||||
UnitPrice any `json:"price"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(resp.Items))
|
||||
}
|
||||
if resp.Items[0].LotName != "NO_UNDERSCORE_NAME" {
|
||||
t.Fatalf("expected lot_name NO_UNDERSCORE_NAME, got %q", resp.Items[0].LotName)
|
||||
}
|
||||
if resp.Items[0].Category != "CPU" {
|
||||
t.Fatalf("expected category CPU, got %q", resp.Items[0].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
local, err := localdb.New(filepath.Join(t.TempDir(), "local_active_only.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 10,
|
||||
Source: "estimate",
|
||||
Version: "E-1",
|
||||
Name: "with-items",
|
||||
CreatedAt: time.Now().Add(-time.Minute),
|
||||
SyncedAt: time.Now().Add(-time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("save with-items pricelist: %v", err)
|
||||
}
|
||||
withItems, err := local.GetLocalPricelistByServerID(10)
|
||||
if err != nil {
|
||||
t.Fatalf("load with-items pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||
{
|
||||
PricelistID: withItems.ID,
|
||||
LotName: "CPU_X",
|
||||
LotCategory: "CPU",
|
||||
Price: 100,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save with-items pricelist items: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: 11,
|
||||
Source: "estimate",
|
||||
Version: "E-2",
|
||||
Name: "without-items",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save without-items pricelist: %v", err)
|
||||
}
|
||||
|
||||
h := NewPricelistHandler(local)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/pricelists?source=estimate&active_only=true", nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
h.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Pricelists []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"pricelists"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Total != 1 {
|
||||
t.Fatalf("expected total=1, got %d", resp.Total)
|
||||
}
|
||||
if len(resp.Pricelists) != 1 {
|
||||
t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists))
|
||||
}
|
||||
if resp.Pricelists[0].ID != 10 {
|
||||
t.Fatalf("expected pricelist id=10, got %d", resp.Pricelists[0].ID)
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mchus/quoteforge/internal/middleware"
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"github.com/mchus/quoteforge/internal/repository"
|
||||
"github.com/mchus/quoteforge/internal/services/alerts"
|
||||
"github.com/mchus/quoteforge/internal/services/pricing"
|
||||
)
|
||||
|
||||
type PricingHandler struct {
|
||||
pricingService *pricing.Service
|
||||
alertService *alerts.Service
|
||||
componentRepo *repository.ComponentRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
}
|
||||
|
||||
func NewPricingHandler(
|
||||
pricingService *pricing.Service,
|
||||
alertService *alerts.Service,
|
||||
componentRepo *repository.ComponentRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
) *PricingHandler {
|
||||
return &PricingHandler{
|
||||
pricingService: pricingService,
|
||||
alertService: alertService,
|
||||
componentRepo: componentRepo,
|
||||
statsRepo: statsRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PricingHandler) GetStats(c *gin.Context) {
|
||||
newAlerts, _ := h.alertService.GetNewAlertsCount()
|
||||
topComponents, _ := h.statsRepo.GetTopComponents(10)
|
||||
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"new_alerts_count": newAlerts,
|
||||
"top_components": topComponents,
|
||||
"trending_components": trendingComponents,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PricingHandler) ListComponents(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
filter := repository.ComponentFilter{
|
||||
Category: c.Query("category"),
|
||||
Vendor: c.Query("vendor"),
|
||||
Search: c.Query("search"),
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
components, total, err := h.componentRepo.List(filter, offset, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"components": components,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
|
||||
lotName := c.Param("lot_name")
|
||||
|
||||
component, err := h.componentRepo.GetByLotName(lotName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.pricingService.GetPriceStats(lotName, 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"component": component,
|
||||
"price_stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
type UpdatePriceRequest struct {
|
||||
LotName string `json:"lot_name" binding:"required"`
|
||||
Method models.PriceMethod `json:"method"`
|
||||
PeriodDays int `json:"period_days"`
|
||||
ManualPrice *float64 `json:"manual_price"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var req UpdatePriceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.ManualPrice != nil && *req.ManualPrice > 0 {
|
||||
err := h.pricingService.SetManualPrice(req.LotName, *req.ManualPrice, req.Reason, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Method != "" {
|
||||
err := h.pricingService.UpdatePriceMethod(req.LotName, req.Method, req.PeriodDays)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "price updated"})
|
||||
}
|
||||
|
||||
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||
// This would be better as a background job
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": "recalculation started"})
|
||||
}
|
||||
|
||||
func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
filter := repository.AlertFilter{
|
||||
Status: models.AlertStatus(c.Query("status")),
|
||||
Severity: models.AlertSeverity(c.Query("severity")),
|
||||
Type: models.AlertType(c.Query("type")),
|
||||
LotName: c.Query("lot_name"),
|
||||
}
|
||||
|
||||
alertsList, total, err := h.alertService.List(filter, page, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"alerts": alertsList,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.alertService.Acknowledge(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "acknowledged"})
|
||||
}
|
||||
|
||||
func (h *PricingHandler) ResolveAlert(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.alertService.Resolve(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "resolved"})
|
||||
}
|
||||
|
||||
func (h *PricingHandler) IgnoreAlert(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.alertService.Ignore(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "ignored"})
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mchus/quoteforge/internal/services"
|
||||
)
|
||||
|
||||
type QuoteHandler struct {
|
||||
@@ -49,3 +49,19 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
|
||||
"total": result.Total,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
|
||||
var req services.PriceLevelsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.quoteService.CalculatePriceLevels(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
265
internal/handlers/setup.go
Normal file
265
internal/handlers/setup.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"github.com/gin-gonic/gin"
|
||||
gormmysql "gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type SetupHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
connMgr *db.ConnectionManager
|
||||
templates map[string]*template.Template
|
||||
restartSig chan struct{}
|
||||
}
|
||||
|
||||
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
}
|
||||
|
||||
templates := make(map[string]*template.Template)
|
||||
|
||||
// Load setup template (standalone, no base needed)
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing setup template: %w", err)
|
||||
}
|
||||
templates["setup.html"] = tmpl
|
||||
|
||||
return &SetupHandler{
|
||||
localDB: localDB,
|
||||
connMgr: connMgr,
|
||||
templates: templates,
|
||||
restartSig: restartSig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowSetup renders the database setup form
|
||||
func (h *SetupHandler) ShowSetup(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
// Get existing settings if any
|
||||
settings, _ := h.localDB.GetSettings()
|
||||
|
||||
data := gin.H{
|
||||
"Settings": settings,
|
||||
}
|
||||
|
||||
tmpl := h.templates["setup.html"]
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil {
|
||||
c.String(http.StatusInternalServerError, "Template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnection tests the database connection without saving
|
||||
func (h *SetupHandler) TestConnection(c *gin.Context) {
|
||||
host := c.PostForm("host")
|
||||
portStr := c.PostForm("port")
|
||||
database := c.PostForm("database")
|
||||
user := c.PostForm("user")
|
||||
password := c.PostForm("password")
|
||||
|
||||
port := 3306
|
||||
if p, err := strconv.Atoi(portStr); err == nil {
|
||||
port = p
|
||||
}
|
||||
|
||||
// If password is empty, try to use saved password
|
||||
if password == "" {
|
||||
if settings, err := h.localDB.GetSettings(); err == nil && settings != nil {
|
||||
password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field
|
||||
}
|
||||
}
|
||||
|
||||
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
||||
|
||||
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Connection failed: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Failed to get database handle: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Ping failed: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for required tables
|
||||
var lotCount int64
|
||||
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Table 'lot' not found or inaccessible: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check write permission
|
||||
canWrite := testWritePermission(db)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"lot_count": lotCount,
|
||||
"can_write": canWrite,
|
||||
"message": fmt.Sprintf("Connected successfully! Found %d components.", lotCount),
|
||||
})
|
||||
}
|
||||
|
||||
// SaveConnection saves the connection settings and signals restart
|
||||
func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||
existingSettings, _ := h.localDB.GetSettings()
|
||||
|
||||
host := c.PostForm("host")
|
||||
portStr := c.PostForm("port")
|
||||
database := c.PostForm("database")
|
||||
user := c.PostForm("user")
|
||||
password := c.PostForm("password")
|
||||
|
||||
port := 3306
|
||||
if p, err := strconv.Atoi(portStr); err == nil {
|
||||
port = p
|
||||
}
|
||||
|
||||
// If password is empty, use saved password
|
||||
if password == "" {
|
||||
if settings, err := h.localDB.GetSettings(); err == nil && settings != nil {
|
||||
password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection first
|
||||
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
||||
|
||||
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Connection failed: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
|
||||
// Save settings
|
||||
if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("Failed to save settings: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Try to connect immediately to verify settings
|
||||
if h.connMgr != nil {
|
||||
if err := h.connMgr.TryConnect(); err != nil {
|
||||
slog.Warn("failed to connect after saving settings", "error", err)
|
||||
} else {
|
||||
slog.Info("successfully connected to database after saving settings")
|
||||
}
|
||||
}
|
||||
|
||||
settingsChanged := existingSettings == nil ||
|
||||
existingSettings.Host != host ||
|
||||
existingSettings.Port != port ||
|
||||
existingSettings.Database != database ||
|
||||
existingSettings.User != user ||
|
||||
existingSettings.PasswordEncrypted != password
|
||||
|
||||
restartQueued := settingsChanged && h.restartSig != nil
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Settings saved.",
|
||||
"restart_required": settingsChanged,
|
||||
"restart_queued": restartQueued,
|
||||
})
|
||||
|
||||
// Signal restart after response is sent (if restart signal is configured)
|
||||
if restartQueued {
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond) // Give time for response to be sent
|
||||
select {
|
||||
case h.restartSig <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns the current setup status
|
||||
func (h *SetupHandler) GetStatus(c *gin.Context) {
|
||||
hasSettings := h.localDB.HasSettings()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"configured": hasSettings,
|
||||
})
|
||||
}
|
||||
|
||||
func testWritePermission(db *gorm.DB) bool {
|
||||
// Simple check: try to create a temporary table and drop it
|
||||
testTable := fmt.Sprintf("qt_write_test_%d", time.Now().UnixNano())
|
||||
|
||||
// Try to create a test table
|
||||
err := db.Exec(fmt.Sprintf("CREATE TABLE %s (id INT)", testTable)).Error
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Drop it immediately
|
||||
db.Exec(fmt.Sprintf("DROP TABLE %s", testTable))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string {
|
||||
cfg := mysqlDriver.NewConfig()
|
||||
cfg.User = user
|
||||
cfg.Passwd = password
|
||||
cfg.Net = "tcp"
|
||||
cfg.Addr = net.JoinHostPort(host, strconv.Itoa(port))
|
||||
cfg.DBName = database
|
||||
cfg.ParseTime = true
|
||||
cfg.Loc = time.Local
|
||||
cfg.Timeout = timeout
|
||||
cfg.Params = map[string]string{
|
||||
"charset": "utf8mb4",
|
||||
}
|
||||
return cfg.FormatDSN()
|
||||
}
|
||||
699
internal/handlers/sync.go
Normal file
699
internal/handlers/sync.go
Normal file
@@ -0,0 +1,699 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
stdsync "sync"
|
||||
"time"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SyncHandler handles sync API endpoints
|
||||
type SyncHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
syncService *sync.Service
|
||||
connMgr *db.ConnectionManager
|
||||
autoSyncInterval time.Duration
|
||||
onlineGraceFactor float64
|
||||
tmpl *template.Template
|
||||
readinessMu stdsync.Mutex
|
||||
readinessCached *sync.SyncReadiness
|
||||
readinessCachedAt time.Time
|
||||
}
|
||||
|
||||
// NewSyncHandler creates a new sync handler
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, _ string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
||||
// Load sync_status partial template
|
||||
tmpl, err := template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SyncHandler{
|
||||
localDB: localDB,
|
||||
syncService: syncService,
|
||||
connMgr: connMgr,
|
||||
autoSyncInterval: autoSyncInterval,
|
||||
onlineGraceFactor: 1.10,
|
||||
tmpl: tmpl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SyncStatusResponse represents the sync status
|
||||
type SyncStatusResponse struct {
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_sync"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||||
}
|
||||
|
||||
type SyncReadinessResponse struct {
|
||||
Status string `json:"status"`
|
||||
Blocked bool `json:"blocked"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
ReasonText string `json:"reason_text,omitempty"`
|
||||
RequiredMinAppVersion *string `json:"required_min_app_version,omitempty"`
|
||||
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||||
}
|
||||
|
||||
// GetStatus returns current sync status
|
||||
// GET /api/sync/status
|
||||
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
// Check online status by pinging MariaDB
|
||||
isOnline := h.checkOnline()
|
||||
|
||||
// Get sync times
|
||||
lastComponentSync := h.localDB.GetComponentSyncTime()
|
||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||
|
||||
// Get counts
|
||||
componentsCount := h.localDB.CountLocalComponents()
|
||||
pricelistsCount := h.localDB.CountLocalPricelists()
|
||||
|
||||
// Get server pricelist count if online
|
||||
serverPricelists := 0
|
||||
needPricelistSync := false
|
||||
if isOnline {
|
||||
status, err := h.syncService.GetStatus()
|
||||
if err == nil {
|
||||
serverPricelists = status.ServerPricelists
|
||||
needPricelistSync = status.NeedsSync
|
||||
}
|
||||
}
|
||||
|
||||
// Check if component sync is needed (older than 24 hours)
|
||||
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
|
||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||
LastComponentSync: lastComponentSync,
|
||||
LastPricelistSync: lastPricelistSync,
|
||||
IsOnline: isOnline,
|
||||
ComponentsCount: componentsCount,
|
||||
PricelistsCount: pricelistsCount,
|
||||
ServerPricelists: serverPricelists,
|
||||
NeedComponentSync: needComponentSync,
|
||||
NeedPricelistSync: needPricelistSync,
|
||||
Readiness: readiness,
|
||||
})
|
||||
}
|
||||
|
||||
// GetReadiness returns sync readiness guard status.
|
||||
// GET /api/sync/readiness
|
||||
func (h *SyncHandler) GetReadiness(c *gin.Context) {
|
||||
readiness, err := h.syncService.GetReadiness()
|
||||
if err != nil && readiness == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if readiness == nil {
|
||||
c.JSON(http.StatusOK, SyncReadinessResponse{Status: sync.ReadinessUnknown, Blocked: false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, SyncReadinessResponse{
|
||||
Status: readiness.Status,
|
||||
Blocked: readiness.Blocked,
|
||||
ReasonCode: readiness.ReasonCode,
|
||||
ReasonText: readiness.ReasonText,
|
||||
RequiredMinAppVersion: readiness.RequiredMinAppVersion,
|
||||
LastCheckedAt: readiness.LastCheckedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool {
|
||||
readiness, err := h.syncService.EnsureReadinessForSync()
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
blocked := &sync.SyncBlockedError{}
|
||||
if errors.As(err, &blocked) {
|
||||
c.JSON(http.StatusLocked, gin.H{
|
||||
"success": false,
|
||||
"error": blocked.Error(),
|
||||
"reason_code": blocked.Readiness.ReasonCode,
|
||||
"reason_text": blocked.Readiness.ReasonText,
|
||||
"required_min_app_version": blocked.Readiness.RequiredMinAppVersion,
|
||||
"status": blocked.Readiness.Status,
|
||||
"blocked": true,
|
||||
"last_checked_at": blocked.Readiness.LastCheckedAt,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
_ = readiness
|
||||
return false
|
||||
}
|
||||
|
||||
// SyncResultResponse represents sync operation result
|
||||
type SyncResultResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Synced int `json:"synced"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
// SyncComponents syncs components from MariaDB to local SQLite
|
||||
// POST /api/sync/components
|
||||
func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
||||
if !h.ensureSyncReadiness(c) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get database connection from ConnectionManager
|
||||
mariaDB, err := h.connMgr.GetDB()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database connection failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.localDB.SyncComponents(mariaDB)
|
||||
if err != nil {
|
||||
slog.Error("component sync failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
Message: "Components synced successfully",
|
||||
Synced: result.TotalSynced,
|
||||
Duration: result.Duration.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// SyncPricelists syncs pricelists from MariaDB to local SQLite
|
||||
// POST /api/sync/pricelists
|
||||
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
if !h.ensureSyncReadiness(c) {
|
||||
return
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
synced, err := h.syncService.SyncPricelists()
|
||||
if err != nil {
|
||||
slog.Error("pricelist sync failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
Message: "Pricelists synced successfully",
|
||||
Synced: synced,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite.
|
||||
// POST /api/sync/partnumber-books
|
||||
func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
|
||||
if !h.ensureSyncReadiness(c) {
|
||||
return
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
pulled, err := h.syncService.PullPartnumberBooks()
|
||||
if err != nil {
|
||||
slog.Error("partnumber books pull failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
Message: "Partnumber books synced successfully",
|
||||
Synced: pulled,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// SyncAllResponse represents result of full sync
|
||||
type SyncAllResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
PendingPushed int `json:"pending_pushed"`
|
||||
ComponentsSynced int `json:"components_synced"`
|
||||
PricelistsSynced int `json:"pricelists_synced"`
|
||||
ProjectsImported int `json:"projects_imported"`
|
||||
ProjectsUpdated int `json:"projects_updated"`
|
||||
ProjectsSkipped int `json:"projects_skipped"`
|
||||
ConfigurationsImported int `json:"configurations_imported"`
|
||||
ConfigurationsUpdated int `json:"configurations_updated"`
|
||||
ConfigurationsSkipped int `json:"configurations_skipped"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
// SyncAll performs full bidirectional sync:
|
||||
// - push pending local changes (projects/configurations) to server
|
||||
// - pull components, pricelists, projects, and configurations from server
|
||||
// POST /api/sync/all
|
||||
func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
if !h.ensureSyncReadiness(c) {
|
||||
return
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
var pendingPushed, componentsSynced, pricelistsSynced int
|
||||
|
||||
// Push local pending changes first (projects/configurations)
|
||||
pendingPushed, err := h.syncService.PushPendingChanges()
|
||||
if err != nil {
|
||||
slog.Error("pending push failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Pending changes push failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Sync components
|
||||
mariaDB, err := h.connMgr.GetDB()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database connection failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
compResult, err := h.localDB.SyncComponents(mariaDB)
|
||||
if err != nil {
|
||||
slog.Error("component sync failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Component sync failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
componentsSynced = compResult.TotalSynced
|
||||
|
||||
// Sync pricelists
|
||||
pricelistsSynced, err = h.syncService.SyncPricelists()
|
||||
if err != nil {
|
||||
slog.Error("pricelist sync failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Pricelist sync failed: " + err.Error(),
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
projectsResult, err := h.syncService.ImportProjectsToLocal()
|
||||
if err != nil {
|
||||
slog.Error("project import failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Project import failed: " + err.Error(),
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
"pricelists_synced": pricelistsSynced,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
configsResult, err := h.syncService.ImportConfigurationsToLocal()
|
||||
if err != nil {
|
||||
slog.Error("configuration import failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Configuration import failed: " + err.Error(),
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
"pricelists_synced": pricelistsSynced,
|
||||
"projects_imported": projectsResult.Imported,
|
||||
"projects_updated": projectsResult.Updated,
|
||||
"projects_skipped": projectsResult.Skipped,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncAllResponse{
|
||||
Success: true,
|
||||
Message: "Full sync completed successfully",
|
||||
PendingPushed: pendingPushed,
|
||||
ComponentsSynced: componentsSynced,
|
||||
PricelistsSynced: pricelistsSynced,
|
||||
ProjectsImported: projectsResult.Imported,
|
||||
ProjectsUpdated: projectsResult.Updated,
|
||||
ProjectsSkipped: projectsResult.Skipped,
|
||||
ConfigurationsImported: configsResult.Imported,
|
||||
ConfigurationsUpdated: configsResult.Updated,
|
||||
ConfigurationsSkipped: configsResult.Skipped,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// checkOnline checks if MariaDB is accessible
|
||||
func (h *SyncHandler) checkOnline() bool {
|
||||
return h.connMgr.IsOnline()
|
||||
}
|
||||
|
||||
// PushPendingChanges pushes all pending changes to the server
|
||||
// POST /api/sync/push
|
||||
func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
|
||||
if !h.ensureSyncReadiness(c) {
|
||||
return
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
pushed, err := h.syncService.PushPendingChanges()
|
||||
if err != nil {
|
||||
slog.Error("push pending changes failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
Message: "Pending changes pushed successfully",
|
||||
Synced: pushed,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// GetPendingCount returns the number of pending changes
|
||||
// GET /api/sync/pending/count
|
||||
func (h *SyncHandler) GetPendingCount(c *gin.Context) {
|
||||
count := h.localDB.GetPendingCount()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPendingChanges returns all pending changes
|
||||
// GET /api/sync/pending
|
||||
func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
||||
changes, err := h.localDB.GetPendingChanges()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"changes": changes,
|
||||
})
|
||||
}
|
||||
|
||||
// RepairPendingChanges attempts to repair errored pending changes
|
||||
// POST /api/sync/repair
|
||||
func (h *SyncHandler) RepairPendingChanges(c *gin.Context) {
|
||||
repaired, remainingErrors, err := h.localDB.RepairPendingChanges()
|
||||
if err != nil {
|
||||
slog.Error("repair pending changes failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"repaired": repaired,
|
||||
"remaining_errors": remainingErrors,
|
||||
})
|
||||
}
|
||||
|
||||
// SyncInfoResponse represents sync information for the modal
|
||||
type SyncInfoResponse struct {
|
||||
// Connection
|
||||
DBHost string `json:"db_host"`
|
||||
DBUser string `json:"db_user"`
|
||||
DBName string `json:"db_name"`
|
||||
|
||||
// Status
|
||||
IsOnline bool `json:"is_online"`
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
|
||||
// Statistics
|
||||
LotCount int64 `json:"lot_count"`
|
||||
LotLogCount int64 `json:"lot_log_count"`
|
||||
ConfigCount int64 `json:"config_count"`
|
||||
ProjectCount int64 `json:"project_count"`
|
||||
|
||||
// Pending changes
|
||||
PendingChanges []localdb.PendingChange `json:"pending_changes"`
|
||||
|
||||
// Errors
|
||||
ErrorCount int `json:"error_count"`
|
||||
Errors []SyncError `json:"errors,omitempty"`
|
||||
|
||||
// Readiness guard
|
||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||||
}
|
||||
|
||||
type SyncUsersStatusResponse struct {
|
||||
IsOnline bool `json:"is_online"`
|
||||
AutoSyncIntervalSeconds int64 `json:"auto_sync_interval_seconds"`
|
||||
OnlineThresholdSeconds int64 `json:"online_threshold_seconds"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Users []sync.UserSyncStatus `json:"users"`
|
||||
}
|
||||
|
||||
// SyncError represents a sync error
|
||||
type SyncError struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// GetInfo returns sync information for modal
|
||||
// GET /api/sync/info
|
||||
func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
// Check online status by pinging MariaDB
|
||||
isOnline := h.checkOnline()
|
||||
|
||||
// Get DB connection info
|
||||
var dbHost, dbUser, dbName string
|
||||
if settings, err := h.localDB.GetSettings(); err == nil {
|
||||
dbHost = settings.Host + ":" + fmt.Sprintf("%d", settings.Port)
|
||||
dbUser = settings.User
|
||||
dbName = settings.Database
|
||||
}
|
||||
|
||||
// Get sync times
|
||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||
|
||||
// Get local counts
|
||||
configCount := h.localDB.CountConfigurations()
|
||||
projectCount := h.localDB.CountProjects()
|
||||
componentCount := h.localDB.CountLocalComponents()
|
||||
pricelistCount := h.localDB.CountLocalPricelists()
|
||||
|
||||
// Get error count (only changes with LastError != "")
|
||||
errorCount := int(h.localDB.CountErroredChanges())
|
||||
|
||||
// Get pending changes
|
||||
changes, err := h.localDB.GetPendingChanges()
|
||||
if err != nil {
|
||||
slog.Error("failed to get pending changes for sync info", "error", err)
|
||||
changes = []localdb.PendingChange{}
|
||||
}
|
||||
|
||||
var syncErrors []SyncError
|
||||
for _, change := range changes {
|
||||
if change.LastError != "" {
|
||||
syncErrors = append(syncErrors, SyncError{
|
||||
Timestamp: change.CreatedAt,
|
||||
Message: change.LastError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to last 10 errors
|
||||
if len(syncErrors) > 10 {
|
||||
syncErrors = syncErrors[:10]
|
||||
}
|
||||
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
|
||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||
DBHost: dbHost,
|
||||
DBUser: dbUser,
|
||||
DBName: dbName,
|
||||
IsOnline: isOnline,
|
||||
LastSyncAt: lastPricelistSync,
|
||||
LotCount: componentCount,
|
||||
LotLogCount: pricelistCount,
|
||||
ConfigCount: configCount,
|
||||
ProjectCount: projectCount,
|
||||
PendingChanges: changes,
|
||||
ErrorCount: errorCount,
|
||||
Errors: syncErrors,
|
||||
Readiness: readiness,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUsersStatus returns last sync timestamps for users with sync heartbeats.
|
||||
// GET /api/sync/users-status
|
||||
func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
|
||||
threshold := time.Duration(float64(h.autoSyncInterval) * h.onlineGraceFactor)
|
||||
isOnline := h.checkOnline()
|
||||
|
||||
if !isOnline {
|
||||
c.JSON(http.StatusOK, SyncUsersStatusResponse{
|
||||
IsOnline: false,
|
||||
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
|
||||
OnlineThresholdSeconds: int64(threshold.Seconds()),
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
Users: []sync.UserSyncStatus{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Keep current client heartbeat fresh so app version is available in the table.
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
|
||||
users, err := h.syncService.ListUserSyncStatuses(threshold)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncUsersStatusResponse{
|
||||
IsOnline: true,
|
||||
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
|
||||
OnlineThresholdSeconds: int64(threshold.Seconds()),
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
Users: users,
|
||||
})
|
||||
}
|
||||
|
||||
// SyncStatusPartial renders the sync status partial for htmx
|
||||
// GET /partials/sync-status
|
||||
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||
// Check online status from middleware
|
||||
isOfflineValue, exists := c.Get("is_offline")
|
||||
isOffline := false
|
||||
if exists {
|
||||
isOffline = isOfflineValue.(bool)
|
||||
} else {
|
||||
// Fallback: check directly if middleware didn't set it
|
||||
isOffline = !h.checkOnline()
|
||||
slog.Warn("is_offline not found in context, checking directly")
|
||||
}
|
||||
|
||||
// Get pending count
|
||||
pendingCount := h.localDB.GetPendingCount()
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
isBlocked := readiness != nil && readiness.Blocked
|
||||
|
||||
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
|
||||
|
||||
data := gin.H{
|
||||
"IsOffline": isOffline,
|
||||
"PendingCount": pendingCount,
|
||||
"IsBlocked": isBlocked,
|
||||
"BlockedReason": func() string {
|
||||
if readiness == nil {
|
||||
return ""
|
||||
}
|
||||
return readiness.ReasonText
|
||||
}(),
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
|
||||
slog.Error("failed to render sync_status template", "error", err)
|
||||
c.String(http.StatusInternalServerError, "Template error: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness {
|
||||
h.readinessMu.Lock()
|
||||
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
|
||||
cached := *h.readinessCached
|
||||
h.readinessMu.Unlock()
|
||||
return &cached
|
||||
}
|
||||
h.readinessMu.Unlock()
|
||||
|
||||
readiness, err := h.syncService.GetReadiness()
|
||||
if err != nil && readiness == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
h.readinessMu.Lock()
|
||||
h.readinessCached = readiness
|
||||
h.readinessCachedAt = time.Now()
|
||||
h.readinessMu.Unlock()
|
||||
return readiness
|
||||
}
|
||||
|
||||
// ReportPartnumberSeen pushes unresolved vendor partnumbers to qt_vendor_partnumber_seen on MariaDB.
|
||||
// POST /api/sync/partnumber-seen
|
||||
func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
|
||||
var body struct {
|
||||
Items []struct {
|
||||
Partnumber string `json:"partnumber"`
|
||||
Description string `json:"description"`
|
||||
Ignored bool `json:"ignored"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]sync.SeenPartnumber, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
if it.Partnumber != "" {
|
||||
items = append(items, sync.SeenPartnumber{
|
||||
Partnumber: it.Partnumber,
|
||||
Description: it.Description,
|
||||
Ignored: it.Ignored,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.syncService.PushPartnumberSeen(items); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"reported": len(items)})
|
||||
}
|
||||
64
internal/handlers/sync_readiness_test.go
Normal file
64
internal/handlers/sync_readiness_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestSyncReadinessOfflineBlocked(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
dir := t.TempDir()
|
||||
local, err := localdb.New(filepath.Join(dir, "qfs.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
|
||||
service := syncsvc.NewService(nil, local)
|
||||
h, err := NewSyncHandler(local, service, nil, filepath.Join("web", "templates"), 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("new sync handler: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/sync/readiness", h.GetReadiness)
|
||||
router.POST("/api/sync/push", h.PushPendingChanges)
|
||||
|
||||
readinessResp := httptest.NewRecorder()
|
||||
readinessReq, _ := http.NewRequest(http.MethodGet, "/api/sync/readiness", nil)
|
||||
router.ServeHTTP(readinessResp, readinessReq)
|
||||
if readinessResp.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected readiness status: %d", readinessResp.Code)
|
||||
}
|
||||
|
||||
var readinessBody map[string]any
|
||||
if err := json.Unmarshal(readinessResp.Body.Bytes(), &readinessBody); err != nil {
|
||||
t.Fatalf("decode readiness body: %v", err)
|
||||
}
|
||||
if blocked, _ := readinessBody["blocked"].(bool); !blocked {
|
||||
t.Fatalf("expected blocked readiness, got %v", readinessBody["blocked"])
|
||||
}
|
||||
|
||||
pushResp := httptest.NewRecorder()
|
||||
pushReq, _ := http.NewRequest(http.MethodPost, "/api/sync/push", nil)
|
||||
router.ServeHTTP(pushResp, pushReq)
|
||||
if pushResp.Code != http.StatusLocked {
|
||||
t.Fatalf("expected 423 for blocked sync push, got %d body=%s", pushResp.Code, pushResp.Body.String())
|
||||
}
|
||||
|
||||
var pushBody map[string]any
|
||||
if err := json.Unmarshal(pushResp.Body.Bytes(), &pushBody); err != nil {
|
||||
t.Fatalf("decode push body: %v", err)
|
||||
}
|
||||
if pushBody["reason_text"] == nil || pushBody["reason_text"] == "" {
|
||||
t.Fatalf("expected reason_text in blocked response, got %v", pushBody)
|
||||
}
|
||||
}
|
||||
209
internal/handlers/vendor_spec.go
Normal file
209
internal/handlers/vendor_spec.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
|
||||
type VendorSpecHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
||||
return &VendorSpecHandler{localDB: localDB}
|
||||
}
|
||||
|
||||
// lookupConfig finds an active configuration by UUID using the standard localDB method.
|
||||
func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfiguration, error) {
|
||||
cfg, err := h.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !cfg.IsActive {
|
||||
return nil, errors.New("not active")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetVendorSpec returns the vendor spec (BOM) for a configuration.
|
||||
// GET /api/configs/:uuid/vendor-spec
|
||||
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
|
||||
cfg, err := h.lookupConfig(c.Param("uuid"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
spec := cfg.VendorSpec
|
||||
if spec == nil {
|
||||
spec = localdb.VendorSpec{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
||||
}
|
||||
|
||||
// PutVendorSpec saves (replaces) the vendor spec for a configuration.
|
||||
// PUT /api/configs/:uuid/vendor-spec
|
||||
func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||
cfg, err := h.lookupConfig(c.Param("uuid"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for i := range body.VendorSpec {
|
||||
if body.VendorSpec[i].SortOrder == 0 {
|
||||
body.VendorSpec[i].SortOrder = (i + 1) * 10
|
||||
}
|
||||
// Persist canonical LOT mapping only.
|
||||
body.VendorSpec[i].LotMappings = normalizeLotMappings(body.VendorSpec[i].LotMappings)
|
||||
body.VendorSpec[i].ResolvedLotName = ""
|
||||
body.VendorSpec[i].ResolutionSource = ""
|
||||
body.VendorSpec[i].ManualLotSuggestion = ""
|
||||
body.VendorSpec[i].LotQtyPerPN = 0
|
||||
body.VendorSpec[i].LotAllocations = nil
|
||||
}
|
||||
|
||||
spec := localdb.VendorSpec(body.VendorSpec)
|
||||
specJSON, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
||||
}
|
||||
|
||||
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
merged := make(map[string]int, len(in))
|
||||
order := make([]string, 0, len(in))
|
||||
for _, m := range in {
|
||||
lot := strings.TrimSpace(m.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
qty := m.QuantityPerPN
|
||||
if qty < 1 {
|
||||
qty = 1
|
||||
}
|
||||
if _, exists := merged[lot]; !exists {
|
||||
order = append(order, lot)
|
||||
}
|
||||
merged[lot] += qty
|
||||
}
|
||||
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
|
||||
for _, lot := range order {
|
||||
out = append(out, localdb.VendorSpecLotMapping{
|
||||
LotName: lot,
|
||||
QuantityPerPN: merged[lot],
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
|
||||
// POST /api/configs/:uuid/vendor-spec/resolve
|
||||
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
||||
if _, err := h.lookupConfig(c.Param("uuid")); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||
resolver := services.NewVendorSpecResolver(bookRepo)
|
||||
|
||||
resolved, err := resolver.Resolve(body.VendorSpec)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
book, _ := bookRepo.GetActiveBook()
|
||||
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"resolved": resolved,
|
||||
"aggregated": aggregated,
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyVendorSpec applies the resolved BOM to the cart (Estimate items).
|
||||
// POST /api/configs/:uuid/vendor-spec/apply
|
||||
func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
|
||||
cfg, err := h.lookupConfig(c.Param("uuid"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
newItems := make(localdb.LocalConfigItems, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
newItems = append(newItems, localdb.LocalConfigItem{
|
||||
LotName: it.LotName,
|
||||
Quantity: it.Quantity,
|
||||
UnitPrice: it.UnitPrice,
|
||||
})
|
||||
}
|
||||
|
||||
itemsJSON, err := json.Marshal(newItems)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": newItems})
|
||||
}
|
||||
265
internal/handlers/web.go
Normal file
265
internal/handlers/web.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WebHandler struct {
|
||||
templates map[string]*template.Template
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewWebHandler(_ string, localDB *localdb.LocalDB) (*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)
|
||||
// Load each page template with base
|
||||
simplePages := []string{"configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html", "partnumber_books.html"}
|
||||
for _, page := range simplePages {
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/"+page,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates[page] = tmpl
|
||||
}
|
||||
|
||||
// Index page needs components_list.html as well
|
||||
var indexTmpl *template.Template
|
||||
var err error
|
||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/index.html",
|
||||
"web/templates/components_list.html",
|
||||
)
|
||||
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 {
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/"+partial,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates[partial] = tmpl
|
||||
}
|
||||
|
||||
return &WebHandler{
|
||||
templates: templates,
|
||||
localDB: localDB,
|
||||
}, 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 projects page - configurator is accessed via /configurator?uuid=...
|
||||
c.Redirect(302, "/projects")
|
||||
}
|
||||
|
||||
func (h *WebHandler) Configurator(c *gin.Context) {
|
||||
uuid := c.Query("uuid")
|
||||
categories, _ := h.localCategories()
|
||||
components, total, err := h.localDB.ListComponents(localdb.ComponentFilter{}, 0, 20)
|
||||
|
||||
data := gin.H{
|
||||
"ActivePage": "configurator",
|
||||
"Categories": categories,
|
||||
"Components": []localComponentView{},
|
||||
"Total": int64(0),
|
||||
"Page": 1,
|
||||
"PerPage": 20,
|
||||
"ConfigUUID": uuid,
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
data["Components"] = toLocalComponentViews(components)
|
||||
data["Total"] = total
|
||||
}
|
||||
|
||||
h.render(c, "index.html", data)
|
||||
}
|
||||
|
||||
func (h *WebHandler) Configs(c *gin.Context) {
|
||||
h.render(c, "configs.html", gin.H{"ActivePage": "configs"})
|
||||
}
|
||||
|
||||
func (h *WebHandler) Projects(c *gin.Context) {
|
||||
h.render(c, "projects.html", gin.H{"ActivePage": "projects"})
|
||||
}
|
||||
|
||||
func (h *WebHandler) ProjectDetail(c *gin.Context) {
|
||||
h.render(c, "project_detail.html", gin.H{
|
||||
"ActivePage": "projects",
|
||||
"ProjectUUID": c.Param("uuid"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebHandler) ConfigRevisions(c *gin.Context) {
|
||||
h.render(c, "config_revisions.html", gin.H{
|
||||
"ActivePage": "configs",
|
||||
"ConfigUUID": c.Param("uuid"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebHandler) Pricelists(c *gin.Context) {
|
||||
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
||||
}
|
||||
|
||||
func (h *WebHandler) PricelistDetail(c *gin.Context) {
|
||||
h.render(c, "pricelist_detail.html", gin.H{"ActivePage": "pricelists"})
|
||||
}
|
||||
|
||||
func (h *WebHandler) PartnumberBooks(c *gin.Context) {
|
||||
h.render(c, "partnumber_books.html", gin.H{"ActivePage": "partnumber-books"})
|
||||
}
|
||||
|
||||
// Partials for htmx
|
||||
|
||||
func (h *WebHandler) ComponentsPartial(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
filter := localdb.ComponentFilter{
|
||||
Category: c.Query("category"),
|
||||
Search: c.Query("search"),
|
||||
}
|
||||
if c.Query("has_price") == "true" {
|
||||
filter.HasPrice = true
|
||||
}
|
||||
offset := (page - 1) * 20
|
||||
|
||||
data := gin.H{
|
||||
"Components": []localComponentView{},
|
||||
"Total": int64(0),
|
||||
"Page": page,
|
||||
"PerPage": 20,
|
||||
}
|
||||
|
||||
components, total, err := h.localDB.ListComponents(filter, offset, 20)
|
||||
if err == nil {
|
||||
data["Components"] = toLocalComponentViews(components)
|
||||
data["Total"] = total
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
type localComponentView struct {
|
||||
LotName string
|
||||
Description string
|
||||
Category string
|
||||
CategoryName string
|
||||
Model string
|
||||
CurrentPrice *float64
|
||||
}
|
||||
|
||||
func toLocalComponentViews(items []localdb.LocalComponent) []localComponentView {
|
||||
result := make([]localComponentView, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, localComponentView{
|
||||
LotName: item.LotName,
|
||||
Description: item.LotDescription,
|
||||
Category: item.Category,
|
||||
CategoryName: item.Category,
|
||||
Model: item.Model,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *WebHandler) localCategories() ([]models.Category, error) {
|
||||
codes, err := h.localDB.GetLocalComponentCategories()
|
||||
if err != nil || len(codes) == 0 {
|
||||
return []models.Category{}, err
|
||||
}
|
||||
|
||||
categories := make([]models.Category, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
trimmed := strings.TrimSpace(code)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
categories = append(categories, models.Category{
|
||||
Code: trimmed,
|
||||
Name: trimmed,
|
||||
})
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
329
internal/localdb/components.go
Normal file
329
internal/localdb/components.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ComponentFilter for searching with filters
|
||||
type ComponentFilter struct {
|
||||
Category string
|
||||
Search string
|
||||
HasPrice bool
|
||||
}
|
||||
|
||||
// ComponentSyncResult contains statistics from component sync
|
||||
type ComponentSyncResult struct {
|
||||
TotalSynced int
|
||||
NewCount int
|
||||
UpdateCount int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// SyncComponents loads components from MariaDB (lot + qt_lot_metadata) into local_components
|
||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Query to join lot with qt_lot_metadata (metadata only, no pricing)
|
||||
// Use LEFT JOIN to include lots without metadata
|
||||
type componentRow struct {
|
||||
LotName string
|
||||
LotDescription string
|
||||
Category *string
|
||||
Model *string
|
||||
}
|
||||
|
||||
var rows []componentRow
|
||||
err := mariaDB.Raw(`
|
||||
SELECT
|
||||
l.lot_name,
|
||||
l.lot_description,
|
||||
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
||||
m.model
|
||||
FROM lot l
|
||||
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
||||
LEFT JOIN qt_categories c ON m.category_id = c.id
|
||||
WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL
|
||||
ORDER BY l.lot_name
|
||||
`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
slog.Warn("no components found in MariaDB")
|
||||
return &ComponentSyncResult{
|
||||
Duration: time.Since(startTime),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get existing local components for comparison
|
||||
existingMap := make(map[string]bool)
|
||||
var existing []LocalComponent
|
||||
if err := l.db.Find(&existing).Error; err != nil {
|
||||
return nil, fmt.Errorf("reading existing local components: %w", err)
|
||||
}
|
||||
for _, c := range existing {
|
||||
existingMap[c.LotName] = true
|
||||
}
|
||||
|
||||
// Prepare components for batch insert/update
|
||||
syncTime := time.Now()
|
||||
components := make([]LocalComponent, 0, len(rows))
|
||||
newCount := 0
|
||||
|
||||
for _, row := range rows {
|
||||
category := ""
|
||||
if row.Category != nil {
|
||||
category = *row.Category
|
||||
} else {
|
||||
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||
parts := strings.SplitN(row.LotName, "_", 2)
|
||||
if len(parts) >= 1 {
|
||||
category = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
model := ""
|
||||
if row.Model != nil {
|
||||
model = *row.Model
|
||||
}
|
||||
|
||||
comp := LocalComponent{
|
||||
LotName: row.LotName,
|
||||
LotDescription: row.LotDescription,
|
||||
Category: category,
|
||||
Model: model,
|
||||
}
|
||||
components = append(components, comp)
|
||||
|
||||
if !existingMap[row.LotName] {
|
||||
newCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Use transaction for bulk upsert
|
||||
err = l.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Delete all existing and insert new (simpler than upsert for SQLite)
|
||||
if err := tx.Where("1=1").Delete(&LocalComponent{}).Error; err != nil {
|
||||
return fmt.Errorf("clearing local components: %w", err)
|
||||
}
|
||||
|
||||
// Batch insert
|
||||
batchSize := 500
|
||||
for i := 0; i < len(components); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(components) {
|
||||
end = len(components)
|
||||
}
|
||||
if err := tx.CreateInBatches(components[i:end], batchSize).Error; err != nil {
|
||||
return fmt.Errorf("inserting components batch: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
if err := l.SetComponentSyncTime(syncTime); err != nil {
|
||||
slog.Warn("failed to update component sync time", "error", err)
|
||||
}
|
||||
|
||||
result := &ComponentSyncResult{
|
||||
TotalSynced: len(components),
|
||||
NewCount: newCount,
|
||||
UpdateCount: len(components) - newCount,
|
||||
Duration: time.Since(startTime),
|
||||
}
|
||||
|
||||
slog.Info("components synced",
|
||||
"total", result.TotalSynced,
|
||||
"new", result.NewCount,
|
||||
"updated", result.UpdateCount,
|
||||
"duration", result.Duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SearchLocalComponents searches components in local cache by query string
|
||||
// Searches in lot_name, lot_description, category, and model fields
|
||||
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
var components []LocalComponent
|
||||
|
||||
if query == "" {
|
||||
// Return all components with limit
|
||||
err := l.db.Order("lot_name").Limit(limit).Find(&components).Error
|
||||
return components, err
|
||||
}
|
||||
|
||||
// Search with LIKE on multiple fields
|
||||
searchPattern := "%" + strings.ToLower(query) + "%"
|
||||
|
||||
err := l.db.Where(
|
||||
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
|
||||
searchPattern, searchPattern, searchPattern, searchPattern,
|
||||
).Order("lot_name").Limit(limit).Find(&components).Error
|
||||
|
||||
return components, err
|
||||
}
|
||||
|
||||
// SearchLocalComponentsByCategory searches components by category and optional query
|
||||
func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string, limit int) ([]LocalComponent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
var components []LocalComponent
|
||||
db := l.db.Where("LOWER(category) = ?", strings.ToLower(category))
|
||||
|
||||
if query != "" {
|
||||
searchPattern := "%" + strings.ToLower(query) + "%"
|
||||
db = db.Where(
|
||||
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(model) LIKE ?",
|
||||
searchPattern, searchPattern, searchPattern,
|
||||
)
|
||||
}
|
||||
|
||||
err := db.Order("lot_name").Limit(limit).Find(&components).Error
|
||||
return components, err
|
||||
}
|
||||
|
||||
// ListComponents returns components with filtering and pagination
|
||||
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
|
||||
db := l.db
|
||||
|
||||
// Apply category filter
|
||||
if filter.Category != "" {
|
||||
db = db.Where("LOWER(category) = ?", strings.ToLower(filter.Category))
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if filter.Search != "" {
|
||||
searchPattern := "%" + strings.ToLower(filter.Search) + "%"
|
||||
db = db.Where(
|
||||
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
|
||||
searchPattern, searchPattern, searchPattern, searchPattern,
|
||||
)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int64
|
||||
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Apply pagination and get results
|
||||
var components []LocalComponent
|
||||
if err := db.Order("lot_name").Offset(offset).Limit(limit).Find(&components).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return components, total, nil
|
||||
}
|
||||
|
||||
// GetLocalComponent returns a single component by lot_name
|
||||
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
||||
var component LocalComponent
|
||||
err := l.db.Where("lot_name = ?", lotName).First(&component).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &component, nil
|
||||
}
|
||||
|
||||
// GetLocalComponentCategoriesByLotNames returns category for each lot_name in the local component cache.
|
||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
|
||||
result := make(map[string]string, len(lotNames))
|
||||
if len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
Category string `gorm:"column:category"`
|
||||
}
|
||||
var rows []row
|
||||
if err := l.db.Model(&LocalComponent{}).
|
||||
Select("lot_name, category").
|
||||
Where("lot_name IN ?", lotNames).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
result[r.LotName] = r.Category
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLocalComponentCategories returns distinct categories from local components
|
||||
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
||||
var categories []string
|
||||
err := l.db.Model(&LocalComponent{}).
|
||||
Distinct("category").
|
||||
Where("category != ''").
|
||||
Order("category").
|
||||
Pluck("category", &categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
// CountLocalComponents returns the total number of local components
|
||||
func (l *LocalDB) CountLocalComponents() int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalComponent{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// CountLocalComponentsByCategory returns component count by category
|
||||
func (l *LocalDB) CountLocalComponentsByCategory(category string) int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalComponent{}).Where("LOWER(category) = ?", strings.ToLower(category)).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// GetComponentSyncTime returns the last component sync timestamp
|
||||
func (l *LocalDB) GetComponentSyncTime() *time.Time {
|
||||
var setting struct {
|
||||
Value string
|
||||
}
|
||||
if err := l.db.Table("app_settings").
|
||||
Where("key = ?", "last_component_sync").
|
||||
First(&setting).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
t, err := time.Parse(time.RFC3339, setting.Value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// SetComponentSyncTime sets the last component sync timestamp
|
||||
func (l *LocalDB) SetComponentSyncTime(t time.Time) error {
|
||||
return l.db.Exec(`
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
`, "last_component_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
||||
}
|
||||
|
||||
// NeedComponentSync checks if component sync is needed (older than specified hours)
|
||||
func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
||||
syncTime := l.GetComponentSyncTime()
|
||||
if syncTime == nil {
|
||||
return true
|
||||
}
|
||||
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
||||
}
|
||||
97
internal/localdb/configuration_business_fields_test.go
Normal file
97
internal/localdb/configuration_business_fields_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
func TestConfigurationConvertersPreserveBusinessFields(t *testing.T) {
|
||||
estimateID := uint(11)
|
||||
warehouseID := uint(22)
|
||||
competitorID := uint(33)
|
||||
|
||||
cfg := &models.Configuration{
|
||||
UUID: "cfg-1",
|
||||
OwnerUsername: "tester",
|
||||
Name: "Config",
|
||||
PricelistID: &estimateID,
|
||||
WarehousePricelistID: &warehouseID,
|
||||
CompetitorPricelistID: &competitorID,
|
||||
DisablePriceRefresh: true,
|
||||
OnlyInStock: true,
|
||||
}
|
||||
|
||||
local := ConfigurationToLocal(cfg)
|
||||
if local.WarehousePricelistID == nil || *local.WarehousePricelistID != warehouseID {
|
||||
t.Fatalf("warehouse pricelist lost in ConfigurationToLocal: %+v", local.WarehousePricelistID)
|
||||
}
|
||||
if local.CompetitorPricelistID == nil || *local.CompetitorPricelistID != competitorID {
|
||||
t.Fatalf("competitor pricelist lost in ConfigurationToLocal: %+v", local.CompetitorPricelistID)
|
||||
}
|
||||
if !local.DisablePriceRefresh {
|
||||
t.Fatalf("disable_price_refresh lost in ConfigurationToLocal")
|
||||
}
|
||||
|
||||
back := LocalToConfiguration(local)
|
||||
if back.WarehousePricelistID == nil || *back.WarehousePricelistID != warehouseID {
|
||||
t.Fatalf("warehouse pricelist lost in LocalToConfiguration: %+v", back.WarehousePricelistID)
|
||||
}
|
||||
if back.CompetitorPricelistID == nil || *back.CompetitorPricelistID != competitorID {
|
||||
t.Fatalf("competitor pricelist lost in LocalToConfiguration: %+v", back.CompetitorPricelistID)
|
||||
}
|
||||
if !back.DisablePriceRefresh {
|
||||
t.Fatalf("disable_price_refresh lost in LocalToConfiguration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
|
||||
estimateID := uint(11)
|
||||
warehouseID := uint(22)
|
||||
competitorID := uint(33)
|
||||
|
||||
cfg := &LocalConfiguration{
|
||||
UUID: "cfg-1",
|
||||
Name: "Config",
|
||||
PricelistID: &estimateID,
|
||||
WarehousePricelistID: &warehouseID,
|
||||
CompetitorPricelistID: &competitorID,
|
||||
DisablePriceRefresh: true,
|
||||
OnlyInStock: true,
|
||||
VendorSpec: VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "PN-1",
|
||||
Quantity: 1,
|
||||
LotMappings: []VendorSpecLotMapping{
|
||||
{LotName: "LOT_A", QuantityPerPN: 2},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
raw, err := BuildConfigurationSnapshot(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfigurationSnapshot: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := DecodeConfigurationSnapshot(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeConfigurationSnapshot: %v", err)
|
||||
}
|
||||
if decoded.WarehousePricelistID == nil || *decoded.WarehousePricelistID != warehouseID {
|
||||
t.Fatalf("warehouse pricelist lost in snapshot: %+v", decoded.WarehousePricelistID)
|
||||
}
|
||||
if decoded.CompetitorPricelistID == nil || *decoded.CompetitorPricelistID != competitorID {
|
||||
t.Fatalf("competitor pricelist lost in snapshot: %+v", decoded.CompetitorPricelistID)
|
||||
}
|
||||
if !decoded.DisablePriceRefresh {
|
||||
t.Fatalf("disable_price_refresh lost in snapshot")
|
||||
}
|
||||
if len(decoded.VendorSpec) != 1 || decoded.VendorSpec[0].VendorPartnumber != "PN-1" {
|
||||
t.Fatalf("vendor_spec lost in snapshot: %+v", decoded.VendorSpec)
|
||||
}
|
||||
if len(decoded.VendorSpec[0].LotMappings) != 1 || decoded.VendorSpec[0].LotMappings[0].LotName != "LOT_A" {
|
||||
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
|
||||
}
|
||||
}
|
||||
332
internal/localdb/converters.go
Normal file
332
internal/localdb/converters.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
// ConfigurationToLocal converts models.Configuration to LocalConfiguration
|
||||
func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
items := make(LocalConfigItems, len(cfg.Items))
|
||||
for i, item := range cfg.Items {
|
||||
items[i] = LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
}
|
||||
|
||||
local := &LocalConfiguration{
|
||||
UUID: cfg.UUID,
|
||||
ProjectUUID: cfg.ProjectUUID,
|
||||
IsActive: true,
|
||||
Name: cfg.Name,
|
||||
Items: items,
|
||||
TotalPrice: cfg.TotalPrice,
|
||||
CustomPrice: cfg.CustomPrice,
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
ServerModel: cfg.ServerModel,
|
||||
SupportCode: cfg.SupportCode,
|
||||
Article: cfg.Article,
|
||||
PricelistID: cfg.PricelistID,
|
||||
WarehousePricelistID: cfg.WarehousePricelistID,
|
||||
CompetitorPricelistID: cfg.CompetitorPricelistID,
|
||||
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
|
||||
DisablePriceRefresh: cfg.DisablePriceRefresh,
|
||||
OnlyInStock: cfg.OnlyInStock,
|
||||
Line: cfg.Line,
|
||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
OriginalUserID: derefUint(cfg.UserID),
|
||||
OriginalUsername: cfg.OwnerUsername,
|
||||
}
|
||||
|
||||
if cfg.ID > 0 {
|
||||
serverID := cfg.ID
|
||||
local.ServerID = &serverID
|
||||
}
|
||||
|
||||
return local
|
||||
}
|
||||
|
||||
// LocalToConfiguration converts LocalConfiguration to models.Configuration
|
||||
func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
items := make(models.ConfigItems, len(local.Items))
|
||||
for i, item := range local.Items {
|
||||
items[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &models.Configuration{
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OriginalUsername,
|
||||
ProjectUUID: local.ProjectUUID,
|
||||
Name: local.Name,
|
||||
Items: items,
|
||||
TotalPrice: local.TotalPrice,
|
||||
CustomPrice: local.CustomPrice,
|
||||
Notes: local.Notes,
|
||||
IsTemplate: local.IsTemplate,
|
||||
ServerCount: local.ServerCount,
|
||||
ServerModel: local.ServerModel,
|
||||
SupportCode: local.SupportCode,
|
||||
Article: local.Article,
|
||||
PricelistID: local.PricelistID,
|
||||
WarehousePricelistID: local.WarehousePricelistID,
|
||||
CompetitorPricelistID: local.CompetitorPricelistID,
|
||||
VendorSpec: localVendorSpecToModel(local.VendorSpec),
|
||||
DisablePriceRefresh: local.DisablePriceRefresh,
|
||||
OnlyInStock: local.OnlyInStock,
|
||||
Line: local.Line,
|
||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||
CreatedAt: local.CreatedAt,
|
||||
}
|
||||
|
||||
if local.ServerID != nil {
|
||||
cfg.ID = *local.ServerID
|
||||
}
|
||||
if local.OriginalUserID != 0 {
|
||||
userID := local.OriginalUserID
|
||||
cfg.UserID = &userID
|
||||
}
|
||||
if local.CurrentVersion != nil {
|
||||
cfg.CurrentVersionNo = local.CurrentVersion.VersionNo
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func derefUint(v *uint) uint {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func modelVendorSpecToLocal(spec models.VendorSpec) VendorSpec {
|
||||
if len(spec) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(VendorSpec, 0, len(spec))
|
||||
for _, item := range spec {
|
||||
row := VendorSpecItem{
|
||||
SortOrder: item.SortOrder,
|
||||
VendorPartnumber: item.VendorPartnumber,
|
||||
Quantity: item.Quantity,
|
||||
Description: item.Description,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: item.TotalPrice,
|
||||
ResolvedLotName: item.ResolvedLotName,
|
||||
ResolutionSource: item.ResolutionSource,
|
||||
ManualLotSuggestion: item.ManualLotSuggestion,
|
||||
LotQtyPerPN: item.LotQtyPerPN,
|
||||
}
|
||||
if len(item.LotAllocations) > 0 {
|
||||
row.LotAllocations = make([]VendorSpecLotAllocation, 0, len(item.LotAllocations))
|
||||
for _, alloc := range item.LotAllocations {
|
||||
row.LotAllocations = append(row.LotAllocations, VendorSpecLotAllocation{
|
||||
LotName: alloc.LotName,
|
||||
Quantity: alloc.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(item.LotMappings) > 0 {
|
||||
row.LotMappings = make([]VendorSpecLotMapping, 0, len(item.LotMappings))
|
||||
for _, mapping := range item.LotMappings {
|
||||
row.LotMappings = append(row.LotMappings, VendorSpecLotMapping{
|
||||
LotName: mapping.LotName,
|
||||
QuantityPerPN: mapping.QuantityPerPN,
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func localVendorSpecToModel(spec VendorSpec) models.VendorSpec {
|
||||
if len(spec) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(models.VendorSpec, 0, len(spec))
|
||||
for _, item := range spec {
|
||||
row := models.VendorSpecItem{
|
||||
SortOrder: item.SortOrder,
|
||||
VendorPartnumber: item.VendorPartnumber,
|
||||
Quantity: item.Quantity,
|
||||
Description: item.Description,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: item.TotalPrice,
|
||||
ResolvedLotName: item.ResolvedLotName,
|
||||
ResolutionSource: item.ResolutionSource,
|
||||
ManualLotSuggestion: item.ManualLotSuggestion,
|
||||
LotQtyPerPN: item.LotQtyPerPN,
|
||||
}
|
||||
if len(item.LotAllocations) > 0 {
|
||||
row.LotAllocations = make([]models.VendorSpecLotAllocation, 0, len(item.LotAllocations))
|
||||
for _, alloc := range item.LotAllocations {
|
||||
row.LotAllocations = append(row.LotAllocations, models.VendorSpecLotAllocation{
|
||||
LotName: alloc.LotName,
|
||||
Quantity: alloc.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(item.LotMappings) > 0 {
|
||||
row.LotMappings = make([]models.VendorSpecLotMapping, 0, len(item.LotMappings))
|
||||
for _, mapping := range item.LotMappings {
|
||||
row.LotMappings = append(row.LotMappings, models.VendorSpecLotMapping{
|
||||
LotName: mapping.LotName,
|
||||
QuantityPerPN: mapping.QuantityPerPN,
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ProjectToLocal(project *models.Project) *LocalProject {
|
||||
local := &LocalProject{
|
||||
UUID: project.UUID,
|
||||
OwnerUsername: project.OwnerUsername,
|
||||
Code: project.Code,
|
||||
Variant: project.Variant,
|
||||
Name: project.Name,
|
||||
TrackerURL: project.TrackerURL,
|
||||
IsActive: project.IsActive,
|
||||
IsSystem: project.IsSystem,
|
||||
CreatedAt: project.CreatedAt,
|
||||
UpdatedAt: project.UpdatedAt,
|
||||
SyncStatus: "pending",
|
||||
}
|
||||
if project.ID > 0 {
|
||||
serverID := project.ID
|
||||
local.ServerID = &serverID
|
||||
}
|
||||
return local
|
||||
}
|
||||
|
||||
func LocalToProject(local *LocalProject) *models.Project {
|
||||
project := &models.Project{
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OwnerUsername,
|
||||
Code: local.Code,
|
||||
Variant: local.Variant,
|
||||
Name: local.Name,
|
||||
TrackerURL: local.TrackerURL,
|
||||
IsActive: local.IsActive,
|
||||
IsSystem: local.IsSystem,
|
||||
CreatedAt: local.CreatedAt,
|
||||
UpdatedAt: local.UpdatedAt,
|
||||
}
|
||||
if local.ServerID != nil {
|
||||
project.ID = *local.ServerID
|
||||
}
|
||||
return project
|
||||
}
|
||||
|
||||
// PricelistToLocal converts models.Pricelist to LocalPricelist
|
||||
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
||||
name := pl.Notification
|
||||
if name == "" {
|
||||
name = pl.Version
|
||||
}
|
||||
|
||||
return &LocalPricelist{
|
||||
ServerID: pl.ID,
|
||||
Source: pl.Source,
|
||||
Version: pl.Version,
|
||||
Name: name,
|
||||
CreatedAt: pl.CreatedAt,
|
||||
SyncedAt: time.Now(),
|
||||
IsUsed: false,
|
||||
}
|
||||
}
|
||||
|
||||
// LocalToPricelist converts LocalPricelist to models.Pricelist
|
||||
func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
|
||||
return &models.Pricelist{
|
||||
ID: local.ServerID,
|
||||
Source: local.Source,
|
||||
Version: local.Version,
|
||||
Notification: local.Name,
|
||||
CreatedAt: local.CreatedAt,
|
||||
IsActive: true,
|
||||
}
|
||||
}
|
||||
|
||||
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
|
||||
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
|
||||
partnumbers := make(LocalStringList, 0, len(item.Partnumbers))
|
||||
partnumbers = append(partnumbers, item.Partnumbers...)
|
||||
return &LocalPricelistItem{
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
LotCategory: item.LotCategory,
|
||||
Price: item.Price,
|
||||
AvailableQty: item.AvailableQty,
|
||||
Partnumbers: partnumbers,
|
||||
}
|
||||
}
|
||||
|
||||
// LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem
|
||||
func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem {
|
||||
partnumbers := make([]string, 0, len(local.Partnumbers))
|
||||
partnumbers = append(partnumbers, local.Partnumbers...)
|
||||
return &models.PricelistItem{
|
||||
ID: local.ID,
|
||||
PricelistID: serverPricelistID,
|
||||
LotName: local.LotName,
|
||||
LotCategory: local.LotCategory,
|
||||
Price: local.Price,
|
||||
AvailableQty: local.AvailableQty,
|
||||
Partnumbers: partnumbers,
|
||||
}
|
||||
}
|
||||
|
||||
// ComponentToLocal converts models.LotMetadata to LocalComponent
|
||||
func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
|
||||
var lotDesc string
|
||||
var category string
|
||||
|
||||
if meta.Lot != nil {
|
||||
lotDesc = meta.Lot.LotDescription
|
||||
}
|
||||
|
||||
// Extract category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||
if len(meta.LotName) > 0 {
|
||||
for i, ch := range meta.LotName {
|
||||
if ch == '_' {
|
||||
category = meta.LotName[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &LocalComponent{
|
||||
LotName: meta.LotName,
|
||||
LotDescription: lotDesc,
|
||||
Category: category,
|
||||
Model: meta.Model,
|
||||
}
|
||||
}
|
||||
|
||||
// LocalToComponent converts LocalComponent to models.LotMetadata
|
||||
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
||||
return &models.LotMetadata{
|
||||
LotName: local.LotName,
|
||||
Model: local.Model,
|
||||
Lot: &models.Lot{
|
||||
LotName: local.LotName,
|
||||
LotDescription: local.LotDescription,
|
||||
},
|
||||
}
|
||||
}
|
||||
34
internal/localdb/converters_test.go
Normal file
34
internal/localdb/converters_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
|
||||
item := &models.PricelistItem{
|
||||
LotName: "CPU_A",
|
||||
LotCategory: "CPU",
|
||||
Price: 10,
|
||||
}
|
||||
|
||||
local := PricelistItemToLocal(item, 123)
|
||||
if local.LotCategory != "CPU" {
|
||||
t.Fatalf("expected LotCategory=CPU, got %q", local.LotCategory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalToPricelistItem_PreservesLotCategory(t *testing.T) {
|
||||
local := &LocalPricelistItem{
|
||||
LotName: "CPU_A",
|
||||
LotCategory: "CPU",
|
||||
Price: 10,
|
||||
}
|
||||
|
||||
item := LocalToPricelistItem(local, 456)
|
||||
if item.LotCategory != "CPU" {
|
||||
t.Fatalf("expected LotCategory=CPU, got %q", item.LotCategory)
|
||||
}
|
||||
}
|
||||
|
||||
213
internal/localdb/encryption.go
Normal file
213
internal/localdb/encryption.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
)
|
||||
|
||||
const encryptionKeyFileName = "local_encryption.key"
|
||||
|
||||
// getEncryptionKey resolves the active encryption key.
|
||||
// Preference order:
|
||||
// 1. QUOTEFORGE_ENCRYPTION_KEY env var
|
||||
// 2. application-managed random key file in the user state directory
|
||||
func getEncryptionKey() ([]byte, error) {
|
||||
key := os.Getenv("QUOTEFORGE_ENCRYPTION_KEY")
|
||||
if key != "" {
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
stateDir, err := resolveEncryptionStateDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve encryption state dir: %w", err)
|
||||
}
|
||||
|
||||
return loadOrCreateEncryptionKey(filepath.Join(stateDir, encryptionKeyFileName))
|
||||
}
|
||||
|
||||
func resolveEncryptionStateDir() (string, error) {
|
||||
configPath, err := appstate.ResolveConfigPath("")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Dir(configPath), nil
|
||||
}
|
||||
|
||||
func loadOrCreateEncryptionKey(path string) ([]byte, error) {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
return parseEncryptionKeyFile(data)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("read encryption key: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return nil, fmt.Errorf("create encryption key dir: %w", err)
|
||||
}
|
||||
|
||||
raw := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, raw); err != nil {
|
||||
return nil, fmt.Errorf("generate encryption key: %w", err)
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(raw)
|
||||
if err := writeKeyFile(path, []byte(encoded+"\n")); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read concurrent encryption key: %w", readErr)
|
||||
}
|
||||
return parseEncryptionKeyFile(data)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func writeKeyFile(path string, data []byte) error {
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := file.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func parseEncryptionKeyFile(data []byte) ([]byte, error) {
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
decoded, err := base64.StdEncoding.DecodeString(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode encryption key file: %w", err)
|
||||
}
|
||||
if len(decoded) != 32 {
|
||||
return nil, fmt.Errorf("invalid encryption key length: %d", len(decoded))
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func getLegacyEncryptionKey() []byte {
|
||||
hostname, _ := os.Hostname()
|
||||
key := hostname + "quoteforge-salt-2024"
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return hash[:]
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext using AES-256-GCM
|
||||
func Encrypt(plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key, err := getEncryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts ciphertext that was encrypted with Encrypt
|
||||
func Decrypt(ciphertext string) (string, error) {
|
||||
if ciphertext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key, err := getEncryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, legacy, err := decryptWithKeys(ciphertext, key, getLegacyEncryptionKey())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = legacy
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func DecryptWithMetadata(ciphertext string) (string, bool, error) {
|
||||
if ciphertext == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
key, err := getEncryptionKey()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return decryptWithKeys(ciphertext, key, getLegacyEncryptionKey())
|
||||
}
|
||||
|
||||
func decryptWithKeys(ciphertext string, primaryKey, legacyKey []byte) (string, bool, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
plaintext, err := decryptWithKey(data, primaryKey)
|
||||
if err == nil {
|
||||
return plaintext, false, nil
|
||||
}
|
||||
|
||||
legacyPlaintext, legacyErr := decryptWithKey(data, legacyKey)
|
||||
if legacyErr == nil {
|
||||
return legacyPlaintext, true, nil
|
||||
}
|
||||
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
func decryptWithKey(data, key []byte) (string, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
97
internal/localdb/encryption_test.go
Normal file
97
internal/localdb/encryption_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptCreatesPersistentKeyFile(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
t.Setenv("QFS_STATE_DIR", stateDir)
|
||||
t.Setenv("QUOTEFORGE_ENCRYPTION_KEY", "")
|
||||
|
||||
ciphertext, err := Encrypt("secret-password")
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
if ciphertext == "" {
|
||||
t.Fatal("expected ciphertext")
|
||||
}
|
||||
|
||||
keyPath := filepath.Join(stateDir, encryptionKeyFileName)
|
||||
info, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat key file: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("expected 0600 key file, got %v", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptMigratesLegacyCiphertext(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
t.Setenv("QFS_STATE_DIR", stateDir)
|
||||
t.Setenv("QUOTEFORGE_ENCRYPTION_KEY", "")
|
||||
|
||||
legacyCiphertext := encryptWithKeyForTest(t, getLegacyEncryptionKey(), "legacy-password")
|
||||
|
||||
plaintext, migrated, err := DecryptWithMetadata(legacyCiphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt legacy: %v", err)
|
||||
}
|
||||
if plaintext != "legacy-password" {
|
||||
t.Fatalf("unexpected plaintext: %q", plaintext)
|
||||
}
|
||||
if !migrated {
|
||||
t.Fatal("expected legacy ciphertext to require migration")
|
||||
}
|
||||
|
||||
currentCiphertext, err := Encrypt("legacy-password")
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt current: %v", err)
|
||||
}
|
||||
plaintext, migrated, err = DecryptWithMetadata(currentCiphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt current: %v", err)
|
||||
}
|
||||
if migrated {
|
||||
t.Fatal("did not expect current ciphertext to require migration")
|
||||
}
|
||||
}
|
||||
|
||||
func encryptWithKeyForTest(t *testing.T, key []byte, plaintext string) string {
|
||||
t.Helper()
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatalf("new gcm: %v", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
t.Fatalf("read nonce: %v", err)
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext)
|
||||
}
|
||||
|
||||
func TestLegacyEncryptionKeyRemainsDeterministic(t *testing.T) {
|
||||
hostname, _ := os.Hostname()
|
||||
expected := sha256.Sum256([]byte(hostname + "quoteforge-salt-2024"))
|
||||
actual := getLegacyEncryptionKey()
|
||||
if string(actual) != string(expected[:]) {
|
||||
t.Fatal("legacy key derivation changed")
|
||||
}
|
||||
}
|
||||
595
internal/localdb/local_migrations_test.go
Normal file
595
internal/localdb/local_migrations_test.go
Normal file
@@ -0,0 +1,595 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy_local.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
cfg := &LocalConfiguration{
|
||||
UUID: "legacy-cfg",
|
||||
Name: "Legacy",
|
||||
Items: LocalConfigItems{},
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg); err != nil {
|
||||
t.Fatalf("save seed config: %v", err)
|
||||
}
|
||||
if err := local.DB().Where("configuration_uuid = ?", "legacy-cfg").Delete(&LocalConfigurationVersion{}).Error; err != nil {
|
||||
t.Fatalf("delete seed versions: %v", err)
|
||||
}
|
||||
if err := local.DB().Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", "legacy-cfg").
|
||||
Update("current_version_id", nil).Error; err != nil {
|
||||
t.Fatalf("clear current_version_id: %v", err)
|
||||
}
|
||||
if err := local.DB().Where("1=1").Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||
t.Fatalf("clear migration records: %v", err)
|
||||
}
|
||||
|
||||
if err := runLocalMigrations(local.DB()); err != nil {
|
||||
t.Fatalf("run local migrations manually: %v", err)
|
||||
}
|
||||
|
||||
migratedCfg, err := local.GetConfigurationByUUID("legacy-cfg")
|
||||
if err != nil {
|
||||
t.Fatalf("get migrated config: %v", err)
|
||||
}
|
||||
if migratedCfg.CurrentVersionID == nil || *migratedCfg.CurrentVersionID == "" {
|
||||
t.Fatalf("expected current_version_id after migration")
|
||||
}
|
||||
if !migratedCfg.IsActive {
|
||||
t.Fatalf("expected migrated config to be active")
|
||||
}
|
||||
|
||||
var versionCount int64
|
||||
if err := local.DB().Model(&LocalConfigurationVersion{}).
|
||||
Where("configuration_uuid = ?", "legacy-cfg").
|
||||
Count(&versionCount).Error; err != nil {
|
||||
t.Fatalf("count versions: %v", err)
|
||||
}
|
||||
if versionCount != 1 {
|
||||
t.Fatalf("expected 1 backfilled version, got %d", versionCount)
|
||||
}
|
||||
|
||||
var migrationCount int64
|
||||
if err := local.DB().Model(&LocalSchemaMigration{}).Count(&migrationCount).Error; err != nil {
|
||||
t.Fatalf("count local migrations: %v", err)
|
||||
}
|
||||
if migrationCount == 0 {
|
||||
t.Fatalf("expected local migrations to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "pricelist_index_fix.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&LocalPricelist{
|
||||
ServerID: 10,
|
||||
Version: "2026-02-06-001",
|
||||
Name: "v1",
|
||||
CreatedAt: time.Now().Add(-time.Hour),
|
||||
SyncedAt: time.Now().Add(-time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("save first pricelist: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_version_legacy
|
||||
ON local_pricelists(version)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create legacy unique version index: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Where("id = ?", "2026_02_06_pricelist_index_fix").
|
||||
Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||
t.Fatalf("delete migration record: %v", err)
|
||||
}
|
||||
|
||||
if err := runLocalMigrations(local.DB()); err != nil {
|
||||
t.Fatalf("rerun local migrations: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelist(&LocalPricelist{
|
||||
ServerID: 11,
|
||||
Version: "2026-02-06-001",
|
||||
Name: "v1-duplicate-version",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save second pricelist with duplicate version: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := local.DB().Model(&LocalPricelist{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count pricelists: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 pricelists, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "versions_dedup.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
cfg := &LocalConfiguration{
|
||||
UUID: "dedup-cfg",
|
||||
Name: "Dedup",
|
||||
Items: LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg); err != nil {
|
||||
t.Fatalf("save seed config: %v", err)
|
||||
}
|
||||
|
||||
baseV1Data, err := BuildConfigurationSnapshot(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("build v1 snapshot: %v", err)
|
||||
}
|
||||
baseV1 := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 1,
|
||||
Data: baseV1Data,
|
||||
AppVersion: "test",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := local.DB().Create(&baseV1).Error; err != nil {
|
||||
t.Fatalf("insert base v1: %v", err)
|
||||
}
|
||||
if err := local.DB().Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", baseV1.ID).Error; err != nil {
|
||||
t.Fatalf("set current_version_id to v1: %v", err)
|
||||
}
|
||||
|
||||
v2 := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 2,
|
||||
Data: baseV1.Data,
|
||||
AppVersion: "test",
|
||||
CreatedAt: time.Now().Add(1 * time.Second),
|
||||
}
|
||||
if err := local.DB().Create(&v2).Error; err != nil {
|
||||
t.Fatalf("insert duplicate v2: %v", err)
|
||||
}
|
||||
|
||||
modified := *cfg
|
||||
modified.Items = LocalConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}}
|
||||
total := modified.Items.Total()
|
||||
modified.TotalPrice = &total
|
||||
modified.UpdatedAt = time.Now()
|
||||
v3Data, err := BuildConfigurationSnapshot(&modified)
|
||||
if err != nil {
|
||||
t.Fatalf("build v3 snapshot: %v", err)
|
||||
}
|
||||
|
||||
v3 := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 3,
|
||||
Data: v3Data,
|
||||
AppVersion: "test",
|
||||
CreatedAt: time.Now().Add(2 * time.Second),
|
||||
}
|
||||
if err := local.DB().Create(&v3).Error; err != nil {
|
||||
t.Fatalf("insert v3: %v", err)
|
||||
}
|
||||
|
||||
v4 := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 4,
|
||||
Data: v3Data,
|
||||
AppVersion: "test",
|
||||
CreatedAt: time.Now().Add(3 * time.Second),
|
||||
}
|
||||
if err := local.DB().Create(&v4).Error; err != nil {
|
||||
t.Fatalf("insert duplicate v4: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", v4.ID).Error; err != nil {
|
||||
t.Fatalf("point current_version_id to duplicate v4: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Where("id = ?", "2026_02_19_configuration_versions_dedup_spec_price").
|
||||
Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||
t.Fatalf("delete dedup migration record: %v", err)
|
||||
}
|
||||
|
||||
if err := runLocalMigrations(local.DB()); err != nil {
|
||||
t.Fatalf("rerun local migrations: %v", err)
|
||||
}
|
||||
|
||||
var versions []LocalConfigurationVersion
|
||||
if err := local.DB().Where("configuration_uuid = ?", cfg.UUID).
|
||||
Order("version_no ASC").
|
||||
Find(&versions).Error; err != nil {
|
||||
t.Fatalf("load versions after dedup: %v", err)
|
||||
}
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions after dedup, got %d", len(versions))
|
||||
}
|
||||
if versions[0].VersionNo != 1 || versions[1].VersionNo != 3 {
|
||||
t.Fatalf("expected kept version numbers [1,3], got [%d,%d]", versions[0].VersionNo, versions[1].VersionNo)
|
||||
}
|
||||
|
||||
var after LocalConfiguration
|
||||
if err := local.DB().Where("uuid = ?", cfg.UUID).First(&after).Error; err != nil {
|
||||
t.Fatalf("load config after dedup: %v", err)
|
||||
}
|
||||
if after.CurrentVersionID == nil || *after.CurrentVersionID != v3.ID {
|
||||
t.Fatalf("expected current_version_id to point to kept latest version v3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLocalMigrationsBackfillsConfigurationLineNo(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "line_no_backfill.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
projectUUID := "project-line"
|
||||
cfg1 := &LocalConfiguration{
|
||||
UUID: "line-cfg-1",
|
||||
ProjectUUID: &projectUUID,
|
||||
Name: "Cfg 1",
|
||||
Items: LocalConfigItems{},
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
}
|
||||
cfg2 := &LocalConfiguration{
|
||||
UUID: "line-cfg-2",
|
||||
ProjectUUID: &projectUUID,
|
||||
Name: "Cfg 2",
|
||||
Items: LocalConfigItems{},
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg1); err != nil {
|
||||
t.Fatalf("save cfg1: %v", err)
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg2); err != nil {
|
||||
t.Fatalf("save cfg2: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Model(&LocalConfiguration{}).Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Update("line_no", 0).Error; err != nil {
|
||||
t.Fatalf("reset line_no: %v", err)
|
||||
}
|
||||
if err := local.DB().Where("id = ?", "2026_02_19_local_config_line_no").Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||
t.Fatalf("delete migration record: %v", err)
|
||||
}
|
||||
|
||||
if err := runLocalMigrations(local.DB()); err != nil {
|
||||
t.Fatalf("rerun local migrations: %v", err)
|
||||
}
|
||||
|
||||
var rows []LocalConfiguration
|
||||
if err := local.DB().Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Order("created_at ASC").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("load configurations: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 configurations, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Line != 10 || rows[1].Line != 20 {
|
||||
t.Fatalf("expected line_no [10,20], got [%d,%d]", rows[0].Line, rows[1].Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLocalMigrationsDeduplicatesCanonicalPartnumberCatalog(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "partnumber_catalog_dedup.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
firstLots := LocalPartnumberBookLots{
|
||||
{LotName: "LOT-A", Qty: 1},
|
||||
}
|
||||
secondLots := LocalPartnumberBookLots{
|
||||
{LotName: "LOT-B", Qty: 2},
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partnumber TEXT NOT NULL,
|
||||
lots_json TEXT NOT NULL,
|
||||
description TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create dirty local_partnumber_book_items: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&LocalPartnumberBookItem{
|
||||
Partnumber: "PN-001",
|
||||
LotsJSON: firstLots,
|
||||
Description: "",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert first duplicate row: %v", err)
|
||||
}
|
||||
if err := db.Create(&LocalPartnumberBookItem{
|
||||
Partnumber: "PN-001",
|
||||
LotsJSON: secondLots,
|
||||
Description: "Canonical description",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert second duplicate row: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateLocalPartnumberBookCatalog(db); err != nil {
|
||||
t.Fatalf("migrate local partnumber catalog: %v", err)
|
||||
}
|
||||
|
||||
var items []LocalPartnumberBookItem
|
||||
if err := db.Order("partnumber ASC").Find(&items).Error; err != nil {
|
||||
t.Fatalf("load migrated partnumber items: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 deduplicated item, got %d", len(items))
|
||||
}
|
||||
if items[0].Partnumber != "PN-001" {
|
||||
t.Fatalf("unexpected partnumber: %s", items[0].Partnumber)
|
||||
}
|
||||
if items[0].Description != "Canonical description" {
|
||||
t.Fatalf("expected merged description, got %q", items[0].Description)
|
||||
}
|
||||
if len(items[0].LotsJSON) != 2 {
|
||||
t.Fatalf("expected merged lots from duplicates, got %d", len(items[0].LotsJSON))
|
||||
}
|
||||
|
||||
var duplicateCount int64
|
||||
if err := db.Model(&LocalPartnumberBookItem{}).
|
||||
Where("partnumber = ?", "PN-001").
|
||||
Count(&duplicateCount).Error; err != nil {
|
||||
t.Fatalf("count deduplicated partnumber: %v", err)
|
||||
}
|
||||
if duplicateCount != 1 {
|
||||
t.Fatalf("expected unique partnumber row after migration, got %d", duplicateCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLocalPartnumberBookCatalogRemovesRowsWithoutPartnumber(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "sanitize_partnumber_catalog.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partnumber TEXT NULL,
|
||||
lots_json TEXT NOT NULL,
|
||||
description TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create local_partnumber_book_items: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO local_partnumber_book_items (partnumber, lots_json, description) VALUES
|
||||
(NULL, '[]', 'null pn'),
|
||||
('', '[]', 'empty pn'),
|
||||
('PN-OK', '[]', 'valid pn')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("seed local_partnumber_book_items: %v", err)
|
||||
}
|
||||
|
||||
if err := sanitizeLocalPartnumberBookCatalog(db); err != nil {
|
||||
t.Fatalf("sanitize local partnumber catalog: %v", err)
|
||||
}
|
||||
|
||||
var items []LocalPartnumberBookItem
|
||||
if err := db.Order("id ASC").Find(&items).Error; err != nil {
|
||||
t.Fatalf("load sanitized items: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 valid item after sanitize, got %d", len(items))
|
||||
}
|
||||
if items[0].Partnumber != "PN-OK" {
|
||||
t.Fatalf("expected remaining partnumber PN-OK, got %q", items[0].Partnumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMigratesLegacyPartnumberBookCatalogBeforeAutoMigrate(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy_partnumber_catalog.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partnumber TEXT NOT NULL UNIQUE,
|
||||
lots_json TEXT NOT NULL,
|
||||
is_primary_pn INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create legacy local_partnumber_book_items: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO local_partnumber_book_items (partnumber, lots_json, is_primary_pn, description)
|
||||
VALUES ('PN-001', '[{"lot_name":"CPU_A","qty":1}]', 0, 'Legacy row')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("seed legacy local_partnumber_book_items: %v", err)
|
||||
}
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb with legacy catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
var columns []struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
if err := local.DB().Raw(`SELECT name FROM pragma_table_info('local_partnumber_book_items')`).Scan(&columns).Error; err != nil {
|
||||
t.Fatalf("load local_partnumber_book_items columns: %v", err)
|
||||
}
|
||||
for _, column := range columns {
|
||||
if column.Name == "is_primary_pn" {
|
||||
t.Fatalf("expected legacy is_primary_pn column to be removed before automigrate")
|
||||
}
|
||||
}
|
||||
|
||||
var items []LocalPartnumberBookItem
|
||||
if err := local.DB().Find(&items).Error; err != nil {
|
||||
t.Fatalf("load migrated local_partnumber_book_items: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Partnumber != "PN-001" {
|
||||
t.Fatalf("unexpected migrated rows: %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecoversBrokenPartnumberBookCatalogCache(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "broken_partnumber_catalog.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partnumber TEXT NOT NULL UNIQUE,
|
||||
lots_json TEXT NOT NULL,
|
||||
description TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create broken local_partnumber_book_items: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO local_partnumber_book_items (partnumber, lots_json, description)
|
||||
VALUES ('PN-001', '{not-json}', 'Broken cache row')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("seed broken local_partnumber_book_items: %v", err)
|
||||
}
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb with broken catalog cache: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
var count int64
|
||||
if err := local.DB().Model(&LocalPartnumberBookItem{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count recovered local_partnumber_book_items: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected empty recovered local_partnumber_book_items, got %d rows", count)
|
||||
}
|
||||
|
||||
var quarantineTables []struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
if err := local.DB().Raw(`
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name LIKE 'local_partnumber_book_items_broken_%'
|
||||
`).Scan(&quarantineTables).Error; err != nil {
|
||||
t.Fatalf("load quarantine tables: %v", err)
|
||||
}
|
||||
if len(quarantineTables) != 1 {
|
||||
t.Fatalf("expected one quarantined broken catalog table, got %d", len(quarantineTables))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleReadOnlyCacheTempTablesDropsShadowTempWhenBaseExists(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "stale_cache_temp.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_pricelist_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pricelist_id INTEGER NOT NULL,
|
||||
partnumber TEXT,
|
||||
brand TEXT NOT NULL DEFAULT '',
|
||||
lot_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price REAL NOT NULL DEFAULT 0,
|
||||
quantity INTEGER NOT NULL DEFAULT 0,
|
||||
reserve INTEGER NOT NULL DEFAULT 0,
|
||||
available_qty REAL,
|
||||
partnumbers TEXT,
|
||||
lot_category TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create local_pricelist_items: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_pricelist_items__temp (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
legacy TEXT
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create local_pricelist_items__temp: %v", err)
|
||||
}
|
||||
|
||||
if err := cleanupStaleReadOnlyCacheTempTables(db); err != nil {
|
||||
t.Fatalf("cleanup stale read-only cache temp tables: %v", err)
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable("local_pricelist_items__temp") {
|
||||
t.Fatalf("expected stale temp table to be dropped")
|
||||
}
|
||||
if !db.Migrator().HasTable("local_pricelist_items") {
|
||||
t.Fatalf("expected base local_pricelist_items table to remain")
|
||||
}
|
||||
}
|
||||
1575
internal/localdb/localdb.go
Normal file
1575
internal/localdb/localdb.go
Normal file
File diff suppressed because it is too large
Load Diff
60
internal/localdb/migration_projects_test.go
Normal file
60
internal/localdb/migration_projects_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "projects_backfill.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
cfg := &LocalConfiguration{
|
||||
UUID: "cfg-without-project",
|
||||
Name: "Cfg no project",
|
||||
Items: LocalConfigItems{},
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
if err := local.DB().
|
||||
Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("project_uuid", nil).Error; err != nil {
|
||||
t.Fatalf("clear project_uuid: %v", err)
|
||||
}
|
||||
if err := local.DB().Where("id = ?", "2026_02_06_projects_backfill").Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||
t.Fatalf("delete local migration record: %v", err)
|
||||
}
|
||||
|
||||
if err := runLocalMigrations(local.DB()); err != nil {
|
||||
t.Fatalf("run local migrations: %v", err)
|
||||
}
|
||||
|
||||
updated, err := local.GetConfigurationByUUID(cfg.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("get updated config: %v", err)
|
||||
}
|
||||
if updated.ProjectUUID == nil || *updated.ProjectUUID == "" {
|
||||
t.Fatalf("expected project_uuid to be backfilled")
|
||||
}
|
||||
|
||||
project, err := local.GetProjectByUUID(*updated.ProjectUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("get system project: %v", err)
|
||||
}
|
||||
if project.Name == nil || *project.Name != "Без проекта" {
|
||||
t.Fatalf("expected system project name, got %v", project.Name)
|
||||
}
|
||||
if !project.IsSystem {
|
||||
t.Fatalf("expected system project flag")
|
||||
}
|
||||
}
|
||||
131
internal/localdb/migration_versioning_test.go
Normal file
131
internal/localdb/migration_versioning_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMigration006BackfillCreatesV1AndCurrentPointer(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "migration_backfill.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_configurations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
server_id INTEGER NULL,
|
||||
name TEXT NOT NULL,
|
||||
items TEXT,
|
||||
total_price REAL,
|
||||
custom_price REAL,
|
||||
notes TEXT,
|
||||
is_template BOOLEAN DEFAULT FALSE,
|
||||
server_count INTEGER DEFAULT 1,
|
||||
price_updated_at DATETIME NULL,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'local',
|
||||
original_user_id INTEGER DEFAULT 0,
|
||||
original_username TEXT DEFAULT ''
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create pre-migration schema: %v", err)
|
||||
}
|
||||
|
||||
items := `[{"lot_name":"CPU_X","quantity":2,"unit_price":1000}]`
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if err := db.Exec(`
|
||||
INSERT INTO local_configurations
|
||||
(uuid, name, items, total_price, notes, server_count, created_at, updated_at, sync_status, original_username)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"cfg-1", "Cfg One", items, 2000.0, "note", 1, now, now, "pending", "tester",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed pre-migration data: %v", err)
|
||||
}
|
||||
|
||||
migrationPath := filepath.Join("..", "..", "migrations", "006_add_local_configuration_versions.sql")
|
||||
sqlBytes, err := os.ReadFile(migrationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read migration file: %v", err)
|
||||
}
|
||||
if err := execSQLScript(db, string(sqlBytes)); err != nil {
|
||||
t.Fatalf("apply migration: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Table("local_configuration_versions").Where("configuration_uuid = ?", "cfg-1").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count versions: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 version, got %d", count)
|
||||
}
|
||||
|
||||
var currentVersionID *string
|
||||
if err := db.Table("local_configurations").Select("current_version_id").Where("uuid = ?", "cfg-1").Scan(¤tVersionID).Error; err != nil {
|
||||
t.Fatalf("read current_version_id: %v", err)
|
||||
}
|
||||
if currentVersionID == nil || *currentVersionID == "" {
|
||||
t.Fatalf("expected current_version_id to be set")
|
||||
}
|
||||
|
||||
var row struct {
|
||||
ID string
|
||||
VersionNo int
|
||||
Data string
|
||||
}
|
||||
if err := db.Table("local_configuration_versions").
|
||||
Select("id, version_no, data").
|
||||
Where("configuration_uuid = ?", "cfg-1").
|
||||
First(&row).Error; err != nil {
|
||||
t.Fatalf("load v1 row: %v", err)
|
||||
}
|
||||
if row.VersionNo != 1 {
|
||||
t.Fatalf("expected version_no=1, got %d", row.VersionNo)
|
||||
}
|
||||
if row.ID != *currentVersionID {
|
||||
t.Fatalf("expected current_version_id=%s, got %s", row.ID, *currentVersionID)
|
||||
}
|
||||
|
||||
var snapshot map[string]any
|
||||
if err := json.Unmarshal([]byte(row.Data), &snapshot); err != nil {
|
||||
t.Fatalf("parse snapshot json: %v", err)
|
||||
}
|
||||
if snapshot["uuid"] != "cfg-1" {
|
||||
t.Fatalf("expected snapshot uuid cfg-1, got %v", snapshot["uuid"])
|
||||
}
|
||||
if snapshot["name"] != "Cfg One" {
|
||||
t.Fatalf("expected snapshot name Cfg One, got %v", snapshot["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func execSQLScript(db *gorm.DB, script string) error {
|
||||
var cleaned []string
|
||||
for _, line := range strings.Split(script, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "--") {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, line)
|
||||
}
|
||||
|
||||
for _, stmt := range strings.Split(strings.Join(cleaned, "\n"), ";") {
|
||||
sql := strings.TrimSpace(stmt)
|
||||
if sql == "" {
|
||||
continue
|
||||
}
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
1094
internal/localdb/migrations.go
Normal file
1094
internal/localdb/migrations.go
Normal file
File diff suppressed because it is too large
Load Diff
344
internal/localdb/models.go
Normal file
344
internal/localdb/models.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppSetting stores application settings in local SQLite
|
||||
type AppSetting struct {
|
||||
Key string `gorm:"primaryKey" json:"key"`
|
||||
Value string `gorm:"not null" json:"value"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (AppSetting) TableName() string {
|
||||
return "app_settings"
|
||||
}
|
||||
|
||||
// LocalConfigItem represents an item in a configuration
|
||||
type LocalConfigItem struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
}
|
||||
|
||||
// LocalConfigItems is a slice of LocalConfigItem that can be stored as JSON
|
||||
type LocalConfigItems []LocalConfigItem
|
||||
|
||||
func (c LocalConfigItems) Value() (driver.Value, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func (c *LocalConfigItems) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*c = make(LocalConfigItems, 0)
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return errors.New("type assertion failed for LocalConfigItems")
|
||||
}
|
||||
return json.Unmarshal(bytes, c)
|
||||
}
|
||||
|
||||
func (c LocalConfigItems) Total() float64 {
|
||||
var total float64
|
||||
for _, item := range c {
|
||||
total += item.UnitPrice * float64(item.Quantity)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// LocalStringList is a JSON-encoded list of strings stored as TEXT in SQLite.
|
||||
type LocalStringList []string
|
||||
|
||||
func (s LocalStringList) Value() (driver.Value, error) {
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
func (s *LocalStringList) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*s = make(LocalStringList, 0)
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return errors.New("type assertion failed for LocalStringList")
|
||||
}
|
||||
return json.Unmarshal(bytes, s)
|
||||
}
|
||||
|
||||
// LocalConfiguration stores configurations in local SQLite
|
||||
type LocalConfiguration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
|
||||
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
|
||||
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
||||
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
|
||||
Article string `gorm:"size:80" json:"article,omitempty"`
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
|
||||
Line int `gorm:"column:line_no;index" json:"line"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
||||
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
|
||||
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
|
||||
}
|
||||
|
||||
func (LocalConfiguration) TableName() string {
|
||||
return "local_configurations"
|
||||
}
|
||||
|
||||
type LocalProject struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id,omitempty"`
|
||||
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
||||
Code string `gorm:"not null;index:idx_local_projects_code_variant,priority:1" json:"code"`
|
||||
Variant string `gorm:"default:'';index:idx_local_projects_code_variant,priority:2" json:"variant"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
TrackerURL string `json:"tracker_url"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SyncedAt *time.Time `json:"synced_at,omitempty"`
|
||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // local/synced/pending
|
||||
}
|
||||
|
||||
func (LocalProject) TableName() string {
|
||||
return "local_projects"
|
||||
}
|
||||
|
||||
// LocalConfigurationVersion stores immutable full snapshots for each configuration version
|
||||
type LocalConfigurationVersion struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
ConfigurationUUID string `gorm:"not null;index:idx_lcv_config_created,priority:1;index:idx_lcv_config_version,priority:1;uniqueIndex:idx_lcv_config_version_unique,priority:1" json:"configuration_uuid"`
|
||||
VersionNo int `gorm:"not null;index:idx_lcv_config_version,sort:desc,priority:2;uniqueIndex:idx_lcv_config_version_unique,priority:2" json:"version_no"`
|
||||
Data string `gorm:"type:text;not null" json:"data"`
|
||||
ChangeNote *string `json:"change_note,omitempty"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
|
||||
CreatedAt time.Time `gorm:"not null;autoCreateTime;index:idx_lcv_config_created,sort:desc,priority:2" json:"created_at"`
|
||||
Configuration *LocalConfiguration `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"configuration,omitempty"`
|
||||
}
|
||||
|
||||
func (LocalConfigurationVersion) TableName() string {
|
||||
return "local_configuration_versions"
|
||||
}
|
||||
|
||||
// LocalPricelist stores cached pricelists from server
|
||||
type LocalPricelist struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ServerID uint `gorm:"not null;uniqueIndex" json:"server_id"` // ID on MariaDB server
|
||||
Source string `gorm:"not null;default:'estimate';index:idx_local_pricelists_source_created_at,priority:1" json:"source"`
|
||||
Version string `gorm:"not null;index" json:"version"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||
}
|
||||
|
||||
func (LocalPricelist) TableName() string {
|
||||
return "local_pricelists"
|
||||
}
|
||||
|
||||
// LocalPricelistItem stores pricelist items
|
||||
type LocalPricelistItem struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
||||
LotName string `gorm:"not null" json:"lot_name"`
|
||||
LotCategory string `gorm:"column:lot_category" json:"lot_category,omitempty"`
|
||||
Price float64 `gorm:"not null" json:"price"`
|
||||
AvailableQty *float64 `json:"available_qty,omitempty"`
|
||||
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`
|
||||
}
|
||||
|
||||
func (LocalPricelistItem) TableName() string {
|
||||
return "local_pricelist_items"
|
||||
}
|
||||
|
||||
// LocalComponent stores cached components for offline search (metadata only)
|
||||
// All pricing is now sourced from local_pricelist_items based on configuration pricelist selection
|
||||
type LocalComponent struct {
|
||||
LotName string `gorm:"primaryKey" json:"lot_name"`
|
||||
LotDescription string `json:"lot_description"`
|
||||
Category string `json:"category"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
func (LocalComponent) TableName() string {
|
||||
return "local_components"
|
||||
}
|
||||
|
||||
// LocalSyncGuardState stores latest sync readiness decision for UI and preflight checks.
|
||||
type LocalSyncGuardState struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Status string `gorm:"size:32;not null;index" json:"status"` // ready|blocked|unknown
|
||||
ReasonCode string `gorm:"size:128" json:"reason_code,omitempty"`
|
||||
ReasonText string `gorm:"type:text" json:"reason_text,omitempty"`
|
||||
RequiredMinAppVersion *string `gorm:"size:64" json:"required_min_app_version,omitempty"`
|
||||
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (LocalSyncGuardState) TableName() string {
|
||||
return "local_sync_guard_state"
|
||||
}
|
||||
|
||||
// PendingChange stores changes that need to be synced to the server
|
||||
type PendingChange struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
EntityType string `gorm:"not null;index" json:"entity_type"` // "configuration", "project", "specification"
|
||||
EntityUUID string `gorm:"not null;index" json:"entity_uuid"`
|
||||
Operation string `gorm:"not null" json:"operation"` // "create", "update", "rollback", "deactivate", "reactivate", "delete"
|
||||
Payload string `gorm:"type:text" json:"payload"` // JSON snapshot of the entity
|
||||
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||
Attempts int `gorm:"default:0" json:"attempts"` // Retry count for sync
|
||||
LastError string `gorm:"type:text" json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
func (PendingChange) TableName() string {
|
||||
return "pending_changes"
|
||||
}
|
||||
|
||||
// LocalPartnumberBook stores a version snapshot of the PN→LOT mapping book (pull-only from PriceForge)
|
||||
type LocalPartnumberBook struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ServerID int `gorm:"uniqueIndex;not null" json:"server_id"`
|
||||
Version string `gorm:"not null" json:"version"`
|
||||
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||
IsActive bool `gorm:"not null;default:true" json:"is_active"`
|
||||
PartnumbersJSON LocalStringList `gorm:"column:partnumbers_json;type:text" json:"partnumbers_json"`
|
||||
}
|
||||
|
||||
func (LocalPartnumberBook) TableName() string {
|
||||
return "local_partnumber_books"
|
||||
}
|
||||
|
||||
type LocalPartnumberBookLot struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Qty float64 `json:"qty"`
|
||||
}
|
||||
|
||||
type LocalPartnumberBookLots []LocalPartnumberBookLot
|
||||
|
||||
func (l LocalPartnumberBookLots) Value() (driver.Value, error) {
|
||||
return json.Marshal(l)
|
||||
}
|
||||
|
||||
func (l *LocalPartnumberBookLots) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*l = make(LocalPartnumberBookLots, 0)
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return errors.New("type assertion failed for LocalPartnumberBookLots")
|
||||
}
|
||||
return json.Unmarshal(bytes, l)
|
||||
}
|
||||
|
||||
// LocalPartnumberBookItem stores the canonical PN composition pulled from PriceForge.
|
||||
type LocalPartnumberBookItem struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Partnumber string `gorm:"not null" json:"partnumber"`
|
||||
LotsJSON LocalPartnumberBookLots `gorm:"column:lots_json;type:text" json:"lots_json"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func (LocalPartnumberBookItem) TableName() string {
|
||||
return "local_partnumber_book_items"
|
||||
}
|
||||
|
||||
// VendorSpecItem represents a single row in a vendor BOM specification
|
||||
type VendorSpecItem struct {
|
||||
SortOrder int `json:"sort_order"`
|
||||
VendorPartnumber string `json:"vendor_partnumber"`
|
||||
Quantity int `json:"quantity"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
|
||||
ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved"
|
||||
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
|
||||
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
|
||||
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
|
||||
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||
}
|
||||
|
||||
type VendorSpecLotAllocation struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"` // quantity of LOT per 1 vendor PN
|
||||
}
|
||||
|
||||
// VendorSpecLotMapping is the canonical persisted LOT mapping for a vendor PN row.
|
||||
// It stores all mapped LOTs (base + bundle) uniformly.
|
||||
type VendorSpecLotMapping struct {
|
||||
LotName string `json:"lot_name"`
|
||||
QuantityPerPN int `json:"quantity_per_pn"`
|
||||
}
|
||||
|
||||
// VendorSpec is a JSON-encodable slice of VendorSpecItem
|
||||
type VendorSpec []VendorSpecItem
|
||||
|
||||
func (v VendorSpec) Value() (driver.Value, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (v *VendorSpec) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*v = nil
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch val := value.(type) {
|
||||
case []byte:
|
||||
bytes = val
|
||||
case string:
|
||||
bytes = []byte(val)
|
||||
default:
|
||||
return errors.New("type assertion failed for VendorSpec")
|
||||
}
|
||||
return json.Unmarshal(bytes, v)
|
||||
}
|
||||
128
internal/localdb/pricelist_latest_test.go
Normal file
128
internal/localdb/pricelist_latest_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetLatestLocalPricelistBySource_SkipsPricelistWithoutItems(t *testing.T) {
|
||||
local, err := New(filepath.Join(t.TempDir(), "latest_without_items.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
base := time.Now().Add(-time.Minute)
|
||||
withItems := &LocalPricelist{
|
||||
ServerID: 1001,
|
||||
Source: "estimate",
|
||||
Version: "E-1",
|
||||
Name: "with-items",
|
||||
CreatedAt: base,
|
||||
SyncedAt: base,
|
||||
}
|
||||
if err := local.SaveLocalPricelist(withItems); err != nil {
|
||||
t.Fatalf("save pricelist with items: %v", err)
|
||||
}
|
||||
storedWithItems, err := local.GetLocalPricelistByServerID(withItems.ServerID)
|
||||
if err != nil {
|
||||
t.Fatalf("load pricelist with items: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||
{
|
||||
PricelistID: storedWithItems.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 100,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save pricelist items: %v", err)
|
||||
}
|
||||
|
||||
withoutItems := &LocalPricelist{
|
||||
ServerID: 1002,
|
||||
Source: "estimate",
|
||||
Version: "E-2",
|
||||
Name: "without-items",
|
||||
CreatedAt: base.Add(2 * time.Second),
|
||||
SyncedAt: base.Add(2 * time.Second),
|
||||
}
|
||||
if err := local.SaveLocalPricelist(withoutItems); err != nil {
|
||||
t.Fatalf("save pricelist without items: %v", err)
|
||||
}
|
||||
|
||||
got, err := local.GetLatestLocalPricelistBySource("estimate")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
|
||||
}
|
||||
if got.ServerID != withItems.ServerID {
|
||||
t.Fatalf("expected server_id=%d, got %d", withItems.ServerID, got.ServerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestLocalPricelistBySource_TieBreaksByID(t *testing.T) {
|
||||
local, err := New(filepath.Join(t.TempDir(), "latest_tie_break.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
base := time.Now().Add(-time.Minute)
|
||||
first := &LocalPricelist{
|
||||
ServerID: 2001,
|
||||
Source: "warehouse",
|
||||
Version: "S-1",
|
||||
Name: "first",
|
||||
CreatedAt: base,
|
||||
SyncedAt: base,
|
||||
}
|
||||
if err := local.SaveLocalPricelist(first); err != nil {
|
||||
t.Fatalf("save first pricelist: %v", err)
|
||||
}
|
||||
storedFirst, err := local.GetLocalPricelistByServerID(first.ServerID)
|
||||
if err != nil {
|
||||
t.Fatalf("load first pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||
{
|
||||
PricelistID: storedFirst.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 101,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save first items: %v", err)
|
||||
}
|
||||
|
||||
second := &LocalPricelist{
|
||||
ServerID: 2002,
|
||||
Source: "warehouse",
|
||||
Version: "S-2",
|
||||
Name: "second",
|
||||
CreatedAt: base,
|
||||
SyncedAt: base,
|
||||
}
|
||||
if err := local.SaveLocalPricelist(second); err != nil {
|
||||
t.Fatalf("save second pricelist: %v", err)
|
||||
}
|
||||
storedSecond, err := local.GetLocalPricelistByServerID(second.ServerID)
|
||||
if err != nil {
|
||||
t.Fatalf("load second pricelist: %v", err)
|
||||
}
|
||||
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||
{
|
||||
PricelistID: storedSecond.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 102,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save second items: %v", err)
|
||||
}
|
||||
|
||||
got, err := local.GetLatestLocalPricelistBySource("warehouse")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
|
||||
}
|
||||
if got.ServerID != second.ServerID {
|
||||
t.Fatalf("expected server_id=%d, got %d", second.ServerID, got.ServerID)
|
||||
}
|
||||
}
|
||||
160
internal/localdb/snapshots.go
Normal file
160
internal/localdb/snapshots.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BuildConfigurationSnapshot serializes the full local configuration state.
|
||||
func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||
snapshot := map[string]interface{}{
|
||||
"id": localCfg.ID,
|
||||
"uuid": localCfg.UUID,
|
||||
"server_id": localCfg.ServerID,
|
||||
"project_uuid": localCfg.ProjectUUID,
|
||||
"current_version_id": localCfg.CurrentVersionID,
|
||||
"is_active": localCfg.IsActive,
|
||||
"name": localCfg.Name,
|
||||
"items": localCfg.Items,
|
||||
"total_price": localCfg.TotalPrice,
|
||||
"custom_price": localCfg.CustomPrice,
|
||||
"notes": localCfg.Notes,
|
||||
"is_template": localCfg.IsTemplate,
|
||||
"server_count": localCfg.ServerCount,
|
||||
"server_model": localCfg.ServerModel,
|
||||
"support_code": localCfg.SupportCode,
|
||||
"article": localCfg.Article,
|
||||
"pricelist_id": localCfg.PricelistID,
|
||||
"warehouse_pricelist_id": localCfg.WarehousePricelistID,
|
||||
"competitor_pricelist_id": localCfg.CompetitorPricelistID,
|
||||
"disable_price_refresh": localCfg.DisablePriceRefresh,
|
||||
"only_in_stock": localCfg.OnlyInStock,
|
||||
"vendor_spec": localCfg.VendorSpec,
|
||||
"line": localCfg.Line,
|
||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||
"created_at": localCfg.CreatedAt,
|
||||
"updated_at": localCfg.UpdatedAt,
|
||||
"synced_at": localCfg.SyncedAt,
|
||||
"sync_status": localCfg.SyncStatus,
|
||||
"original_user_id": localCfg.OriginalUserID,
|
||||
"original_username": localCfg.OriginalUsername,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal configuration snapshot: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// DecodeConfigurationSnapshot returns editable fields from one saved snapshot.
|
||||
func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
var snapshot struct {
|
||||
ProjectUUID *string `json:"project_uuid"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Name string `json:"name"`
|
||||
Items LocalConfigItems `json:"items"`
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
ServerModel string `json:"server_model"`
|
||||
SupportCode string `json:"support_code"`
|
||||
Article string `json:"article"`
|
||||
PricelistID *uint `json:"pricelist_id"`
|
||||
WarehousePricelistID *uint `json:"warehouse_pricelist_id"`
|
||||
CompetitorPricelistID *uint `json:"competitor_pricelist_id"`
|
||||
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
VendorSpec VendorSpec `json:"vendor_spec"`
|
||||
Line int `json:"line"`
|
||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||
OriginalUserID uint `json:"original_user_id"`
|
||||
OriginalUsername string `json:"original_username"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal snapshot JSON: %w", err)
|
||||
}
|
||||
|
||||
isActive := true
|
||||
if snapshot.IsActive != nil {
|
||||
isActive = *snapshot.IsActive
|
||||
}
|
||||
|
||||
return &LocalConfiguration{
|
||||
IsActive: isActive,
|
||||
ProjectUUID: snapshot.ProjectUUID,
|
||||
Name: snapshot.Name,
|
||||
Items: snapshot.Items,
|
||||
TotalPrice: snapshot.TotalPrice,
|
||||
CustomPrice: snapshot.CustomPrice,
|
||||
Notes: snapshot.Notes,
|
||||
IsTemplate: snapshot.IsTemplate,
|
||||
ServerCount: snapshot.ServerCount,
|
||||
ServerModel: snapshot.ServerModel,
|
||||
SupportCode: snapshot.SupportCode,
|
||||
Article: snapshot.Article,
|
||||
PricelistID: snapshot.PricelistID,
|
||||
WarehousePricelistID: snapshot.WarehousePricelistID,
|
||||
CompetitorPricelistID: snapshot.CompetitorPricelistID,
|
||||
DisablePriceRefresh: snapshot.DisablePriceRefresh,
|
||||
OnlyInStock: snapshot.OnlyInStock,
|
||||
VendorSpec: snapshot.VendorSpec,
|
||||
Line: snapshot.Line,
|
||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||
OriginalUserID: snapshot.OriginalUserID,
|
||||
OriginalUsername: snapshot.OriginalUsername,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type configurationSpecPriceFingerprint struct {
|
||||
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
||||
ServerCount int `json:"server_count"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
CustomPrice *float64 `json:"custom_price,omitempty"`
|
||||
}
|
||||
|
||||
type configurationSpecPriceFingerprintItem struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
}
|
||||
|
||||
// BuildConfigurationSpecPriceFingerprint returns a stable JSON key based on
|
||||
// spec + price fields only, used for revision deduplication.
|
||||
func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (string, error) {
|
||||
items := make([]configurationSpecPriceFingerprintItem, 0, len(localCfg.Items))
|
||||
for _, item := range localCfg.Items {
|
||||
items = append(items, configurationSpecPriceFingerprintItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].LotName != items[j].LotName {
|
||||
return items[i].LotName < items[j].LotName
|
||||
}
|
||||
if items[i].Quantity != items[j].Quantity {
|
||||
return items[i].Quantity < items[j].Quantity
|
||||
}
|
||||
return items[i].UnitPrice < items[j].UnitPrice
|
||||
})
|
||||
|
||||
payload := configurationSpecPriceFingerprint{
|
||||
Items: items,
|
||||
ServerCount: localCfg.ServerCount,
|
||||
TotalPrice: localCfg.TotalPrice,
|
||||
CustomPrice: localCfg.CustomPrice,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal spec+price fingerprint: %w", err)
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"github.com/mchus/quoteforge/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthUserKey = "auth_user"
|
||||
AuthClaimsKey = "auth_claims"
|
||||
)
|
||||
|
||||
func Auth(authService *services.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "authorization header required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid authorization header format",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authService.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(AuthClaimsKey, claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireRole(roles ...models.UserRole) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, exists := c.Get(AuthClaimsKey)
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "authentication required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
authClaims := claims.(*services.Claims)
|
||||
|
||||
for _, role := range roles {
|
||||
if authClaims.Role == role {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "insufficient permissions",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequireEditor() gin.HandlerFunc {
|
||||
return RequireRole(models.RoleEditor, models.RolePricingAdmin, models.RoleAdmin)
|
||||
}
|
||||
|
||||
func RequirePricingAdmin() gin.HandlerFunc {
|
||||
return RequireRole(models.RolePricingAdmin, models.RoleAdmin)
|
||||
}
|
||||
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return RequireRole(models.RoleAdmin)
|
||||
}
|
||||
|
||||
// GetClaims extracts auth claims from context
|
||||
func GetClaims(c *gin.Context) *services.Claims {
|
||||
claims, exists := c.Get(AuthClaimsKey)
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
return claims.(*services.Claims)
|
||||
}
|
||||
|
||||
// GetUserID extracts user ID from context
|
||||
func GetUserID(c *gin.Context) uint {
|
||||
claims := GetClaims(c)
|
||||
if claims == nil {
|
||||
return 0
|
||||
}
|
||||
return claims.UserID
|
||||
}
|
||||
@@ -1,22 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||
if origin != "" {
|
||||
if isLoopbackOrigin(origin) {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
} else if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func isLoopbackOrigin(origin string) bool {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
host := strings.TrimSpace(u.Hostname())
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
29
internal/middleware/offline.go
Normal file
29
internal/middleware/offline.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
)
|
||||
|
||||
// OfflineDetector creates middleware that detects offline mode
|
||||
// Sets context values:
|
||||
// - "is_offline" (bool) - true if MariaDB is unavailable
|
||||
// - "localdb" (*localdb.LocalDB) - local database instance for fallback
|
||||
func OfflineDetector(connMgr *db.ConnectionManager, local *localdb.LocalDB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
isOffline := !connMgr.IsOnline()
|
||||
|
||||
// Set context values for handlers
|
||||
c.Set("is_offline", isOffline)
|
||||
c.Set("localdb", local)
|
||||
|
||||
if isOffline {
|
||||
slog.Debug("offline mode detected - MariaDB unavailable")
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,32 @@ func (Category) TableName() string {
|
||||
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{
|
||||
{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: "MEM", Name: "Memory", NameRu: "Оперативная память", DisplayOrder: 3, IsRequired: true},
|
||||
{Code: "GPU", Name: "Graphics Card", NameRu: "Видеокарта", DisplayOrder: 4},
|
||||
{Code: "SSD", Name: "SSD Storage", NameRu: "SSD накопитель", DisplayOrder: 5},
|
||||
{Code: "HDD", Name: "HDD Storage", NameRu: "HDD накопитель", DisplayOrder: 6},
|
||||
{Code: "RAID", Name: "RAID Controller", NameRu: "RAID контроллер", DisplayOrder: 7},
|
||||
{Code: "RAID", Name: "RAID Controller", NameRu: "RAID контроллер", DisplayOrder: 6},
|
||||
{Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 7},
|
||||
{Code: "NIC", Name: "Network Card", NameRu: "Сетевая карта", DisplayOrder: 8},
|
||||
{Code: "HCA", Name: "HCA Adapter", NameRu: "HCA адаптер", DisplayOrder: 9},
|
||||
{Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 10},
|
||||
{Code: "DPU", Name: "DPU", NameRu: "DPU", DisplayOrder: 11},
|
||||
{Code: "PS", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 12},
|
||||
{Code: "PSU", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 9},
|
||||
{Code: "RISERS", Name: "Risers", NameRu: "Райзеры", DisplayOrder: 10},
|
||||
{Code: "ACC", Name: "Accessories", NameRu: "Аксессуары", DisplayOrder: 11},
|
||||
// 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
|
||||
|
||||
@@ -39,18 +39,84 @@ func (c ConfigItems) Total() float64 {
|
||||
return total
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
UserID uint `gorm:"not null" json:"user_id"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
type VendorSpecLotAllocation struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
type VendorSpecLotMapping struct {
|
||||
LotName string `json:"lot_name"`
|
||||
QuantityPerPN int `json:"quantity_per_pn"`
|
||||
}
|
||||
|
||||
type VendorSpecItem struct {
|
||||
SortOrder int `json:"sort_order"`
|
||||
VendorPartnumber string `json:"vendor_partnumber"`
|
||||
Quantity int `json:"quantity"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
|
||||
ResolutionSource string `json:"resolution_source,omitempty"`
|
||||
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
|
||||
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
|
||||
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
|
||||
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||
}
|
||||
|
||||
type VendorSpec []VendorSpecItem
|
||||
|
||||
func (v VendorSpec) Value() (driver.Value, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (v *VendorSpec) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*v = nil
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch val := value.(type) {
|
||||
case []byte:
|
||||
bytes = val
|
||||
case string:
|
||||
bytes = []byte(val)
|
||||
default:
|
||||
return errors.New("type assertion failed for VendorSpec")
|
||||
}
|
||||
return json.Unmarshal(bytes, v)
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership
|
||||
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
|
||||
ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"`
|
||||
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
|
||||
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
|
||||
Article string `gorm:"size:80" json:"article,omitempty"`
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||
VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,omitempty"`
|
||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
Line int `gorm:"column:line_no;index" json:"line"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`
|
||||
}
|
||||
|
||||
func (Configuration) TableName() string {
|
||||
@@ -65,8 +131,6 @@ type PriceOverride struct {
|
||||
ValidUntil *time.Time `gorm:"type:date" json:"valid_until"`
|
||||
Reason string `gorm:"type:text" json:"reason"`
|
||||
CreatedBy uint `gorm:"not null" json:"created_by"`
|
||||
|
||||
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"`
|
||||
}
|
||||
|
||||
func (PriceOverride) TableName() string {
|
||||
|
||||
@@ -2,10 +2,11 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Lot represents existing lot table (READ-ONLY)
|
||||
// Lot represents existing lot table
|
||||
type Lot struct {
|
||||
LotName string `gorm:"column:lot_name;primaryKey;size:255"`
|
||||
LotDescription string `gorm:"column:lot_description;size:10000"`
|
||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||
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 {
|
||||
@@ -36,3 +37,33 @@ type Supplier struct {
|
||||
func (Supplier) TableName() string {
|
||||
return "supplier"
|
||||
}
|
||||
|
||||
// StockLog stores warehouse stock snapshots imported from external files.
|
||||
type StockLog struct {
|
||||
StockLogID uint `gorm:"column:stock_log_id;primaryKey;autoIncrement"`
|
||||
Partnumber string `gorm:"column:partnumber;size:255;not null"`
|
||||
Supplier *string `gorm:"column:supplier;size:255"`
|
||||
Date time.Time `gorm:"column:date;type:date;not null"`
|
||||
Price float64 `gorm:"column:price;not null"`
|
||||
Quality *string `gorm:"column:quality;size:255"`
|
||||
Comments *string `gorm:"column:comments;size:15000"`
|
||||
Vendor *string `gorm:"column:vendor;size:255"`
|
||||
Qty *float64 `gorm:"column:qty"`
|
||||
}
|
||||
|
||||
func (StockLog) TableName() string {
|
||||
return "stock_log"
|
||||
}
|
||||
|
||||
// StockIgnoreRule contains import ignore pattern rules.
|
||||
type StockIgnoreRule struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Target string `gorm:"column:target;size:20;not null" json:"target"` // partnumber|description
|
||||
MatchType string `gorm:"column:match_type;size:20;not null" json:"match_type"` // exact|prefix|suffix
|
||||
Pattern string `gorm:"column:pattern;size:500;not null" json:"pattern"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (StockIgnoreRule) TableName() string {
|
||||
return "stock_ignore_rules"
|
||||
}
|
||||
|
||||
@@ -35,18 +35,23 @@ func (s *Specs) Scan(value interface{}) error {
|
||||
}
|
||||
|
||||
type LotMetadata struct {
|
||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||
CategoryID *uint `gorm:"column:category_id" json:"category_id"`
|
||||
Vendor string `gorm:"size:50" json:"vendor"`
|
||||
Model string `gorm:"size:100" json:"model"`
|
||||
Specs Specs `gorm:"type:json" json:"specs"`
|
||||
CurrentPrice *float64 `gorm:"type:decimal(12,2)" json:"current_price"`
|
||||
PriceMethod PriceMethod `gorm:"type:enum('manual','median','average','weighted_median');default:'median'" json:"price_method"`
|
||||
PricePeriodDays int `gorm:"default:90" json:"price_period_days"`
|
||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||
RequestCount int `gorm:"default:0" json:"request_count"`
|
||||
LastRequestDate *time.Time `gorm:"type:date" json:"last_request_date"`
|
||||
PopularityScore float64 `gorm:"type:decimal(10,4);default:0" json:"popularity_score"`
|
||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||
CategoryID *uint `gorm:"column:category_id" json:"category_id"`
|
||||
Model string `gorm:"size:100" json:"model"`
|
||||
Specs Specs `gorm:"type:json" json:"specs"`
|
||||
CurrentPrice *float64 `gorm:"type:decimal(12,2)" json:"current_price"`
|
||||
PriceMethod PriceMethod `gorm:"type:enum('manual','median','average','weighted_median');default:'median'" json:"price_method"`
|
||||
PricePeriodDays int `gorm:"default:90" json:"price_period_days"`
|
||||
PriceCoefficient float64 `gorm:"type:decimal(5,2);default:0" json:"price_coefficient"`
|
||||
ManualPrice *float64 `gorm:"type:decimal(12,2)" json:"manual_price"`
|
||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||
RequestCount int `gorm:"default:0" json:"request_count"`
|
||||
LastRequestDate *time.Time `gorm:"type:date" json:"last_request_date"`
|
||||
PopularityScore float64 `gorm:"type:decimal(10,4);default:0" json:"popularity_score"`
|
||||
MetaPrices string `gorm:"size:1000" json:"meta_prices"`
|
||||
MetaMethod string `gorm:"size:20" json:"meta_method"`
|
||||
MetaPeriodDays int `gorm:"default:90" json:"meta_period_days"`
|
||||
IsHidden bool `gorm:"default:false" json:"is_hidden"`
|
||||
|
||||
// Relations
|
||||
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AllModels returns all models for auto-migration
|
||||
func AllModels() []interface{} {
|
||||
return []interface{}{
|
||||
&User{},
|
||||
&Category{},
|
||||
&LotMetadata{},
|
||||
&Project{},
|
||||
&Configuration{},
|
||||
&PriceOverride{},
|
||||
&PricingAlert{},
|
||||
&ComponentUsageStats{},
|
||||
&Pricelist{},
|
||||
&PricelistItem{},
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate runs auto-migration for all QuoteForge tables
|
||||
// Handles MySQL constraint errors gracefully for existing tables
|
||||
func Migrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(AllModels()...)
|
||||
for _, model := range AllModels() {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
// Skip known MySQL constraint errors for existing tables
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Can't DROP") ||
|
||||
strings.Contains(errStr, "Duplicate key name") ||
|
||||
strings.Contains(errStr, "check that it exists") {
|
||||
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedCategories inserts default categories if not exist
|
||||
|
||||
91
internal/models/pricelist.go
Normal file
91
internal/models/pricelist.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type PricelistSource string
|
||||
|
||||
const (
|
||||
PricelistSourceEstimate PricelistSource = "estimate"
|
||||
PricelistSourceWarehouse PricelistSource = "warehouse"
|
||||
PricelistSourceCompetitor PricelistSource = "competitor"
|
||||
)
|
||||
|
||||
func (s PricelistSource) IsValid() bool {
|
||||
switch s {
|
||||
case PricelistSourceEstimate, PricelistSourceWarehouse, PricelistSourceCompetitor:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizePricelistSource(source string) PricelistSource {
|
||||
switch PricelistSource(source) {
|
||||
case PricelistSourceWarehouse:
|
||||
return PricelistSourceWarehouse
|
||||
case PricelistSourceCompetitor:
|
||||
return PricelistSourceCompetitor
|
||||
default:
|
||||
return PricelistSourceEstimate
|
||||
}
|
||||
}
|
||||
|
||||
// Pricelist represents a versioned snapshot of prices
|
||||
type Pricelist struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Source string `gorm:"size:20;not null;default:'estimate';uniqueIndex:idx_qt_pricelists_source_version,priority:1;index:idx_qt_pricelists_source_created_at,priority:1" json:"source"`
|
||||
Version string `gorm:"size:20;not null;uniqueIndex:idx_qt_pricelists_source_version,priority:2" json:"version"` // Format: YYYY-MM-DD-NNN
|
||||
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
|
||||
CreatedAt time.Time `gorm:"index:idx_qt_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
|
||||
CreatedBy string `gorm:"size:100" json:"created_by"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
UsageCount int `gorm:"default:0" json:"usage_count"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
ItemCount int `gorm:"-" json:"item_count,omitempty"` // Virtual field for display
|
||||
}
|
||||
|
||||
func (Pricelist) TableName() string {
|
||||
return "qt_pricelists"
|
||||
}
|
||||
|
||||
// PricelistItem represents a single item in a pricelist
|
||||
type PricelistItem struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
|
||||
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
|
||||
LotCategory string `gorm:"column:lot_category;size:50" json:"lot_category,omitempty"`
|
||||
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
||||
PriceMethod string `gorm:"size:20" json:"price_method"`
|
||||
|
||||
// Price calculation settings (snapshot from qt_lot_metadata)
|
||||
PricePeriodDays int `gorm:"default:90" json:"price_period_days"`
|
||||
PriceCoefficient float64 `gorm:"type:decimal(5,2);default:0" json:"price_coefficient"`
|
||||
ManualPrice *float64 `gorm:"type:decimal(12,2)" json:"manual_price,omitempty"`
|
||||
MetaPrices string `gorm:"size:1000" json:"meta_prices,omitempty"`
|
||||
|
||||
// Virtual fields for display
|
||||
LotDescription string `gorm:"-" json:"lot_description,omitempty"`
|
||||
Category string `gorm:"-" json:"category,omitempty"`
|
||||
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
|
||||
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
|
||||
}
|
||||
|
||||
func (PricelistItem) TableName() string {
|
||||
return "qt_pricelist_items"
|
||||
}
|
||||
|
||||
// PricelistSummary is used for list views
|
||||
type PricelistSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Version string `json:"version"`
|
||||
Notification string `json:"notification"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
IsActive bool `json:"is_active"`
|
||||
UsageCount int `json:"usage_count"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
21
internal/models/project.go
Normal file
21
internal/models/project.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Project struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
||||
Code string `gorm:"size:100;not null;index:idx_qt_projects_code_variant,priority:1" json:"code"`
|
||||
Variant string `gorm:"size:100;not null;default:'';index:idx_qt_projects_code_variant,priority:2" json:"variant"`
|
||||
Name *string `gorm:"size:200" json:"name,omitempty"`
|
||||
TrackerURL string `gorm:"size:500" json:"tracker_url"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Project) TableName() string {
|
||||
return "qt_projects"
|
||||
}
|
||||
227
internal/models/sql_migrations.go
Normal file
227
internal/models/sql_migrations.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SQLSchemaMigration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Filename string `gorm:"size:255;uniqueIndex;not null"`
|
||||
AppliedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func (SQLSchemaMigration) TableName() string {
|
||||
return "qt_schema_migrations"
|
||||
}
|
||||
|
||||
// NeedsSQLMigrations reports whether at least one SQL migration from migrationsDir
|
||||
// is not yet recorded in qt_schema_migrations.
|
||||
func NeedsSQLMigrations(db *gorm.DB, migrationsDir string) (bool, error) {
|
||||
files, err := listSQLMigrationFiles(migrationsDir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// If tracking table does not exist yet, migrations are required.
|
||||
if !db.Migrator().HasTable(&SQLSchemaMigration{}) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&SQLSchemaMigration{}).Where("filename IN ?", files).Count(&count).Error; err != nil {
|
||||
return false, fmt.Errorf("check applied migrations: %w", err)
|
||||
}
|
||||
|
||||
return count < int64(len(files)), nil
|
||||
}
|
||||
|
||||
// RunSQLMigrations applies SQL files from migrationsDir once and records them in qt_schema_migrations.
|
||||
// Local SQLite-only scripts are skipped automatically.
|
||||
func RunSQLMigrations(db *gorm.DB, migrationsDir string) error {
|
||||
if err := ensureSQLMigrationsTable(db); err != nil {
|
||||
return fmt.Errorf("migrate qt_schema_migrations table: %w", err)
|
||||
}
|
||||
|
||||
files, err := listSQLMigrationFiles(migrationsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, filename := range files {
|
||||
var count int64
|
||||
if err := db.Model(&SQLSchemaMigration{}).Where("filename = ?", filename).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("check migration %s: %w", filename, err)
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(migrationsDir, filename)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
statements := splitSQLStatements(string(content))
|
||||
if len(statements) == 0 {
|
||||
if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil {
|
||||
return fmt.Errorf("record empty migration %s: %w", filename, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := executeMigrationStatements(db, filename, statements); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsMigrationPermissionError returns true if err indicates insufficient privileges
|
||||
// to create/alter/read migration metadata or target schema objects.
|
||||
func IsMigrationPermissionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var mysqlErr *mysqlDriver.MySQLError
|
||||
if errors.As(err, &mysqlErr) {
|
||||
switch mysqlErr.Number {
|
||||
case 1044, 1045, 1142, 1143, 1227:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
lower := strings.ToLower(err.Error())
|
||||
patterns := []string{
|
||||
"command denied to user",
|
||||
"access denied for user",
|
||||
"permission denied",
|
||||
"insufficient privilege",
|
||||
"sqlstate 42000",
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if strings.Contains(lower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ensureSQLMigrationsTable(db *gorm.DB) error {
|
||||
stmt := `
|
||||
CREATE TABLE IF NOT EXISTS qt_schema_migrations (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
return db.Exec(stmt).Error
|
||||
}
|
||||
|
||||
func executeMigrationStatements(db *gorm.DB, filename string, statements []string) error {
|
||||
for _, stmt := range statements {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
if isIgnorableMigrationError(err.Error()) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("exec migration %s statement %q: %w", filename, stmt, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSQLiteOnlyMigration(filename string) bool {
|
||||
lower := strings.ToLower(filename)
|
||||
return strings.Contains(lower, "local_")
|
||||
}
|
||||
|
||||
func isIgnorableMigrationError(message string) bool {
|
||||
lower := strings.ToLower(message)
|
||||
ignorable := []string{
|
||||
"duplicate column name",
|
||||
"duplicate key name",
|
||||
"already exists",
|
||||
"can't create table",
|
||||
"duplicate foreign key constraint name",
|
||||
"errno 121",
|
||||
}
|
||||
for _, pattern := range ignorable {
|
||||
if strings.Contains(lower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitSQLStatements(script string) []string {
|
||||
scanner := bufio.NewScanner(strings.NewReader(script))
|
||||
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||||
|
||||
lines := make([]string, 0, 128)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "--") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
|
||||
combined := strings.Join(lines, "\n")
|
||||
raw := strings.Split(combined, ";")
|
||||
stmts := make([]string, 0, len(raw))
|
||||
for _, stmt := range raw {
|
||||
trimmed := strings.TrimSpace(stmt)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
stmts = append(stmts, trimmed)
|
||||
}
|
||||
return stmts
|
||||
}
|
||||
|
||||
func listSQLMigrationFiles(migrationsDir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(migrationsDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read migrations dir %s: %w", migrationsDir, err)
|
||||
}
|
||||
|
||||
files := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".sql") {
|
||||
continue
|
||||
}
|
||||
if isSQLiteOnlyMigration(name) {
|
||||
continue
|
||||
}
|
||||
files = append(files, name)
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleViewer UserRole = "viewer"
|
||||
RoleEditor UserRole = "editor"
|
||||
RolePricingAdmin UserRole = "pricing_admin"
|
||||
RoleAdmin UserRole = "admin"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"size:100;uniqueIndex;not null" json:"username"`
|
||||
Email string `gorm:"size:255;uniqueIndex;not null" json:"email"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Role UserRole `gorm:"type:enum('viewer','editor','pricing_admin','admin');default:'viewer'" json:"role"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "qt_users"
|
||||
}
|
||||
|
||||
func (u *User) CanEdit() bool {
|
||||
return u.Role == RoleEditor || u.Role == RolePricingAdmin || u.Role == RoleAdmin
|
||||
}
|
||||
|
||||
func (u *User) CanManagePricing() bool {
|
||||
return u.Role == RolePricingAdmin || u.Role == RoleAdmin
|
||||
}
|
||||
|
||||
func (u *User) CanManageUsers() bool {
|
||||
return u.Role == RoleAdmin
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -36,3 +36,41 @@ func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) {
|
||||
}
|
||||
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 (
|
||||
"time"
|
||||
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -16,10 +16,12 @@ func NewComponentRepository(db *gorm.DB) *ComponentRepository {
|
||||
}
|
||||
|
||||
type ComponentFilter struct {
|
||||
Category string
|
||||
Vendor string
|
||||
Search string
|
||||
HasPrice bool
|
||||
Category string
|
||||
Search string
|
||||
HasPrice bool
|
||||
ExcludeHidden bool
|
||||
SortField string
|
||||
SortDir string
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||
@@ -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").
|
||||
Where("qt_categories.code = ?", filter.Category)
|
||||
}
|
||||
if filter.Vendor != "" {
|
||||
query = query.Where("vendor = ?", filter.Vendor)
|
||||
}
|
||||
if filter.Search != "" {
|
||||
search := "%" + filter.Search + "%"
|
||||
query = query.Where("lot_name LIKE ? OR model LIKE ?", search, search)
|
||||
@@ -44,13 +43,39 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
|
||||
if filter.HasPrice {
|
||||
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
||||
}
|
||||
if filter.ExcludeHidden {
|
||||
query = query.Where("is_hidden = ? OR is_hidden IS NULL", false)
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
|
||||
// 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.
|
||||
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
||||
Order("popularity_score DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&components).Error
|
||||
@@ -85,19 +110,12 @@ func (r *ComponentRepository) Update(component *models.LotMetadata) error {
|
||||
return r.db.Save(component).Error
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
||||
return r.db.Create(component).Error
|
||||
func (r *ComponentRepository) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
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) Create(component *models.LotMetadata) error {
|
||||
return r.db.Create(component).Error
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) IncrementRequestCount(lotName string) error {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -14,12 +16,18 @@ func NewConfigurationRepository(db *gorm.DB) *ConfigurationRepository {
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) Create(config *models.Configuration) error {
|
||||
return r.db.Create(config).Error
|
||||
if err := r.db.Create(config).Error; err != nil {
|
||||
if isUnknownLineNoColumnError(err) {
|
||||
return r.db.Omit("line_no").Create(config).Error
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
||||
var config models.Configuration
|
||||
err := r.db.Preload("User").First(&config, id).Error
|
||||
err := r.db.First(&config, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -28,7 +36,7 @@ func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error
|
||||
|
||||
func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
|
||||
var config models.Configuration
|
||||
err := r.db.Preload("User").Where("uuid = ?", uuid).First(&config).Error
|
||||
err := r.db.Where("uuid = ?", uuid).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -36,20 +44,36 @@ func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration,
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) Update(config *models.Configuration) error {
|
||||
return r.db.Save(config).Error
|
||||
if err := r.db.Save(config).Error; err != nil {
|
||||
if isUnknownLineNoColumnError(err) {
|
||||
return r.db.Omit("line_no").Save(config).Error
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUnknownLineNoColumnError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unknown column 'line_no'") || strings.Contains(msg, "no column named line_no")
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Configuration{}, id).Error
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) ListByUser(userID uint, offset, limit int) ([]models.Configuration, int64, error) {
|
||||
func (r *ConfigurationRepository) ListByUser(ownerUsername string, offset, limit int) ([]models.Configuration, int64, error) {
|
||||
var configs []models.Configuration
|
||||
var total int64
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where("user_id = ?", userID).Count(&total)
|
||||
ownerScope := "owner_username = ?"
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername).Count(&total)
|
||||
err := r.db.
|
||||
Where("user_id = ?", userID).
|
||||
Where(ownerScope, ownerUsername).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
@@ -64,7 +88,6 @@ func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Con
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where("is_template = ?", true).Count(&total)
|
||||
err := r.db.
|
||||
Preload("User").
|
||||
Where("is_template = ?", true).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
@@ -73,3 +96,18 @@ func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Con
|
||||
|
||||
return configs, total, err
|
||||
}
|
||||
|
||||
// ListAll returns all configurations without user filter
|
||||
func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configuration, int64, error) {
|
||||
var configs []models.Configuration
|
||||
var total int64
|
||||
|
||||
r.db.Model(&models.Configuration{}).Count(&total)
|
||||
err := r.db.
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&configs).Error
|
||||
|
||||
return configs, total, err
|
||||
}
|
||||
|
||||
174
internal/repository/partnumber_book.go
Normal file
174
internal/repository/partnumber_book.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// PartnumberBookRepository provides read-only access to local partnumber book snapshots.
|
||||
type PartnumberBookRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPartnumberBookRepository(db *gorm.DB) *PartnumberBookRepository {
|
||||
return &PartnumberBookRepository{db: db}
|
||||
}
|
||||
|
||||
// GetActiveBook returns the most recently active local partnumber book.
|
||||
func (r *PartnumberBookRepository) GetActiveBook() (*localdb.LocalPartnumberBook, error) {
|
||||
var book localdb.LocalPartnumberBook
|
||||
err := r.db.Where("is_active = 1").Order("created_at DESC, id DESC").First(&book).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &book, nil
|
||||
}
|
||||
|
||||
// GetBookItems returns all items for the given local book ID.
|
||||
func (r *PartnumberBookRepository) GetBookItems(bookID uint) ([]localdb.LocalPartnumberBookItem, error) {
|
||||
book, err := r.getBook(bookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, _, err := r.listCatalogItems(book.PartnumbersJSON, "", 0, 0)
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetBookItemsPage returns items for the given local book ID with optional search and pagination.
|
||||
func (r *PartnumberBookRepository) GetBookItemsPage(bookID uint, search string, page, perPage int) ([]localdb.LocalPartnumberBookItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
book, err := r.getBook(bookID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return r.listCatalogItems(book.PartnumbersJSON, search, page, perPage)
|
||||
}
|
||||
|
||||
// FindLotByPartnumber looks up a partnumber in the active book and returns the matching items.
|
||||
func (r *PartnumberBookRepository) FindLotByPartnumber(bookID uint, partnumber string) ([]localdb.LocalPartnumberBookItem, error) {
|
||||
book, err := r.getBook(bookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := false
|
||||
for _, pn := range book.PartnumbersJSON {
|
||||
if pn == partnumber {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
var items []localdb.LocalPartnumberBookItem
|
||||
err = r.db.Where("partnumber = ?", partnumber).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// ListBooks returns all local partnumber books ordered newest first.
|
||||
func (r *PartnumberBookRepository) ListBooks() ([]localdb.LocalPartnumberBook, error) {
|
||||
var books []localdb.LocalPartnumberBook
|
||||
err := r.db.Order("created_at DESC, id DESC").Find(&books).Error
|
||||
return books, err
|
||||
}
|
||||
|
||||
// SaveBook saves a new partnumber book snapshot.
|
||||
func (r *PartnumberBookRepository) SaveBook(book *localdb.LocalPartnumberBook) error {
|
||||
return r.db.Save(book).Error
|
||||
}
|
||||
|
||||
// SaveBookItems upserts canonical PN catalog rows.
|
||||
func (r *PartnumberBookRepository) SaveBookItems(items []localdb.LocalPartnumberBookItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "partnumber"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"lots_json",
|
||||
"description",
|
||||
}),
|
||||
}).CreateInBatches(items, 500).Error
|
||||
}
|
||||
|
||||
// CountBookItems returns the number of items for a given local book ID.
|
||||
func (r *PartnumberBookRepository) CountBookItems(bookID uint) int64 {
|
||||
book, err := r.getBook(bookID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(len(book.PartnumbersJSON))
|
||||
}
|
||||
|
||||
func (r *PartnumberBookRepository) CountDistinctLots(bookID uint) int64 {
|
||||
items, err := r.GetBookItems(bookID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
for _, lot := range item.LotsJSON {
|
||||
if lot.LotName == "" {
|
||||
continue
|
||||
}
|
||||
seen[lot.LotName] = struct{}{}
|
||||
}
|
||||
}
|
||||
return int64(len(seen))
|
||||
}
|
||||
|
||||
func (r *PartnumberBookRepository) HasAllBookItems(bookID uint) bool {
|
||||
book, err := r.getBook(bookID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if len(book.PartnumbersJSON) == 0 {
|
||||
return true
|
||||
}
|
||||
var count int64
|
||||
if err := r.db.Model(&localdb.LocalPartnumberBookItem{}).
|
||||
Where("partnumber IN ?", []string(book.PartnumbersJSON)).
|
||||
Count(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count == int64(len(book.PartnumbersJSON))
|
||||
}
|
||||
|
||||
func (r *PartnumberBookRepository) getBook(bookID uint) (*localdb.LocalPartnumberBook, error) {
|
||||
var book localdb.LocalPartnumberBook
|
||||
if err := r.db.First(&book, bookID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &book, nil
|
||||
}
|
||||
|
||||
func (r *PartnumberBookRepository) listCatalogItems(partnumbers localdb.LocalStringList, search string, page, perPage int) ([]localdb.LocalPartnumberBookItem, int64, error) {
|
||||
if len(partnumbers) == 0 {
|
||||
return []localdb.LocalPartnumberBookItem{}, 0, nil
|
||||
}
|
||||
|
||||
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers))
|
||||
if search != "" {
|
||||
trimmedSearch := "%" + search + "%"
|
||||
query = query.Where("partnumber LIKE ? OR lots_json LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var items []localdb.LocalPartnumberBookItem
|
||||
if page > 0 && perPage > 0 {
|
||||
query = query.Offset((page - 1) * perPage).Limit(perPage)
|
||||
}
|
||||
err := query.Order("partnumber ASC, id ASC").Find(&items).Error
|
||||
return items, total, err
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package repository
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -97,3 +97,28 @@ func (r *PriceRepository) GetQuoteCount(lotName string, periodDays int) (int64,
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
432
internal/repository/pricelist.go
Normal file
432
internal/repository/pricelist.go
Normal file
@@ -0,0 +1,432 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PricelistRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPricelistRepository(db *gorm.DB) *PricelistRepository {
|
||||
return &PricelistRepository{db: db}
|
||||
}
|
||||
|
||||
// List returns pricelists with pagination
|
||||
func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||
return r.ListBySource("", offset, limit)
|
||||
}
|
||||
|
||||
// ListBySource returns pricelists filtered by source when provided.
|
||||
func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||
query := r.db.Model(&models.Pricelist{}).
|
||||
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)")
|
||||
if source != "" {
|
||||
query = query.Where("source = ?", source)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("counting pricelists: %w", err)
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
||||
}
|
||||
|
||||
return r.toSummaries(pricelists), total, nil
|
||||
}
|
||||
|
||||
// ListActive returns active pricelists with pagination.
|
||||
func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||
return r.ListActiveBySource("", offset, limit)
|
||||
}
|
||||
|
||||
// ListActiveBySource returns active pricelists filtered by source when provided.
|
||||
func (r *PricelistRepository) ListActiveBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||
query := r.db.Model(&models.Pricelist{}).
|
||||
Where("is_active = ?", true).
|
||||
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)")
|
||||
if source != "" {
|
||||
query = query.Where("source = ?", source)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
||||
}
|
||||
|
||||
return r.toSummaries(pricelists), total, nil
|
||||
}
|
||||
|
||||
// CountActive returns the number of active pricelists.
|
||||
func (r *PricelistRepository) CountActive() (int64, error) {
|
||||
var total int64
|
||||
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil {
|
||||
return 0, fmt.Errorf("counting active pricelists: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []models.PricelistSummary {
|
||||
// Get item counts for each pricelist
|
||||
summaries := make([]models.PricelistSummary, len(pricelists))
|
||||
for i, pl := range pricelists {
|
||||
var itemCount int64
|
||||
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
|
||||
usageCount, _ := r.CountUsage(pl.ID)
|
||||
|
||||
summaries[i] = models.PricelistSummary{
|
||||
ID: pl.ID,
|
||||
Source: pl.Source,
|
||||
Version: pl.Version,
|
||||
Notification: pl.Notification,
|
||||
CreatedAt: pl.CreatedAt,
|
||||
CreatedBy: pl.CreatedBy,
|
||||
IsActive: pl.IsActive,
|
||||
UsageCount: int(usageCount),
|
||||
ExpiresAt: pl.ExpiresAt,
|
||||
ItemCount: itemCount,
|
||||
}
|
||||
}
|
||||
|
||||
return summaries
|
||||
}
|
||||
|
||||
// GetByID returns a pricelist by ID
|
||||
func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
|
||||
var pricelist models.Pricelist
|
||||
if err := r.db.First(&pricelist, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Get item count
|
||||
var itemCount int64
|
||||
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
|
||||
pricelist.ItemCount = int(itemCount)
|
||||
if usageCount, err := r.CountUsage(id); err == nil {
|
||||
pricelist.UsageCount = int(usageCount)
|
||||
}
|
||||
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetByVersion returns a pricelist by version string
|
||||
func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) {
|
||||
return r.GetBySourceAndVersion(string(models.PricelistSourceEstimate), version)
|
||||
}
|
||||
|
||||
// GetBySourceAndVersion returns a pricelist by source/version.
|
||||
func (r *PricelistRepository) GetBySourceAndVersion(source, version string) (*models.Pricelist, error) {
|
||||
var pricelist models.Pricelist
|
||||
if err := r.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting pricelist by version: %w", err)
|
||||
}
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetLatestActive returns the most recent active pricelist
|
||||
func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
|
||||
return r.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
|
||||
}
|
||||
|
||||
// GetLatestActiveBySource returns the most recent active pricelist by source.
|
||||
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
||||
var pricelist models.Pricelist
|
||||
if err := r.db.
|
||||
Where("is_active = ? AND source = ?", true, source).
|
||||
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
||||
}
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// Create creates a new pricelist
|
||||
func (r *PricelistRepository) Create(pricelist *models.Pricelist) error {
|
||||
if err := r.db.Create(pricelist).Error; err != nil {
|
||||
return fmt.Errorf("creating pricelist: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates a pricelist
|
||||
func (r *PricelistRepository) Update(pricelist *models.Pricelist) error {
|
||||
if err := r.db.Save(pricelist).Error; err != nil {
|
||||
return fmt.Errorf("updating pricelist: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes a pricelist if usage_count is 0
|
||||
func (r *PricelistRepository) Delete(id uint) error {
|
||||
usageCount, err := r.CountUsage(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if usageCount > 0 {
|
||||
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", usageCount)
|
||||
}
|
||||
|
||||
// Delete items first
|
||||
if err := r.db.Where("pricelist_id = ?", id).Delete(&models.PricelistItem{}).Error; err != nil {
|
||||
return fmt.Errorf("deleting pricelist items: %w", err)
|
||||
}
|
||||
|
||||
// Delete pricelist
|
||||
if err := r.db.Delete(&models.Pricelist{}, id).Error; err != nil {
|
||||
return fmt.Errorf("deleting pricelist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateItems batch inserts pricelist items
|
||||
func (r *PricelistRepository) CreateItems(items []models.PricelistItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use batch insert for better performance
|
||||
batchSize := 500
|
||||
for i := 0; i < len(items); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
if err := r.db.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
||||
return fmt.Errorf("batch inserting pricelist items: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetItems returns pricelist items with pagination
|
||||
func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, search string) ([]models.PricelistItem, int64, error) {
|
||||
var total int64
|
||||
query := r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pricelistID)
|
||||
|
||||
if search != "" {
|
||||
query = query.Where("lot_name LIKE ?", "%"+search+"%")
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("counting pricelist items: %w", err)
|
||||
}
|
||||
|
||||
var items []models.PricelistItem
|
||||
if err := query.Order("lot_name").Offset(offset).Limit(limit).Find(&items).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing pricelist items: %w", err)
|
||||
}
|
||||
|
||||
// Enrich with lot descriptions
|
||||
for i := range items {
|
||||
var lot models.Lot
|
||||
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
|
||||
items[i].LotDescription = lot.LotDescription
|
||||
}
|
||||
items[i].Category = strings.TrimSpace(items[i].LotCategory)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetLotNames returns distinct lot names from pricelist items.
|
||||
func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
|
||||
var lotNames []string
|
||||
if err := r.db.Model(&models.PricelistItem{}).
|
||||
Where("pricelist_id = ?", pricelistID).
|
||||
Distinct("lot_name").
|
||||
Order("lot_name ASC").
|
||||
Pluck("lot_name", &lotNames).Error; err != nil {
|
||||
return nil, fmt.Errorf("listing pricelist lot names: %w", err)
|
||||
}
|
||||
return lotNames, nil
|
||||
}
|
||||
|
||||
// GetPriceForLot returns item price for a lot within a pricelist.
|
||||
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||
var item models.PricelistItem
|
||||
if err := r.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).First(&item).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return item.Price, nil
|
||||
}
|
||||
|
||||
// GetPricesForLots returns price map for given lots within a pricelist.
|
||||
func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
|
||||
result := make(map[string]float64, len(lotNames))
|
||||
if pricelistID == 0 || len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var rows []models.PricelistItem
|
||||
if err := r.db.Select("lot_name, price").
|
||||
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if row.Price > 0 {
|
||||
result[row.LotName] = row.Price
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SetActive toggles active flag on a pricelist.
|
||||
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
|
||||
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).Error
|
||||
}
|
||||
|
||||
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
|
||||
func (r *PricelistRepository) GenerateVersion() (string, error) {
|
||||
return r.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
||||
}
|
||||
|
||||
// GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source.
|
||||
func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
prefix := versionPrefixBySource(source)
|
||||
|
||||
var last models.Pricelist
|
||||
err := r.db.Model(&models.Pricelist{}).
|
||||
Select("version").
|
||||
Where("source = ? AND version LIKE ?", source, prefix+"-"+today+"-%").
|
||||
Order("version DESC").
|
||||
Limit(1).
|
||||
Take(&last).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Sprintf("%s-%s-001", prefix, today), nil
|
||||
}
|
||||
return "", fmt.Errorf("loading latest today's pricelist version: %w", err)
|
||||
}
|
||||
|
||||
parts := strings.Split(last.Version, "-")
|
||||
if len(parts) < 4 {
|
||||
return "", fmt.Errorf("invalid pricelist version format: %s", last.Version)
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(parts[len(parts)-1])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing pricelist sequence %q: %w", parts[len(parts)-1], err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s-%s-%03d", prefix, today, n+1), nil
|
||||
}
|
||||
|
||||
func versionPrefixBySource(source string) string {
|
||||
switch models.NormalizePricelistSource(source) {
|
||||
case models.PricelistSourceWarehouse:
|
||||
return "S"
|
||||
case models.PricelistSourceCompetitor:
|
||||
return "B"
|
||||
default:
|
||||
return "E"
|
||||
}
|
||||
}
|
||||
|
||||
// GetPriceForLotBySource returns item price for a lot from latest active pricelist of source.
|
||||
func (r *PricelistRepository) GetPriceForLotBySource(source, lotName string) (float64, uint, error) {
|
||||
latest, err := r.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
price, err := r.GetPriceForLot(latest.ID, lotName)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return price, latest.ID, nil
|
||||
}
|
||||
|
||||
// CanWrite checks if the current database user has INSERT permission on qt_pricelists
|
||||
func (r *PricelistRepository) CanWrite() bool {
|
||||
canWrite, _ := r.CanWriteDebug()
|
||||
return canWrite
|
||||
}
|
||||
|
||||
// CanWriteDebug checks write permission and returns debug info
|
||||
// Uses raw SQL with explicit columns to avoid schema mismatch issues
|
||||
func (r *PricelistRepository) CanWriteDebug() (bool, string) {
|
||||
// Check if table exists first
|
||||
var count int64
|
||||
if err := r.db.Table("qt_pricelists").Count(&count).Error; err != nil {
|
||||
return false, fmt.Sprintf("table check failed: %v", err)
|
||||
}
|
||||
|
||||
// Use raw SQL with only essential columns that always exist
|
||||
// This avoids GORM model validation and schema mismatch issues
|
||||
tx := r.db.Begin()
|
||||
if tx.Error != nil {
|
||||
return false, fmt.Sprintf("begin tx failed: %v", tx.Error)
|
||||
}
|
||||
defer tx.Rollback() // Always rollback - this is just a permission test
|
||||
|
||||
testVersion := fmt.Sprintf("test-%06d", time.Now().Unix()%1000000)
|
||||
|
||||
// Raw SQL insert with only core columns
|
||||
err := tx.Exec(`
|
||||
INSERT INTO qt_pricelists (version, created_by, is_active)
|
||||
VALUES (?, 'system', 1)
|
||||
`, testVersion).Error
|
||||
|
||||
if err != nil {
|
||||
// Check if it's a permission error vs other errors
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "INSERT command denied") ||
|
||||
strings.Contains(errStr, "Access denied") {
|
||||
return false, "no write permission"
|
||||
}
|
||||
return false, fmt.Sprintf("insert failed: %v", err)
|
||||
}
|
||||
|
||||
return true, "ok"
|
||||
}
|
||||
|
||||
// IncrementUsageCount increments the usage count for a pricelist
|
||||
func (r *PricelistRepository) IncrementUsageCount(id uint) error {
|
||||
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).
|
||||
UpdateColumn("usage_count", gorm.Expr("usage_count + 1")).Error
|
||||
}
|
||||
|
||||
// DecrementUsageCount decrements the usage count for a pricelist
|
||||
func (r *PricelistRepository) DecrementUsageCount(id uint) error {
|
||||
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).
|
||||
UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error
|
||||
}
|
||||
|
||||
// CountUsage returns number of configurations referencing pricelist.
|
||||
func (r *PricelistRepository) CountUsage(id uint) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.Table("qt_configurations").Where("pricelist_id = ?", id).Count(&count).Error; err != nil {
|
||||
return 0, fmt.Errorf("counting configurations for pricelist %d: %w", id, err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetExpiredUnused returns pricelists that are expired and unused
|
||||
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
|
||||
var pricelists []models.Pricelist
|
||||
if err := r.db.Where("expires_at < ? AND usage_count = 0", time.Now()).
|
||||
Find(&pricelists).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting expired pricelists: %w", err)
|
||||
}
|
||||
return pricelists, nil
|
||||
}
|
||||
184
internal/repository/pricelist_test.go
Normal file
184
internal/repository/pricelist_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestGenerateVersion_FirstOfDay(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
|
||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
want := fmt.Sprintf("E-%s-001", today)
|
||||
if version != want {
|
||||
t.Fatalf("expected %s, got %s", want, version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
seed := []models.Pricelist{
|
||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-001", today), CreatedBy: "test", IsActive: true},
|
||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-003", today), CreatedBy: "test", IsActive: true},
|
||||
}
|
||||
for _, pl := range seed {
|
||||
if err := repo.Create(&pl); err != nil {
|
||||
t.Fatalf("seed insert failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
||||
}
|
||||
|
||||
want := fmt.Sprintf("E-%s-004", today)
|
||||
if version != want {
|
||||
t.Fatalf("expected %s, got %s", want, version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateVersion_IsolatedBySource(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
seed := []models.Pricelist{
|
||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("E-%s-009", today), CreatedBy: "test", IsActive: true},
|
||||
{Source: string(models.PricelistSourceWarehouse), Version: fmt.Sprintf("S-%s-002", today), CreatedBy: "test", IsActive: true},
|
||||
}
|
||||
for _, pl := range seed {
|
||||
if err := repo.Create(&pl); err != nil {
|
||||
t.Fatalf("seed insert failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceWarehouse))
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
||||
}
|
||||
|
||||
want := fmt.Sprintf("S-%s-003", today)
|
||||
if version != want {
|
||||
t.Fatalf("expected %s, got %s", want, version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestActiveBySource_SkipsPricelistsWithoutItems(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
db := repo.db
|
||||
ts := time.Now().Add(-time.Minute)
|
||||
source := "test-estimate-skip-empty"
|
||||
|
||||
emptyLatest := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "E-empty",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts.Add(2 * time.Second),
|
||||
}
|
||||
if err := db.Create(&emptyLatest).Error; err != nil {
|
||||
t.Fatalf("create empty pricelist: %v", err)
|
||||
}
|
||||
|
||||
withItems := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "E-with-items",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts,
|
||||
}
|
||||
if err := db.Create(&withItems).Error; err != nil {
|
||||
t.Fatalf("create pricelist with items: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: withItems.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 100,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create pricelist item: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestActiveBySource: %v", err)
|
||||
}
|
||||
if got.ID != withItems.ID {
|
||||
t.Fatalf("expected pricelist with items id=%d, got id=%d", withItems.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestActiveBySource_TieBreaksByID(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
db := repo.db
|
||||
ts := time.Now().Add(-time.Minute)
|
||||
source := "test-warehouse-tie-break"
|
||||
|
||||
first := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "S-1",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts,
|
||||
}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatalf("create first pricelist: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: first.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 101,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create first item: %v", err)
|
||||
}
|
||||
|
||||
second := models.Pricelist{
|
||||
Source: source,
|
||||
Version: "S-2",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
CreatedAt: ts,
|
||||
}
|
||||
if err := db.Create(&second).Error; err != nil {
|
||||
t.Fatalf("create second pricelist: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: second.ID,
|
||||
LotName: "CPU_A",
|
||||
Price: 102,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create second item: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestActiveBySource: %v", err)
|
||||
}
|
||||
if got.ID != second.ID {
|
||||
t.Fatalf("expected later inserted pricelist id=%d, got id=%d", second.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.StockLog{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return NewPricelistRepository(db)
|
||||
}
|
||||
196
internal/repository/project.go
Normal file
196
internal/repository/project.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ProjectRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewProjectRepository(db *gorm.DB) *ProjectRepository {
|
||||
return &ProjectRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Create(project *models.Project) error {
|
||||
return r.db.Create(project).Error
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Update(project *models.Project) error {
|
||||
return r.db.Save(project).Error
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
|
||||
if err := r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"owner_username",
|
||||
"code",
|
||||
"variant",
|
||||
"name",
|
||||
"tracker_url",
|
||||
"is_active",
|
||||
"is_system",
|
||||
"updated_at",
|
||||
}),
|
||||
}).Create(project).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure caller always gets canonical server ID.
|
||||
var persisted models.Project
|
||||
if err := r.db.Where("uuid = ?", project.UUID).First(&persisted).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
project.ID = persisted.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetByUUID(uuid string) (*models.Project, error) {
|
||||
var project models.Project
|
||||
if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetSystemByOwner(ownerUsername string) (*models.Project, error) {
|
||||
var project models.Project
|
||||
if err := r.db.Where("owner_username = ? AND is_system = ? AND name = ?", ownerUsername, true, "Без проекта").
|
||||
First(&project).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) List(offset, limit int, includeArchived bool) ([]models.Project, int64, error) {
|
||||
var projects []models.Project
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.Project{})
|
||||
if !includeArchived {
|
||||
query = query.Where("is_active = ?", true)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&projects).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return projects, total, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) ListByOwner(ownerUsername string, includeArchived bool) ([]models.Project, error) {
|
||||
var projects []models.Project
|
||||
|
||||
query := r.db.Where("owner_username = ?", ownerUsername)
|
||||
if !includeArchived {
|
||||
query = query.Where("is_active = ?", true)
|
||||
}
|
||||
|
||||
if err := query.Order("created_at DESC").Find(&projects).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Archive(uuid string) error {
|
||||
return r.db.Model(&models.Project{}).Where("uuid = ?", uuid).Update("is_active", false).Error
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Reactivate(uuid string) error {
|
||||
return r.db.Model(&models.Project{}).Where("uuid = ?", uuid).Update("is_active", true).Error
|
||||
}
|
||||
|
||||
// PurgeEmptyNamelessProjects removes service-trash projects that have no configurations attached:
|
||||
// 1) projects with empty names;
|
||||
// 2) duplicate "Без проекта" rows without configurations (case-insensitive, trimmed).
|
||||
func (r *ProjectRepository) PurgeEmptyNamelessProjects() (int64, error) {
|
||||
tx := r.db.Exec(`
|
||||
DELETE p
|
||||
FROM qt_projects p
|
||||
WHERE (
|
||||
TRIM(COALESCE(p.name, '')) = ''
|
||||
OR LOWER(TRIM(COALESCE(p.name, ''))) = LOWER('Без проекта')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM qt_configurations c
|
||||
WHERE c.project_uuid = p.uuid
|
||||
)`)
|
||||
return tx.RowsAffected, tx.Error
|
||||
}
|
||||
|
||||
// EnsureSystemProjectsAndBackfillConfigurations ensures there is a single shared system project
|
||||
// named "Без проекта", reassigns orphan/legacy links to it and removes duplicates.
|
||||
func (r *ProjectRepository) EnsureSystemProjectsAndBackfillConfigurations() error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
type row struct {
|
||||
UUID string `gorm:"column:uuid"`
|
||||
}
|
||||
var canonical row
|
||||
err := tx.Raw(`
|
||||
SELECT uuid
|
||||
FROM qt_projects
|
||||
WHERE LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
|
||||
AND is_system = TRUE
|
||||
ORDER BY CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC
|
||||
LIMIT 1`).Scan(&canonical).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if canonical.UUID == "" {
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO qt_projects (uuid, owner_username, name, is_active, is_system, created_at, updated_at)
|
||||
VALUES (UUID(), '', 'Без проекта', TRUE, TRUE, NOW(), NOW())`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT uuid
|
||||
FROM qt_projects
|
||||
WHERE LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1`).Scan(&canonical).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if canonical.UUID == "" {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE qt_projects
|
||||
SET name = 'Без проекта',
|
||||
is_active = TRUE,
|
||||
is_system = TRUE
|
||||
WHERE uuid = ?`, canonical.UUID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE qt_configurations
|
||||
SET project_uuid = ?
|
||||
WHERE project_uuid IS NULL OR project_uuid = ''`, canonical.UUID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE qt_configurations c
|
||||
JOIN qt_projects p ON p.uuid = c.project_uuid
|
||||
SET c.project_uuid = ?
|
||||
WHERE LOWER(TRIM(COALESCE(p.name, ''))) = LOWER('Без проекта')
|
||||
AND p.uuid <> ?`, canonical.UUID, canonical.UUID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Exec(`
|
||||
DELETE FROM qt_projects
|
||||
WHERE LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
|
||||
AND uuid <> ?`, canonical.UUID).Error
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package repository
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -90,3 +90,26 @@ func (r *StatsRepository) ResetMonthlyCounters() error {
|
||||
Where("1 = 1").
|
||||
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
|
||||
}
|
||||
|
||||
393
internal/repository/unified.go
Normal file
393
internal/repository/unified.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DataSource defines the unified interface for data access
|
||||
// It abstracts whether data comes from MariaDB (online) or SQLite (offline)
|
||||
type DataSource interface {
|
||||
// Components
|
||||
GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error)
|
||||
GetComponent(lotName string) (*models.LotMetadata, error)
|
||||
|
||||
// Configurations
|
||||
SaveConfiguration(cfg *models.Configuration) error
|
||||
GetConfigurations(ownerUsername string) ([]models.Configuration, error)
|
||||
GetConfigurationByUUID(uuid string) (*models.Configuration, error)
|
||||
DeleteConfiguration(uuid string) error
|
||||
|
||||
// Pricelists (read-only in offline mode)
|
||||
GetPricelists() ([]models.PricelistSummary, error)
|
||||
GetPricelistByID(id uint) (*models.Pricelist, error)
|
||||
GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error)
|
||||
GetLatestPricelist() (*models.Pricelist, error)
|
||||
}
|
||||
|
||||
// UnifiedRepo implements DataSource with automatic online/offline switching
|
||||
type UnifiedRepo struct {
|
||||
mariaDB *gorm.DB
|
||||
localDB *localdb.LocalDB
|
||||
isOnline bool
|
||||
}
|
||||
|
||||
// NewUnifiedRepo creates a new unified repository
|
||||
func NewUnifiedRepo(mariaDB *gorm.DB, localDB *localdb.LocalDB, isOnline bool) *UnifiedRepo {
|
||||
return &UnifiedRepo{
|
||||
mariaDB: mariaDB,
|
||||
localDB: localDB,
|
||||
isOnline: isOnline,
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnlineStatus updates the online/offline status
|
||||
func (r *UnifiedRepo) SetOnlineStatus(online bool) {
|
||||
r.isOnline = online
|
||||
}
|
||||
|
||||
// IsOnline returns the current online/offline status
|
||||
func (r *UnifiedRepo) IsOnline() bool {
|
||||
return r.isOnline
|
||||
}
|
||||
|
||||
// Component methods
|
||||
|
||||
// GetComponents returns components from MariaDB (online) or local cache (offline)
|
||||
func (r *UnifiedRepo) GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||
if r.isOnline {
|
||||
return r.getComponentsOnline(filter, offset, limit)
|
||||
}
|
||||
return r.getComponentsOffline(filter, offset, limit)
|
||||
}
|
||||
|
||||
func (r *UnifiedRepo) getComponentsOnline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||
repo := NewComponentRepository(r.mariaDB)
|
||||
return repo.List(filter, offset, limit)
|
||||
}
|
||||
|
||||
func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||
var components []localdb.LocalComponent
|
||||
query := r.localDB.DB().Model(&localdb.LocalComponent{})
|
||||
|
||||
// Apply filters
|
||||
if filter.Category != "" {
|
||||
query = query.Where("category = ?", filter.Category)
|
||||
}
|
||||
if filter.Search != "" {
|
||||
search := "%" + filter.Search + "%"
|
||||
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
|
||||
}
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// Apply sorting
|
||||
sortDir := "ASC"
|
||||
if filter.SortDir == "desc" {
|
||||
sortDir = "DESC"
|
||||
}
|
||||
switch filter.SortField {
|
||||
case "lot_name":
|
||||
query = query.Order("lot_name " + sortDir)
|
||||
default:
|
||||
query = query.Order("lot_name ASC")
|
||||
}
|
||||
|
||||
if err := query.Offset(offset).Limit(limit).Find(&components).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("fetching offline components: %w", err)
|
||||
}
|
||||
|
||||
// Convert to models.LotMetadata
|
||||
result := make([]models.LotMetadata, len(components))
|
||||
for i, comp := range components {
|
||||
result[i] = models.LotMetadata{
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
Lot: &models.Lot{
|
||||
LotName: comp.LotName,
|
||||
LotDescription: comp.LotDescription,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
// GetComponent returns a single component by lot name
|
||||
func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error) {
|
||||
if r.isOnline {
|
||||
repo := NewComponentRepository(r.mariaDB)
|
||||
return repo.GetByLotName(lotName)
|
||||
}
|
||||
|
||||
var comp localdb.LocalComponent
|
||||
if err := r.localDB.DB().Where("lot_name = ?", lotName).First(&comp).Error; err != nil {
|
||||
return nil, fmt.Errorf("fetching offline component: %w", err)
|
||||
}
|
||||
|
||||
return &models.LotMetadata{
|
||||
LotName: comp.LotName,
|
||||
Model: comp.Model,
|
||||
Lot: &models.Lot{
|
||||
LotName: comp.LotName,
|
||||
LotDescription: comp.LotDescription,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Configuration methods
|
||||
|
||||
// SaveConfiguration saves a configuration (online: MariaDB, offline: SQLite + pending_changes)
|
||||
func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
|
||||
if r.isOnline {
|
||||
repo := NewConfigurationRepository(r.mariaDB)
|
||||
return repo.Create(cfg)
|
||||
}
|
||||
|
||||
// Offline: save to local SQLite and queue for sync
|
||||
localCfg := &localdb.LocalConfiguration{
|
||||
UUID: cfg.UUID,
|
||||
Name: cfg.Name,
|
||||
TotalPrice: cfg.TotalPrice,
|
||||
CustomPrice: cfg.CustomPrice,
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: cfg.OwnerUsername,
|
||||
}
|
||||
|
||||
// Convert items
|
||||
localItems := make(localdb.LocalConfigItems, len(cfg.Items))
|
||||
for i, item := range cfg.Items {
|
||||
localItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
}
|
||||
localCfg.Items = localItems
|
||||
|
||||
if err := r.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return fmt.Errorf("saving local configuration: %w", err)
|
||||
}
|
||||
|
||||
// Add to pending changes queue
|
||||
payload, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling configuration for sync: %w", err)
|
||||
}
|
||||
|
||||
return r.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload))
|
||||
}
|
||||
|
||||
// GetConfigurations returns all configurations for a user
|
||||
func (r *UnifiedRepo) GetConfigurations(ownerUsername string) ([]models.Configuration, error) {
|
||||
if r.isOnline {
|
||||
repo := NewConfigurationRepository(r.mariaDB)
|
||||
configs, _, err := repo.ListByUser(ownerUsername, 0, 1000)
|
||||
return configs, err
|
||||
}
|
||||
|
||||
// Offline: get from local SQLite
|
||||
localConfigs, err := r.localDB.GetConfigurations()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching local configurations: %w", err)
|
||||
}
|
||||
|
||||
// Convert to models.Configuration
|
||||
result := make([]models.Configuration, len(localConfigs))
|
||||
for i, lc := range localConfigs {
|
||||
items := make(models.ConfigItems, len(lc.Items))
|
||||
for j, item := range lc.Items {
|
||||
items[j] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
}
|
||||
|
||||
result[i] = models.Configuration{
|
||||
UUID: lc.UUID,
|
||||
OwnerUsername: lc.OriginalUsername,
|
||||
Name: lc.Name,
|
||||
Items: items,
|
||||
TotalPrice: lc.TotalPrice,
|
||||
CustomPrice: lc.CustomPrice,
|
||||
Notes: lc.Notes,
|
||||
IsTemplate: lc.IsTemplate,
|
||||
ServerCount: lc.ServerCount,
|
||||
CreatedAt: lc.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetConfigurationByUUID returns a configuration by UUID
|
||||
func (r *UnifiedRepo) GetConfigurationByUUID(uuid string) (*models.Configuration, error) {
|
||||
if r.isOnline {
|
||||
repo := NewConfigurationRepository(r.mariaDB)
|
||||
return repo.GetByUUID(uuid)
|
||||
}
|
||||
|
||||
localCfg, err := r.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching local configuration: %w", err)
|
||||
}
|
||||
|
||||
items := make(models.ConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
items[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
}
|
||||
}
|
||||
|
||||
return &models.Configuration{
|
||||
UUID: localCfg.UUID,
|
||||
Name: localCfg.Name,
|
||||
Items: items,
|
||||
TotalPrice: localCfg.TotalPrice,
|
||||
CustomPrice: localCfg.CustomPrice,
|
||||
Notes: localCfg.Notes,
|
||||
IsTemplate: localCfg.IsTemplate,
|
||||
ServerCount: localCfg.ServerCount,
|
||||
CreatedAt: localCfg.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteConfiguration deletes a configuration
|
||||
func (r *UnifiedRepo) DeleteConfiguration(uuid string) error {
|
||||
if r.isOnline {
|
||||
// Get ID first
|
||||
cfg, err := r.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repo := NewConfigurationRepository(r.mariaDB)
|
||||
return repo.Delete(cfg.ID)
|
||||
}
|
||||
|
||||
// Offline: delete from local and queue sync
|
||||
if err := r.localDB.DeleteConfiguration(uuid); err != nil {
|
||||
return fmt.Errorf("deleting local configuration: %w", err)
|
||||
}
|
||||
|
||||
return r.localDB.AddPendingChange("configuration", uuid, "delete", "")
|
||||
}
|
||||
|
||||
// Pricelist methods
|
||||
|
||||
// GetPricelists returns all pricelists
|
||||
func (r *UnifiedRepo) GetPricelists() ([]models.PricelistSummary, error) {
|
||||
if r.isOnline {
|
||||
repo := NewPricelistRepository(r.mariaDB)
|
||||
summaries, _, err := repo.List(0, 1000)
|
||||
return summaries, err
|
||||
}
|
||||
|
||||
// Offline: get from local cache
|
||||
localPLs, err := r.localDB.GetLocalPricelists()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching local pricelists: %w", err)
|
||||
}
|
||||
|
||||
summaries := make([]models.PricelistSummary, len(localPLs))
|
||||
for i, pl := range localPLs {
|
||||
itemCount := r.localDB.CountLocalPricelistItems(pl.ID)
|
||||
summaries[i] = models.PricelistSummary{
|
||||
ID: pl.ServerID,
|
||||
Version: pl.Version,
|
||||
CreatedAt: pl.CreatedAt,
|
||||
ItemCount: itemCount,
|
||||
}
|
||||
}
|
||||
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
// GetPricelistByID returns a pricelist by ID
|
||||
func (r *UnifiedRepo) GetPricelistByID(id uint) (*models.Pricelist, error) {
|
||||
if r.isOnline {
|
||||
repo := NewPricelistRepository(r.mariaDB)
|
||||
return repo.GetByID(id)
|
||||
}
|
||||
|
||||
// Offline: get from local cache
|
||||
localPL, err := r.localDB.GetLocalPricelistByServerID(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching local pricelist: %w", err)
|
||||
}
|
||||
|
||||
itemCount := r.localDB.CountLocalPricelistItems(localPL.ID)
|
||||
return &models.Pricelist{
|
||||
ID: localPL.ServerID,
|
||||
Version: localPL.Version,
|
||||
CreatedAt: localPL.CreatedAt,
|
||||
ItemCount: int(itemCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPricelistItems returns items for a pricelist
|
||||
func (r *UnifiedRepo) GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error) {
|
||||
if r.isOnline {
|
||||
repo := NewPricelistRepository(r.mariaDB)
|
||||
items, _, err := repo.GetItems(pricelistID, 0, 100000, "")
|
||||
return items, err
|
||||
}
|
||||
|
||||
// Offline: get from local cache
|
||||
// First find the local pricelist by server ID
|
||||
localPL, err := r.localDB.GetLocalPricelistByServerID(pricelistID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching local pricelist: %w", err)
|
||||
}
|
||||
|
||||
localItems, err := r.localDB.GetLocalPricelistItems(localPL.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching local pricelist items: %w", err)
|
||||
}
|
||||
|
||||
items := make([]models.PricelistItem, len(localItems))
|
||||
for i, item := range localItems {
|
||||
items[i] = models.PricelistItem{
|
||||
ID: item.ID,
|
||||
PricelistID: pricelistID,
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetLatestPricelist returns the latest pricelist
|
||||
func (r *UnifiedRepo) GetLatestPricelist() (*models.Pricelist, error) {
|
||||
if r.isOnline {
|
||||
repo := NewPricelistRepository(r.mariaDB)
|
||||
return repo.GetLatestActive()
|
||||
}
|
||||
|
||||
// Offline: get from local cache
|
||||
localPL, err := r.localDB.GetLatestLocalPricelist()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching latest local pricelist: %w", err)
|
||||
}
|
||||
|
||||
itemCount := r.localDB.CountLocalPricelistItems(localPL.ID)
|
||||
return &models.Pricelist{
|
||||
ID: localPL.ServerID,
|
||||
Version: localPL.Version,
|
||||
CreatedAt: localPL.CreatedAt,
|
||||
ItemCount: int(itemCount),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(user *models.User) error {
|
||||
return r.db.Create(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByID(id uint) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.First(&user, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByUsername(username string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.Where("username = ?", username).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.Where("email = ?", email).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(user *models.User) error {
|
||||
return r.db.Save(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.User{}, id).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) List(offset, limit int) ([]models.User, int64, error) {
|
||||
var users []models.User
|
||||
var total int64
|
||||
|
||||
r.db.Model(&models.User{}).Count(&total)
|
||||
err := r.db.Offset(offset).Limit(limit).Find(&users).Error
|
||||
return users, total, err
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mchus/quoteforge/internal/config"
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"github.com/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
alertRepo *repository.AlertRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
priceRepo *repository.PriceRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
config config.AlertsConfig
|
||||
pricingConfig config.PricingConfig
|
||||
}
|
||||
|
||||
func NewService(
|
||||
alertRepo *repository.AlertRepository,
|
||||
componentRepo *repository.ComponentRepository,
|
||||
priceRepo *repository.PriceRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
alertCfg config.AlertsConfig,
|
||||
pricingCfg config.PricingConfig,
|
||||
) *Service {
|
||||
return &Service{
|
||||
alertRepo: alertRepo,
|
||||
componentRepo: componentRepo,
|
||||
priceRepo: priceRepo,
|
||||
statsRepo: statsRepo,
|
||||
config: alertCfg,
|
||||
pricingConfig: pricingCfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) List(filter repository.AlertFilter, page, perPage int) ([]models.PricingAlert, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
return s.alertRepo.List(filter, offset, perPage)
|
||||
}
|
||||
|
||||
func (s *Service) Acknowledge(id uint) error {
|
||||
return s.alertRepo.UpdateStatus(id, models.AlertStatusAcknowledged)
|
||||
}
|
||||
|
||||
func (s *Service) Resolve(id uint) error {
|
||||
return s.alertRepo.UpdateStatus(id, models.AlertStatusResolved)
|
||||
}
|
||||
|
||||
func (s *Service) Ignore(id uint) error {
|
||||
return s.alertRepo.UpdateStatus(id, models.AlertStatusIgnored)
|
||||
}
|
||||
|
||||
func (s *Service) GetNewAlertsCount() (int64, error) {
|
||||
return s.alertRepo.CountByStatus(models.AlertStatusNew)
|
||||
}
|
||||
|
||||
// CheckAndGenerateAlerts scans components and creates alerts
|
||||
func (s *Service) CheckAndGenerateAlerts() error {
|
||||
if !s.config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get top components by usage
|
||||
topComponents, err := s.statsRepo.GetTopComponents(100)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, stats := range topComponents {
|
||||
component, err := s.componentRepo.GetByLotName(stats.LotName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check high demand + stale price
|
||||
if err := s.checkHighDemandStalePrice(component, &stats); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check trending without price
|
||||
if err := s.checkTrendingNoPrice(component, &stats); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check no recent quotes
|
||||
if err := s.checkNoRecentQuotes(component, &stats); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) checkHighDemandStalePrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
|
||||
// high_demand_stale_price: >= 5 quotes/month AND price > 60 days old
|
||||
if stats.QuotesLast30d < s.config.HighDemandThreshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
if comp.PriceUpdatedAt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
daysSinceUpdate := int(time.Since(*comp.PriceUpdatedAt).Hours() / 24)
|
||||
if daysSinceUpdate <= s.pricingConfig.FreshnessYellowDays {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if alert already exists
|
||||
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertHighDemandStalePrice)
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
alert := &models.PricingAlert{
|
||||
LotName: comp.LotName,
|
||||
AlertType: models.AlertHighDemandStalePrice,
|
||||
Severity: models.SeverityCritical,
|
||||
Message: fmt.Sprintf("Компонент %s: высокий спрос (%d КП/мес), но цена устарела (%d дней)", comp.LotName, stats.QuotesLast30d, daysSinceUpdate),
|
||||
Details: models.AlertDetails{
|
||||
"quotes_30d": stats.QuotesLast30d,
|
||||
"days_since_update": daysSinceUpdate,
|
||||
},
|
||||
}
|
||||
|
||||
return s.alertRepo.Create(alert)
|
||||
}
|
||||
|
||||
func (s *Service) checkTrendingNoPrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
|
||||
// trending_no_price: trend > 50% AND no price
|
||||
if stats.TrendDirection != models.TrendUp || stats.TrendPercent < float64(s.config.TrendingThresholdPercent) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if comp.CurrentPrice != nil && *comp.CurrentPrice > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertTrendingNoPrice)
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
alert := &models.PricingAlert{
|
||||
LotName: comp.LotName,
|
||||
AlertType: models.AlertTrendingNoPrice,
|
||||
Severity: models.SeverityHigh,
|
||||
Message: fmt.Sprintf("Компонент %s: рост спроса +%.0f%%, но цена не установлена", comp.LotName, stats.TrendPercent),
|
||||
Details: models.AlertDetails{
|
||||
"trend_percent": stats.TrendPercent,
|
||||
},
|
||||
}
|
||||
|
||||
return s.alertRepo.Create(alert)
|
||||
}
|
||||
|
||||
func (s *Service) checkNoRecentQuotes(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
|
||||
// no_recent_quotes: popular component, no supplier quotes > 90 days
|
||||
if stats.QuotesLast30d < 3 {
|
||||
return nil
|
||||
}
|
||||
|
||||
quoteCount, err := s.priceRepo.GetQuoteCount(comp.LotName, s.pricingConfig.FreshnessRedDays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if quoteCount > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertNoRecentQuotes)
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
alert := &models.PricingAlert{
|
||||
LotName: comp.LotName,
|
||||
AlertType: models.AlertNoRecentQuotes,
|
||||
Severity: models.SeverityMedium,
|
||||
Message: fmt.Sprintf("Компонент %s: популярный (%d КП), но нет новых котировок >%d дней", comp.LotName, stats.QuotesLast30d, s.pricingConfig.FreshnessRedDays),
|
||||
Details: models.AlertDetails{
|
||||
"quotes_30d": stats.QuotesLast30d,
|
||||
"no_quotes_days": s.pricingConfig.FreshnessRedDays,
|
||||
},
|
||||
}
|
||||
|
||||
return s.alertRepo.Create(alert)
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/mchus/quoteforge/internal/config"
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"github.com/mchus/quoteforge/internal/repository"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserInactive = errors.New("user account is inactive")
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
userRepo *repository.UserRepository
|
||||
config config.AuthConfig
|
||||
}
|
||||
|
||||
func NewAuthService(userRepo *repository.UserRepository, cfg config.AuthConfig) *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: userRepo,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role models.UserRole `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(username, password string) (*TokenPair, *models.User, error) {
|
||||
user, err := s.userRepo.GetByUsername(username)
|
||||
if err != nil {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, nil, ErrUserInactive
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
tokens, err := s.generateTokenPair(user)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return tokens, user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
|
||||
claims, err := s.ValidateToken(refreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetByID(claims.UserID)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, ErrUserInactive
|
||||
}
|
||||
|
||||
return s.generateTokenPair(user)
|
||||
}
|
||||
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(s.config.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) generateTokenPair(user *models.User) (*TokenPair, error) {
|
||||
now := time.Now()
|
||||
accessExpiry := now.Add(s.config.TokenExpiry)
|
||||
refreshExpiry := now.Add(s.config.RefreshExpiry)
|
||||
|
||||
accessClaims := &Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(accessExpiry),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Subject: user.Username,
|
||||
},
|
||||
}
|
||||
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||
accessTokenString, err := accessToken.SignedString([]byte(s.config.JWTSecret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshClaims := &Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(refreshExpiry),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Subject: user.Username,
|
||||
},
|
||||
}
|
||||
|
||||
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||
refreshTokenString, err := refreshToken.SignedString([]byte(s.config.JWTSecret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessTokenString,
|
||||
RefreshToken: refreshTokenString,
|
||||
ExpiresAt: accessExpiry.Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func (s *AuthService) CreateUser(username, email, password string, role models.UserRole) (*models.User, error) {
|
||||
hash, err := s.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: hash,
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"github.com/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
type ComponentService struct {
|
||||
@@ -25,19 +26,16 @@ func NewComponentService(
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePartNumber extracts category, vendor, model from lot_name
|
||||
// "CPU_AMD_9654" → category="CPU", vendor="AMD", model="9654"
|
||||
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", vendor="INTEL", model="4.Sapphire_2S_32xDDR5"
|
||||
func ParsePartNumber(lotName string) (category, vendor, model string) {
|
||||
parts := strings.SplitN(lotName, "_", 3)
|
||||
// ParsePartNumber extracts category and model from lot_name
|
||||
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
|
||||
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", model="INTEL_4.Sapphire_2S_32xDDR5"
|
||||
func ParsePartNumber(lotName string) (category, model string) {
|
||||
parts := strings.SplitN(lotName, "_", 2)
|
||||
if len(parts) >= 1 {
|
||||
category = parts[0]
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
vendor = parts[1]
|
||||
}
|
||||
if len(parts) >= 3 {
|
||||
model = parts[2]
|
||||
model = parts[1]
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -50,25 +48,37 @@ type ComponentListResult struct {
|
||||
}
|
||||
|
||||
type ComponentView struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Vendor string `json:"vendor"`
|
||||
Model string `json:"model"`
|
||||
CurrentPrice *float64 `json:"current_price"`
|
||||
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
||||
PopularityScore float64 `json:"popularity_score"`
|
||||
Specs models.Specs `json:"specs,omitempty"`
|
||||
LotName string `json:"lot_name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Model string `json:"model"`
|
||||
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
||||
PopularityScore float64 `json:"popularity_score"`
|
||||
Specs models.Specs `json:"specs,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) {
|
||||
// If no database connection (offline mode), return empty list
|
||||
// Components should be loaded via /api/sync/components first
|
||||
if s.componentRepo == nil {
|
||||
return &ComponentListResult{
|
||||
Components: []ComponentView{},
|
||||
Total: 0,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
if perPage > 5000 {
|
||||
perPage = 5000
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
components, total, err := s.componentRepo.List(filter, offset, perPage)
|
||||
@@ -80,9 +90,7 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
||||
for i, c := range components {
|
||||
view := ComponentView{
|
||||
LotName: c.LotName,
|
||||
Vendor: c.Vendor,
|
||||
Model: c.Model,
|
||||
CurrentPrice: c.CurrentPrice,
|
||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||
PopularityScore: c.PopularityScore,
|
||||
Specs: c.Specs,
|
||||
@@ -108,6 +116,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
||||
}
|
||||
|
||||
func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) {
|
||||
// If no database connection (offline mode), return error
|
||||
if s.componentRepo == nil {
|
||||
return nil, fmt.Errorf("offline mode: component data not available")
|
||||
}
|
||||
|
||||
c, err := s.componentRepo.GetByLotName(lotName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -118,9 +131,7 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
||||
|
||||
view := &ComponentView{
|
||||
LotName: c.LotName,
|
||||
Vendor: c.Vendor,
|
||||
Model: c.Model,
|
||||
CurrentPrice: c.CurrentPrice,
|
||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||
PopularityScore: c.PopularityScore,
|
||||
Specs: c.Specs,
|
||||
@@ -138,15 +149,20 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
||||
}
|
||||
|
||||
func (s *ComponentService) GetCategories() ([]models.Category, error) {
|
||||
// If no database connection (offline mode), return default categories
|
||||
if s.categoryRepo == nil {
|
||||
return models.DefaultCategories, nil
|
||||
}
|
||||
return s.categoryRepo.GetAll()
|
||||
}
|
||||
|
||||
func (s *ComponentService) GetVendors(category string) ([]string, error) {
|
||||
return s.componentRepo.GetVendors(category)
|
||||
}
|
||||
|
||||
// ImportFromLot creates metadata entries for lots that don't have them
|
||||
func (s *ComponentService) ImportFromLot() (int, error) {
|
||||
// If no database connection (offline mode), return error
|
||||
if s.componentRepo == nil || s.categoryRepo == nil {
|
||||
return 0, fmt.Errorf("offline mode: import not available")
|
||||
}
|
||||
|
||||
lots, err := s.componentRepo.GetLotsWithoutMetadata()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -159,22 +175,36 @@ func (s *ComponentService) ImportFromLot() (int, error) {
|
||||
|
||||
categoryMap := make(map[string]uint)
|
||||
for _, cat := range categories {
|
||||
categoryMap[cat.Code] = cat.ID
|
||||
categoryMap[strings.ToUpper(cat.Code)] = cat.ID
|
||||
}
|
||||
|
||||
imported := 0
|
||||
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{
|
||||
LotName: lot.LotName,
|
||||
Vendor: vendor,
|
||||
Model: model,
|
||||
Specs: make(models.Specs),
|
||||
}
|
||||
|
||||
if catID, ok := categoryMap[category]; ok {
|
||||
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 {
|
||||
|
||||
@@ -1,55 +1,110 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"github.com/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConfigNotFound = errors.New("configuration not found")
|
||||
ErrConfigForbidden = errors.New("access to configuration forbidden")
|
||||
ErrConfigNotFound = errors.New("configuration not found")
|
||||
ErrConfigForbidden = errors.New("access to configuration forbidden")
|
||||
)
|
||||
|
||||
// ConfigurationGetter is an interface for services that can retrieve configurations
|
||||
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
|
||||
type ConfigurationGetter interface {
|
||||
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
|
||||
}
|
||||
|
||||
type ConfigurationService struct {
|
||||
configRepo *repository.ConfigurationRepository
|
||||
projectRepo *repository.ProjectRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
quoteService *QuoteService
|
||||
}
|
||||
|
||||
func NewConfigurationService(
|
||||
configRepo *repository.ConfigurationRepository,
|
||||
projectRepo *repository.ProjectRepository,
|
||||
componentRepo *repository.ComponentRepository,
|
||||
pricelistRepo *repository.PricelistRepository,
|
||||
quoteService *QuoteService,
|
||||
) *ConfigurationService {
|
||||
return &ConfigurationService{
|
||||
configRepo: configRepo,
|
||||
projectRepo: projectRepo,
|
||||
componentRepo: componentRepo,
|
||||
pricelistRepo: pricelistRepo,
|
||||
quoteService: quoteService,
|
||||
}
|
||||
}
|
||||
|
||||
type CreateConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
Items models.ConfigItems `json:"items"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
Name string `json:"name"`
|
||||
Items models.ConfigItems `json:"items"`
|
||||
ProjectUUID *string `json:"project_uuid,omitempty"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
ServerModel string `json:"server_model,omitempty"`
|
||||
SupportCode string `json:"support_code,omitempty"`
|
||||
Article string `json:"article,omitempty"`
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
|
||||
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
type ArticlePreviewRequest struct {
|
||||
Items models.ConfigItems `json:"items"`
|
||||
ServerModel string `json:"server_model"`
|
||||
SupportCode string `json:"support_code,omitempty"`
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
config := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: projectUUID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: req.CustomPrice,
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
ServerModel: req.ServerModel,
|
||||
SupportCode: req.SupportCode,
|
||||
Article: req.Article,
|
||||
PricelistID: pricelistID,
|
||||
WarehousePricelistID: req.WarehousePricelistID,
|
||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(config); err != nil {
|
||||
@@ -62,37 +117,62 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) GetByUUID(uuid string, userID uint) (*models.Configuration, error) {
|
||||
func (s *ConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
// Allow access if user owns config or it's a template
|
||||
if config.UserID != userID && !config.IsTemplate {
|
||||
if !s.isOwner(config, ownerUsername) && !config.IsTemplate {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if config.UserID != userID {
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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.ProjectUUID = projectUUID
|
||||
config.Items = req.Items
|
||||
config.TotalPrice = &total
|
||||
config.CustomPrice = req.CustomPrice
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
config.ServerModel = req.ServerModel
|
||||
config.SupportCode = req.SupportCode
|
||||
config.Article = req.Article
|
||||
config.PricelistID = pricelistID
|
||||
config.WarehousePricelistID = req.WarehousePricelistID
|
||||
config.CompetitorPricelistID = req.CompetitorPricelistID
|
||||
config.DisablePriceRefresh = req.DisablePriceRefresh
|
||||
config.OnlyInStock = req.OnlyInStock
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
@@ -101,20 +181,92 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Delete(uuid string, userID uint) error {
|
||||
func (s *ConfigurationService) Delete(uuid string, ownerUsername string) error {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
|
||||
if config.UserID != userID {
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
return ErrConfigForbidden
|
||||
}
|
||||
|
||||
return s.configRepo.Delete(config.ID)
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
|
||||
func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
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, ownerUsername string, newName string) (*models.Configuration, error) {
|
||||
return s.CloneToProject(configUUID, ownerUsername, newName, nil)
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUID(configUUID, ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
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(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false, // Clone is never a template
|
||||
ServerCount: original.ServerCount,
|
||||
ServerModel: original.ServerModel,
|
||||
SupportCode: original.SupportCode,
|
||||
Article: original.Article,
|
||||
PricelistID: original.PricelistID,
|
||||
WarehousePricelistID: original.WarehousePricelistID,
|
||||
CompetitorPricelistID: original.CompetitorPricelistID,
|
||||
DisablePriceRefresh: original.DisablePriceRefresh,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -123,7 +275,244 @@ func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]mod
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
return s.configRepo.ListByUser(userID, offset, perPage)
|
||||
return s.configRepo.ListByUser(ownerUsername, offset, perPage)
|
||||
}
|
||||
|
||||
// ListAll returns all configurations without user filter (for use when auth is disabled)
|
||||
func (s *ConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
return s.configRepo.ListAll(offset, perPage)
|
||||
}
|
||||
|
||||
// GetByUUIDNoAuth returns configuration without ownership check (for use when auth is disabled)
|
||||
func (s *ConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// UpdateNoAuth updates configuration without ownership check
|
||||
func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(config.OwnerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
config.Name = req.Name
|
||||
config.ProjectUUID = projectUUID
|
||||
config.Items = req.Items
|
||||
config.TotalPrice = &total
|
||||
config.CustomPrice = req.CustomPrice
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
config.ServerModel = req.ServerModel
|
||||
config.SupportCode = req.SupportCode
|
||||
config.Article = req.Article
|
||||
config.PricelistID = pricelistID
|
||||
config.WarehousePricelistID = req.WarehousePricelistID
|
||||
config.CompetitorPricelistID = req.CompetitorPricelistID
|
||||
config.DisablePriceRefresh = req.DisablePriceRefresh
|
||||
config.OnlyInStock = req.OnlyInStock
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// DeleteNoAuth deletes configuration without ownership check
|
||||
func (s *ConfigurationService) DeleteNoAuth(uuid string) error {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
return s.configRepo.Delete(config.ID)
|
||||
}
|
||||
|
||||
// RenameNoAuth renames configuration without ownership check
|
||||
func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
config.Name = newName
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// CloneNoAuth clones configuration without ownership check
|
||||
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
|
||||
return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil)
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
||||
original, err := s.configRepo.GetByUUID(configUUID)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
total := original.Items.Total()
|
||||
if original.ServerCount > 1 {
|
||||
total *= float64(original.ServerCount)
|
||||
}
|
||||
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
|
||||
_ = ownerUsername
|
||||
if s.projectRepo == nil {
|
||||
return projectUUID, nil
|
||||
}
|
||||
if projectUUID == nil || *projectUUID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
project, err := s.projectRepo.GetByUUID(*projectUUID)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if !project.IsActive {
|
||||
return nil, errors.New("project is archived")
|
||||
}
|
||||
|
||||
return &project.UUID, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
|
||||
if s.pricelistRepo == nil {
|
||||
return pricelistID, nil
|
||||
}
|
||||
if pricelistID != nil && *pricelistID > 0 {
|
||||
if _, err := s.pricelistRepo.GetByID(*pricelistID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pricelistID, nil
|
||||
}
|
||||
latest, err := s.pricelistRepo.GetLatestActive()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &latest.ID, nil
|
||||
}
|
||||
|
||||
// RefreshPricesNoAuth refreshes prices without ownership check
|
||||
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
var latestPricelistID *uint
|
||||
if s.pricelistRepo != nil {
|
||||
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
|
||||
latestPricelistID = &pl.ID
|
||||
}
|
||||
}
|
||||
|
||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||
for i, item := range config.Items {
|
||||
if latestPricelistID != nil {
|
||||
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: price,
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if s.componentRepo == nil {
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||
if err != nil || metadata.CurrentPrice == nil {
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *metadata.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
config.Items = updatedItems
|
||||
total := updatedItems.Total()
|
||||
if config.ServerCount > 1 {
|
||||
total *= float64(config.ServerCount)
|
||||
}
|
||||
|
||||
config.TotalPrice = &total
|
||||
if latestPricelistID != nil {
|
||||
config.PricelistID = latestPricelistID
|
||||
}
|
||||
now := time.Now()
|
||||
config.PriceUpdatedAt = &now
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
|
||||
@@ -138,39 +527,123 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config
|
||||
return s.configRepo.ListTemplates(offset, perPage)
|
||||
}
|
||||
|
||||
// Export configuration as JSON
|
||||
type ConfigExport struct {
|
||||
Name string `json:"name"`
|
||||
Notes string `json:"notes"`
|
||||
Items models.ConfigItems `json:"items"`
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ExportJSON(uuid string, userID uint) ([]byte, error) {
|
||||
config, err := s.GetByUUID(uuid, userID)
|
||||
// RefreshPrices updates all component prices in the configuration with current prices
|
||||
func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
var latestPricelistID *uint
|
||||
if s.pricelistRepo != nil {
|
||||
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
|
||||
latestPricelistID = &pl.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Update prices for all items
|
||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||
for i, item := range config.Items {
|
||||
if latestPricelistID != nil {
|
||||
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: price,
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Get current component price
|
||||
if s.componentRepo == nil {
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||
if err != nil || metadata.CurrentPrice == nil {
|
||||
// Keep original item if component not found or no price available
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
// Update item with current price
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *metadata.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
config.Items = updatedItems
|
||||
total := updatedItems.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
if config.ServerCount > 1 {
|
||||
total *= float64(config.ServerCount)
|
||||
}
|
||||
|
||||
config.TotalPrice = &total
|
||||
if latestPricelistID != nil {
|
||||
config.PricelistID = latestPricelistID
|
||||
}
|
||||
|
||||
// Set price update timestamp
|
||||
now := time.Now()
|
||||
config.PriceUpdatedAt = &now
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
export := ConfigExport{
|
||||
Name: config.Name,
|
||||
Notes: config.Notes,
|
||||
Items: config.Items,
|
||||
}
|
||||
|
||||
return json.MarshalIndent(export, "", " ")
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ImportJSON(userID uint, data []byte) (*models.Configuration, error) {
|
||||
var export ConfigExport
|
||||
if err := json.Unmarshal(data, &export); err != nil {
|
||||
return nil, err
|
||||
func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUsername string) bool {
|
||||
if config == nil || ownerUsername == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
req := &CreateConfigRequest{
|
||||
Name: export.Name,
|
||||
Notes: export.Notes,
|
||||
Items: export.Items,
|
||||
}
|
||||
|
||||
return s.Create(userID, req)
|
||||
return config.OwnerUsername == ownerUsername
|
||||
}
|
||||
|
||||
// // Export configuration as JSON
|
||||
// type ConfigExport struct {
|
||||
// Name string `json:"name"`
|
||||
// Notes string `json:"notes"`
|
||||
// Items models.ConfigItems `json:"items"`
|
||||
// }
|
||||
//
|
||||
// func (s *ConfigurationService) ExportJSON(uuid string, userID uint) ([]byte, error) {
|
||||
// config, err := s.GetByUUID(uuid, userID)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
//
|
||||
// export := ConfigExport{
|
||||
// Name: config.Name,
|
||||
// Notes: config.Notes,
|
||||
// Items: config.Items,
|
||||
// }
|
||||
//
|
||||
// return json.MarshalIndent(export, "", " ")
|
||||
// }
|
||||
//
|
||||
// func (s *ConfigurationService) ImportJSON(userID uint, data []byte) (*models.Configuration, error) {
|
||||
// var export ConfigExport
|
||||
// if err := json.Unmarshal(data, &export); err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
//
|
||||
// req := &CreateConfigRequest{
|
||||
// Name: export.Name,
|
||||
// Notes: export.Notes,
|
||||
// Items: export.Items,
|
||||
// }
|
||||
//
|
||||
// return s.Create(userID, req)
|
||||
// }
|
||||
|
||||
@@ -4,29 +4,33 @@ import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mchus/quoteforge/internal/config"
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"github.com/xuri/excelize/v2"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
type ExportService struct {
|
||||
config config.ExportConfig
|
||||
config config.ExportConfig
|
||||
categoryRepo *repository.CategoryRepository
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewExportService(cfg config.ExportConfig) *ExportService {
|
||||
return &ExportService{config: cfg}
|
||||
}
|
||||
|
||||
type ExportData struct {
|
||||
Name string
|
||||
Items []ExportItem
|
||||
Total float64
|
||||
Notes string
|
||||
CreatedAt time.Time
|
||||
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository, local *localdb.LocalDB) *ExportService {
|
||||
return &ExportService{
|
||||
config: cfg,
|
||||
categoryRepo: categoryRepo,
|
||||
localDB: local,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportItem represents a single component in an export block.
|
||||
type ExportItem struct {
|
||||
LotName string
|
||||
Description string
|
||||
@@ -36,140 +40,830 @@ type ExportItem struct {
|
||||
TotalPrice float64
|
||||
}
|
||||
|
||||
func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := csv.NewWriter(&buf)
|
||||
|
||||
// Header
|
||||
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
||||
if err := w.Write(headers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Items
|
||||
for _, item := range data.Items {
|
||||
row := []string{
|
||||
item.LotName,
|
||||
item.Description,
|
||||
item.Category,
|
||||
fmt.Sprintf("%d", item.Quantity),
|
||||
fmt.Sprintf("%.2f", item.UnitPrice),
|
||||
fmt.Sprintf("%.2f", item.TotalPrice),
|
||||
}
|
||||
if err := w.Write(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Total row
|
||||
if err := w.Write([]string{"", "", "", "", "ИТОГО:", fmt.Sprintf("%.2f", data.Total)}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return buf.Bytes(), w.Error()
|
||||
// ConfigExportBlock represents one configuration (server) in the export.
|
||||
type ConfigExportBlock struct {
|
||||
Article string
|
||||
Line int
|
||||
ServerCount int
|
||||
UnitPrice float64 // sum of component prices for one server
|
||||
Items []ExportItem
|
||||
}
|
||||
|
||||
func (s *ExportService) ToXLSX(data *ExportData) ([]byte, error) {
|
||||
f := excelize.NewFile()
|
||||
sheet := "Конфигурация"
|
||||
f.SetSheetName("Sheet1", sheet)
|
||||
// ProjectExportData holds all configuration blocks for a project-level export.
|
||||
type ProjectExportData struct {
|
||||
Configs []ConfigExportBlock
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// 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},
|
||||
},
|
||||
})
|
||||
type ProjectPricingExportOptions struct {
|
||||
IncludeLOT bool `json:"include_lot"`
|
||||
IncludeBOM bool `json:"include_bom"`
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
}
|
||||
|
||||
totalStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Size: 12},
|
||||
Fill: excelize.Fill{Type: "pattern", Color: []string{"#E2EFDA"}, Pattern: 1},
|
||||
})
|
||||
type ProjectPricingExportData struct {
|
||||
Configs []ProjectPricingExportConfig
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
priceStyle, _ := f.NewStyle(&excelize.Style{
|
||||
NumFmt: 4, // #,##0.00
|
||||
})
|
||||
type ProjectPricingExportConfig struct {
|
||||
Name string
|
||||
Article string
|
||||
Line int
|
||||
ServerCount int
|
||||
Rows []ProjectPricingExportRow
|
||||
}
|
||||
|
||||
// Title
|
||||
f.SetCellValue(sheet, "A1", s.config.CompanyName)
|
||||
f.SetCellValue(sheet, "A2", "Коммерческое предложение: "+data.Name)
|
||||
f.SetCellValue(sheet, "A3", "Дата: "+data.CreatedAt.Format("02.01.2006"))
|
||||
type ProjectPricingExportRow struct {
|
||||
LotDisplay string
|
||||
VendorPN string
|
||||
Description string
|
||||
Quantity int
|
||||
BOMTotal *float64
|
||||
Estimate *float64
|
||||
Stock *float64
|
||||
Competitor *float64
|
||||
}
|
||||
|
||||
// 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)
|
||||
// ToCSV writes project export data in the new structured CSV format.
|
||||
//
|
||||
// Format:
|
||||
//
|
||||
// Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)
|
||||
// 10;;DL380-ARTICLE;;;10;10470;104 700
|
||||
// ;;MB_INTEL_...;;1;;2074,5;
|
||||
// ...
|
||||
// (empty row)
|
||||
// 20;;DL380-ARTICLE-2;;;2;10470;20 940
|
||||
// ...
|
||||
func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
|
||||
// Write UTF-8 BOM for Excel compatibility
|
||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||
return fmt.Errorf("failed to write BOM: %w", err)
|
||||
}
|
||||
|
||||
// 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++
|
||||
csvWriter := csv.NewWriter(w)
|
||||
csvWriter.Comma = ';'
|
||||
defer csvWriter.Flush()
|
||||
|
||||
// Header
|
||||
headers := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
||||
if err := csvWriter.Write(headers); err != nil {
|
||||
return fmt.Errorf("failed to write header: %w", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
for i, block := range data.Configs {
|
||||
lineNo := block.Line
|
||||
if lineNo <= 0 {
|
||||
lineNo = (i + 1) * 10
|
||||
}
|
||||
|
||||
serverCount := block.ServerCount
|
||||
if serverCount < 1 {
|
||||
serverCount = 1
|
||||
}
|
||||
|
||||
totalPrice := block.UnitPrice * float64(serverCount)
|
||||
|
||||
// Server summary row
|
||||
serverRow := []string{
|
||||
fmt.Sprintf("%d", lineNo), // Line
|
||||
"", // Type
|
||||
block.Article, // p/n
|
||||
"", // Description
|
||||
"", // Qty (1 pcs.)
|
||||
fmt.Sprintf("%d", serverCount), // Qty (total)
|
||||
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
|
||||
formatPriceWithSpace(totalPrice), // Price (total)
|
||||
}
|
||||
if err := csvWriter.Write(serverRow); err != nil {
|
||||
return fmt.Errorf("failed to write server row: %w", err)
|
||||
}
|
||||
|
||||
// Sort items by category display order
|
||||
sortedItems := make([]ExportItem, len(block.Items))
|
||||
copy(sortedItems, block.Items)
|
||||
sortItemsByCategory(sortedItems, categoryOrder)
|
||||
|
||||
// Component rows
|
||||
for _, item := range sortedItems {
|
||||
componentRow := []string{
|
||||
"", // Line
|
||||
item.Category, // Type
|
||||
item.LotName, // p/n
|
||||
"", // Description
|
||||
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
|
||||
"", // Qty (total)
|
||||
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
|
||||
"", // Price (total)
|
||||
}
|
||||
if err := csvWriter.Write(componentRow); err != nil {
|
||||
return fmt.Errorf("failed to write component row: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty separator row between blocks (skip after last)
|
||||
if i < len(data.Configs)-1 {
|
||||
if err := csvWriter.Write([]string{"", "", "", "", "", "", "", ""}); err != nil {
|
||||
return fmt.Errorf("failed to write separator row: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
csvWriter.Flush()
|
||||
if err := csvWriter.Error(); err != nil {
|
||||
return fmt.Errorf("csv writer error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes.
|
||||
func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := f.Write(&buf); err != nil {
|
||||
if err := s.ToCSV(&buf, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *ExportService) ConfigToExportData(config *models.Configuration) *ExportData {
|
||||
items := make([]ExportItem, len(config.Items))
|
||||
var total float64
|
||||
func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
|
||||
sortedConfigs := make([]models.Configuration, len(configs))
|
||||
copy(sortedConfigs, configs)
|
||||
sort.Slice(sortedConfigs, func(i, j int) bool {
|
||||
leftLine := sortedConfigs[i].Line
|
||||
rightLine := sortedConfigs[j].Line
|
||||
|
||||
for i, item := range config.Items {
|
||||
if leftLine <= 0 {
|
||||
leftLine = int(^uint(0) >> 1)
|
||||
}
|
||||
if rightLine <= 0 {
|
||||
rightLine = int(^uint(0) >> 1)
|
||||
}
|
||||
if leftLine != rightLine {
|
||||
return leftLine < rightLine
|
||||
}
|
||||
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
|
||||
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
|
||||
}
|
||||
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
|
||||
})
|
||||
|
||||
blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs))
|
||||
for i := range sortedConfigs {
|
||||
block, err := s.buildPricingExportBlock(&sortedConfigs[i], opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
|
||||
return &ProjectPricingExportData{
|
||||
Configs: blocks,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData, opts ProjectPricingExportOptions) error {
|
||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||
return fmt.Errorf("failed to write BOM: %w", err)
|
||||
}
|
||||
|
||||
csvWriter := csv.NewWriter(w)
|
||||
csvWriter.Comma = ';'
|
||||
defer csvWriter.Flush()
|
||||
|
||||
headers := pricingCSVHeaders(opts)
|
||||
if err := csvWriter.Write(headers); err != nil {
|
||||
return fmt.Errorf("failed to write pricing header: %w", err)
|
||||
}
|
||||
|
||||
for idx, cfg := range data.Configs {
|
||||
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
|
||||
return fmt.Errorf("failed to write config summary row: %w", err)
|
||||
}
|
||||
for _, row := range cfg.Rows {
|
||||
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
||||
return fmt.Errorf("failed to write pricing row: %w", err)
|
||||
}
|
||||
}
|
||||
if idx < len(data.Configs)-1 {
|
||||
if err := csvWriter.Write([]string{}); err != nil {
|
||||
return fmt.Errorf("failed to write separator row: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
csvWriter.Flush()
|
||||
if err := csvWriter.Error(); err != nil {
|
||||
return fmt.Errorf("csv writer error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigToExportData converts a single configuration into ProjectExportData.
|
||||
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
|
||||
block := s.buildExportBlock(cfg)
|
||||
return &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{block},
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
||||
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
||||
sortedConfigs := make([]models.Configuration, len(configs))
|
||||
copy(sortedConfigs, configs)
|
||||
sort.Slice(sortedConfigs, func(i, j int) bool {
|
||||
leftLine := sortedConfigs[i].Line
|
||||
rightLine := sortedConfigs[j].Line
|
||||
|
||||
if leftLine <= 0 {
|
||||
leftLine = int(^uint(0) >> 1)
|
||||
}
|
||||
if rightLine <= 0 {
|
||||
rightLine = int(^uint(0) >> 1)
|
||||
}
|
||||
if leftLine != rightLine {
|
||||
return leftLine < rightLine
|
||||
}
|
||||
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
|
||||
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
|
||||
}
|
||||
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
|
||||
})
|
||||
|
||||
blocks := make([]ConfigExportBlock, 0, len(configs))
|
||||
for i := range sortedConfigs {
|
||||
blocks = append(blocks, s.buildExportBlock(&sortedConfigs[i]))
|
||||
}
|
||||
return &ProjectExportData{
|
||||
Configs: blocks,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock {
|
||||
// Batch-fetch categories from local data (pricelist items → local_components fallback)
|
||||
lotNames := make([]string, len(cfg.Items))
|
||||
for i, item := range cfg.Items {
|
||||
lotNames[i] = item.LotName
|
||||
}
|
||||
categories := s.resolveCategories(cfg.PricelistID, lotNames)
|
||||
|
||||
items := make([]ExportItem, len(cfg.Items))
|
||||
var unitTotal float64
|
||||
|
||||
for i, item := range cfg.Items {
|
||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||
items[i] = ExportItem{
|
||||
LotName: item.LotName,
|
||||
Category: categories[item.LotName],
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: itemTotal,
|
||||
}
|
||||
total += itemTotal
|
||||
unitTotal += itemTotal
|
||||
}
|
||||
|
||||
return &ExportData{
|
||||
Name: config.Name,
|
||||
Items: items,
|
||||
Total: total,
|
||||
Notes: config.Notes,
|
||||
CreatedAt: config.CreatedAt,
|
||||
serverCount := cfg.ServerCount
|
||||
if serverCount < 1 {
|
||||
serverCount = 1
|
||||
}
|
||||
|
||||
return ConfigExportBlock{
|
||||
Article: cfg.Article,
|
||||
Line: cfg.Line,
|
||||
ServerCount: serverCount,
|
||||
UnitPrice: unitTotal,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts ProjectPricingExportOptions) (ProjectPricingExportConfig, error) {
|
||||
block := ProjectPricingExportConfig{
|
||||
Name: cfg.Name,
|
||||
Article: cfg.Article,
|
||||
Line: cfg.Line,
|
||||
ServerCount: exportPositiveInt(cfg.ServerCount, 1),
|
||||
Rows: make([]ProjectPricingExportRow, 0),
|
||||
}
|
||||
if s.localDB == nil {
|
||||
for _, item := range cfg.Items {
|
||||
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||
LotDisplay: item.LotName,
|
||||
VendorPN: "—",
|
||||
Quantity: item.Quantity,
|
||||
Estimate: floatPtr(item.UnitPrice * float64(item.Quantity)),
|
||||
})
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
||||
if err != nil {
|
||||
localCfg = nil
|
||||
}
|
||||
|
||||
priceMap := s.resolvePricingTotals(cfg, localCfg, opts)
|
||||
componentDescriptions := s.resolveLotDescriptions(cfg, localCfg)
|
||||
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
|
||||
coveredLots := make(map[string]struct{})
|
||||
for _, row := range localCfg.VendorSpec {
|
||||
rowMappings := normalizeLotMappings(row.LotMappings)
|
||||
for _, mapping := range rowMappings {
|
||||
coveredLots[mapping.LotName] = struct{}{}
|
||||
}
|
||||
|
||||
description := strings.TrimSpace(row.Description)
|
||||
if description == "" && len(rowMappings) > 0 {
|
||||
description = componentDescriptions[rowMappings[0].LotName]
|
||||
}
|
||||
|
||||
pricingRow := ProjectPricingExportRow{
|
||||
LotDisplay: formatLotDisplay(rowMappings),
|
||||
VendorPN: row.VendorPartnumber,
|
||||
Description: description,
|
||||
Quantity: exportPositiveInt(row.Quantity, 1),
|
||||
BOMTotal: vendorRowTotal(row),
|
||||
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
|
||||
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
|
||||
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
|
||||
}
|
||||
block.Rows = append(block.Rows, pricingRow)
|
||||
}
|
||||
|
||||
for _, item := range cfg.Items {
|
||||
if item.LotName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := coveredLots[item.LotName]; ok {
|
||||
continue
|
||||
}
|
||||
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
|
||||
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||
LotDisplay: item.LotName,
|
||||
VendorPN: "—",
|
||||
Description: componentDescriptions[item.LotName],
|
||||
Quantity: exportPositiveInt(item.Quantity, 1),
|
||||
Estimate: estimate,
|
||||
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
|
||||
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
||||
})
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
for _, item := range cfg.Items {
|
||||
if item.LotName == "" {
|
||||
continue
|
||||
}
|
||||
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
|
||||
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||
LotDisplay: item.LotName,
|
||||
VendorPN: "—",
|
||||
Description: componentDescriptions[item.LotName],
|
||||
Quantity: exportPositiveInt(item.Quantity, 1),
|
||||
Estimate: estimate,
|
||||
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
|
||||
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
||||
})
|
||||
}
|
||||
|
||||
return block, nil
|
||||
}
|
||||
|
||||
// resolveCategories returns lot_name → category map.
|
||||
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
||||
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
||||
if len(lotNames) == 0 || s.localDB == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
categories := make(map[string]string, len(lotNames))
|
||||
|
||||
// Primary: pricelist items
|
||||
if pricelistID != nil && *pricelistID > 0 {
|
||||
if cats, err := s.localDB.GetLocalLotCategoriesByServerPricelistID(*pricelistID, lotNames); err == nil {
|
||||
for lot, cat := range cats {
|
||||
if strings.TrimSpace(cat) != "" {
|
||||
categories[lot] = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: local_components for any still missing
|
||||
var missing []string
|
||||
for _, lot := range lotNames {
|
||||
if categories[lot] == "" {
|
||||
missing = append(missing, lot)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
if fallback, err := s.localDB.GetLocalComponentCategoriesByLotNames(missing); err == nil {
|
||||
for lot, cat := range fallback {
|
||||
if strings.TrimSpace(cat) != "" {
|
||||
categories[lot] = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return categories
|
||||
}
|
||||
|
||||
// sortItemsByCategory sorts items by category display order (items without category go to the end).
|
||||
func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
|
||||
for i := 0; i < len(items)-1; i++ {
|
||||
for j := i + 1; j < len(items); j++ {
|
||||
orderI, hasI := categoryOrder[items[i].Category]
|
||||
orderJ, hasJ := categoryOrder[items[j].Category]
|
||||
|
||||
if !hasI && hasJ {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
} else if hasI && hasJ && orderI > orderJ {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type pricingLevels struct {
|
||||
Estimate *float64
|
||||
Stock *float64
|
||||
Competitor *float64
|
||||
}
|
||||
|
||||
func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, opts ProjectPricingExportOptions) map[string]pricingLevels {
|
||||
result := map[string]pricingLevels{}
|
||||
lots := collectPricingLots(cfg, localCfg, opts.IncludeBOM)
|
||||
if len(lots) == 0 || s.localDB == nil {
|
||||
return result
|
||||
}
|
||||
|
||||
estimateID := cfg.PricelistID
|
||||
if estimateID == nil || *estimateID == 0 {
|
||||
if latest, err := s.localDB.GetLatestLocalPricelistBySource("estimate"); err == nil && latest != nil {
|
||||
estimateID = &latest.ServerID
|
||||
}
|
||||
}
|
||||
|
||||
var warehouseID *uint
|
||||
var competitorID *uint
|
||||
if localCfg != nil {
|
||||
warehouseID = localCfg.WarehousePricelistID
|
||||
competitorID = localCfg.CompetitorPricelistID
|
||||
}
|
||||
if warehouseID == nil || *warehouseID == 0 {
|
||||
if latest, err := s.localDB.GetLatestLocalPricelistBySource("warehouse"); err == nil && latest != nil {
|
||||
warehouseID = &latest.ServerID
|
||||
}
|
||||
}
|
||||
if competitorID == nil || *competitorID == 0 {
|
||||
if latest, err := s.localDB.GetLatestLocalPricelistBySource("competitor"); err == nil && latest != nil {
|
||||
competitorID = &latest.ServerID
|
||||
}
|
||||
}
|
||||
|
||||
for _, lot := range lots {
|
||||
level := pricingLevels{}
|
||||
level.Estimate = s.lookupPricePointer(estimateID, lot)
|
||||
level.Stock = s.lookupPricePointer(warehouseID, lot)
|
||||
level.Competitor = s.lookupPricePointer(competitorID, lot)
|
||||
result[lot] = level
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 {
|
||||
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" {
|
||||
return nil
|
||||
}
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
||||
if err != nil || price <= 0 {
|
||||
return nil
|
||||
}
|
||||
return floatPtr(price)
|
||||
}
|
||||
|
||||
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
|
||||
lots := collectPricingLots(cfg, localCfg, true)
|
||||
result := make(map[string]string, len(lots))
|
||||
if s.localDB == nil {
|
||||
return result
|
||||
}
|
||||
for _, lot := range lots {
|
||||
component, err := s.localDB.GetLocalComponent(lot)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result[lot] = component.LotDescription
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0)
|
||||
if includeBOM && localCfg != nil {
|
||||
for _, row := range localCfg.VendorSpec {
|
||||
for _, mapping := range normalizeLotMappings(row.LotMappings) {
|
||||
if _, ok := seen[mapping.LotName]; ok {
|
||||
continue
|
||||
}
|
||||
seen[mapping.LotName] = struct{}{}
|
||||
out = append(out, mapping.LotName)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range cfg.Items {
|
||||
lot := strings.TrimSpace(item.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[lot]; ok {
|
||||
continue
|
||||
}
|
||||
seen[lot] = struct{}{}
|
||||
out = append(out, lot)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||
if len(mappings) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]localdb.VendorSpecLotMapping, 0, len(mappings))
|
||||
for _, mapping := range mappings {
|
||||
lot := strings.TrimSpace(mapping.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
qty := mapping.QuantityPerPN
|
||||
if qty < 1 {
|
||||
qty = 1
|
||||
}
|
||||
out = append(out, localdb.VendorSpecLotMapping{
|
||||
LotName: lot,
|
||||
QuantityPerPN: qty,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
|
||||
if row.TotalPrice != nil {
|
||||
return floatPtr(*row.TotalPrice)
|
||||
}
|
||||
if row.UnitPrice == nil {
|
||||
return nil
|
||||
}
|
||||
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
|
||||
}
|
||||
|
||||
func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 {
|
||||
if len(mappings) == 0 {
|
||||
return nil
|
||||
}
|
||||
total := 0.0
|
||||
hasValue := false
|
||||
qty := exportPositiveInt(pnQty, 1)
|
||||
for _, mapping := range mappings {
|
||||
price := selector(priceMap[mapping.LotName])
|
||||
if price == nil || *price <= 0 {
|
||||
continue
|
||||
}
|
||||
total += *price * float64(qty*mapping.QuantityPerPN)
|
||||
hasValue = true
|
||||
}
|
||||
if !hasValue {
|
||||
return nil
|
||||
}
|
||||
return floatPtr(total)
|
||||
}
|
||||
|
||||
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
|
||||
if unitPrice == nil || *unitPrice <= 0 {
|
||||
return nil
|
||||
}
|
||||
total := *unitPrice * float64(exportPositiveInt(quantity, 1))
|
||||
return &total
|
||||
}
|
||||
|
||||
func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quantity int) *float64 {
|
||||
if estimatePrice != nil && *estimatePrice > 0 {
|
||||
return totalForUnitPrice(estimatePrice, quantity)
|
||||
}
|
||||
if fallbackUnitPrice <= 0 {
|
||||
return nil
|
||||
}
|
||||
total := fallbackUnitPrice * float64(maxInt(quantity, 1))
|
||||
return &total
|
||||
}
|
||||
|
||||
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||
headers := make([]string, 0, 8)
|
||||
headers = append(headers, "Line Item")
|
||||
if opts.IncludeLOT {
|
||||
headers = append(headers, "LOT")
|
||||
}
|
||||
headers = append(headers, "PN вендора", "Описание", "Кол-во")
|
||||
if opts.IncludeBOM {
|
||||
headers = append(headers, "BOM")
|
||||
}
|
||||
if opts.IncludeEstimate {
|
||||
headers = append(headers, "Estimate")
|
||||
}
|
||||
if opts.IncludeStock {
|
||||
headers = append(headers, "Stock")
|
||||
}
|
||||
if opts.IncludeCompetitor {
|
||||
headers = append(headers, "Конкуренты")
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
|
||||
record := make([]string, 0, 8)
|
||||
record = append(record, "")
|
||||
if opts.IncludeLOT {
|
||||
record = append(record, emptyDash(row.LotDisplay))
|
||||
}
|
||||
record = append(record,
|
||||
emptyDash(row.VendorPN),
|
||||
emptyDash(row.Description),
|
||||
fmt.Sprintf("%d", exportPositiveInt(row.Quantity, 1)),
|
||||
)
|
||||
if opts.IncludeBOM {
|
||||
record = append(record, formatMoneyValue(row.BOMTotal))
|
||||
}
|
||||
if opts.IncludeEstimate {
|
||||
record = append(record, formatMoneyValue(row.Estimate))
|
||||
}
|
||||
if opts.IncludeStock {
|
||||
record = append(record, formatMoneyValue(row.Stock))
|
||||
}
|
||||
if opts.IncludeCompetitor {
|
||||
record = append(record, formatMoneyValue(row.Competitor))
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
|
||||
record := make([]string, 0, 8)
|
||||
record = append(record, fmt.Sprintf("%d", cfg.Line))
|
||||
if opts.IncludeLOT {
|
||||
record = append(record, "")
|
||||
}
|
||||
record = append(record,
|
||||
"",
|
||||
emptyDash(cfg.Name),
|
||||
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
|
||||
)
|
||||
if opts.IncludeBOM {
|
||||
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.BOMTotal })))
|
||||
}
|
||||
if opts.IncludeEstimate {
|
||||
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Estimate })))
|
||||
}
|
||||
if opts.IncludeStock {
|
||||
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Stock })))
|
||||
}
|
||||
if opts.IncludeCompetitor {
|
||||
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
|
||||
switch len(mappings) {
|
||||
case 0:
|
||||
return "н/д"
|
||||
case 1:
|
||||
return mappings[0].LotName
|
||||
default:
|
||||
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func formatMoneyValue(value *float64) string {
|
||||
if value == nil {
|
||||
return "—"
|
||||
}
|
||||
n := math.Round(*value*100) / 100
|
||||
sign := ""
|
||||
if n < 0 {
|
||||
sign = "-"
|
||||
n = -n
|
||||
}
|
||||
whole := int64(n)
|
||||
fraction := int(math.Round((n - float64(whole)) * 100))
|
||||
if fraction == 100 {
|
||||
whole++
|
||||
fraction = 0
|
||||
}
|
||||
return fmt.Sprintf("%s%s,%02d", sign, formatIntWithSpace(whole), fraction)
|
||||
}
|
||||
|
||||
func emptyDash(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "—"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sumPricingColumn(rows []ProjectPricingExportRow, selector func(ProjectPricingExportRow) *float64) *float64 {
|
||||
total := 0.0
|
||||
hasValue := false
|
||||
for _, row := range rows {
|
||||
value := selector(row)
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
total += *value
|
||||
hasValue = true
|
||||
}
|
||||
if !hasValue {
|
||||
return nil
|
||||
}
|
||||
return floatPtr(total)
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 {
|
||||
v := value
|
||||
return &v
|
||||
}
|
||||
|
||||
func exportPositiveInt(value, fallback int) int {
|
||||
if value < 1 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").
|
||||
// Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown.
|
||||
func formatPriceComma(value float64) string {
|
||||
if value == math.Trunc(value) {
|
||||
return fmt.Sprintf("%.0f", value)
|
||||
}
|
||||
s := fmt.Sprintf("%.2f", value)
|
||||
s = strings.ReplaceAll(s, ".", ",")
|
||||
// Trim trailing zero: "2074,50" -> "2074,5"
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimRight(s, ",")
|
||||
return s
|
||||
}
|
||||
|
||||
// formatPriceInt formats price as integer (rounded), no decimal.
|
||||
func formatPriceInt(value float64) string {
|
||||
return fmt.Sprintf("%.0f", math.Round(value))
|
||||
}
|
||||
|
||||
// formatPriceWithSpace formats a price as an integer with space as thousands separator (e.g., "104 700").
|
||||
func formatPriceWithSpace(value float64) string {
|
||||
intVal := int64(math.Round(value))
|
||||
if intVal < 0 {
|
||||
return "-" + formatIntWithSpace(-intVal)
|
||||
}
|
||||
return formatIntWithSpace(intVal)
|
||||
}
|
||||
|
||||
func formatIntWithSpace(n int64) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
remainder := len(s) % 3
|
||||
if remainder > 0 {
|
||||
result.WriteString(s[:remainder])
|
||||
}
|
||||
for i := remainder; i < len(s); i += 3 {
|
||||
if result.Len() > 0 {
|
||||
result.WriteByte(' ')
|
||||
}
|
||||
result.WriteString(s[i : i+3])
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user