Compare commits
196 Commits
ad3b1e036c
...
v1.5.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a360992a01 | ||
|
|
1ea21ece33 | ||
|
|
7ae804d2d3 | ||
|
|
da5414c708 | ||
|
|
7a69c1513d | ||
|
|
f448111e77 | ||
|
|
a5dafd37d3 | ||
|
|
3661e345b1 | ||
|
|
f915866f83 | ||
|
|
c34a42aaf5 | ||
|
|
7de0f359b6 | ||
|
|
a8d8d7dfa9 | ||
|
|
20ce0124be | ||
|
|
b0a106415f | ||
|
|
a054fc7564 | ||
|
|
68cd087356 | ||
|
|
579ff46a7f | ||
|
|
35c5600b36 | ||
|
|
c599897142 | ||
|
|
c964d66e64 | ||
|
|
f0e6bba7e9 | ||
|
|
61d7e493bd | ||
|
|
f930c79b34 | ||
|
|
a0a57e0969 | ||
|
|
b3003c4858 | ||
|
|
e2da8b4253 | ||
|
|
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 | ||
|
|
44ccb01203 | ||
|
|
190a9aa0a3 |
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"
|
||||
57
.gitignore
vendored
57
.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,12 @@ Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Release artifacts (binaries, archives, checksums), but keep markdown notes tracked
|
||||
releases/*
|
||||
!releases/README.md
|
||||
!releases/memory/
|
||||
!releases/memory/**
|
||||
!releases/**/
|
||||
releases/**/*
|
||||
!releases/README.md
|
||||
!releases/*/RELEASE_NOTES.md
|
||||
|
||||
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/`.
|
||||
17
CLAUDE.md
Normal file
17
CLAUDE.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# QuoteForge — Instructions for Claude
|
||||
|
||||
## 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/`.
|
||||
|
||||
```bash
|
||||
go build ./cmd/qfs && go vet ./... # verify
|
||||
go run ./cmd/qfs # run
|
||||
make build-release # release build
|
||||
```
|
||||
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)"
|
||||
248
README.md
248
README.md
@@ -1,231 +1,53 @@
|
||||
# QuoteForge
|
||||
|
||||
**Server Configuration & Quotation Tool**
|
||||
Local-first desktop web app for server configuration, quotation, and project work.
|
||||
|
||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений. Позволяет быстро собрать спецификацию сервера из каталога компонентов с автоматическим расчётом цен.
|
||||
Runtime model:
|
||||
- user work is stored in local SQLite;
|
||||
- MariaDB is used only for setup checks and background sync;
|
||||
- HTTP server binds to loopback only.
|
||||
|
||||

|
||||

|
||||

|
||||
## What the app does
|
||||
|
||||
## Возможности
|
||||
- configuration editor with price refresh from synced pricelists;
|
||||
- projects with variants and ordered configurations;
|
||||
- vendor BOM import and PN -> LOT resolution;
|
||||
- revision history with rollback;
|
||||
- rotating local backups.
|
||||
|
||||
### Для пользователей
|
||||
- 📱 **Mobile-first интерфейс** — удобная работа с телефона и планшета
|
||||
- 🖥️ **Конфигуратор серверов** — пошаговый выбор компонентов с проверкой совместимости
|
||||
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
|
||||
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
|
||||
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
|
||||
- 📤 **Импорт/экспорт JSON** — обмен конфигурациями между пользователями
|
||||
|
||||
### Для ценовых администраторов
|
||||
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
|
||||
- 🎯 **Система алертов** — уведомления о популярных компонентах с устаревшими ценами
|
||||
- 📉 **Аналитика использования** — какие компоненты востребованы в КП
|
||||
- ⚙️ **Гибкие настройки** — периоды расчёта, методы, ручные переопределения
|
||||
|
||||
### Индикация актуальности цен
|
||||
| Цвет | Статус | Условие |
|
||||
|------|--------|---------|
|
||||
| 🟢 Зелёный | Свежая | < 30 дней, ≥ 3 источника |
|
||||
| 🟡 Жёлтый | Нормальная | 30-60 дней |
|
||||
| 🟠 Оранжевый | Устаревающая | 60-90 дней |
|
||||
| 🔴 Красный | Устаревшая | > 90 дней или нет данных |
|
||||
|
||||
## Технологии
|
||||
|
||||
- **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. Клонирование репозитория
|
||||
## Run
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-company/quoteforge.git
|
||||
cd quoteforge
|
||||
go run ./cmd/qfs
|
||||
```
|
||||
|
||||
### 2. Настройка конфигурации
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
go run ./cmd/qfs -migrate
|
||||
go test ./...
|
||||
go vet ./...
|
||||
make build-release
|
||||
```
|
||||
|
||||
Отредактируйте `config.yaml`:
|
||||
On first run the app creates a minimal `config.yaml`, starts on `http://127.0.0.1:8080`, and opens `/setup` if DB credentials were not saved yet.
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
mode: "release"
|
||||
## Documentation
|
||||
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 3306
|
||||
name: "RFQ_LOG"
|
||||
user: "quoteforge"
|
||||
password: "your-secure-password"
|
||||
- Shared engineering rules: [bible/README.md](bible/README.md)
|
||||
- Project architecture: [bible-local/README.md](bible-local/README.md)
|
||||
- Release notes: `releases/<version>/RELEASE_NOTES.md`
|
||||
|
||||
auth:
|
||||
jwt_secret: "your-jwt-secret-min-32-chars"
|
||||
token_expiry: "24h"
|
||||
`bible-local/` is the source of truth for QuoteForge-specific architecture. If code changes behavior, update the matching file there in the same commit.
|
||||
|
||||
## Repository map
|
||||
|
||||
```text
|
||||
cmd/ entry points and migration tools
|
||||
internal/ application code
|
||||
web/ templates and static assets
|
||||
bible/ shared engineering rules
|
||||
bible-local/ project architecture and contracts
|
||||
releases/ packaged release artifacts and release notes
|
||||
config.example.yaml runtime config reference
|
||||
```
|
||||
|
||||
### 3. Миграции базы данных
|
||||
|
||||
```bash
|
||||
make migrate
|
||||
```
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
|
||||
```bash
|
||||
make seed
|
||||
```
|
||||
|
||||
### 5. Запуск
|
||||
|
||||
```bash
|
||||
# Development
|
||||
make run
|
||||
|
||||
# Production
|
||||
make build
|
||||
./bin/quoteforge
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу: http://localhost:8080
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Сборка образа
|
||||
docker build -t quoteforge .
|
||||
|
||||
# Запуск с docker-compose
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## Роли пользователей
|
||||
|
||||
| Роль | Описание |
|
||||
|------|----------|
|
||||
| `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) для подробностей.
|
||||
|
||||
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 52444350c1
70
bible-local/01-overview.md
Normal file
70
bible-local/01-overview.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 01 - Overview
|
||||
|
||||
## Product
|
||||
|
||||
QuoteForge is a local-first tool for server configuration, quotation, and project tracking.
|
||||
|
||||
Core user flows:
|
||||
- create and edit configurations locally;
|
||||
- calculate prices from synced pricelists;
|
||||
- group configurations into projects and variants;
|
||||
- import vendor workspaces and map vendor PNs to internal LOTs;
|
||||
- review revision history and roll back safely.
|
||||
|
||||
## Runtime model
|
||||
|
||||
QuoteForge is a single-user thick client.
|
||||
|
||||
Rules:
|
||||
- runtime HTTP binds to loopback only;
|
||||
- browser requests are treated as part of the same local user session;
|
||||
- MariaDB is not a live dependency for normal CRUD;
|
||||
- if non-loopback deployment is ever introduced, auth/RBAC must be added first.
|
||||
|
||||
## Product scope
|
||||
|
||||
In scope:
|
||||
- configurator and quote calculation;
|
||||
- projects, variants, and configuration ordering;
|
||||
- local revision history;
|
||||
- read-only pricelist browsing from SQLite cache;
|
||||
- background sync with MariaDB;
|
||||
- rotating local backups.
|
||||
|
||||
Out of scope and intentionally removed:
|
||||
- admin pricing UI/API;
|
||||
- alerts and notification workflows;
|
||||
- stock import tooling;
|
||||
- cron jobs and importer utilities.
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Stack |
|
||||
| --- | --- |
|
||||
| Backend | Go, Gin, GORM |
|
||||
| Frontend | HTML templates, htmx, Tailwind CSS |
|
||||
| Local storage | SQLite |
|
||||
| Sync transport | MariaDB |
|
||||
| Export | CSV and XLSX generation |
|
||||
|
||||
## Repository map
|
||||
|
||||
```text
|
||||
cmd/
|
||||
qfs/ main HTTP runtime
|
||||
migrate/ server migration tool
|
||||
migrate_ops_projects/ OPS project migration helper
|
||||
internal/
|
||||
appstate/ backup and runtime state
|
||||
config/ runtime config parsing
|
||||
handlers/ HTTP handlers
|
||||
localdb/ SQLite models and migrations
|
||||
repository/ repositories
|
||||
services/ business logic and sync
|
||||
web/
|
||||
templates/ HTML templates
|
||||
static/ static assets
|
||||
bible/ shared engineering rules
|
||||
bible-local/ project-specific architecture
|
||||
releases/ release artifacts and notes
|
||||
```
|
||||
116
bible-local/02-architecture.md
Normal file
116
bible-local/02-architecture.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 02 - Architecture
|
||||
|
||||
## Local-first rule
|
||||
|
||||
SQLite is the runtime source of truth.
|
||||
MariaDB is sync transport plus setup and migration tooling.
|
||||
|
||||
```text
|
||||
browser -> Gin handlers -> SQLite
|
||||
-> pending_changes
|
||||
background sync <------> MariaDB
|
||||
```
|
||||
|
||||
Rules:
|
||||
- user CRUD must continue when MariaDB is offline;
|
||||
- runtime handlers and pages must read and write SQLite only;
|
||||
- MariaDB access in runtime code is allowed only inside sync and setup flows;
|
||||
- no live MariaDB fallback for reads that already exist in local cache.
|
||||
|
||||
## Sync contract
|
||||
|
||||
Bidirectional:
|
||||
- projects;
|
||||
- configurations;
|
||||
- `vendor_spec`;
|
||||
- pending change metadata.
|
||||
|
||||
Pull-only:
|
||||
- components;
|
||||
- pricelists and pricelist items;
|
||||
- partnumber books and partnumber book items.
|
||||
|
||||
Readiness guard:
|
||||
- every sync push/pull runs a preflight check;
|
||||
- blocked sync returns `423 Locked` with a machine-readable reason;
|
||||
- local work continues even when sync is blocked.
|
||||
- sync metadata updates must preserve project `updated_at`; sync time belongs in `synced_at`, not in the user-facing last-modified timestamp.
|
||||
- pricelist pull must persist a new local snapshot atomically: header and items appear together, and `last_pricelist_sync` advances only after item download succeeds.
|
||||
- UI sync status must distinguish "last sync failed" from "up to date"; if the app can prove newer server pricelist data exists, the indicator must say local cache is incomplete.
|
||||
|
||||
## Pricing contract
|
||||
|
||||
Prices come only from `local_pricelist_items`.
|
||||
|
||||
Rules:
|
||||
- `local_components` is metadata-only;
|
||||
- quote calculation must not read prices from components;
|
||||
- latest pricelist selection ignores snapshots without items;
|
||||
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
|
||||
|
||||
## Pricing tab layout
|
||||
|
||||
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
|
||||
|
||||
Column order (both tables):
|
||||
|
||||
```
|
||||
PN вендора | Описание | LOT | Кол-во | Estimate | Склад | Конкуренты | Ручная цена
|
||||
```
|
||||
|
||||
Per-LOT row expansion rules:
|
||||
- each `lot_mappings` entry in a BOM row becomes its own table row with its own quantity and prices;
|
||||
- `baseLot` (resolved LOT without an explicit mapping) is treated as the first sub-row with `quantity_per_pn` from `_getRowLotQtyPerPN`;
|
||||
- when one vendor PN expands into N LOT sub-rows, PN вендора and Описание cells use `rowspan="N"` and appear only on the first sub-row;
|
||||
- a visual top border (`border-t border-gray-200`) separates each vendor PN group.
|
||||
|
||||
Vendor price attachment:
|
||||
- `vendorOrig` and `vendorOrigUnit` (BOM unit/total price) are attached to the first LOT sub-row only;
|
||||
- subsequent sub-rows carry empty `data-vendor-orig` so `setPricingCustomPriceFromVendor` counts each vendor PN exactly once.
|
||||
|
||||
Controls terminology:
|
||||
- custom price input is labeled **Ручная цена** (not "Своя цена");
|
||||
- the button that fills custom price from BOM totals is labeled **BOM Цена** (not "Проставить цены BOM").
|
||||
|
||||
CSV export reads PN вендора, Описание, and LOT from `data-vendor-pn`, `data-desc`, `data-lot` row attributes to bypass the rowspan cell offset problem.
|
||||
|
||||
## Configuration versioning
|
||||
|
||||
Configuration revisions are append-only snapshots stored in `local_configuration_versions`.
|
||||
|
||||
Rules:
|
||||
- the editable working configuration is always the implicit head named `main`; UI must not switch the user to a numbered revision after save;
|
||||
- create a new revision when spec, BOM, or pricing content changes;
|
||||
- revision history is retrospective: the revisions page shows past snapshots, not the current `main` state;
|
||||
- rollback creates a new head revision from an old snapshot;
|
||||
- rename, reorder, project move, and similar operational edits do not create a new revision snapshot;
|
||||
- revision deduplication includes `items`, `server_count`, `total_price`, `custom_price`, `vendor_spec`, pricelist selectors, `disable_price_refresh`, and `only_in_stock`;
|
||||
- BOM updates must use version-aware save flow, not a direct SQL field update;
|
||||
- current revision pointer must be recoverable if legacy or damaged rows are found locally.
|
||||
|
||||
## Sync UX
|
||||
|
||||
UI-facing sync status must never block on live MariaDB calls.
|
||||
|
||||
Rules:
|
||||
- navbar sync indicator and sync info modal read only local cached state from SQLite/app settings;
|
||||
- background/manual sync may talk to MariaDB, but polling endpoints must stay fast even on slow or broken connections;
|
||||
- any MariaDB timeout/invalid-connection during sync must invalidate the cached remote handle immediately so UI stops treating the connection as healthy.
|
||||
|
||||
## Naming collisions
|
||||
|
||||
UI-driven rename and copy flows use one suffix convention for conflicts.
|
||||
|
||||
Rules:
|
||||
- configuration and variant names must auto-resolve collisions with `_копия`, then `_копия2`, `_копия3`, and so on;
|
||||
- copy checkboxes and copy modals must prefill `_копия`, not ` (копия)`;
|
||||
- the literal variant name `main` is reserved and must not be allowed for non-main variants.
|
||||
|
||||
## Vendor BOM contract
|
||||
|
||||
Vendor BOM is stored in `vendor_spec` on the configuration row.
|
||||
|
||||
Rules:
|
||||
- PN to LOT resolution uses the active local partnumber book;
|
||||
- canonical persisted mapping is `lot_mappings[]`;
|
||||
- QuoteForge does not use legacy BOM tables such as `qt_bom`, `qt_lot_bundles`, or `qt_lot_bundle_items`.
|
||||
405
bible-local/03-database.md
Normal file
405
bible-local/03-database.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# 03 - Database
|
||||
|
||||
## SQLite
|
||||
|
||||
SQLite is the local runtime database.
|
||||
|
||||
Main tables:
|
||||
|
||||
| Table | Purpose |
|
||||
| --- | --- |
|
||||
| `local_components` | synced component metadata |
|
||||
| `local_pricelists` | local pricelist headers |
|
||||
| `local_pricelist_items` | local pricelist rows, the only runtime price source |
|
||||
| `local_projects` | user projects |
|
||||
| `local_configurations` | user configurations |
|
||||
| `local_configuration_versions` | immutable revision snapshots |
|
||||
| `local_partnumber_books` | partnumber book headers |
|
||||
| `local_partnumber_book_items` | PN -> LOT catalog payload |
|
||||
| `pending_changes` | sync queue |
|
||||
| `connection_settings` | encrypted MariaDB connection settings |
|
||||
| `app_settings` | local app state |
|
||||
| `local_schema_migrations` | applied local migration markers |
|
||||
|
||||
Rules:
|
||||
- cache tables may be rebuilt if local migration recovery requires it;
|
||||
- user-authored tables must not be dropped as a recovery shortcut;
|
||||
- `local_pricelist_items` is the only valid runtime source of prices;
|
||||
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows.
|
||||
|
||||
## MariaDB
|
||||
|
||||
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-03-21.
|
||||
|
||||
### QuoteForge tables (qt_* and stock_*)
|
||||
|
||||
Runtime read:
|
||||
- `qt_categories` — pricelist categories
|
||||
- `qt_lot_metadata` — component metadata, price settings
|
||||
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
||||
- `qt_pricelist_items` — pricelist rows
|
||||
- `stock_log` — raw supplier price log, source for pricelist generation
|
||||
- `stock_ignore_rules` — patterns to skip during stock import
|
||||
- `qt_partnumber_books` — partnumber book headers
|
||||
- `qt_partnumber_book_items` — PN→LOT catalog payload
|
||||
|
||||
Runtime read/write:
|
||||
- `qt_projects` — projects
|
||||
- `qt_configurations` — configurations
|
||||
- `qt_client_schema_state` — per-client sync status and version tracking
|
||||
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
|
||||
|
||||
Insert-only tracking:
|
||||
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync
|
||||
|
||||
Server-side only (not queried by client runtime):
|
||||
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
||||
- `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime)
|
||||
- `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`)
|
||||
- `qt_scheduler_runs` — server background job tracking (no Go code references it in this repo)
|
||||
|
||||
### Competitor subsystem (server-side only, not used by QuoteForge Go code)
|
||||
|
||||
- `qt_competitors` — competitor registry
|
||||
- `partnumber_log_competitors` — competitor price log (FK → qt_competitors)
|
||||
|
||||
These tables exist in the schema and are maintained by another tool or workflow.
|
||||
QuoteForge references competitor pricelists only via `qt_pricelists` (source='competitor').
|
||||
|
||||
### Legacy RFQ tables (pre-QuoteForge, no Go code references)
|
||||
|
||||
- `lot` — original component registry (data preserved; superseded by `qt_lot_metadata`)
|
||||
- `lot_log` — original supplier price log (superseded by `stock_log`)
|
||||
- `supplier` — supplier registry (FK target for lot_log and machine_log)
|
||||
- `machine` — device model registry
|
||||
- `machine_log` — device price/quote log
|
||||
|
||||
These tables are retained for historical data. QuoteForge does not read or write them at runtime.
|
||||
|
||||
Rules:
|
||||
- QuoteForge runtime must not depend on any legacy RFQ tables;
|
||||
- stock enrichment happens during sync and is persisted into SQLite;
|
||||
- normal UI requests must not query MariaDB tables directly;
|
||||
- `qt_client_local_migrations` was removed from the schema on 2026-03-21 (was in earlier drafts).
|
||||
|
||||
## MariaDB Table Structures
|
||||
|
||||
Full column reference as of 2026-03-21 (`RFQ_LOG` final schema).
|
||||
|
||||
### qt_categories
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| code | varchar(20) UNIQUE NOT NULL | |
|
||||
| name | varchar(100) NOT NULL | |
|
||||
| name_ru | varchar(100) | |
|
||||
| display_order | bigint DEFAULT 0 | |
|
||||
| is_required | tinyint(1) DEFAULT 0 | |
|
||||
|
||||
### qt_client_schema_state
|
||||
PK: (username, hostname)
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| username | varchar(100) | |
|
||||
| hostname | varchar(255) DEFAULT '' | |
|
||||
| last_applied_migration_id | varchar(128) | |
|
||||
| app_version | varchar(64) | |
|
||||
| last_sync_at | datetime | |
|
||||
| last_sync_status | varchar(32) | |
|
||||
| pending_changes_count | int DEFAULT 0 | |
|
||||
| pending_errors_count | int DEFAULT 0 | |
|
||||
| configurations_count | int DEFAULT 0 | |
|
||||
| projects_count | int DEFAULT 0 | |
|
||||
| estimate_pricelist_version | varchar(128) | |
|
||||
| warehouse_pricelist_version | varchar(128) | |
|
||||
| competitor_pricelist_version | varchar(128) | |
|
||||
| last_sync_error_code | varchar(128) | |
|
||||
| last_sync_error_text | text | |
|
||||
| last_checked_at | datetime NOT NULL | |
|
||||
| updated_at | datetime NOT NULL | |
|
||||
|
||||
### qt_component_usage_stats
|
||||
PK: lot_name
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| lot_name | varchar(255) | |
|
||||
| quotes_total | bigint DEFAULT 0 | |
|
||||
| quotes_last30d | bigint DEFAULT 0 | |
|
||||
| quotes_last7d | bigint DEFAULT 0 | |
|
||||
| total_quantity | bigint 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 | datetime(3) | |
|
||||
|
||||
### qt_competitors
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| name | varchar(255) NOT NULL | |
|
||||
| code | varchar(100) UNIQUE NOT NULL | |
|
||||
| delivery_basis | varchar(50) DEFAULT 'DDP' | |
|
||||
| currency | varchar(10) DEFAULT 'USD' | |
|
||||
| column_mapping | longtext JSON | |
|
||||
| is_active | tinyint(1) DEFAULT 1 | |
|
||||
| created_at | timestamp | |
|
||||
| updated_at | timestamp ON UPDATE | |
|
||||
| price_uplift | decimal(8,4) DEFAULT 1.3 | effective_price = price / price_uplift |
|
||||
|
||||
### qt_configurations
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| uuid | varchar(36) UNIQUE NOT NULL | |
|
||||
| user_id | bigint UNSIGNED | |
|
||||
| owner_username | varchar(100) NOT NULL | |
|
||||
| app_version | varchar(64) | |
|
||||
| project_uuid | char(36) | FK → qt_projects.uuid ON DELETE SET NULL |
|
||||
| name | varchar(200) NOT NULL | |
|
||||
| items | longtext JSON NOT NULL | component list |
|
||||
| total_price | decimal(12,2) | |
|
||||
| notes | text | |
|
||||
| is_template | tinyint(1) DEFAULT 0 | |
|
||||
| created_at | datetime(3) | |
|
||||
| custom_price | decimal(12,2) | |
|
||||
| server_count | bigint DEFAULT 1 | |
|
||||
| server_model | varchar(100) | |
|
||||
| support_code | varchar(20) | |
|
||||
| article | varchar(80) | |
|
||||
| pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
|
||||
| warehouse_pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
|
||||
| competitor_pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
|
||||
| disable_price_refresh | tinyint(1) DEFAULT 0 | |
|
||||
| only_in_stock | tinyint(1) DEFAULT 0 | |
|
||||
| line_no | int | position within project |
|
||||
| price_updated_at | timestamp | |
|
||||
| vendor_spec | longtext JSON | |
|
||||
|
||||
### qt_lot_metadata
|
||||
PK: lot_name
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| lot_name | varchar(255) | |
|
||||
| category_id | bigint UNSIGNED | FK → qt_categories.id |
|
||||
| vendor | varchar(50) | |
|
||||
| model | varchar(100) | |
|
||||
| specs | longtext JSON | |
|
||||
| current_price | decimal(12,2) | cached computed price |
|
||||
| price_method | enum('manual','median','average','weighted_median') DEFAULT 'median' | |
|
||||
| price_period_days | bigint DEFAULT 90 | |
|
||||
| price_updated_at | datetime(3) | |
|
||||
| request_count | bigint DEFAULT 0 | |
|
||||
| last_request_date | date | |
|
||||
| popularity_score | decimal(10,4) DEFAULT 0 | |
|
||||
| price_coefficient | decimal(5,2) DEFAULT 0 | markup % |
|
||||
| manual_price | decimal(12,2) | |
|
||||
| meta_prices | varchar(1000) | raw price samples JSON |
|
||||
| meta_method | varchar(20) | method used for last compute |
|
||||
| meta_period_days | bigint DEFAULT 90 | |
|
||||
| is_hidden | tinyint(1) DEFAULT 0 | |
|
||||
|
||||
### qt_partnumber_books
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| version | varchar(30) UNIQUE NOT NULL | |
|
||||
| created_at | timestamp | |
|
||||
| created_by | varchar(100) | |
|
||||
| is_active | tinyint(1) DEFAULT 0 | only one active at a time |
|
||||
| partnumbers_json | longtext DEFAULT '[]' | flat list of partnumbers |
|
||||
|
||||
### qt_partnumber_book_items
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| partnumber | varchar(255) UNIQUE NOT NULL | |
|
||||
| lots_json | longtext NOT NULL | JSON array of lot_names |
|
||||
| description | varchar(10000) | |
|
||||
|
||||
### qt_pricelists
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| source | varchar(20) DEFAULT 'estimate' | 'estimate' / 'warehouse' / 'competitor' |
|
||||
| version | varchar(20) NOT NULL | UNIQUE with source |
|
||||
| created_at | datetime(3) | |
|
||||
| created_by | varchar(100) | |
|
||||
| is_active | tinyint(1) DEFAULT 1 | |
|
||||
| usage_count | bigint DEFAULT 0 | |
|
||||
| expires_at | datetime(3) | |
|
||||
| notification | varchar(500) | shown to clients on sync |
|
||||
|
||||
### qt_pricelist_items
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| pricelist_id | bigint UNSIGNED NOT NULL | FK → qt_pricelists.id |
|
||||
| lot_name | varchar(255) NOT NULL | INDEX with pricelist_id |
|
||||
| lot_category | varchar(50) | |
|
||||
| price | decimal(12,2) NOT NULL | |
|
||||
| price_method | varchar(20) | |
|
||||
| price_period_days | bigint DEFAULT 90 | |
|
||||
| price_coefficient | decimal(5,2) DEFAULT 0 | |
|
||||
| manual_price | decimal(12,2) | |
|
||||
| meta_prices | varchar(1000) | |
|
||||
|
||||
### qt_pricelist_sync_status
|
||||
PK: username
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| username | varchar(100) | |
|
||||
| last_sync_at | datetime NOT NULL | |
|
||||
| updated_at | datetime NOT NULL | |
|
||||
| app_version | varchar(64) | |
|
||||
|
||||
### qt_pricing_alerts
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| lot_name | varchar(255) NOT NULL | |
|
||||
| alert_type | enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price') | |
|
||||
| severity | enum('low','medium','high','critical') DEFAULT 'medium' | |
|
||||
| message | text NOT NULL | |
|
||||
| details | longtext JSON | |
|
||||
| status | enum('new','acknowledged','resolved','ignored') DEFAULT 'new' | |
|
||||
| created_at | datetime(3) | |
|
||||
|
||||
### qt_projects
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| uuid | char(36) UNIQUE NOT NULL | |
|
||||
| owner_username | varchar(100) NOT NULL | |
|
||||
| code | varchar(100) NOT NULL | UNIQUE with variant |
|
||||
| variant | varchar(100) DEFAULT '' | UNIQUE with code |
|
||||
| name | varchar(200) | |
|
||||
| tracker_url | varchar(500) | |
|
||||
| is_active | tinyint(1) DEFAULT 1 | |
|
||||
| is_system | tinyint(1) DEFAULT 0 | |
|
||||
| created_at | timestamp | |
|
||||
| updated_at | timestamp ON UPDATE | |
|
||||
|
||||
### qt_schema_migrations
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| filename | varchar(255) UNIQUE NOT NULL | |
|
||||
| applied_at | datetime(3) | |
|
||||
|
||||
### qt_scheduler_runs
|
||||
PK: job_name
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| job_name | varchar(100) | |
|
||||
| last_started_at | datetime | |
|
||||
| last_finished_at | datetime | |
|
||||
| last_status | varchar(20) DEFAULT 'idle' | |
|
||||
| last_error | text | |
|
||||
| updated_at | timestamp ON UPDATE | |
|
||||
|
||||
### qt_vendor_partnumber_seen
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| source_type | varchar(32) NOT NULL | |
|
||||
| vendor | varchar(255) DEFAULT '' | |
|
||||
| partnumber | varchar(255) UNIQUE NOT NULL | |
|
||||
| description | varchar(10000) | |
|
||||
| last_seen_at | datetime(3) NOT NULL | |
|
||||
| is_ignored | tinyint(1) DEFAULT 0 | |
|
||||
| is_pattern | tinyint(1) DEFAULT 0 | |
|
||||
| ignored_at | datetime(3) | |
|
||||
| ignored_by | varchar(100) | |
|
||||
| created_at | datetime(3) | |
|
||||
| updated_at | datetime(3) | |
|
||||
|
||||
### stock_ignore_rules
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| target | varchar(20) NOT NULL | UNIQUE with match_type+pattern |
|
||||
| match_type | varchar(20) NOT NULL | |
|
||||
| pattern | varchar(500) NOT NULL | |
|
||||
| created_at | timestamp | |
|
||||
|
||||
### stock_log
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| stock_log_id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| partnumber | varchar(255) NOT NULL | INDEX with date |
|
||||
| supplier | varchar(255) | |
|
||||
| date | date NOT NULL | |
|
||||
| price | decimal(12,2) NOT NULL | |
|
||||
| quality | varchar(255) | |
|
||||
| comments | text | |
|
||||
| vendor | varchar(255) | INDEX |
|
||||
| qty | decimal(14,3) | |
|
||||
|
||||
### partnumber_log_competitors
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| competitor_id | bigint UNSIGNED NOT NULL | FK → qt_competitors.id |
|
||||
| partnumber | varchar(255) NOT NULL | |
|
||||
| description | varchar(500) | |
|
||||
| vendor | varchar(255) | |
|
||||
| price | decimal(12,2) NOT NULL | |
|
||||
| price_loccur | decimal(12,2) | local currency price |
|
||||
| currency | varchar(10) | |
|
||||
| qty | decimal(12,4) DEFAULT 1 | |
|
||||
| date | date NOT NULL | |
|
||||
| created_at | timestamp | |
|
||||
|
||||
### Legacy tables (lot / lot_log / machine / machine_log / supplier)
|
||||
|
||||
Retained for historical data only. Not queried by QuoteForge.
|
||||
|
||||
**lot**: lot_name (PK, char 255), lot_category, lot_description
|
||||
**lot_log**: lot_log_id AUTO_INCREMENT, lot (FK→lot), supplier (FK→supplier), date, price double, quality, comments
|
||||
**supplier**: supplier_name (PK, char 255), supplier_comment
|
||||
**machine**: machine_name (PK, char 255), machine_description
|
||||
**machine_log**: machine_log_id AUTO_INCREMENT, date, supplier (FK→supplier), country, opty, type, machine (FK→machine), customer_requirement, variant, price_gpl, price_estimate, qty, quality, carepack, lead_time_weeks, prepayment_percent, price_got, Comment
|
||||
|
||||
## MariaDB User Permissions
|
||||
|
||||
The application user needs read-only access to reference tables and read/write access to runtime tables.
|
||||
|
||||
```sql
|
||||
-- Read-only: reference and pricing data
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.stock_log TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.stock_ignore_rules TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
|
||||
|
||||
-- Read/write: runtime sync and user data
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_projects TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `qt_client_schema_state` requires INSERT + UPDATE for sync status tracking (uses `ON DUPLICATE KEY UPDATE`);
|
||||
- `qt_vendor_partnumber_seen` requires INSERT + UPDATE (vendor PN discovery during sync);
|
||||
- no DELETE is needed on sync/tracking tables — rows are never removed by the client;
|
||||
- `lot` SELECT is required for the connection validation probe in `/setup`;
|
||||
- the setup page shows `can_write: true` only when `qt_client_schema_state` INSERT succeeds.
|
||||
|
||||
## Migrations
|
||||
|
||||
SQLite:
|
||||
- schema creation and additive changes go through GORM `AutoMigrate`;
|
||||
- data fixes, index repair, and one-off rewrites go through `runLocalMigrations`;
|
||||
- local migration state is tracked in `local_schema_migrations`.
|
||||
|
||||
MariaDB:
|
||||
- SQL files live in `migrations/`;
|
||||
- they are applied by `go run ./cmd/qfs -migrate`.
|
||||
125
bible-local/04-api.md
Normal file
125
bible-local/04-api.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 04 - API
|
||||
|
||||
## Public web routes
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `/` | configurator |
|
||||
| `/configs` | configuration list |
|
||||
| `/configs/:uuid/revisions` | revision history page |
|
||||
| `/projects` | project list |
|
||||
| `/projects/:uuid` | project detail |
|
||||
| `/pricelists` | pricelist list |
|
||||
| `/pricelists/:id` | pricelist detail |
|
||||
| `/partnumber-books` | partnumber book page |
|
||||
| `/setup` | DB setup page |
|
||||
|
||||
## Setup and health
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/health` | process health |
|
||||
| `GET` | `/setup` | setup page |
|
||||
| `POST` | `/setup` | save tested DB settings |
|
||||
| `POST` | `/setup/test` | test DB connection |
|
||||
| `GET` | `/setup/status` | setup status |
|
||||
| `GET` | `/api/db-status` | current DB/sync status |
|
||||
| `GET` | `/api/current-user` | local user identity |
|
||||
| `GET` | `/api/ping` | lightweight API ping |
|
||||
|
||||
`POST /api/restart` exists only in `debug` mode.
|
||||
|
||||
## Reference data
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/components` | list component metadata |
|
||||
| `GET` | `/api/components/:lot_name` | one component |
|
||||
| `GET` | `/api/categories` | list categories |
|
||||
| `GET` | `/api/pricelists` | list local pricelists |
|
||||
| `GET` | `/api/pricelists/latest` | latest pricelist by source |
|
||||
| `GET` | `/api/pricelists/:id` | pricelist header |
|
||||
| `GET` | `/api/pricelists/:id/items` | pricelist rows |
|
||||
| `GET` | `/api/pricelists/:id/lots` | lot names in a pricelist |
|
||||
| `GET` | `/api/partnumber-books` | local partnumber books |
|
||||
| `GET` | `/api/partnumber-books/:id` | book items by `server_id` |
|
||||
|
||||
## Quote and export
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/quote/validate` | validate config items |
|
||||
| `POST` | `/api/quote/calculate` | calculate quote totals |
|
||||
| `POST` | `/api/quote/price-levels` | resolve estimate/warehouse/competitor prices |
|
||||
| `POST` | `/api/export/csv` | export a single configuration |
|
||||
| `GET` | `/api/configs/:uuid/export` | export a stored configuration |
|
||||
| `GET` | `/api/projects/:uuid/export` | legacy project BOM export |
|
||||
| `POST` | `/api/projects/:uuid/export` | pricing-tab project export |
|
||||
|
||||
## Configurations
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/configs` | list configurations |
|
||||
| `POST` | `/api/configs/import` | import configurations from server |
|
||||
| `POST` | `/api/configs` | create configuration |
|
||||
| `POST` | `/api/configs/preview-article` | preview article |
|
||||
| `GET` | `/api/configs/:uuid` | get configuration |
|
||||
| `PUT` | `/api/configs/:uuid` | update configuration |
|
||||
| `DELETE` | `/api/configs/:uuid` | archive configuration |
|
||||
| `POST` | `/api/configs/:uuid/reactivate` | reactivate configuration |
|
||||
| `PATCH` | `/api/configs/:uuid/rename` | rename configuration |
|
||||
| `POST` | `/api/configs/:uuid/clone` | clone configuration |
|
||||
| `POST` | `/api/configs/:uuid/refresh-prices` | refresh prices |
|
||||
| `PATCH` | `/api/configs/:uuid/project` | move configuration to project |
|
||||
| `GET` | `/api/configs/:uuid/versions` | list revisions |
|
||||
| `GET` | `/api/configs/:uuid/versions/:version` | get one revision |
|
||||
| `POST` | `/api/configs/:uuid/rollback` | rollback by creating a new head revision |
|
||||
| `PATCH` | `/api/configs/:uuid/server-count` | update server count |
|
||||
| `GET` | `/api/configs/:uuid/vendor-spec` | read vendor BOM |
|
||||
| `PUT` | `/api/configs/:uuid/vendor-spec` | replace vendor BOM |
|
||||
| `POST` | `/api/configs/:uuid/vendor-spec/resolve` | resolve PN -> LOT |
|
||||
| `POST` | `/api/configs/:uuid/vendor-spec/apply` | apply BOM to cart |
|
||||
|
||||
## Projects
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/projects` | paginated project list |
|
||||
| `GET` | `/api/projects/all` | lightweight list for dropdowns |
|
||||
| `POST` | `/api/projects` | create project |
|
||||
| `GET` | `/api/projects/:uuid` | get project |
|
||||
| `PUT` | `/api/projects/:uuid` | update project |
|
||||
| `POST` | `/api/projects/:uuid/archive` | archive project |
|
||||
| `POST` | `/api/projects/:uuid/reactivate` | reactivate project |
|
||||
| `DELETE` | `/api/projects/:uuid` | delete project variant only |
|
||||
| `GET` | `/api/projects/:uuid/configs` | list project configurations |
|
||||
| `PATCH` | `/api/projects/:uuid/configs/reorder` | persist line order |
|
||||
| `POST` | `/api/projects/:uuid/configs` | create configuration inside project |
|
||||
| `POST` | `/api/projects/:uuid/configs/:config_uuid/clone` | clone config into project |
|
||||
| `POST` | `/api/projects/:uuid/vendor-import` | import CFXML workspace into project |
|
||||
|
||||
Vendor import contract:
|
||||
- multipart field name is `file`;
|
||||
- file limit is `1 GiB`;
|
||||
- oversized payloads are rejected before XML parsing.
|
||||
|
||||
## Sync
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/sync/status` | sync status |
|
||||
| `GET` | `/api/sync/readiness` | sync readiness |
|
||||
| `GET` | `/api/sync/info` | sync modal data |
|
||||
| `GET` | `/api/sync/users-status` | remote user status |
|
||||
| `GET` | `/api/sync/pending/count` | pending queue count |
|
||||
| `GET` | `/api/sync/pending` | pending queue rows |
|
||||
| `POST` | `/api/sync/components` | pull components |
|
||||
| `POST` | `/api/sync/pricelists` | pull pricelists |
|
||||
| `POST` | `/api/sync/partnumber-books` | pull partnumber books |
|
||||
| `POST` | `/api/sync/partnumber-seen` | report unresolved vendor PN |
|
||||
| `POST` | `/api/sync/all` | push and pull full sync |
|
||||
| `POST` | `/api/sync/push` | push pending changes |
|
||||
| `POST` | `/api/sync/repair` | repair broken pending rows |
|
||||
|
||||
When readiness is blocked, sync write endpoints return `423 Locked`.
|
||||
74
bible-local/05-config.md
Normal file
74
bible-local/05-config.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 05 - Config
|
||||
|
||||
## Runtime files
|
||||
|
||||
| Artifact | Default location |
|
||||
| --- | --- |
|
||||
| `qfs.db` | OS-specific user state directory |
|
||||
| `config.yaml` | same state directory as `qfs.db` |
|
||||
| `local_encryption.key` | same state directory as `qfs.db` |
|
||||
| `backups/` | next to `qfs.db` unless overridden |
|
||||
|
||||
The runtime state directory can be overridden with `QFS_STATE_DIR`.
|
||||
Direct paths can be overridden with `QFS_DB_PATH` and `QFS_CONFIG_PATH`.
|
||||
|
||||
## Runtime config shape
|
||||
|
||||
Runtime keeps `config.yaml` intentionally small:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 8080
|
||||
mode: "release"
|
||||
read_timeout: 30s
|
||||
write_timeout: 30s
|
||||
|
||||
backup:
|
||||
time: "00:00"
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
output: "stdout"
|
||||
```
|
||||
|
||||
Rules:
|
||||
- QuoteForge creates this file automatically if it does not exist;
|
||||
- startup rewrites legacy config files into this minimal runtime shape;
|
||||
- startup normalizes any `server.host` value to `127.0.0.1` before saving the runtime config;
|
||||
- `server.host` must stay on loopback.
|
||||
|
||||
Saved MariaDB credentials do not live in `config.yaml`.
|
||||
They are stored in SQLite and encrypted with `local_encryption.key` unless `QUOTEFORGE_ENCRYPTION_KEY` overrides the key material.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `QFS_STATE_DIR` | override runtime state directory |
|
||||
| `QFS_DB_PATH` | explicit SQLite path |
|
||||
| `QFS_CONFIG_PATH` | explicit config path |
|
||||
| `QFS_BACKUP_DIR` | explicit backup root |
|
||||
| `QFS_BACKUP_DISABLE` | disable rotating backups |
|
||||
| `QUOTEFORGE_ENCRYPTION_KEY` | override encryption key |
|
||||
| `QF_SERVER_PORT` | override HTTP port |
|
||||
|
||||
`QFS_BACKUP_DISABLE` accepts `1`, `true`, or `yes`.
|
||||
|
||||
## CLI flags
|
||||
|
||||
| Flag | Purpose |
|
||||
| --- | --- |
|
||||
| `-config <path>` | config file path |
|
||||
| `-localdb <path>` | SQLite path |
|
||||
| `-reset-localdb` | destructive local DB reset |
|
||||
| `-migrate` | apply server migrations and exit |
|
||||
| `-version` | print app version and exit |
|
||||
|
||||
## First run
|
||||
|
||||
1. runtime ensures `config.yaml` exists;
|
||||
2. runtime opens the local SQLite database;
|
||||
3. if no stored MariaDB credentials exist, `/setup` is served;
|
||||
4. after setup, runtime works locally and sync uses saved DB settings in the background.
|
||||
55
bible-local/06-backup.md
Normal file
55
bible-local/06-backup.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 06 - Backup
|
||||
|
||||
## Scope
|
||||
|
||||
QuoteForge creates rotating local ZIP backups of:
|
||||
- a consistent SQLite snapshot saved as `qfs.db`;
|
||||
- `config.yaml` when present.
|
||||
|
||||
The backup intentionally does not include `local_encryption.key`.
|
||||
|
||||
## Location and naming
|
||||
|
||||
Default root:
|
||||
- `<db dir>/backups`
|
||||
|
||||
Subdirectories:
|
||||
- `daily/`
|
||||
- `weekly/`
|
||||
- `monthly/`
|
||||
- `yearly/`
|
||||
|
||||
Archive name:
|
||||
- `qfs-backp-YYYY-MM-DD.zip`
|
||||
|
||||
## Retention
|
||||
|
||||
| Period | Keep |
|
||||
| --- | --- |
|
||||
| Daily | 7 |
|
||||
| Weekly | 4 |
|
||||
| Monthly | 12 |
|
||||
| Yearly | 10 |
|
||||
|
||||
## Behavior
|
||||
|
||||
- on startup, QuoteForge creates a backup if the current period has none yet;
|
||||
- a daily scheduler creates the next backup at `backup.time`;
|
||||
- duplicate snapshots inside the same period are prevented by a period marker file;
|
||||
- old archives are pruned automatically.
|
||||
|
||||
## Safety rules
|
||||
|
||||
- backup root must be outside the git worktree;
|
||||
- backup creation is blocked if the resolved backup root sits inside the repository;
|
||||
- SQLite snapshot must be created from a consistent database copy, not by copying live WAL files directly;
|
||||
- restore to another machine requires re-entering DB credentials unless the encryption key is migrated separately.
|
||||
|
||||
## Restore
|
||||
|
||||
1. stop QuoteForge;
|
||||
2. unpack the chosen archive outside the repository;
|
||||
3. replace `qfs.db`;
|
||||
4. replace `config.yaml` if needed;
|
||||
5. restart the app;
|
||||
6. re-enter MariaDB credentials if the original encryption key is unavailable.
|
||||
35
bible-local/07-dev.md
Normal file
35
bible-local/07-dev.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 07 - Development
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
go run ./cmd/qfs
|
||||
go run ./cmd/qfs -migrate
|
||||
go run ./cmd/migrate_project_updated_at
|
||||
go test ./...
|
||||
go vet ./...
|
||||
make build-release
|
||||
make install-hooks
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- run `gofmt` before commit;
|
||||
- use `slog` for server logging;
|
||||
- keep runtime business logic SQLite-only;
|
||||
- limit MariaDB access to sync, setup, and migration tooling;
|
||||
- keep `config.yaml` out of git and use `config.example.yaml` only as a template;
|
||||
- update `bible-local/` in the same commit as architecture changes.
|
||||
|
||||
## Removed features that must not return
|
||||
|
||||
- admin pricing UI/API;
|
||||
- alerts and notification workflows;
|
||||
- stock import tooling;
|
||||
- cron jobs;
|
||||
- standalone importer utility.
|
||||
|
||||
## Release notes
|
||||
|
||||
Release history belongs under `releases/<version>/RELEASE_NOTES.md`.
|
||||
Do not keep temporary change summaries in the repository root.
|
||||
64
bible-local/09-vendor-spec.md
Normal file
64
bible-local/09-vendor-spec.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 09 - Vendor BOM
|
||||
|
||||
## Storage contract
|
||||
|
||||
Vendor BOM is stored in `local_configurations.vendor_spec` and synced with `qt_configurations.vendor_spec`.
|
||||
|
||||
Each row uses this canonical shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"sort_order": 10,
|
||||
"vendor_partnumber": "ABC-123",
|
||||
"quantity": 2,
|
||||
"description": "row description",
|
||||
"unit_price": 4500.0,
|
||||
"total_price": 9000.0,
|
||||
"lot_mappings": [
|
||||
{ "lot_name": "LOT_A", "quantity_per_pn": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `lot_mappings[]` is the only persisted PN -> LOT mapping contract;
|
||||
- QuoteForge does not use legacy BOM tables;
|
||||
- apply flow rebuilds cart rows from `lot_mappings[]`.
|
||||
|
||||
## Partnumber books
|
||||
|
||||
Partnumber books are pull-only snapshots from PriceForge.
|
||||
|
||||
Local tables:
|
||||
- `local_partnumber_books`
|
||||
- `local_partnumber_book_items`
|
||||
|
||||
Server tables:
|
||||
- `qt_partnumber_books`
|
||||
- `qt_partnumber_book_items`
|
||||
|
||||
Resolution flow:
|
||||
1. load the active local book;
|
||||
2. find `vendor_partnumber`;
|
||||
3. copy `lots_json` into `lot_mappings[]`;
|
||||
4. keep unresolved rows editable in the UI.
|
||||
|
||||
## CFXML import
|
||||
|
||||
`POST /api/projects/:uuid/vendor-import` imports one vendor workspace into an existing project.
|
||||
|
||||
Rules:
|
||||
- accepted file field is `file`;
|
||||
- maximum file size is `1 GiB`;
|
||||
- one `ProprietaryGroupIdentifier` becomes one QuoteForge configuration;
|
||||
- software rows stay inside their hardware group and never become standalone configurations;
|
||||
- primary group row is selected structurally, without vendor-specific SKU hardcoding;
|
||||
- imported configuration order follows workspace order.
|
||||
|
||||
Imported configuration fields:
|
||||
- `name` from primary row `ProductName`
|
||||
- `server_count` from primary row `Quantity`
|
||||
- `server_model` from primary row `ProductDescription`
|
||||
- `article` or `support_code` from `ProprietaryProductIdentifier`
|
||||
|
||||
Imported BOM rows become `vendor_spec` rows and are resolved through the active local partnumber book when possible.
|
||||
30
bible-local/README.md
Normal file
30
bible-local/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# QuoteForge Bible
|
||||
|
||||
Project-specific architecture and operational contracts.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Scope |
|
||||
| --- | --- |
|
||||
| [01-overview.md](01-overview.md) | Product scope, runtime model, repository map |
|
||||
| [02-architecture.md](02-architecture.md) | Local-first rules, sync, pricing, versioning |
|
||||
| [03-database.md](03-database.md) | SQLite and MariaDB data model, permissions, migrations |
|
||||
| [04-api.md](04-api.md) | HTTP routes and API contract |
|
||||
| [05-config.md](05-config.md) | Runtime config, paths, env vars, startup behavior |
|
||||
| [06-backup.md](06-backup.md) | Backup contract and restore workflow |
|
||||
| [07-dev.md](07-dev.md) | Development commands and guardrails |
|
||||
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
|
||||
|
||||
## Rules
|
||||
|
||||
- `bible-local/` is the source of truth for QuoteForge-specific behavior.
|
||||
- Keep these files in English.
|
||||
- Update the matching file in the same commit as any architectural change.
|
||||
- Remove stale documentation instead of preserving history in place.
|
||||
|
||||
## Quick reference
|
||||
|
||||
- Local DB path: see [05-config.md](05-config.md)
|
||||
- Runtime bind: loopback only
|
||||
- Local backups: see [06-backup.md](06-backup.md)
|
||||
- Release notes: `releases/<version>/RELEASE_NOTES.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
|
||||
}
|
||||
173
cmd/migrate_project_updated_at/main.go
Normal file
173
cmd/migrate_project_updated_at/main.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"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"
|
||||
)
|
||||
|
||||
type projectTimestampRow struct {
|
||||
UUID string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type updatePlanRow struct {
|
||||
UUID string
|
||||
Code string
|
||||
Variant string
|
||||
LocalUpdatedAt time.Time
|
||||
ServerUpdatedAt time.Time
|
||||
}
|
||||
|
||||
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 updates to local SQLite (default is preview only)")
|
||||
flag.Parse()
|
||||
|
||||
local, err := localdb.New(*localDBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize local database: %v", err)
|
||||
}
|
||||
defer local.Close()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
db, 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)
|
||||
}
|
||||
|
||||
serverRows, err := loadServerProjects(db)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load server projects: %v", err)
|
||||
}
|
||||
|
||||
localProjects, err := local.GetAllProjects(true)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load local projects: %v", err)
|
||||
}
|
||||
|
||||
plan := buildUpdatePlan(localProjects, serverRows)
|
||||
printPlan(plan, *apply)
|
||||
|
||||
if !*apply || len(plan) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
updated := 0
|
||||
for i := range plan {
|
||||
project, err := local.GetProjectByUUID(plan[i].UUID)
|
||||
if err != nil {
|
||||
log.Printf("skip %s: load local project: %v", plan[i].UUID, err)
|
||||
continue
|
||||
}
|
||||
project.UpdatedAt = plan[i].ServerUpdatedAt
|
||||
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
|
||||
log.Printf("skip %s: save local project: %v", plan[i].UUID, err)
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
}
|
||||
|
||||
log.Printf("updated %d local project timestamps", updated)
|
||||
}
|
||||
|
||||
func loadServerProjects(db *gorm.DB) (map[string]time.Time, error) {
|
||||
var rows []projectTimestampRow
|
||||
if err := db.Model(&models.Project{}).
|
||||
Select("uuid, updated_at").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]time.Time, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.UUID == "" {
|
||||
continue
|
||||
}
|
||||
out[row.UUID] = row.UpdatedAt
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildUpdatePlan(localProjects []localdb.LocalProject, serverRows map[string]time.Time) []updatePlanRow {
|
||||
plan := make([]updatePlanRow, 0)
|
||||
for i := range localProjects {
|
||||
project := localProjects[i]
|
||||
serverUpdatedAt, ok := serverRows[project.UUID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if project.UpdatedAt.Equal(serverUpdatedAt) {
|
||||
continue
|
||||
}
|
||||
plan = append(plan, updatePlanRow{
|
||||
UUID: project.UUID,
|
||||
Code: project.Code,
|
||||
Variant: project.Variant,
|
||||
LocalUpdatedAt: project.UpdatedAt,
|
||||
ServerUpdatedAt: serverUpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(plan, func(i, j int) bool {
|
||||
if plan[i].Code != plan[j].Code {
|
||||
return plan[i].Code < plan[j].Code
|
||||
}
|
||||
return plan[i].Variant < plan[j].Variant
|
||||
})
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
func printPlan(plan []updatePlanRow, apply bool) {
|
||||
mode := "preview"
|
||||
if apply {
|
||||
mode = "apply"
|
||||
}
|
||||
log.Printf("project updated_at resync mode=%s changes=%d", mode, len(plan))
|
||||
if len(plan) == 0 {
|
||||
log.Printf("no local project timestamps need resync")
|
||||
return
|
||||
}
|
||||
for _, row := range plan {
|
||||
variant := row.Variant
|
||||
if variant == "" {
|
||||
variant = "main"
|
||||
}
|
||||
log.Printf("%s [%s] local=%s server=%s", row.Code, variant, formatStamp(row.LocalUpdatedAt), formatStamp(row.ServerUpdatedAt))
|
||||
}
|
||||
if !apply {
|
||||
fmt.Println("Re-run with -apply to write server updated_at into local SQLite.")
|
||||
}
|
||||
}
|
||||
|
||||
func formatStamp(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return "zero"
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
106
cmd/qfs/config_migration_test.go
Normal file
106
cmd/qfs/config_migration_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
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)
|
||||
cfg.Server.Host, _, err = normalizeLoopbackServerHost(cfg.Server.Host)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize server host: %v", err)
|
||||
}
|
||||
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, "host: 127.0.0.1") {
|
||||
t.Fatalf("migrated config did not normalize server host:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "level: debug") {
|
||||
t.Fatalf("migrated config did not preserve logging level:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLoopbackServerHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
host string
|
||||
want string
|
||||
wantChanged bool
|
||||
wantErr bool
|
||||
}{
|
||||
{host: "127.0.0.1", want: "127.0.0.1", wantChanged: false, wantErr: false},
|
||||
{host: "localhost", want: "127.0.0.1", wantChanged: true, wantErr: false},
|
||||
{host: "::1", want: "127.0.0.1", wantChanged: true, wantErr: false},
|
||||
{host: "0.0.0.0", want: "127.0.0.1", wantChanged: true, wantErr: false},
|
||||
{host: "192.168.1.10", want: "127.0.0.1", wantChanged: true, wantErr: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got, changed, err := normalizeLoopbackServerHost(tc.host)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("expected error for host %q", tc.host)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error for host %q: %v", tc.host, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("unexpected normalized host for %q: got %q want %q", tc.host, got, tc.want)
|
||||
}
|
||||
if changed != tc.wantChanged {
|
||||
t.Fatalf("unexpected changed flag for %q: got %t want %t", tc.host, changed, tc.wantChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
1859
cmd/qfs/main.go
Normal file
1859
cmd/qfs/main.go
Normal file
File diff suppressed because it is too large
Load Diff
48
cmd/qfs/request_logger_test.go
Normal file
48
cmd/qfs/request_logger_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRequestLoggerDoesNotLogResponseBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var logBuffer bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{})))
|
||||
defer slog.SetDefault(previousLogger)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(requestLogger())
|
||||
router.GET("/fail", func(c *gin.Context) {
|
||||
_ = c.Error(errors.New("root cause"))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "do not log this body"})
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/fail?debug=1", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
|
||||
logOutput := logBuffer.String()
|
||||
if !strings.Contains(logOutput, "request failed") {
|
||||
t.Fatalf("expected request failure log, got %q", logOutput)
|
||||
}
|
||||
if strings.Contains(logOutput, "do not log this body") {
|
||||
t.Fatalf("response body leaked into logs: %q", logOutput)
|
||||
}
|
||||
if !strings.Contains(logOutput, "root cause") {
|
||||
t.Fatalf("expected error details in logs, got %q", logOutput)
|
||||
}
|
||||
}
|
||||
411
cmd/qfs/versioning_api_test.go
Normal file
411
cmd/qfs/versioning_api_test.go
Normal file
@@ -0,0 +1,411 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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 TestVendorImportRejectsOversizedUpload(t *testing.T) {
|
||||
moveToRepoRoot(t)
|
||||
|
||||
prevLimit := vendorImportMaxBytes
|
||||
vendorImportMaxBytes = 128
|
||||
defer func() { vendorImportMaxBytes = prevLimit }()
|
||||
|
||||
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":"Import Project","code":"IMP"}`)))
|
||||
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)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "huge.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
payload := "<CFXML>" + strings.Repeat("A", int(vendorImportMaxBytes)+1) + "</CFXML>"
|
||||
if _, err := part.Write([]byte(payload)); err != nil {
|
||||
t.Fatalf("write multipart payload: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/vendor-import", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for oversized upload, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "1 GiB") {
|
||||
t.Fatalf("expected size limit message, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateConfigMalformedJSONReturnsGenericError(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)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/configs", bytes.NewReader([]byte(`{"name":`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for malformed json, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(strings.ToLower(rec.Body.String()), "unexpected eof") {
|
||||
t.Fatalf("expected sanitized error body, got %s", rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "invalid request") {
|
||||
t.Fatalf("expected generic invalid request message, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
18
config.example.yaml
Normal file
18
config.example.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# QuoteForge runtime config
|
||||
# Runtime creates a minimal config automatically on first start.
|
||||
# This file is only a reference template.
|
||||
|
||||
server:
|
||||
host: "127.0.0.1" # Loopback only; remote HTTP binding is unsupported
|
||||
port: 8080
|
||||
mode: "release" # debug | release
|
||||
read_timeout: "30s"
|
||||
write_timeout: "30s"
|
||||
|
||||
backup:
|
||||
time: "00:00"
|
||||
|
||||
logging:
|
||||
level: "info" # debug | info | warn | error
|
||||
format: "json" # json | text
|
||||
output: "stdout" # stdout | stderr | /path/to/file
|
||||
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.
49
go.mod
Normal file
49
go.mod
Normal file
@@ -0,0 +1,49 @@
|
||||
module git.mchus.pro/mchus/quoteforge
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/google/uuid v1.6.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.5.2
|
||||
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/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/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
|
||||
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
|
||||
)
|
||||
121
go.sum
Normal file
121
go.sum
Normal file
@@ -0,0 +1,121 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/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=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/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=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/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=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/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=
|
||||
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/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
|
||||
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
|
||||
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.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"
|
||||
}
|
||||
|
||||
393
internal/appstate/backup.go
Normal file
393
internal/appstate/backup.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
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 {
|
||||
snapshotPath, cleanup, err := createSQLiteSnapshot(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
file, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(file)
|
||||
if err := addZipFileAs(zipWriter, snapshotPath, filepath.Base(dbPath)); err != nil {
|
||||
_ = zipWriter.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(configPath) != "" {
|
||||
_ = addZipOptionalFile(zipWriter, configPath)
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func createSQLiteSnapshot(dbPath string) (string, func(), error) {
|
||||
tempFile, err := os.CreateTemp("", "qfs-backup-*.db")
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
if err := tempFile.Close(); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return "", func() {}, err
|
||||
}
|
||||
if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) {
|
||||
return "", func() {}, err
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return "", func() {}, err
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return "", func() {}, err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if err := db.Exec("PRAGMA busy_timeout = 5000").Error; err != nil {
|
||||
cleanup()
|
||||
return "", func() {}, fmt.Errorf("configure sqlite busy_timeout: %w", err)
|
||||
}
|
||||
|
||||
literalPath := strings.ReplaceAll(tempPath, "'", "''")
|
||||
if err := vacuumIntoWithRetry(db, literalPath); err != nil {
|
||||
cleanup()
|
||||
return "", func() {}, err
|
||||
}
|
||||
|
||||
return tempPath, cleanup, nil
|
||||
}
|
||||
|
||||
func vacuumIntoWithRetry(db *gorm.DB, literalPath string) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if err := db.Exec("VACUUM INTO '" + literalPath + "'").Error; err != nil {
|
||||
lastErr = err
|
||||
if !isSQLiteBusyError(err) {
|
||||
return fmt.Errorf("create sqlite snapshot: %w", err)
|
||||
}
|
||||
time.Sleep(time.Duration(attempt+1) * 250 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("create sqlite snapshot after retries: %w", lastErr)
|
||||
}
|
||||
|
||||
func isSQLiteBusyError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(err.Error())
|
||||
return strings.Contains(lower, "database is locked") || strings.Contains(lower, "database is busy")
|
||||
}
|
||||
|
||||
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 {
|
||||
return addZipFileAs(writer, path, filepath.Base(path))
|
||||
}
|
||||
|
||||
func addZipFileAs(writer *zip.Writer, path string, archiveName 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 = archiveName
|
||||
header.Method = zip.Deflate
|
||||
|
||||
out, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
157
internal/appstate/backup_test.go
Normal file
157
internal/appstate/backup_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
dbPath := filepath.Join(temp, "qfs.db")
|
||||
cfgPath := filepath.Join(temp, "config.yaml")
|
||||
|
||||
if err := writeTestSQLiteDB(dbPath); err != nil {
|
||||
t.Fatalf("write sqlite 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)
|
||||
}
|
||||
assertZipContains(t, dailyArchive, "qfs.db", "config.yaml")
|
||||
|
||||
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 := writeTestSQLiteDB(dbPath); err != nil {
|
||||
t.Fatalf("write sqlite 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 := writeTestSQLiteDB(dbPath); err != nil {
|
||||
t.Fatalf("write sqlite 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)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestSQLiteDB(path string) error {
|
||||
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
return db.Exec(`
|
||||
CREATE TABLE sample_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO sample_items(name) VALUES ('backup');
|
||||
`).Error
|
||||
}
|
||||
|
||||
func assertZipContains(t *testing.T, archivePath string, expected ...string) {
|
||||
t.Helper()
|
||||
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open archive: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
found := make(map[string]bool, len(reader.File))
|
||||
for _, file := range reader.File {
|
||||
found[file.Name] = true
|
||||
}
|
||||
for _, name := range expected {
|
||||
if !found[name] {
|
||||
t.Fatalf("archive %s missing %s", archivePath, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
93
internal/config/config.go
Normal file
93
internal/config/config.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Export ExportConfig `yaml:"export"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Backup BackupConfig `yaml:"backup"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Mode string `yaml:"mode"`
|
||||
ReadTimeout time.Duration `yaml:"read_timeout"`
|
||||
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||||
}
|
||||
|
||||
type LoggingConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
Output string `yaml:"output"`
|
||||
FilePath string `yaml:"file_path"`
|
||||
}
|
||||
|
||||
// ExportConfig is kept for constructor compatibility in export services.
|
||||
// Runtime no longer persists an export section in config.yaml.
|
||||
type ExportConfig struct{}
|
||||
|
||||
type BackupConfig struct {
|
||||
Time string `yaml:"time"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config file: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config file: %w", err)
|
||||
}
|
||||
|
||||
cfg.setDefaults()
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.Server.Host == "" {
|
||||
c.Server.Host = "127.0.0.1"
|
||||
}
|
||||
if c.Server.Port == 0 {
|
||||
c.Server.Port = 8080
|
||||
}
|
||||
if c.Server.Mode == "" {
|
||||
c.Server.Mode = "release"
|
||||
}
|
||||
if c.Server.ReadTimeout == 0 {
|
||||
c.Server.ReadTimeout = 30 * time.Second
|
||||
}
|
||||
if c.Server.WriteTimeout == 0 {
|
||||
c.Server.WriteTimeout = 30 * time.Second
|
||||
}
|
||||
|
||||
if c.Logging.Level == "" {
|
||||
c.Logging.Level = "info"
|
||||
}
|
||||
if c.Logging.Format == "" {
|
||||
c.Logging.Format = "json"
|
||||
}
|
||||
if c.Logging.Output == "" {
|
||||
c.Logging.Output = "stdout"
|
||||
}
|
||||
|
||||
if c.Backup.Time == "" {
|
||||
c.Backup.Time = "00:00"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Address() string {
|
||||
return net.JoinHostPort(c.Server.Host, strconv.Itoa(c.Server.Port))
|
||||
}
|
||||
350
internal/db/connection.go
Normal file
350
internal/db/connection.go
Normal file
@@ -0,0 +1,350 @@
|
||||
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
|
||||
}
|
||||
|
||||
// MarkOffline closes the current connection and preserves the last observed error.
|
||||
func (cm *ConnectionManager) MarkOffline(err error) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
if cm.db != nil {
|
||||
sqlDB, dbErr := cm.db.DB()
|
||||
if dbErr == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
cm.db = nil
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
108
internal/handlers/component.go
Normal file
108
internal/handlers/component.go
Normal file
@@ -0,0 +1,108 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type ComponentHandler struct {
|
||||
componentService *services.ComponentService
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
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"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
|
||||
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 {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
|
||||
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.localDB.GetLocalComponent(lotName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
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, models.DefaultCategories)
|
||||
}
|
||||
271
internal/handlers/export.go
Normal file
271
internal/handlers/export.go
Normal file
@@ -0,0 +1,271 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type ExportHandler struct {
|
||||
exportService *services.ExportService
|
||||
configService services.ConfigurationGetter
|
||||
projectService *services.ProjectService
|
||||
dbUsername string
|
||||
}
|
||||
|
||||
func NewExportHandler(
|
||||
exportService *services.ExportService,
|
||||
configService services.ConfigurationGetter,
|
||||
projectService *services.ProjectService,
|
||||
dbUsername string,
|
||||
) *ExportHandler {
|
||||
return &ExportHandler{
|
||||
exportService: exportService,
|
||||
configService: configService,
|
||||
projectService: projectService,
|
||||
dbUsername: dbUsername,
|
||||
}
|
||||
}
|
||||
|
||||
type ExportRequest 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"`
|
||||
} `json:"items" binding:"required,min=1"`
|
||||
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 {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
data := h.buildExportData(&req)
|
||||
|
||||
// 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 {
|
||||
RespondError(c, http.StatusNotFound, "resource not found", err)
|
||||
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 {
|
||||
RespondError(c, http.StatusNotFound, "resource not found", err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
|
||||
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) ExportProjectPricingCSV(c *gin.Context) {
|
||||
projectUUID := c.Param("uuid")
|
||||
|
||||
var req ProjectExportOptionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusNotFound, "resource not found", err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
if len(result.Configs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
if err := h.exportService.ToPricingCSV(c.Writer, data, opts); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
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 {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
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 {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
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),
|
||||
})
|
||||
}
|
||||
279
internal/handlers/pricelist.go
Normal file
279
internal/handlers/pricelist.go
Normal file
@@ -0,0 +1,279 @@
|
||||
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 {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
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 {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
lotNames := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
lotNames[i] = item.LotName
|
||||
}
|
||||
type compRow struct {
|
||||
LotName string
|
||||
LotDescription string
|
||||
}
|
||||
var comps []compRow
|
||||
if len(lotNames) > 0 {
|
||||
h.localDB.DB().Table("local_components").
|
||||
Select("lot_name, lot_description").
|
||||
Where("lot_name IN ?", lotNames).
|
||||
Scan(&comps)
|
||||
}
|
||||
descMap := make(map[string]string, len(comps))
|
||||
for _, c := range comps {
|
||||
descMap[c.LotName] = c.LotDescription
|
||||
}
|
||||
|
||||
resultItems := make([]gin.H, 0, len(items))
|
||||
for _, item := range items {
|
||||
resultItems = append(resultItems, gin.H{
|
||||
"id": item.ID,
|
||||
"lot_name": item.LotName,
|
||||
"lot_description": descMap[item.LotName],
|
||||
"price": item.Price,
|
||||
"category": item.LotCategory,
|
||||
"available_qty": item.AvailableQty,
|
||||
"partnumbers": []string(item.Partnumbers),
|
||||
"partnumber_qtys": map[string]interface{}{},
|
||||
"competitor_names": []string{},
|
||||
"price_spread_pct": nil,
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
67
internal/handlers/quote.go
Normal file
67
internal/handlers/quote.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type QuoteHandler struct {
|
||||
quoteService *services.QuoteService
|
||||
}
|
||||
|
||||
func NewQuoteHandler(quoteService *services.QuoteService) *QuoteHandler {
|
||||
return &QuoteHandler{quoteService: quoteService}
|
||||
}
|
||||
|
||||
func (h *QuoteHandler) Validate(c *gin.Context) {
|
||||
var req services.QuoteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.quoteService.ValidateAndCalculate(&req)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *QuoteHandler) Calculate(c *gin.Context) {
|
||||
var req services.QuoteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.quoteService.ValidateAndCalculate(&req)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": result.Items,
|
||||
"total": result.Total,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
|
||||
var req services.PriceLevelsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.quoteService.CalculatePriceLevels(&req)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
73
internal/handlers/respond.go
Normal file
73
internal/handlers/respond.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RespondError(c *gin.Context, status int, fallback string, err error) {
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
}
|
||||
c.JSON(status, gin.H{"error": clientFacingErrorMessage(status, fallback, err)})
|
||||
}
|
||||
|
||||
func clientFacingErrorMessage(status int, fallback string, err error) string {
|
||||
if err == nil {
|
||||
return fallback
|
||||
}
|
||||
if status >= 500 {
|
||||
return fallback
|
||||
}
|
||||
if isRequestDecodeError(err) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(err.Error())
|
||||
if message == "" {
|
||||
return fallback
|
||||
}
|
||||
if looksTechnicalError(message) {
|
||||
return fallback
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func isRequestDecodeError(err error) bool {
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
var unmarshalTypeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &unmarshalTypeErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
return errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF)
|
||||
}
|
||||
|
||||
func looksTechnicalError(message string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(message))
|
||||
needles := []string{
|
||||
"sql",
|
||||
"gorm",
|
||||
"driver",
|
||||
"constraint",
|
||||
"syntax error",
|
||||
"unexpected eof",
|
||||
"record not found",
|
||||
"no such table",
|
||||
"stack trace",
|
||||
}
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(lower, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
41
internal/handlers/respond_test.go
Normal file
41
internal/handlers/respond_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientFacingErrorMessageKeepsDomain4xx(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := clientFacingErrorMessage(400, "invalid request", &json.SyntaxError{Offset: 1})
|
||||
if got != "invalid request" {
|
||||
t.Fatalf("expected fallback for decode error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientFacingErrorMessagePreservesBusinessMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := errString("main project variant cannot be deleted")
|
||||
got := clientFacingErrorMessage(400, "invalid request", err)
|
||||
if got != err.Error() {
|
||||
t.Fatalf("expected business message, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientFacingErrorMessageHidesTechnical4xx(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := errString("sql: no rows in result set")
|
||||
got := clientFacingErrorMessage(404, "resource not found", err)
|
||||
if got != "resource not found" {
|
||||
t.Fatalf("expected fallback for technical error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
259
internal/handlers/setup.go
Normal file
259
internal/handlers/setup.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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"
|
||||
"github.com/gin-gonic/gin"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
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{}
|
||||
}
|
||||
|
||||
var errPermissionProbeRollback = errors.New("permission probe rollback")
|
||||
|
||||
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.Error(err)
|
||||
c.String(http.StatusInternalServerError, "Template error")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
lotCount, canWrite, err := validateMariaDBConnection(dsn)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": "Connection check failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
if _, _, err := validateMariaDBConnection(dsn); err != nil {
|
||||
_ = c.Error(err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "Connection check failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Save settings
|
||||
if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil {
|
||||
_ = c.Error(err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Failed to save settings",
|
||||
})
|
||||
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 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()
|
||||
}
|
||||
|
||||
func validateMariaDBConnection(dsn string) (int64, bool, error) {
|
||||
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("open MariaDB connection: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("get database handle: %w", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return 0, false, fmt.Errorf("ping MariaDB: %w", err)
|
||||
}
|
||||
|
||||
var lotCount int64
|
||||
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
|
||||
return 0, false, fmt.Errorf("check required table lot: %w", err)
|
||||
}
|
||||
|
||||
return lotCount, testSyncWritePermission(db), nil
|
||||
}
|
||||
|
||||
func testSyncWritePermission(db *gorm.DB) bool {
|
||||
sentinel := fmt.Sprintf("quoteforge-permission-check-%d", time.Now().UnixNano())
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO qt_client_schema_state (username, hostname, last_checked_at, updated_at)
|
||||
VALUES (?, ?, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_checked_at = VALUES(last_checked_at),
|
||||
updated_at = VALUES(updated_at)
|
||||
`, sentinel, "setup-check").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return errPermissionProbeRollback
|
||||
})
|
||||
|
||||
return errors.Is(err, errPermissionProbeRollback)
|
||||
}
|
||||
748
internal/handlers/sync.go
Normal file
748
internal/handlers/sync.go
Normal file
@@ -0,0 +1,748 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
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"`
|
||||
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||||
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||||
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
|
||||
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
|
||||
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
|
||||
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) {
|
||||
connStatus := h.connMgr.GetStatus()
|
||||
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||||
lastComponentSync := h.localDB.GetComponentSyncTime()
|
||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||
componentsCount := h.localDB.CountLocalComponents()
|
||||
pricelistsCount := h.localDB.CountLocalPricelists()
|
||||
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||
readiness := h.getReadinessLocal()
|
||||
|
||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||
LastComponentSync: lastComponentSync,
|
||||
LastPricelistSync: lastPricelistSync,
|
||||
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||
LastPricelistSyncError: lastPricelistSyncError,
|
||||
HasIncompleteServerSync: hasFailedSync,
|
||||
KnownServerChangesMiss: hasFailedSync,
|
||||
IsOnline: isOnline,
|
||||
ComponentsCount: componentsCount,
|
||||
PricelistsCount: pricelistsCount,
|
||||
ServerPricelists: 0,
|
||||
NeedComponentSync: needComponentSync,
|
||||
NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
|
||||
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 {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
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": "internal server error",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
_ = 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",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
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": "component sync failed",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
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": "pricelist sync failed",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
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": "partnumber books sync failed",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
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",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Sync components
|
||||
mariaDB, err := h.connMgr.GetDB()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "database connection failed",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
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",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
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",
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
})
|
||||
_ = c.Error(err)
|
||||
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",
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
"pricelists_synced": pricelistsSynced,
|
||||
})
|
||||
_ = c.Error(err)
|
||||
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",
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
"pricelists_synced": pricelistsSynced,
|
||||
"projects_imported": projectsResult.Imported,
|
||||
"projects_updated": projectsResult.Updated,
|
||||
"projects_skipped": projectsResult.Skipped,
|
||||
})
|
||||
_ = c.Error(err)
|
||||
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": "pending changes push failed",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
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 {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
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": "pending changes repair failed",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
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"`
|
||||
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||||
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||||
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
|
||||
|
||||
// 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) {
|
||||
connStatus := h.connMgr.GetStatus()
|
||||
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||||
|
||||
// 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()
|
||||
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||
needPricelistSync := lastPricelistSync == nil || hasFailedSync
|
||||
hasIncompleteServerSync := hasFailedSync
|
||||
|
||||
// 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.getReadinessLocal()
|
||||
|
||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||
DBHost: dbHost,
|
||||
DBUser: dbUser,
|
||||
DBName: dbName,
|
||||
IsOnline: isOnline,
|
||||
LastSyncAt: lastPricelistSync,
|
||||
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||
LastPricelistSyncError: lastPricelistSyncError,
|
||||
NeedPricelistSync: needPricelistSync,
|
||||
HasIncompleteServerSync: hasIncompleteServerSync,
|
||||
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 {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
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.getReadinessLocal()
|
||||
isBlocked := readiness != nil && readiness.Blocked
|
||||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||
hasIncompleteServerSync := hasFailedSync
|
||||
|
||||
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
|
||||
|
||||
data := gin.H{
|
||||
"IsOffline": isOffline,
|
||||
"PendingCount": pendingCount,
|
||||
"IsBlocked": isBlocked,
|
||||
"HasFailedSync": hasFailedSync,
|
||||
"HasIncompleteServerSync": hasIncompleteServerSync,
|
||||
"SyncIssueTitle": func() string {
|
||||
if hasIncompleteServerSync {
|
||||
return "Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые не загружены локально."
|
||||
}
|
||||
if hasFailedSync {
|
||||
if lastPricelistSyncError != "" {
|
||||
return lastPricelistSyncError
|
||||
}
|
||||
return "Последняя синхронизация прайслистов завершилась ошибкой."
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
"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.Error(err)
|
||||
c.String(http.StatusInternalServerError, "Template error")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness {
|
||||
h.readinessMu.Lock()
|
||||
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < 10*time.Second {
|
||||
cached := *h.readinessCached
|
||||
h.readinessMu.Unlock()
|
||||
return &cached
|
||||
}
|
||||
h.readinessMu.Unlock()
|
||||
|
||||
state, err := h.localDB.GetSyncGuardState()
|
||||
if err != nil || state == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
readiness := &sync.SyncReadiness{
|
||||
Status: state.Status,
|
||||
Blocked: state.Status == sync.ReadinessBlocked,
|
||||
ReasonCode: state.ReasonCode,
|
||||
ReasonText: state.ReasonText,
|
||||
RequiredMinAppVersion: state.RequiredMinAppVersion,
|
||||
LastCheckedAt: state.LastCheckedAt,
|
||||
}
|
||||
|
||||
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 {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
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 {
|
||||
RespondError(c, http.StatusServiceUnavailable, "service unavailable", err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
201
internal/handlers/vendor_spec.go
Normal file
201
internal/handlers/vendor_spec.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"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
|
||||
configService *services.LocalConfigurationService
|
||||
}
|
||||
|
||||
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
||||
return &VendorSpecHandler{
|
||||
localDB: localDB,
|
||||
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
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)
|
||||
if _, err := h.configService.UpdateVendorSpecNoAuth(cfg.UUID, spec); err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
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 {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
return
|
||||
}
|
||||
|
||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||
resolver := services.NewVendorSpecResolver(bookRepo)
|
||||
|
||||
resolved, err := resolver.Resolve(body.VendorSpec)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
|
||||
book, _ := bookRepo.GetActiveBook()
|
||||
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
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 {
|
||||
RespondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := h.configService.ApplyVendorSpecItemsNoAuth(cfg.UUID, newItems); err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": newItems})
|
||||
}
|
||||
270
internal/handlers/web.go
Normal file
270
internal/handlers/web.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"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) {
|
||||
data["AppVersion"] = appmeta.Version()
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl, ok := h.templates[name]
|
||||
if !ok {
|
||||
_ = c.Error(fmt.Errorf("template %q not found", name))
|
||||
c.String(500, "Template error")
|
||||
return
|
||||
}
|
||||
// Execute the page template which will use base
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
|
||||
_ = c.Error(err)
|
||||
c.String(500, "Template error")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
47
internal/handlers/web_test.go
Normal file
47
internal/handlers/web_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestWebHandlerRenderHidesTemplateExecutionError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tmpl := template.Must(template.New("broken.html").Funcs(template.FuncMap{
|
||||
"boom": func() (string, error) {
|
||||
return "", errors.New("secret template failure")
|
||||
},
|
||||
}).Parse(`{{define "broken.html"}}{{boom}}{{end}}`))
|
||||
|
||||
handler := &WebHandler{
|
||||
templates: map[string]*template.Template{
|
||||
"broken.html": tmpl,
|
||||
},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/broken", nil)
|
||||
|
||||
handler.render(ctx, "broken.html", gin.H{})
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", rec.Code)
|
||||
}
|
||||
if body := strings.TrimSpace(rec.Body.String()); body != "Template error" {
|
||||
t.Fatalf("expected generic template error, got %q", body)
|
||||
}
|
||||
if len(ctx.Errors) != 1 {
|
||||
t.Fatalf("expected logged template error, got %d", len(ctx.Errors))
|
||||
}
|
||||
if !strings.Contains(ctx.Errors.String(), "secret template failure") {
|
||||
t.Fatalf("expected original error in gin context, got %q", ctx.Errors.String())
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
154
internal/localdb/configuration_business_fields_test.go
Normal file
154
internal/localdb/configuration_business_fields_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationFingerprintIncludesPricingSelectorsAndVendorSpec(t *testing.T) {
|
||||
estimateID := uint(11)
|
||||
warehouseID := uint(22)
|
||||
competitorID := uint(33)
|
||||
|
||||
base := &LocalConfiguration{
|
||||
UUID: "cfg-1",
|
||||
Name: "Config",
|
||||
ServerCount: 1,
|
||||
Items: LocalConfigItems{{LotName: "LOT_A", Quantity: 1, UnitPrice: 100}},
|
||||
PricelistID: &estimateID,
|
||||
WarehousePricelistID: &warehouseID,
|
||||
CompetitorPricelistID: &competitorID,
|
||||
DisablePriceRefresh: true,
|
||||
OnlyInStock: true,
|
||||
VendorSpec: VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "PN-1",
|
||||
Quantity: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
baseFingerprint, err := BuildConfigurationSpecPriceFingerprint(base)
|
||||
if err != nil {
|
||||
t.Fatalf("base fingerprint: %v", err)
|
||||
}
|
||||
|
||||
changedPricelist := *base
|
||||
newEstimateID := uint(44)
|
||||
changedPricelist.PricelistID = &newEstimateID
|
||||
pricelistFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedPricelist)
|
||||
if err != nil {
|
||||
t.Fatalf("pricelist fingerprint: %v", err)
|
||||
}
|
||||
if pricelistFingerprint == baseFingerprint {
|
||||
t.Fatalf("expected pricelist selector to affect fingerprint")
|
||||
}
|
||||
|
||||
changedVendorSpec := *base
|
||||
changedVendorSpec.VendorSpec = VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "PN-2",
|
||||
Quantity: 1,
|
||||
},
|
||||
}
|
||||
vendorFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedVendorSpec)
|
||||
if err != nil {
|
||||
t.Fatalf("vendor fingerprint: %v", err)
|
||||
}
|
||||
if vendorFingerprint == baseFingerprint {
|
||||
t.Fatalf("expected vendor spec to affect fingerprint")
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
1741
internal/localdb/localdb.go
Normal file
1741
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
|
||||
}
|
||||
1122
internal/localdb/migrations.go
Normal file
1122
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)
|
||||
}
|
||||
}
|
||||
53
internal/localdb/project_sync_timestamp_test.go
Normal file
53
internal/localdb/project_sync_timestamp_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSaveProjectPreservingUpdatedAtKeepsProvidedTimestamp(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "project_sync_timestamp.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
createdAt := time.Date(2026, 2, 1, 10, 0, 0, 0, time.UTC)
|
||||
updatedAt := time.Date(2026, 2, 3, 12, 30, 0, 0, time.UTC)
|
||||
project := &LocalProject{
|
||||
UUID: "project-1",
|
||||
OwnerUsername: "tester",
|
||||
Code: "OPS-1",
|
||||
Variant: "Lenovo",
|
||||
IsActive: true,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
SyncStatus: "synced",
|
||||
}
|
||||
|
||||
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
|
||||
t.Fatalf("save project: %v", err)
|
||||
}
|
||||
|
||||
syncedAt := time.Date(2026, 3, 16, 8, 45, 0, 0, time.UTC)
|
||||
project.SyncedAt = &syncedAt
|
||||
project.SyncStatus = "synced"
|
||||
|
||||
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
|
||||
t.Fatalf("save project second time: %v", err)
|
||||
}
|
||||
|
||||
stored, err := local.GetProjectByUUID(project.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("get project: %v", err)
|
||||
}
|
||||
if !stored.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("updated_at changed during sync save: got %s want %s", stored.UpdatedAt, updatedAt)
|
||||
}
|
||||
if stored.SyncedAt == nil || !stored.SyncedAt.Equal(syncedAt) {
|
||||
t.Fatalf("synced_at not updated correctly: got %+v want %s", stored.SyncedAt, syncedAt)
|
||||
}
|
||||
}
|
||||
172
internal/localdb/snapshots.go
Normal file
172
internal/localdb/snapshots.go
Normal file
@@ -0,0 +1,172 @@
|
||||
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"`
|
||||
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"`
|
||||
VendorSpec VendorSpec `json:"vendor_spec,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,
|
||||
PricelistID: localCfg.PricelistID,
|
||||
WarehousePricelistID: localCfg.WarehousePricelistID,
|
||||
CompetitorPricelistID: localCfg.CompetitorPricelistID,
|
||||
DisablePriceRefresh: localCfg.DisablePriceRefresh,
|
||||
OnlyInStock: localCfg.OnlyInStock,
|
||||
VendorSpec: localCfg.VendorSpec,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal spec+price fingerprint: %w", err)
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
55
internal/middleware/cors.go
Normal file
55
internal/middleware/cors.go
Normal file
@@ -0,0 +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) {
|
||||
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 == 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()
|
||||
}
|
||||
}
|
||||
93
internal/models/alert.go
Normal file
93
internal/models/alert.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AlertType string
|
||||
|
||||
const (
|
||||
AlertHighDemandStalePrice AlertType = "high_demand_stale_price"
|
||||
AlertPriceSpike AlertType = "price_spike"
|
||||
AlertPriceDrop AlertType = "price_drop"
|
||||
AlertNoRecentQuotes AlertType = "no_recent_quotes"
|
||||
AlertTrendingNoPrice AlertType = "trending_no_price"
|
||||
)
|
||||
|
||||
type AlertSeverity string
|
||||
|
||||
const (
|
||||
SeverityLow AlertSeverity = "low"
|
||||
SeverityMedium AlertSeverity = "medium"
|
||||
SeverityHigh AlertSeverity = "high"
|
||||
SeverityCritical AlertSeverity = "critical"
|
||||
)
|
||||
|
||||
type AlertStatus string
|
||||
|
||||
const (
|
||||
AlertStatusNew AlertStatus = "new"
|
||||
AlertStatusAcknowledged AlertStatus = "acknowledged"
|
||||
AlertStatusResolved AlertStatus = "resolved"
|
||||
AlertStatusIgnored AlertStatus = "ignored"
|
||||
)
|
||||
|
||||
type AlertDetails map[string]interface{}
|
||||
|
||||
func (d AlertDetails) Value() (driver.Value, error) {
|
||||
return json.Marshal(d)
|
||||
}
|
||||
|
||||
func (d *AlertDetails) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*d = make(AlertDetails)
|
||||
return nil
|
||||
}
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New("type assertion to []byte failed")
|
||||
}
|
||||
return json.Unmarshal(bytes, d)
|
||||
}
|
||||
|
||||
type PricingAlert struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
LotName string `gorm:"size:255;not null" json:"lot_name"`
|
||||
AlertType AlertType `gorm:"type:enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price');not null" json:"alert_type"`
|
||||
Severity AlertSeverity `gorm:"type:enum('low','medium','high','critical');default:'medium'" json:"severity"`
|
||||
Message string `gorm:"type:text;not null" json:"message"`
|
||||
Details AlertDetails `gorm:"type:json" json:"details"`
|
||||
Status AlertStatus `gorm:"type:enum('new','acknowledged','resolved','ignored');default:'new'" json:"status"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (PricingAlert) TableName() string {
|
||||
return "qt_pricing_alerts"
|
||||
}
|
||||
|
||||
type TrendDirection string
|
||||
|
||||
const (
|
||||
TrendUp TrendDirection = "up"
|
||||
TrendStable TrendDirection = "stable"
|
||||
TrendDown TrendDirection = "down"
|
||||
)
|
||||
|
||||
type ComponentUsageStats struct {
|
||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||
QuotesTotal int `gorm:"default:0" json:"quotes_total"`
|
||||
QuotesLast30d int `gorm:"default:0" json:"quotes_last_30d"`
|
||||
QuotesLast7d int `gorm:"default:0" json:"quotes_last_7d"`
|
||||
TotalQuantity int `gorm:"default:0" json:"total_quantity"`
|
||||
TotalRevenue float64 `gorm:"type:decimal(14,2);default:0" json:"total_revenue"`
|
||||
TrendDirection TrendDirection `gorm:"type:enum('up','stable','down');default:'stable'" json:"trend_direction"`
|
||||
TrendPercent float64 `gorm:"type:decimal(5,2);default:0" json:"trend_percent"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
}
|
||||
|
||||
func (ComponentUsageStats) TableName() string {
|
||||
return "qt_component_usage_stats"
|
||||
}
|
||||
44
internal/models/category.go
Normal file
44
internal/models/category.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package models
|
||||
|
||||
type Category struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Code string `gorm:"size:20;uniqueIndex;not null" json:"code"`
|
||||
Name string `gorm:"size:100;not null" json:"name"`
|
||||
NameRu string `gorm:"size:100" json:"name_ru"`
|
||||
DisplayOrder int `gorm:"default:0" json:"display_order"`
|
||||
IsRequired bool `gorm:"default:false" json:"is_required"`
|
||||
}
|
||||
|
||||
func (Category) TableName() string {
|
||||
return "qt_categories"
|
||||
}
|
||||
|
||||
// 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: "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: "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: "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
|
||||
138
internal/models/configuration.go
Normal file
138
internal/models/configuration.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ConfigItem struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
}
|
||||
|
||||
type ConfigItems []ConfigItem
|
||||
|
||||
func (c ConfigItems) Value() (driver.Value, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func (c *ConfigItems) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*c = make(ConfigItems, 0)
|
||||
return nil
|
||||
}
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New("type assertion to []byte failed")
|
||||
}
|
||||
return json.Unmarshal(bytes, c)
|
||||
}
|
||||
|
||||
func (c ConfigItems) Total() float64 {
|
||||
var total float64
|
||||
for _, item := range c {
|
||||
total += item.UnitPrice * float64(item.Quantity)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
type VendorSpecLotAllocation struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
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 {
|
||||
return "qt_configurations"
|
||||
}
|
||||
|
||||
type PriceOverride struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
LotName string `gorm:"size:255;not null" json:"lot_name"`
|
||||
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
|
||||
ValidFrom time.Time `gorm:"type:date;not null" json:"valid_from"`
|
||||
ValidUntil *time.Time `gorm:"type:date" json:"valid_until"`
|
||||
Reason string `gorm:"type:text" json:"reason"`
|
||||
CreatedBy uint `gorm:"not null" json:"created_by"`
|
||||
}
|
||||
|
||||
func (PriceOverride) TableName() string {
|
||||
return "qt_price_overrides"
|
||||
}
|
||||
69
internal/models/lot.go
Normal file
69
internal/models/lot.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Lot represents existing lot table
|
||||
type Lot struct {
|
||||
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 {
|
||||
return "lot"
|
||||
}
|
||||
|
||||
// LotLog represents existing lot_log table (READ-ONLY)
|
||||
type LotLog struct {
|
||||
LotLogID uint `gorm:"column:lot_log_id;primaryKey;autoIncrement"`
|
||||
Lot string `gorm:"column:lot;size:255;not null"`
|
||||
Supplier string `gorm:"column:supplier;size:255;not null"`
|
||||
Date time.Time `gorm:"column:date;type:date;not null"`
|
||||
Price float64 `gorm:"column:price;not null"`
|
||||
Quality string `gorm:"column:quality;size:255"`
|
||||
Comments string `gorm:"column:comments;size:15000"`
|
||||
}
|
||||
|
||||
func (LotLog) TableName() string {
|
||||
return "lot_log"
|
||||
}
|
||||
|
||||
// Supplier represents existing supplier table (READ-ONLY)
|
||||
type Supplier struct {
|
||||
SupplierName string `gorm:"column:supplier_name;primaryKey;size:255"`
|
||||
SupplierComment string `gorm:"column:supplier_comment;size:10000"`
|
||||
}
|
||||
|
||||
func (Supplier) TableName() string {
|
||||
return "supplier"
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
92
internal/models/metadata.go
Normal file
92
internal/models/metadata.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PriceMethod string
|
||||
|
||||
const (
|
||||
PriceMethodManual PriceMethod = "manual"
|
||||
PriceMethodMedian PriceMethod = "median"
|
||||
PriceMethodAverage PriceMethod = "average"
|
||||
PriceMethodWeightedMedian PriceMethod = "weighted_median"
|
||||
)
|
||||
|
||||
type Specs map[string]interface{}
|
||||
|
||||
func (s Specs) Value() (driver.Value, error) {
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
func (s *Specs) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*s = make(Specs)
|
||||
return nil
|
||||
}
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New("type assertion to []byte failed")
|
||||
}
|
||||
return json.Unmarshal(bytes, s)
|
||||
}
|
||||
|
||||
type LotMetadata struct {
|
||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
||||
CategoryID *uint `gorm:"column:category_id" json:"category_id"`
|
||||
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"`
|
||||
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
}
|
||||
|
||||
func (LotMetadata) TableName() string {
|
||||
return "qt_lot_metadata"
|
||||
}
|
||||
|
||||
type PriceFreshness string
|
||||
|
||||
const (
|
||||
FreshnessFresh PriceFreshness = "fresh"
|
||||
FreshnessNormal PriceFreshness = "normal"
|
||||
FreshnessStale PriceFreshness = "stale"
|
||||
FreshnessCritical PriceFreshness = "critical"
|
||||
)
|
||||
|
||||
func (m *LotMetadata) GetPriceFreshness(greenDays, yellowDays, redDays, minQuotes int) PriceFreshness {
|
||||
if m.CurrentPrice == nil || *m.CurrentPrice == 0 {
|
||||
return FreshnessCritical
|
||||
}
|
||||
if m.PriceUpdatedAt == nil {
|
||||
return FreshnessCritical
|
||||
}
|
||||
|
||||
daysSince := int(time.Since(*m.PriceUpdatedAt).Hours() / 24)
|
||||
|
||||
if daysSince < greenDays && m.RequestCount >= minQuotes {
|
||||
return FreshnessFresh
|
||||
} else if daysSince < yellowDays {
|
||||
return FreshnessNormal
|
||||
} else if daysSince < redDays {
|
||||
return FreshnessStale
|
||||
}
|
||||
return FreshnessCritical
|
||||
}
|
||||
53
internal/models/models.go
Normal file
53
internal/models/models.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AllModels returns all models for auto-migration
|
||||
func AllModels() []interface{} {
|
||||
return []interface{}{
|
||||
&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 {
|
||||
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
|
||||
func SeedCategories(db *gorm.DB) error {
|
||||
for _, cat := range DefaultCategories {
|
||||
result := db.Where("code = ?", cat.Code).FirstOrCreate(&cat)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
91
internal/repository/alert.go
Normal file
91
internal/repository/alert.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AlertRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAlertRepository(db *gorm.DB) *AlertRepository {
|
||||
return &AlertRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *AlertRepository) Create(alert *models.PricingAlert) error {
|
||||
return r.db.Create(alert).Error
|
||||
}
|
||||
|
||||
func (r *AlertRepository) GetByID(id uint) (*models.PricingAlert, error) {
|
||||
var alert models.PricingAlert
|
||||
err := r.db.First(&alert, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &alert, nil
|
||||
}
|
||||
|
||||
func (r *AlertRepository) Update(alert *models.PricingAlert) error {
|
||||
return r.db.Save(alert).Error
|
||||
}
|
||||
|
||||
type AlertFilter struct {
|
||||
Status models.AlertStatus
|
||||
Severity models.AlertSeverity
|
||||
Type models.AlertType
|
||||
LotName string
|
||||
}
|
||||
|
||||
func (r *AlertRepository) List(filter AlertFilter, offset, limit int) ([]models.PricingAlert, int64, error) {
|
||||
var alerts []models.PricingAlert
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.PricingAlert{})
|
||||
|
||||
if filter.Status != "" {
|
||||
query = query.Where("status = ?", filter.Status)
|
||||
}
|
||||
if filter.Severity != "" {
|
||||
query = query.Where("severity = ?", filter.Severity)
|
||||
}
|
||||
if filter.Type != "" {
|
||||
query = query.Where("alert_type = ?", filter.Type)
|
||||
}
|
||||
if filter.LotName != "" {
|
||||
query = query.Where("lot_name = ?", filter.LotName)
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
|
||||
err := query.
|
||||
Order("FIELD(severity, 'critical', 'high', 'medium', 'low')").
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&alerts).Error
|
||||
|
||||
return alerts, total, err
|
||||
}
|
||||
|
||||
func (r *AlertRepository) CountByStatus(status models.AlertStatus) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.PricingAlert{}).
|
||||
Where("status = ?", status).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *AlertRepository) UpdateStatus(id uint, status models.AlertStatus) error {
|
||||
return r.db.Model(&models.PricingAlert{}).
|
||||
Where("id = ?", id).
|
||||
Update("status", status).Error
|
||||
}
|
||||
|
||||
func (r *AlertRepository) ExistsByLotAndType(lotName string, alertType models.AlertType) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.PricingAlert{}).
|
||||
Where("lot_name = ? AND alert_type = ? AND status IN ('new', 'acknowledged')", lotName, alertType).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
76
internal/repository/category.go
Normal file
76
internal/repository/category.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CategoryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
|
||||
return &CategoryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *CategoryRepository) GetAll() ([]models.Category, error) {
|
||||
var categories []models.Category
|
||||
err := r.db.Order("display_order ASC").Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
func (r *CategoryRepository) GetByCode(code string) (*models.Category, error) {
|
||||
var category models.Category
|
||||
err := r.db.Where("code = ?", code).First(&category).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) {
|
||||
var category models.Category
|
||||
err := r.db.First(&category, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
145
internal/repository/component.go
Normal file
145
internal/repository/component.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ComponentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewComponentRepository(db *gorm.DB) *ComponentRepository {
|
||||
return &ComponentRepository{db: db}
|
||||
}
|
||||
|
||||
type ComponentFilter struct {
|
||||
Category string
|
||||
Search string
|
||||
HasPrice bool
|
||||
ExcludeHidden bool
|
||||
SortField string
|
||||
SortDir string
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||
var components []models.LotMetadata
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.LotMetadata{}).
|
||||
Preload("Lot").
|
||||
Preload("Category")
|
||||
|
||||
if filter.Category != "" {
|
||||
query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id").
|
||||
Where("qt_categories.code = ?", filter.Category)
|
||||
}
|
||||
if filter.Search != "" {
|
||||
search := "%" + filter.Search + "%"
|
||||
query = query.Where("lot_name LIKE ? OR model LIKE ?", search, search)
|
||||
}
|
||||
if filter.HasPrice {
|
||||
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
||||
}
|
||||
if filter.ExcludeHidden {
|
||||
query = query.Where("is_hidden = ? OR is_hidden IS NULL", false)
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
|
||||
// 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.
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&components).Error
|
||||
|
||||
return components, total, err
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) GetByLotName(lotName string) (*models.LotMetadata, error) {
|
||||
var component models.LotMetadata
|
||||
err := r.db.
|
||||
Preload("Lot").
|
||||
Preload("Category").
|
||||
Where("lot_name = ?", lotName).
|
||||
First(&component).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &component, nil
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) GetMultiple(lotNames []string) ([]models.LotMetadata, error) {
|
||||
var components []models.LotMetadata
|
||||
err := r.db.
|
||||
Preload("Lot").
|
||||
Preload("Category").
|
||||
Where("lot_name IN ?", lotNames).
|
||||
Find(&components).Error
|
||||
return components, err
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) Update(component *models.LotMetadata) error {
|
||||
return r.db.Save(component).Error
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
||||
return r.db.Create(component).Error
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) IncrementRequestCount(lotName string) error {
|
||||
now := time.Now()
|
||||
return r.db.Model(&models.LotMetadata{}).
|
||||
Where("lot_name = ?", lotName).
|
||||
Updates(map[string]interface{}{
|
||||
"request_count": gorm.Expr("request_count + 1"),
|
||||
"last_request_date": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// GetAllLots returns all lots from the existing lot table
|
||||
func (r *ComponentRepository) GetAllLots() ([]models.Lot, error) {
|
||||
var lots []models.Lot
|
||||
err := r.db.Find(&lots).Error
|
||||
return lots, err
|
||||
}
|
||||
|
||||
// GetLotsWithoutMetadata returns lots that don't have qt_lot_metadata entries
|
||||
func (r *ComponentRepository) GetLotsWithoutMetadata() ([]models.Lot, error) {
|
||||
var lots []models.Lot
|
||||
err := r.db.
|
||||
Where("lot_name NOT IN (SELECT lot_name FROM qt_lot_metadata)").
|
||||
Find(&lots).Error
|
||||
return lots, err
|
||||
}
|
||||
113
internal/repository/configuration.go
Normal file
113
internal/repository/configuration.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ConfigurationRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewConfigurationRepository(db *gorm.DB) *ConfigurationRepository {
|
||||
return &ConfigurationRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) Create(config *models.Configuration) error {
|
||||
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.First(&config, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
|
||||
var config models.Configuration
|
||||
err := r.db.Where("uuid = ?", uuid).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) Update(config *models.Configuration) 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(ownerUsername string, offset, limit int) ([]models.Configuration, int64, error) {
|
||||
var configs []models.Configuration
|
||||
var total int64
|
||||
|
||||
ownerScope := "owner_username = ?"
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername).Count(&total)
|
||||
err := r.db.
|
||||
Where(ownerScope, ownerUsername).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&configs).Error
|
||||
|
||||
return configs, total, err
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Configuration, int64, error) {
|
||||
var configs []models.Configuration
|
||||
var total int64
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where("is_template = ?", true).Count(&total)
|
||||
err := r.db.
|
||||
Where("is_template = ?", true).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&configs).Error
|
||||
|
||||
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
|
||||
}
|
||||
124
internal/repository/price.go
Normal file
124
internal/repository/price.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PriceRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPriceRepository(db *gorm.DB) *PriceRepository {
|
||||
return &PriceRepository{db: db}
|
||||
}
|
||||
|
||||
type PricePoint struct {
|
||||
Price float64
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
// GetPriceHistory returns price history from lot_log for a component
|
||||
func (r *PriceRepository) GetPriceHistory(lotName string, periodDays int) ([]PricePoint, error) {
|
||||
var points []PricePoint
|
||||
since := time.Now().AddDate(0, 0, -periodDays)
|
||||
|
||||
err := r.db.Model(&models.LotLog{}).
|
||||
Select("price, date").
|
||||
Where("lot = ? AND date >= ?", lotName, since).
|
||||
Order("date DESC").
|
||||
Scan(&points).Error
|
||||
|
||||
return points, err
|
||||
}
|
||||
|
||||
// GetLatestPrice returns the most recent price for a component
|
||||
func (r *PriceRepository) GetLatestPrice(lotName string) (*PricePoint, error) {
|
||||
var point PricePoint
|
||||
err := r.db.Model(&models.LotLog{}).
|
||||
Select("price, date").
|
||||
Where("lot = ?", lotName).
|
||||
Order("date DESC").
|
||||
First(&point).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &point, nil
|
||||
}
|
||||
|
||||
// GetPriceOverride returns active override for a component
|
||||
func (r *PriceRepository) GetPriceOverride(lotName string) (*models.PriceOverride, error) {
|
||||
var override models.PriceOverride
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
|
||||
err := r.db.
|
||||
Where("lot_name = ?", lotName).
|
||||
Where("valid_from <= ?", today).
|
||||
Where("valid_until IS NULL OR valid_until >= ?", today).
|
||||
Order("valid_from DESC").
|
||||
First(&override).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &override, nil
|
||||
}
|
||||
|
||||
// CreatePriceOverride creates a new price override
|
||||
func (r *PriceRepository) CreatePriceOverride(override *models.PriceOverride) error {
|
||||
return r.db.Create(override).Error
|
||||
}
|
||||
|
||||
// GetPriceOverrides returns all overrides for a component
|
||||
func (r *PriceRepository) GetPriceOverrides(lotName string) ([]models.PriceOverride, error) {
|
||||
var overrides []models.PriceOverride
|
||||
err := r.db.
|
||||
Where("lot_name = ?", lotName).
|
||||
Order("valid_from DESC").
|
||||
Find(&overrides).Error
|
||||
return overrides, err
|
||||
}
|
||||
|
||||
// DeletePriceOverride deletes an override
|
||||
func (r *PriceRepository) DeletePriceOverride(id uint) error {
|
||||
return r.db.Delete(&models.PriceOverride{}, id).Error
|
||||
}
|
||||
|
||||
// GetQuoteCount returns the number of quotes in lot_log for a period
|
||||
func (r *PriceRepository) GetQuoteCount(lotName string, periodDays int) (int64, error) {
|
||||
var count int64
|
||||
since := time.Now().AddDate(0, 0, -periodDays)
|
||||
|
||||
err := r.db.Model(&models.LotLog{}).
|
||||
Where("lot = ? AND date >= ?", lotName, since).
|
||||
Count(&count).Error
|
||||
|
||||
return count, err
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
115
internal/repository/stats.go
Normal file
115
internal/repository/stats.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type StatsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewStatsRepository(db *gorm.DB) *StatsRepository {
|
||||
return &StatsRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *StatsRepository) GetByLotName(lotName string) (*models.ComponentUsageStats, error) {
|
||||
var stats models.ComponentUsageStats
|
||||
err := r.db.Where("lot_name = ?", lotName).First(&stats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
func (r *StatsRepository) Upsert(stats *models.ComponentUsageStats) error {
|
||||
return r.db.Save(stats).Error
|
||||
}
|
||||
|
||||
func (r *StatsRepository) IncrementUsage(lotName string, quantity int, revenue float64) error {
|
||||
now := time.Now()
|
||||
|
||||
result := r.db.Model(&models.ComponentUsageStats{}).
|
||||
Where("lot_name = ?", lotName).
|
||||
Updates(map[string]interface{}{
|
||||
"quotes_total": gorm.Expr("quotes_total + 1"),
|
||||
"quotes_last_30d": gorm.Expr("quotes_last_30d + 1"),
|
||||
"quotes_last_7d": gorm.Expr("quotes_last_7d + 1"),
|
||||
"total_quantity": gorm.Expr("total_quantity + ?", quantity),
|
||||
"total_revenue": gorm.Expr("total_revenue + ?", revenue),
|
||||
"last_used_at": now,
|
||||
})
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
stats := &models.ComponentUsageStats{
|
||||
LotName: lotName,
|
||||
QuotesTotal: 1,
|
||||
QuotesLast30d: 1,
|
||||
QuotesLast7d: 1,
|
||||
TotalQuantity: quantity,
|
||||
TotalRevenue: revenue,
|
||||
LastUsedAt: &now,
|
||||
}
|
||||
return r.db.Create(stats).Error
|
||||
}
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func (r *StatsRepository) GetTopComponents(limit int) ([]models.ComponentUsageStats, error) {
|
||||
var stats []models.ComponentUsageStats
|
||||
err := r.db.
|
||||
Order("quotes_last_30d DESC").
|
||||
Limit(limit).
|
||||
Find(&stats).Error
|
||||
return stats, err
|
||||
}
|
||||
|
||||
func (r *StatsRepository) GetTrendingComponents(limit int) ([]models.ComponentUsageStats, error) {
|
||||
var stats []models.ComponentUsageStats
|
||||
err := r.db.
|
||||
Where("trend_direction = ? AND trend_percent > ?", models.TrendUp, 20).
|
||||
Order("trend_percent DESC").
|
||||
Limit(limit).
|
||||
Find(&stats).Error
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// ResetWeeklyCounters resets quotes_last_7d (run weekly via cron)
|
||||
func (r *StatsRepository) ResetWeeklyCounters() error {
|
||||
return r.db.Model(&models.ComponentUsageStats{}).
|
||||
Where("1 = 1").
|
||||
Update("quotes_last_7d", 0).Error
|
||||
}
|
||||
|
||||
// ResetMonthlyCounters resets quotes_last_30d (run monthly via cron)
|
||||
func (r *StatsRepository) ResetMonthlyCounters() error {
|
||||
return r.db.Model(&models.ComponentUsageStats{}).
|
||||
Where("1 = 1").
|
||||
Update("quotes_last_30d", 0).Error
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
217
internal/services/component.go
Normal file
217
internal/services/component.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
type ComponentService struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
categoryRepo *repository.CategoryRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
}
|
||||
|
||||
func NewComponentService(
|
||||
componentRepo *repository.ComponentRepository,
|
||||
categoryRepo *repository.CategoryRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
) *ComponentService {
|
||||
return &ComponentService{
|
||||
componentRepo: componentRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
statsRepo: statsRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePartNumber extracts category 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 {
|
||||
model = parts[1]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type ComponentListResult struct {
|
||||
Components []ComponentView `json:"components"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
}
|
||||
|
||||
type ComponentView struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
CategoryName string `json:"category_name"`
|
||||
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 = 20
|
||||
}
|
||||
if perPage > 5000 {
|
||||
perPage = 5000
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
components, total, err := s.componentRepo.List(filter, offset, perPage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
views := make([]ComponentView, len(components))
|
||||
for i, c := range components {
|
||||
view := ComponentView{
|
||||
LotName: c.LotName,
|
||||
Model: c.Model,
|
||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||
PopularityScore: c.PopularityScore,
|
||||
Specs: c.Specs,
|
||||
}
|
||||
|
||||
if c.Lot != nil {
|
||||
view.Description = c.Lot.LotDescription
|
||||
}
|
||||
if c.Category != nil {
|
||||
view.Category = c.Category.Code
|
||||
view.CategoryName = c.Category.Name
|
||||
}
|
||||
|
||||
views[i] = view
|
||||
}
|
||||
|
||||
return &ComponentListResult{
|
||||
Components: views,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Track usage
|
||||
_ = s.componentRepo.IncrementRequestCount(lotName)
|
||||
|
||||
view := &ComponentView{
|
||||
LotName: c.LotName,
|
||||
Model: c.Model,
|
||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||
PopularityScore: c.PopularityScore,
|
||||
Specs: c.Specs,
|
||||
}
|
||||
|
||||
if c.Lot != nil {
|
||||
view.Description = c.Lot.LotDescription
|
||||
}
|
||||
if c.Category != nil {
|
||||
view.Category = c.Category.Code
|
||||
view.CategoryName = c.Category.Name
|
||||
}
|
||||
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (s *ComponentService) GetCategories() ([]models.Category, error) {
|
||||
// If no database connection (offline mode), return default categories
|
||||
if s.categoryRepo == nil {
|
||||
return models.DefaultCategories, nil
|
||||
}
|
||||
return s.categoryRepo.GetAll()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
categories, err := s.categoryRepo.GetAll()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
categoryMap := make(map[string]uint)
|
||||
for _, cat := range categories {
|
||||
categoryMap[strings.ToUpper(cat.Code)] = cat.ID
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, lot := range lots {
|
||||
// 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,
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
return imported, nil
|
||||
}
|
||||
649
internal/services/configuration.go
Normal file
649
internal/services/configuration.go
Normal file
@@ -0,0 +1,649 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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(),
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Record usage stats
|
||||
_ = s.quoteService.RecordUsage(req.Items)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
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 !s.isOwner(config, ownerUsername) && !config.IsTemplate {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
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 !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
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Delete(uuid string, ownerUsername string) error {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
return ErrConfigForbidden
|
||||
}
|
||||
|
||||
return s.configRepo.Delete(config.ID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * 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) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
return s.configRepo.ListTemplates(offset, perPage)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUsername string) bool {
|
||||
if config == nil || ownerUsername == "" {
|
||||
return false
|
||||
}
|
||||
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)
|
||||
// }
|
||||
869
internal/services/export.go
Normal file
869
internal/services/export.go
Normal file
@@ -0,0 +1,869 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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
|
||||
categoryRepo *repository.CategoryRepository
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
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
|
||||
Category string
|
||||
Quantity int
|
||||
UnitPrice float64
|
||||
TotalPrice float64
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ProjectExportData holds all configuration blocks for a project-level export.
|
||||
type ProjectExportData struct {
|
||||
Configs []ConfigExportBlock
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type ProjectPricingExportData struct {
|
||||
Configs []ProjectPricingExportConfig
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ProjectPricingExportConfig struct {
|
||||
Name string
|
||||
Article string
|
||||
Line int
|
||||
ServerCount int
|
||||
Rows []ProjectPricingExportRow
|
||||
}
|
||||
|
||||
type ProjectPricingExportRow struct {
|
||||
LotDisplay string
|
||||
VendorPN string
|
||||
Description string
|
||||
Quantity int
|
||||
BOMTotal *float64
|
||||
Estimate *float64
|
||||
Stock *float64
|
||||
Competitor *float64
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 := s.ToCSV(&buf, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
}
|
||||
unitTotal += itemTotal
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
563
internal/services/export_test.go
Normal file
563
internal/services/export_test.go
Normal file
@@ -0,0 +1,563 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
func newTestProjectData(items []ExportItem, article string, serverCount int) *ProjectExportData {
|
||||
var unitTotal float64
|
||||
for _, item := range items {
|
||||
unitTotal += item.UnitPrice * float64(item.Quantity)
|
||||
}
|
||||
if serverCount < 1 {
|
||||
serverCount = 1
|
||||
}
|
||||
return &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{
|
||||
{
|
||||
Article: article,
|
||||
ServerCount: serverCount,
|
||||
UnitPrice: unitTotal,
|
||||
Items: items,
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_UTF8BOM(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Category: "CAT",
|
||||
Quantity: 1,
|
||||
UnitPrice: 100.0,
|
||||
TotalPrice: 100.0,
|
||||
},
|
||||
}, "TEST-ARTICLE", 1)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
if len(csvBytes) < 3 {
|
||||
t.Fatalf("CSV too short to contain BOM")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := csvBytes[:3]
|
||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{
|
||||
LotName: "LOT-001",
|
||||
Category: "CAT",
|
||||
Quantity: 2,
|
||||
UnitPrice: 100.50,
|
||||
TotalPrice: 201.00,
|
||||
},
|
||||
}, "TEST-ARTICLE", 1)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Read header
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 8 {
|
||||
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||
}
|
||||
|
||||
expectedHeader := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
||||
for i, col := range expectedHeader {
|
||||
if i < len(header) && header[i] != col {
|
||||
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Read server row
|
||||
serverRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read server row: %v", err)
|
||||
}
|
||||
if serverRow[0] != "10" {
|
||||
t.Errorf("Expected line number 10, got %s", serverRow[0])
|
||||
}
|
||||
if serverRow[2] != "TEST-ARTICLE" {
|
||||
t.Errorf("Expected article TEST-ARTICLE, got %s", serverRow[2])
|
||||
}
|
||||
|
||||
// Read component row
|
||||
itemRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read item row: %v", err)
|
||||
}
|
||||
if itemRow[2] != "LOT-001" {
|
||||
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[2])
|
||||
}
|
||||
if itemRow[4] != "2" {
|
||||
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[4])
|
||||
}
|
||||
if itemRow[6] != "100,5" {
|
||||
t.Errorf("Unit price mismatch: expected 100,5, got %s", itemRow[6])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_ServerRow(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
{LotName: "LOT-002", Category: "CAT", Quantity: 2, UnitPrice: 50.0, TotalPrice: 100.0},
|
||||
}, "DL380-ART", 10)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Skip header
|
||||
reader.Read()
|
||||
|
||||
// Read server row
|
||||
serverRow, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read server row: %v", err)
|
||||
}
|
||||
|
||||
if serverRow[0] != "10" {
|
||||
t.Errorf("Expected line 10, got %s", serverRow[0])
|
||||
}
|
||||
if serverRow[2] != "DL380-ART" {
|
||||
t.Errorf("Expected article DL380-ART, got %s", serverRow[2])
|
||||
}
|
||||
if serverRow[5] != "10" {
|
||||
t.Errorf("Expected server count 10, got %s", serverRow[5])
|
||||
}
|
||||
// UnitPrice = 100 + 100 = 200
|
||||
if serverRow[6] != "200" {
|
||||
t.Errorf("Expected unit price 200, got %s", serverRow[6])
|
||||
}
|
||||
// TotalPrice = 200 * 10 = 2000
|
||||
if serverRow[7] != "2 000" {
|
||||
t.Errorf("Expected total price '2 000', got %q", serverRow[7])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_CategorySorting(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT-A", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
{LotName: "LOT-002", Category: "CAT-C", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
{LotName: "LOT-003", Category: "CAT-B", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
}, "ART", 1)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
// Skip header and server row
|
||||
reader.Read()
|
||||
reader.Read()
|
||||
|
||||
// Without category repo, items maintain original order
|
||||
row1, _ := reader.Read()
|
||||
if row1[2] != "LOT-001" {
|
||||
t.Errorf("Expected LOT-001 first, got %s", row1[2])
|
||||
}
|
||||
|
||||
row2, _ := reader.Read()
|
||||
if row2[2] != "LOT-002" {
|
||||
t.Errorf("Expected LOT-002 second, got %s", row2[2])
|
||||
}
|
||||
|
||||
row3, _ := reader.Read()
|
||||
if row3[2] != "LOT-003" {
|
||||
t.Errorf("Expected LOT-003 third, got %s", row3[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_EmptyData(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read header: %v", err)
|
||||
}
|
||||
|
||||
if len(header) != 8 {
|
||||
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||
}
|
||||
|
||||
// No more rows expected
|
||||
_, err = reader.Read()
|
||||
if err != io.EOF {
|
||||
t.Errorf("Expected EOF after header, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
}, "ART", 1)
|
||||
|
||||
csvBytes, err := svc.ToCSVBytes(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ToCSVBytes failed: %v", err)
|
||||
}
|
||||
|
||||
if len(csvBytes) < 3 {
|
||||
t.Fatalf("CSV bytes too short")
|
||||
}
|
||||
|
||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||
actualBOM := csvBytes[:3]
|
||||
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_WriterError(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := newTestProjectData([]ExportItem{
|
||||
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||
}, "ART", 1)
|
||||
|
||||
failingWriter := &failingWriter{}
|
||||
|
||||
if err := svc.ToCSV(failingWriter, data); err == nil {
|
||||
t.Errorf("Expected error from failing writer, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToCSV_MultipleBlocks(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
data := &ProjectExportData{
|
||||
Configs: []ConfigExportBlock{
|
||||
{
|
||||
Article: "ART-1",
|
||||
ServerCount: 2,
|
||||
UnitPrice: 500.0,
|
||||
Items: []ExportItem{
|
||||
{LotName: "LOT-A", Category: "CPU", Quantity: 1, UnitPrice: 500.0, TotalPrice: 500.0},
|
||||
},
|
||||
},
|
||||
{
|
||||
Article: "ART-2",
|
||||
ServerCount: 3,
|
||||
UnitPrice: 1000.0,
|
||||
Items: []ExportItem{
|
||||
{LotName: "LOT-B", Category: "MEM", Quantity: 2, UnitPrice: 500.0, TotalPrice: 1000.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToCSV(&buf, data); err != nil {
|
||||
t.Fatalf("ToCSV failed: %v", err)
|
||||
}
|
||||
|
||||
csvBytes := buf.Bytes()
|
||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||
reader.Comma = ';'
|
||||
reader.FieldsPerRecord = -1 // allow variable fields
|
||||
|
||||
// Header
|
||||
reader.Read()
|
||||
|
||||
// Block 1: server row
|
||||
srv1, _ := reader.Read()
|
||||
if srv1[0] != "10" {
|
||||
t.Errorf("Block 1 line: expected 10, got %s", srv1[0])
|
||||
}
|
||||
if srv1[7] != "1 000" {
|
||||
t.Errorf("Block 1 total: expected '1 000', got %q", srv1[7])
|
||||
}
|
||||
|
||||
// Block 1: component row
|
||||
comp1, _ := reader.Read()
|
||||
if comp1[2] != "LOT-A" {
|
||||
t.Errorf("Block 1 component: expected LOT-A, got %s", comp1[2])
|
||||
}
|
||||
|
||||
// Separator row
|
||||
sep, _ := reader.Read()
|
||||
allEmpty := true
|
||||
for _, v := range sep {
|
||||
if v != "" {
|
||||
allEmpty = false
|
||||
}
|
||||
}
|
||||
if !allEmpty {
|
||||
t.Errorf("Expected empty separator row, got %v", sep)
|
||||
}
|
||||
|
||||
// Block 2: server row
|
||||
srv2, _ := reader.Read()
|
||||
if srv2[0] != "20" {
|
||||
t.Errorf("Block 2 line: expected 20, got %s", srv2[0])
|
||||
}
|
||||
if srv2[7] != "3 000" {
|
||||
t.Errorf("Block 2 total: expected '3 000', got %q", srv2[7])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectToExportData_SortsByLine(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
|
||||
configs := []models.Configuration{
|
||||
{
|
||||
UUID: "cfg-1",
|
||||
Line: 30,
|
||||
Article: "ART-30",
|
||||
ServerCount: 1,
|
||||
Items: models.ConfigItems{{LotName: "LOT-30", Quantity: 1, UnitPrice: 300}},
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
},
|
||||
{
|
||||
UUID: "cfg-2",
|
||||
Line: 10,
|
||||
Article: "ART-10",
|
||||
ServerCount: 1,
|
||||
Items: models.ConfigItems{{LotName: "LOT-10", Quantity: 1, UnitPrice: 100}},
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
},
|
||||
{
|
||||
UUID: "cfg-3",
|
||||
Line: 20,
|
||||
Article: "ART-20",
|
||||
ServerCount: 1,
|
||||
Items: models.ConfigItems{{LotName: "LOT-20", Quantity: 1, UnitPrice: 200}},
|
||||
CreatedAt: time.Now().Add(-3 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
data := svc.ProjectToExportData(configs)
|
||||
if len(data.Configs) != 3 {
|
||||
t.Fatalf("expected 3 blocks, got %d", len(data.Configs))
|
||||
}
|
||||
if data.Configs[0].Article != "ART-10" || data.Configs[0].Line != 10 {
|
||||
t.Fatalf("first block must be line 10, got article=%s line=%d", data.Configs[0].Article, data.Configs[0].Line)
|
||||
}
|
||||
if data.Configs[1].Article != "ART-20" || data.Configs[1].Line != 20 {
|
||||
t.Fatalf("second block must be line 20, got article=%s line=%d", data.Configs[1].Article, data.Configs[1].Line)
|
||||
}
|
||||
if data.Configs[2].Article != "ART-30" || data.Configs[2].Line != 30 {
|
||||
t.Fatalf("third block must be line 30, got article=%s line=%d", data.Configs[2].Article, data.Configs[2].Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPriceWithSpace(t *testing.T) {
|
||||
tests := []struct {
|
||||
input float64
|
||||
expected string
|
||||
}{
|
||||
{0, "0"},
|
||||
{100, "100"},
|
||||
{1000, "1 000"},
|
||||
{10470, "10 470"},
|
||||
{104700, "104 700"},
|
||||
{1000000, "1 000 000"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatPriceWithSpace(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatPriceWithSpace(%v): expected %q, got %q", tt.input, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPriceComma(t *testing.T) {
|
||||
tests := []struct {
|
||||
input float64
|
||||
expected string
|
||||
}{
|
||||
{100.0, "100"},
|
||||
{2074.5, "2074,5"},
|
||||
{100.50, "100,5"},
|
||||
{99.99, "99,99"},
|
||||
{0, "0"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatPriceComma(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatPriceComma(%v): expected %q, got %q", tt.input, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
data := &ProjectPricingExportData{
|
||||
Configs: []ProjectPricingExportConfig{
|
||||
{
|
||||
Name: "Config A",
|
||||
Article: "ART-1",
|
||||
Line: 10,
|
||||
ServerCount: 2,
|
||||
Rows: []ProjectPricingExportRow{
|
||||
{
|
||||
LotDisplay: "LOT_A +1",
|
||||
VendorPN: "PN-001",
|
||||
Description: "Bundle row",
|
||||
Quantity: 2,
|
||||
BOMTotal: floatPtr(2400.5),
|
||||
Estimate: floatPtr(2000),
|
||||
Stock: floatPtr(1800.25),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
opts := ProjectPricingExportOptions{
|
||||
IncludeLOT: true,
|
||||
IncludeBOM: true,
|
||||
IncludeEstimate: true,
|
||||
IncludeStock: true,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ToPricingCSV(&buf, data, opts); err != nil {
|
||||
t.Fatalf("ToPricingCSV failed: %v", err)
|
||||
}
|
||||
|
||||
reader := csv.NewReader(bytes.NewReader(buf.Bytes()[3:]))
|
||||
reader.Comma = ';'
|
||||
reader.FieldsPerRecord = -1
|
||||
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("read header row: %v", err)
|
||||
}
|
||||
expectedHeader := []string{"Line Item", "LOT", "PN вендора", "Описание", "Кол-во", "BOM", "Estimate", "Stock"}
|
||||
for i, want := range expectedHeader {
|
||||
if header[i] != want {
|
||||
t.Fatalf("header[%d]: expected %q, got %q", i, want, header[i])
|
||||
}
|
||||
}
|
||||
|
||||
summary, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("read summary row: %v", err)
|
||||
}
|
||||
expectedSummary := []string{"10", "", "", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
|
||||
for i, want := range expectedSummary {
|
||||
if summary[i] != want {
|
||||
t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i])
|
||||
}
|
||||
}
|
||||
|
||||
row, err := reader.Read()
|
||||
if err != nil {
|
||||
t.Fatalf("read data row: %v", err)
|
||||
}
|
||||
expectedRow := []string{"", "LOT_A +1", "PN-001", "Bundle row", "2", "2 400,50", "2 000,00", "1 800,25"}
|
||||
for i, want := range expectedRow {
|
||||
if row[i] != want {
|
||||
t.Fatalf("row[%d]: expected %q, got %q", i, want, row[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectToPricingExportData_UsesCartRowsWithoutBOM(t *testing.T) {
|
||||
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||
configs := []models.Configuration{
|
||||
{
|
||||
UUID: "cfg-1",
|
||||
Name: "Config A",
|
||||
Article: "ART-1",
|
||||
ServerCount: 1,
|
||||
Items: models.ConfigItems{
|
||||
{LotName: "LOT_A", Quantity: 2, UnitPrice: 300},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
data, err := svc.ProjectToPricingExportData(configs, ProjectPricingExportOptions{
|
||||
IncludeLOT: true,
|
||||
IncludeEstimate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectToPricingExportData failed: %v", err)
|
||||
}
|
||||
if len(data.Configs) != 1 || len(data.Configs[0].Rows) != 1 {
|
||||
t.Fatalf("unexpected rows count: %+v", data.Configs)
|
||||
}
|
||||
row := data.Configs[0].Rows[0]
|
||||
if row.LotDisplay != "LOT_A" {
|
||||
t.Fatalf("expected LOT_A, got %q", row.LotDisplay)
|
||||
}
|
||||
if row.VendorPN != "—" {
|
||||
t.Fatalf("expected vendor dash, got %q", row.VendorPN)
|
||||
}
|
||||
if row.Estimate == nil || *row.Estimate != 600 {
|
||||
t.Fatalf("expected estimate total 600, got %+v", row.Estimate)
|
||||
}
|
||||
}
|
||||
|
||||
// failingWriter always returns an error
|
||||
type failingWriter struct{}
|
||||
|
||||
func (fw *failingWriter) Write(p []byte) (int, error) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
1728
internal/services/local_configuration.go
Normal file
1728
internal/services/local_configuration.go
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user