Remove admin pricing stack and prepare v1.0.4 release

This commit is contained in:
2026-02-07 21:23:23 +03:00
parent 86ed26fdd6
commit d904db216f
28 changed files with 611 additions and 7584 deletions

194
CLAUDE.md
View File

@@ -1,163 +1,71 @@
# QuoteForge - Claude Code Instructions # QuoteForge - Claude Code Instructions
## Overview ## Overview
Корпоративный конфигуратор серверов и формирование КП. MariaDB (RFQ_LOG) + SQLite для оффлайн. Корпоративный конфигуратор серверов с offline-first архитектурой.
Приложение работает через локальную SQLite базу, синхронизация с MariaDB выполняется фоново.
## Development Phases ## Product Scope
- Конфигуратор компонентов и расчёт КП
- Проекты и конфигурации
- Read-only просмотр прайслистов из локального кэша
- Sync (pull компонентов/прайслистов, push локальных изменений)
### Phase 1: Pricelists in MariaDB ✅ DONE Из области исключены:
### Phase 2: Local SQLite Database ✅ DONE - admin pricing UI/API
- stock import
- alerts
- cron/importer утилиты
### Phase 2.5: Full Offline Mode 🔶 IN PROGRESS ## Architecture
**Local-first architecture:** приложение ВСЕГДА работает с SQLite, MariaDB только для синхронизации. - Local-first: чтение и запись происходят в SQLite
- MariaDB используется как сервер синхронизации
- Background worker: периодический sync push+pull
**Принцип работы:** ## Key SQLite Data
- ВСЕ операции (CRUD) выполняются в SQLite - `connection_settings`
- При создании конфигурации: - `local_components`
1. Если online → проверить новые прайслисты на сервере → скачать если есть - `local_pricelists`, `local_pricelist_items`
2. Далее работаем с local_pricelists (и online, и offline одинаково) - `local_configurations`
- Background sync: push pending_changes → pull updates - `local_projects`
- `pending_changes`
**DONE:**
- ✅ Sync queue table (pending_changes) - `internal/localdb/models.go`
- ✅ Model converters: MariaDB ↔ SQLite - `internal/localdb/converters.go`
- ✅ LocalConfigurationService: все CRUD через SQLite - `internal/services/local_configuration.go`
- ✅ Pre-create pricelist check: `SyncPricelistsIfNeeded()` - `internal/services/sync/service.go`
- ✅ Push pending changes: `PushPendingChanges()` - sync service + handlers
- ✅ Sync API endpoints: `/api/sync/push`, `/pending/count`, `/pending`
- ✅ Integrate LocalConfigurationService in main.go (replace ConfigurationService)
- ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`)
- ✅ ConfigurationGetter interface for handler compatibility
- ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go`
- ✅ UI: sync status indicator (pending badge + sync button + offline/online dot) - `web/templates/partials/sync_status.html`
- ✅ RefreshPrices for local mode:
- `RefreshPrices()` / `RefreshPricesNoAuth()` в `local_configuration.go`
- Берёт цены из `local_components.current_price`
- Graceful degradation при отсутствии компонента
- Добавлено поле `price_updated_at` в `LocalConfiguration` (models.go:72)
- Обновлены converters для PriceUpdatedAt
- UI кнопка "Пересчитать цену" работает offline/online
- ✅ Fixed sync bugs:
- Duplicate entry error при update конфигураций (`sync/service.go:334-365`)
- pushConfigurationUpdate теперь проверяет наличие server_id перед update
- Если нет ID → получает из LocalConfiguration.ServerID или ищет на сервере
- Fixed setup.go: `settings.Password``settings.PasswordEncrypted`
**TODO:**
- ❌ Conflict resolution (Phase 4, last-write-wins default)
### UI Improvements ✅ MOSTLY DONE
**1. Sync UI + pricelist badge: ✅ DONE**
-`sync_status.html`: SVG иконки Online/Offline (кликабельные → открывают модал)
- ✅ Кнопка sync → иконка circular arrows (только full sync)
- ✅ Модальное окно "Статус системы" в `base.html` (info о БД, ошибки синхронизации)
-`configs.html`: badge с версией активного прайслиста
- ✅ Загрузка через `/api/pricelists/latest` при DOMContentLoaded
- ✅ Удалён dropdown с Push changes (упрощение UI)
**2. Прайслисты → вкладка в "Администратор цен": ✅ DONE**
-`base.html`: убрана ссылка "Прайслисты" из навигации
-`admin_pricing.html`: добавлена вкладка "Прайслисты"
- ✅ Логика перенесена из `pricelists.html` (table, create modal, CRUD)
- ✅ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists`
- ✅ Поддержка URL param `?tab=pricelists`
**3. Модал "Настройка цены" - кол-во котировок с учётом периода: ❌ TODO**
- Текущее: показывает только общее кол-во котировок
- Новое: показывать `N (всего: M)` где N - за выбранный период, M - всего
-`admin_pricing.html`: обновить `#modal-quote-count`
-`admin_pricing_handler.go`: в `/api/admin/pricing/preview` возвращать `quote_count_period` + `quote_count_total`
**4. Страница настроек: ❌ ОТЛОЖЕНО**
- Перенесено в Phase 3 (после основных UI улучшений)
### Phase 3: Projects and Specifications
- qt_projects, qt_specifications tables (MariaDB)
- Replace qt_configurations → Project/Specification hierarchy
- Fields: opty, customer_requirement, variant, qty, rev
- Local projects/specs with server sync
### Phase 4: Price Versioning
- Bind specifications to pricelist versions
- Price diff comparison
- Auto-cleanup expired pricelists (>1 year, usage_count=0)
## Tech Stack
Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind CDN | excelize
## Key Tables
### READ-ONLY (external systems)
- `lot` (lot_name PK, lot_description)
- `lot_log` (lot, supplier, date, price, quality, comments)
- `supplier` (supplier_name PK)
### MariaDB (qt_* prefix)
- `qt_lot_metadata` - component prices, methods, popularity
- `qt_categories` - category codes and names
- `qt_pricelists` - version snapshots (YYYY-MM-DD-NNN format)
- `qt_pricelist_items` - prices per pricelist
- `qt_projects` - uuid, opty, customer_requirement, name (Phase 3)
- `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3)
### SQLite (data/quoteforge.db)
- `connection_settings` - encrypted DB credentials (PasswordEncrypted field)
- `local_pricelists/items` - cached from server
- `local_components` - lot cache for offline search (with current_price)
- `local_configurations` - UUID, items, price_updated_at, sync_status (pending/synced/conflict), server_id
- `local_projects/specifications` - Phase 3
- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at, attempts, last_error)
## Business Logic
**Part number parsing:** `CPU_AMD_9654` → category=`CPU`, model=`AMD_9654`
**Price methods:** manual | median | average | weighted_median
**Price freshness:** fresh (<30d, ≥3 quotes) | normal (<60d) | stale (<90d) | critical
**Pricelist version:** `YYYY-MM-DD-NNN` (e.g., `2024-01-31-001`)
## API Endpoints ## API Endpoints
| Group | Endpoints | | Group | Endpoints |
|-------|-----------| |-------|-----------|
| Setup | GET/POST /setup, POST /setup/test | | Setup | `GET /setup`, `POST /setup`, `POST /setup/test`, `GET /setup/status` |
| Components | GET /api/components, /api/categories | | Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` |
| Pricelists | CRUD /api/pricelists, GET /latest, POST /compare | | Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` |
| Projects | CRUD /api/projects/:uuid (Phase 3) | | Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` |
| Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) | | Configs | CRUD + refresh/clone/reactivate/rename/project binding via `/api/configs/*` |
| Configs | POST /:uuid/refresh-prices (обновить цены из local_components) | | Projects | CRUD + nested configs via `/api/projects/*` |
| Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict | | Sync | `GET /api/sync/status`, `GET /api/sync/readiness`, `GET /api/sync/info`, `GET /api/sync/users-status`, `POST /api/sync/components`, `POST /api/sync/pricelists`, `POST /api/sync/all`, `POST /api/sync/push`, `GET /api/sync/pending`, `GET /api/sync/pending/count` |
| Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export | | Export | `POST /api/export/csv` |
## Web Routes
- `/configs`
- `/configurator`
- `/projects`
- `/projects/:uuid`
- `/pricelists`
- `/pricelists/:id`
- `/setup`
## Commands ## Commands
```bash ```bash
# Development # Development
go run ./cmd/qfs # Dev server go run ./cmd/qfs
make run # Dev server (via Makefile) make run
# Production build # Build
make build-release # Optimized build with version (recommended) make build-release
VERSION=$(git describe --tags --always --dirty) CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
# Cron jobs # Verification
go run ./cmd/cron -job=cleanup-pricelists # Remove old unused pricelists go build ./cmd/qfs
go run ./cmd/cron -job=update-prices # Recalculate all prices go vet ./...
go run ./cmd/cron -job=update-popularity # Update popularity scores
# Check version
./bin/qfs -version
``` ```
## Code Style ## Code Style
- gofmt, structured logging (slog), wrap errors with context - gofmt
- snake_case files, PascalCase types - structured logging (`slog`)
- RBAC disabled: DB username = user_id via `models.EnsureDBUser()` - explicit error wrapping with context
## UI Guidelines
- htmx (hx-get/post/target/swap), Tailwind CDN
- Freshness colors: green (fresh) → yellow → orange → red (critical)
- Sync status + offline indicator in header

View File

@@ -2,7 +2,8 @@
**Server Configuration & Quotation Tool** **Server Configuration & Quotation Tool**
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG. QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП).
Приложение работает в strict local-first режиме: пользовательские операции выполняются через локальную SQLite, MariaDB используется для синхронизации и серверного администрирования прайслистов.
![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat&logo=go) ![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat&logo=go)
![License](https://img.shields.io/badge/License-Proprietary-red) ![License](https://img.shields.io/badge/License-Proprietary-red)
@@ -16,6 +17,8 @@ QuoteForge — корпоративный инструмент для конфи
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок - 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов - 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования - 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
- 🔌 **Полная офлайн-работа** — можно продолжать работу без сети и синхронизировать позже
- 🛡️ **Защищенная синхронизация** — sync блокируется preflight-проверкой, если локальная схема не готова
### Для ценовых администраторов ### Для ценовых администраторов
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее - 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
@@ -35,7 +38,7 @@ QuoteForge — корпоративный инструмент для конфи
- **Backend:** Go 1.22+, Gin, GORM - **Backend:** Go 1.22+, Gin, GORM
- **Frontend:** HTML, Tailwind CSS, htmx - **Frontend:** HTML, Tailwind CSS, htmx
- **Database:** MariaDB 11+ - **Database:** SQLite (runtime/local-first), MariaDB 11+ (sync + server admin)
- **Export:** excelize (XLSX), encoding/csv - **Export:** excelize (XLSX), encoding/csv
## Требования ## Требования
@@ -207,6 +210,18 @@ make help # Показать все команды
Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`. Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`.
#### Sync readiness guard
Перед `push/pull` выполняется preflight-проверка:
- доступен ли сервер (MariaDB);
- можно ли проверить и применить централизованные миграции локальной БД;
- подходит ли версия приложения под `min_app_version` миграций.
Если проверка не пройдена:
- локальная работа (CRUD) продолжается;
- sync API возвращает `423 Locked` с `reason_code` и `reason_text`;
- в UI показывается красный индикатор и причина блокировки в модалке синхронизации.
### Версионность конфигураций (local-first) ### Версионность конфигураций (local-first)
Для `local_configurations` используется append-only versioning через полные snapshot-версии: Для `local_configurations` используется append-only versioning через полные snapshot-версии:
@@ -301,6 +316,13 @@ GET /api/configs/:uuid/versions # Список версий конф
GET /api/configs/:uuid/versions/:version # Получить конкретную версию GET /api/configs/:uuid/versions/:version # Получить конкретную версию
POST /api/configs/:uuid/rollback # Rollback на указанную версию POST /api/configs/:uuid/rollback # Rollback на указанную версию
POST /api/configs/:uuid/reactivate # Вернуть архивную конфигурацию в активные POST /api/configs/:uuid/reactivate # Вернуть архивную конфигурацию в активные
GET /api/sync/readiness # Статус readiness guard (ready|blocked|unknown)
GET /api/sync/status # Сводный статус синхронизации
GET /api/sync/info # Данные для модалки синхронизации
POST /api/sync/push # Push pending changes (423, если blocked)
POST /api/sync/all # Full sync push+pull (423, если blocked)
POST /api/sync/components # Pull components (423, если blocked)
POST /api/sync/pricelists # Pull pricelists (423, если blocked)
``` ```
#### Sync payload для versioning #### Sync payload для versioning

View File

@@ -1,84 +0,0 @@
package main
import (
"flag"
"log"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
cronJob := flag.String("job", "", "type of cron job to run (alerts, update-prices)")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// Ensure tables exist
if err := models.Migrate(db); err != nil {
log.Fatalf("Migration failed: %v", err)
}
// Initialize repositories
statsRepo := repository.NewStatsRepository(db)
alertRepo := repository.NewAlertRepository(db)
componentRepo := repository.NewComponentRepository(db)
priceRepo := repository.NewPriceRepository(db)
// Initialize services
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
switch *cronJob {
case "alerts":
log.Println("Running alerts check...")
if err := alertService.CheckAndGenerateAlerts(); err != nil {
log.Printf("Error running alerts check: %v", err)
} else {
log.Println("Alerts check completed successfully")
}
case "update-prices":
log.Println("Recalculating all prices...")
updated, errors := pricingService.RecalculateAllPrices()
log.Printf("Prices recalculated: %d updated, %d errors", updated, errors)
case "reset-counters":
log.Println("Resetting usage counters...")
if err := statsRepo.ResetWeeklyCounters(); err != nil {
log.Printf("Error resetting weekly counters: %v", err)
}
if err := statsRepo.ResetMonthlyCounters(); err != nil {
log.Printf("Error resetting monthly counters: %v", err)
}
log.Println("Usage counters reset completed")
case "update-popularity":
log.Println("Updating popularity scores...")
if err := statsRepo.UpdatePopularityScores(); err != nil {
log.Printf("Error updating popularity scores: %v", err)
} else {
log.Println("Popularity scores updated successfully")
}
default:
log.Println("No valid cron job specified. Available jobs:")
log.Println(" - alerts: Check and generate alerts")
log.Println(" - update-prices: Recalculate all prices")
log.Println(" - reset-counters: Reset usage counters")
log.Println(" - update-popularity: Update popularity scores")
}
}

View File

@@ -1,160 +0,0 @@
package main
import (
"flag"
"log"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
log.Println("Connected to database")
// Ensure tables exist
if err := models.Migrate(db); err != nil {
log.Fatalf("Migration failed: %v", err)
}
if err := models.SeedCategories(db); err != nil {
log.Fatalf("Seeding categories failed: %v", err)
}
// Load categories for lookup
var categories []models.Category
db.Find(&categories)
categoryMap := make(map[string]uint)
for _, c := range categories {
categoryMap[c.Code] = c.ID
}
log.Printf("Loaded %d categories", len(categories))
// Get all lots
var lots []models.Lot
if err := db.Find(&lots).Error; err != nil {
log.Fatalf("Failed to load lots: %v", err)
}
log.Printf("Found %d lots to import", len(lots))
// Import each lot
var imported, skipped, updated int
for _, lot := range lots {
category, model := ParsePartNumber(lot.LotName)
var categoryID *uint
if id, ok := categoryMap[category]; ok && id > 0 {
categoryID = &id
} else {
// Try to find by prefix match
for code, id := range categoryMap {
if strings.HasPrefix(category, code) {
categoryID = &id
break
}
}
}
// Check if already exists
var existing models.LotMetadata
result := db.Where("lot_name = ?", lot.LotName).First(&existing)
if result.Error == gorm.ErrRecordNotFound {
// Check if there are prices in the last 90 days
var recentPriceCount int64
db.Model(&models.LotLog{}).
Where("lot = ? AND date >= DATE_SUB(NOW(), INTERVAL 90 DAY)", lot.LotName).
Count(&recentPriceCount)
// Default to 90 days, but use "all time" (0) if no recent prices
periodDays := 90
if recentPriceCount == 0 {
periodDays = 0
}
// Create new
metadata := models.LotMetadata{
LotName: lot.LotName,
CategoryID: categoryID,
Model: model,
PricePeriodDays: periodDays,
}
if err := db.Create(&metadata).Error; err != nil {
log.Printf("Failed to create metadata for %s: %v", lot.LotName, err)
continue
}
imported++
} else if result.Error == nil {
// Update if needed
needsUpdate := false
if existing.Model == "" {
existing.Model = model
needsUpdate = true
}
if existing.CategoryID == nil {
existing.CategoryID = categoryID
needsUpdate = true
}
// Check if using default period (90 days) but no recent prices
if existing.PricePeriodDays == 90 {
var recentPriceCount int64
db.Model(&models.LotLog{}).
Where("lot = ? AND date >= DATE_SUB(NOW(), INTERVAL 90 DAY)", lot.LotName).
Count(&recentPriceCount)
if recentPriceCount == 0 {
existing.PricePeriodDays = 0
needsUpdate = true
}
}
if needsUpdate {
db.Save(&existing)
updated++
} else {
skipped++
}
}
}
log.Printf("Import complete: %d imported, %d updated, %d skipped", imported, updated, skipped)
// Show final counts
var metadataCount int64
db.Model(&models.LotMetadata{}).Count(&metadataCount)
log.Printf("Total metadata records: %d", metadataCount)
}
// ParsePartNumber extracts category and model from lot_name
// Examples:
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
// "MB_INTEL_4.Sapphire_2S" → category="MB", model="INTEL_4.Sapphire_2S"
func ParsePartNumber(lotName string) (category, model string) {
parts := strings.SplitN(lotName, "_", 2)
if len(parts) >= 1 {
category = parts[0]
}
if len(parts) >= 2 {
model = parts[1]
}
return
}

View File

@@ -17,6 +17,7 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
syncpkg "sync"
"syscall" "syscall"
"time" "time"
@@ -31,9 +32,6 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"git.mchus.pro/mchus/quoteforge/internal/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
@@ -45,6 +43,7 @@ import (
var Version = "dev" var Version = "dev"
const backgroundSyncInterval = 5 * time.Minute const backgroundSyncInterval = 5 * time.Minute
const onDemandPullCooldown = 30 * time.Second
func main() { func main() {
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)") configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
@@ -207,6 +206,15 @@ func main() {
os.Exit(1) os.Exit(1)
} }
if readiness, readinessErr := syncService.GetReadiness(); readinessErr != nil {
slog.Warn("sync readiness check failed on startup", "error", readinessErr)
} else if readiness != nil && readiness.Blocked {
slog.Warn("sync readiness blocked on startup",
"reason_code", readiness.ReasonCode,
"reason_text", readiness.ReasonText,
)
}
// Start background sync worker (will auto-skip when offline) // Start background sync worker (will auto-skip when offline)
workerCtx, workerCancel := context.WithCancel(context.Background()) workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel() defer workerCancel()
@@ -446,8 +454,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Repositories // Repositories
var componentRepo *repository.ComponentRepository var componentRepo *repository.ComponentRepository
var categoryRepo *repository.CategoryRepository var categoryRepo *repository.CategoryRepository
var priceRepo *repository.PriceRepository
var alertRepo *repository.AlertRepository
var statsRepo *repository.StatsRepository var statsRepo *repository.StatsRepository
var pricelistRepo *repository.PricelistRepository var pricelistRepo *repository.PricelistRepository
@@ -455,8 +461,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if mariaDB != nil { if mariaDB != nil {
componentRepo = repository.NewComponentRepository(mariaDB) componentRepo = repository.NewComponentRepository(mariaDB)
categoryRepo = repository.NewCategoryRepository(mariaDB) categoryRepo = repository.NewCategoryRepository(mariaDB)
priceRepo = repository.NewPriceRepository(mariaDB)
alertRepo = repository.NewAlertRepository(mariaDB)
statsRepo = repository.NewStatsRepository(mariaDB) statsRepo = repository.NewStatsRepository(mariaDB)
pricelistRepo = repository.NewPricelistRepository(mariaDB) pricelistRepo = repository.NewPricelistRepository(mariaDB)
} else { } else {
@@ -465,13 +469,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
// Services // Services
var pricingService *pricing.Service
var componentService *services.ComponentService var componentService *services.ComponentService
var quoteService *services.QuoteService var quoteService *services.QuoteService
var exportService *services.ExportService var exportService *services.ExportService
var alertService *alerts.Service
var pricelistService *pricelist.Service
var stockImportService *services.StockImportService
var syncService *sync.Service var syncService *sync.Service
var projectService *services.ProjectService var projectService *services.ProjectService
@@ -479,22 +479,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
syncService = sync.NewService(connMgr, local) syncService = sync.NewService(connMgr, local)
if mariaDB != nil { if mariaDB != nil {
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo) componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, pricingService) quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
exportService = services.NewExportService(cfg.Export, categoryRepo) exportService = services.NewExportService(cfg.Export, categoryRepo)
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService)
stockImportService = services.NewStockImportService(mariaDB, pricelistService)
} else { } else {
// In offline mode, we still need to create services that don't require DB // In offline mode, we still need to create services that don't require DB.
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
componentService = services.NewComponentService(nil, nil, nil) componentService = services.NewComponentService(nil, nil, nil)
quoteService = services.NewQuoteService(nil, nil, nil, local, pricingService) quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
exportService = services.NewExportService(cfg.Export, nil) exportService = services.NewExportService(cfg.Export, nil)
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(nil, nil, nil, nil)
stockImportService = nil
} }
// isOnline function for local-first architecture // isOnline function for local-first architecture
@@ -526,20 +518,75 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
} }
syncProjectsFromServer := func() { type pullState struct {
if !connMgr.IsOnline() { mu syncpkg.Mutex
running bool
lastStarted time.Time
}
triggerPull := func(label string, state *pullState, pullFn func() error) {
state.mu.Lock()
if state.running {
state.mu.Unlock()
return return
} }
if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) { if !state.lastStarted.IsZero() && time.Since(state.lastStarted) < onDemandPullCooldown {
slog.Warn("failed to sync projects from server", "error", err) state.mu.Unlock()
return
} }
state.running = true
state.lastStarted = time.Now()
state.mu.Unlock()
go func() {
defer func() {
state.mu.Lock()
state.running = false
state.mu.Unlock()
}()
if err := pullFn(); err != nil {
slog.Warn("on-demand pull failed", "scope", label, "error", err)
}
}()
} }
syncConfigurationsFromServer := func() { var projectsPullState pullState
var configsPullState pullState
syncProjectsFromServer := func() error {
if !connMgr.IsOnline() { if !connMgr.IsOnline() {
return return nil
} }
_, _ = configService.ImportFromServer() if readiness, err := syncService.EnsureReadinessForSync(); err != nil {
slog.Warn("skipping project pull: sync readiness blocked",
"error", err,
"reason_code", readiness.ReasonCode,
"reason_text", readiness.ReasonText,
)
return nil
}
if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) {
return err
}
return nil
}
syncConfigurationsFromServer := func() error {
if !connMgr.IsOnline() {
return nil
}
if readiness, err := syncService.EnsureReadinessForSync(); err != nil {
slog.Warn("skipping configuration pull: sync readiness blocked",
"error", err,
"reason_code", readiness.ReasonCode,
"reason_text", readiness.ReasonText,
)
return nil
}
_, err := configService.ImportFromServer()
if err != nil && !errors.Is(err, sync.ErrOffline) {
return err
}
return nil
} }
// Use filepath.Join for cross-platform path compatibility // Use filepath.Join for cross-platform path compatibility
@@ -549,17 +596,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
componentHandler := handlers.NewComponentHandler(componentService, local) componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService) quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler( pricelistHandler := handlers.NewPricelistHandler(local)
mariaDB,
pricingService,
alertService,
componentRepo,
priceRepo,
statsRepo,
stockImportService,
local.GetDBUser(),
)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err) return nil, nil, fmt.Errorf("creating sync handler: %w", err)
@@ -615,28 +652,39 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// DB status endpoint // DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) { router.GET("/api/db-status", func(c *gin.Context) {
var lotCount, lotLogCount, metadataCount int64 var lotCount, lotLogCount, metadataCount int64
var dbOK bool = false var dbOK bool
var dbError string var dbError string
includeCounts := c.Query("include_counts") == "true"
// Check if connection exists (fast check, no reconnect attempt) // Fast status path: do not execute heavy COUNT queries unless requested.
status := connMgr.GetStatus() status := connMgr.GetStatus()
if status.IsConnected { dbOK = status.IsConnected
// Already connected, safe to use if !status.IsConnected {
if db, err := connMgr.GetDB(); err == nil && db != nil {
dbOK = true
db.Table("lot").Count(&lotCount)
db.Table("lot_log").Count(&lotLogCount)
db.Table("qt_lot_metadata").Count(&metadataCount)
}
} else {
// Not connected - don't try to reconnect on status check
// This prevents 3s timeout on every request
dbError = "Database not connected (offline mode)" dbError = "Database not connected (offline mode)"
if status.LastError != "" { if status.LastError != "" {
dbError = status.LastError dbError = status.LastError
} }
} }
// Optional diagnostics mode with server table counts.
if includeCounts && status.IsConnected {
if db, err := connMgr.GetDB(); err == nil && db != nil {
_ = db.Table("lot").Count(&lotCount)
_ = db.Table("lot_log").Count(&lotLogCount)
_ = db.Table("qt_lot_metadata").Count(&metadataCount)
} else if err != nil {
dbOK = false
dbError = err.Error()
} else {
dbOK = false
dbError = "Database not connected (offline mode)"
}
} else {
lotCount = 0
lotLogCount = 0
metadataCount = 0
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"connected": dbOK, "connected": dbOK,
"error": dbError, "error": dbError,
@@ -667,12 +715,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.GET("/configurator", webHandler.Configurator) router.GET("/configurator", webHandler.Configurator)
router.GET("/projects", webHandler.Projects) router.GET("/projects", webHandler.Projects)
router.GET("/projects/:uuid", webHandler.ProjectDetail) router.GET("/projects/:uuid", webHandler.ProjectDetail)
router.GET("/pricelists", func(c *gin.Context) { router.GET("/pricelists", webHandler.Pricelists)
// Redirect to admin/pricing with pricelists tab
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
})
router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/admin/pricing", webHandler.AdminPricing)
// htmx partials // htmx partials
partials := router.Group("/partials") partials := router.Group("/partials")
@@ -716,22 +760,17 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricelists := api.Group("/pricelists") pricelists := api.Group("/pricelists")
{ {
pricelists.GET("", pricelistHandler.List) pricelists.GET("", pricelistHandler.List)
pricelists.GET("/can-write", pricelistHandler.CanWrite)
pricelists.GET("/latest", pricelistHandler.GetLatest) pricelists.GET("/latest", pricelistHandler.GetLatest)
pricelists.GET("/:id", pricelistHandler.Get) pricelists.GET("/:id", pricelistHandler.Get)
pricelists.GET("/:id/items", pricelistHandler.GetItems) pricelists.GET("/:id/items", pricelistHandler.GetItems)
pricelists.GET("/:id/lots", pricelistHandler.GetLotNames) pricelists.GET("/:id/lots", pricelistHandler.GetLotNames)
pricelists.POST("", pricelistHandler.Create)
pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress)
pricelists.PATCH("/:id/active", pricelistHandler.SetActive)
pricelists.DELETE("/:id", pricelistHandler.Delete)
} }
// Configurations (public - RBAC disabled) // Configurations (public - RBAC disabled)
configs := api.Group("/configs") configs := api.Group("/configs")
{ {
configs.GET("", func(c *gin.Context) { configs.GET("", func(c *gin.Context) {
syncConfigurationsFromServer() triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
@@ -1018,8 +1057,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects := api.Group("/projects") projects := api.Group("/projects")
{ {
projects.GET("", func(c *gin.Context) { projects.GET("", func(c *gin.Context) {
syncProjectsFromServer() triggerPull("projects", &projectsPullState, syncProjectsFromServer)
syncConfigurationsFromServer() triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
status := c.DefaultQuery("status", "active") status := c.DefaultQuery("status", "active")
search := strings.ToLower(strings.TrimSpace(c.Query("search"))) search := strings.ToLower(strings.TrimSpace(c.Query("search")))
@@ -1128,17 +1167,26 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
paged = filtered[start:end] paged = filtered[start:end]
} }
// Build per-project active config stats in one pass (avoid N+1 scans).
projectConfigCount := map[string]int{}
projectConfigTotal := map[string]float64{}
if localConfigs, cfgErr := local.GetConfigurations(); cfgErr == nil {
for i := range localConfigs {
cfg := localConfigs[i]
if !cfg.IsActive || cfg.ProjectUUID == nil || *cfg.ProjectUUID == "" {
continue
}
projectUUID := *cfg.ProjectUUID
projectConfigCount[projectUUID]++
if cfg.TotalPrice != nil {
projectConfigTotal[projectUUID] += *cfg.TotalPrice
}
}
}
projectRows := make([]gin.H, 0, len(paged)) projectRows := make([]gin.H, 0, len(paged))
for i := range paged { for i := range paged {
p := paged[i] p := paged[i]
configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active")
if err != nil {
configs = &services.ProjectConfigurationsResult{
ProjectUUID: p.UUID,
Configs: []models.Configuration{},
Total: 0,
}
}
projectRows = append(projectRows, gin.H{ projectRows = append(projectRows, gin.H{
"id": p.ID, "id": p.ID,
"uuid": p.UUID, "uuid": p.UUID,
@@ -1149,8 +1197,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
"is_system": p.IsSystem, "is_system": p.IsSystem,
"created_at": p.CreatedAt, "created_at": p.CreatedAt,
"updated_at": p.UpdatedAt, "updated_at": p.UpdatedAt,
"config_count": len(configs.Configs), "config_count": projectConfigCount[p.UUID],
"total": configs.Total, "total": projectConfigTotal[p.UUID],
}) })
} }
@@ -1258,7 +1306,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}) })
projects.GET("/:uuid/configs", func(c *gin.Context) { projects.GET("/:uuid/configs", func(c *gin.Context) {
syncConfigurationsFromServer() triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
status := c.DefaultQuery("status", "active") status := c.DefaultQuery("status", "active")
if status != "active" && status != "archived" && status != "all" { if status != "active" && status != "archived" && status != "all" {
@@ -1318,35 +1366,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}) })
} }
// Pricing admin (public - RBAC disabled)
pricingAdmin := api.Group("/admin/pricing")
{
pricingAdmin.GET("/stats", pricingHandler.GetStats)
pricingAdmin.GET("/components", pricingHandler.ListComponents)
pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing)
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
pricingAdmin.GET("/lots", pricingHandler.ListLots)
pricingAdmin.GET("/lots-table", pricingHandler.ListLotsTable)
pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog)
pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings)
pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping)
pricingAdmin.DELETE("/stock/mappings/:partnumber", pricingHandler.DeleteStockMapping)
pricingAdmin.GET("/stock/ignore-rules", pricingHandler.ListStockIgnoreRules)
pricingAdmin.POST("/stock/ignore-rules", pricingHandler.UpsertStockIgnoreRule)
pricingAdmin.DELETE("/stock/ignore-rules/:id", pricingHandler.DeleteStockIgnoreRule)
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)
pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert)
pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert)
}
// Sync API (for offline mode) // Sync API (for offline mode)
syncAPI := api.Group("/sync") syncAPI := api.Group("/sync")
{ {
syncAPI.GET("/status", syncHandler.GetStatus) syncAPI.GET("/status", syncHandler.GetStatus)
syncAPI.GET("/readiness", syncHandler.GetReadiness)
syncAPI.GET("/info", syncHandler.GetInfo) syncAPI.GET("/info", syncHandler.GetInfo)
syncAPI.GET("/users-status", syncHandler.GetUsersStatus) syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
syncAPI.POST("/components", syncHandler.SyncComponents) syncAPI.POST("/components", syncHandler.SyncComponents)

View File

@@ -3,8 +3,10 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "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/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -25,6 +27,12 @@ func NewComponentHandler(componentService *services.ComponentService, localDB *l
func (h *ComponentHandler) List(c *gin.Context) { func (h *ComponentHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
filter := repository.ComponentFilter{ filter := repository.ComponentFilter{
Category: c.Query("category"), Category: c.Query("category"),
@@ -33,73 +41,70 @@ func (h *ComponentHandler) List(c *gin.Context) {
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
} }
result, err := h.componentService.List(filter, page, perPage) 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
// If offline mode (empty result), fallback to local components components := make([]services.ComponentView, len(localComps))
isOffline := false for i, lc := range localComps {
if v, ok := c.Get("is_offline"); ok { components[i] = services.ComponentView{
if b, ok := v.(bool); ok { LotName: lc.LotName,
isOffline = b Description: lc.LotDescription,
} Category: lc.Category,
} CategoryName: lc.Category,
if isOffline && result.Total == 0 && h.localDB != nil { Model: lc.Model,
localFilter := localdb.ComponentFilter{ CurrentPrice: lc.CurrentPrice,
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 && len(localComps) > 0 {
// Convert local components to ComponentView format
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, // No translation in local mode
Model: lc.Model,
CurrentPrice: lc.CurrentPrice,
}
}
c.JSON(http.StatusOK, &services.ComponentListResult{
Components: components,
Total: total,
Page: page,
PerPage: perPage,
})
return
} }
} }
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, &services.ComponentListResult{
Components: components,
Total: total,
Page: page,
PerPage: perPage,
})
} }
func (h *ComponentHandler) Get(c *gin.Context) { func (h *ComponentHandler) Get(c *gin.Context) {
lotName := c.Param("lot_name") lotName := c.Param("lot_name")
component, err := h.localDB.GetLocalComponent(lotName)
component, err := h.componentService.GetByLotName(lotName)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
return return
} }
c.JSON(http.StatusOK, component) c.JSON(http.StatusOK, services.ComponentView{
LotName: component.LotName,
Description: component.LotDescription,
Category: component.Category,
CategoryName: component.Category,
Model: component.Model,
CurrentPrice: component.CurrentPrice,
})
} }
func (h *ComponentHandler) GetCategories(c *gin.Context) { func (h *ComponentHandler) GetCategories(c *gin.Context) {
categories, err := h.componentService.GetCategories() codes, err := h.localDB.GetLocalComponentCategories()
if err != nil { if err == nil && len(codes) > 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 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 return
} }
c.JSON(http.StatusOK, categories) c.JSON(http.StatusOK, models.DefaultCategories)
} }

View File

@@ -1,121 +1,25 @@
package handlers package handlers
import ( import (
"errors"
"fmt"
"io"
"net/http" "net/http"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type PricelistHandler struct { type PricelistHandler struct {
service *pricelist.Service
localDB *localdb.LocalDB localDB *localdb.LocalDB
} }
func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *PricelistHandler { func NewPricelistHandler(localDB *localdb.LocalDB) *PricelistHandler {
return &PricelistHandler{service: service, localDB: localDB} return &PricelistHandler{localDB: localDB}
} }
// refreshLocalPricelistCacheFromServer rehydrates local metadata + items for one server pricelist. // List returns all pricelists with pagination.
func (h *PricelistHandler) refreshLocalPricelistCacheFromServer(serverID uint, onProgress func(synced, total int, message string)) error {
if h.localDB == nil {
return nil
}
report := func(synced, total int, message string) {
if onProgress != nil {
onProgress(synced, total, message)
}
}
report(0, 0, "Подготовка локального кэша")
pl, err := h.service.GetByID(serverID)
if err != nil {
return err
}
if existing, err := h.localDB.GetLocalPricelistByServerID(serverID); err == nil {
if err := h.localDB.DeleteLocalPricelist(existing.ID); err != nil {
return err
}
}
localPL := &localdb.LocalPricelist{
ServerID: pl.ID,
Source: pl.Source,
Version: pl.Version,
Name: pl.Notification,
CreatedAt: pl.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}
if err := h.localDB.SaveLocalPricelist(localPL); err != nil {
return err
}
report(0, 0, "Локальный кэш обновлён")
// Ensure we use persisted local row id (upsert path may not populate struct ID reliably).
persistedLocalPL, err := h.localDB.GetLocalPricelistByServerID(serverID)
if err != nil {
return err
}
if persistedLocalPL.ID == 0 {
return fmt.Errorf("local pricelist id is zero after save (server_id=%d)", serverID)
}
const perPage = 2000
synced := 0
totalItems := 0
gotTotal := false
for page := 1; ; page++ {
items, total, err := h.service.GetItems(serverID, page, perPage, "")
if err != nil {
return err
}
if !gotTotal {
totalItems = int(total)
gotTotal = true
}
if len(items) == 0 {
break
}
localItems := make([]localdb.LocalPricelistItem, 0, len(items))
for _, item := range items {
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
partnumbers = append(partnumbers, item.Partnumbers...)
localItems = append(localItems, localdb.LocalPricelistItem{
PricelistID: persistedLocalPL.ID,
LotName: item.LotName,
Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
})
}
if err := h.localDB.SaveLocalPricelistItems(localItems); err != nil {
return err
}
synced += len(localItems)
report(synced, totalItems, "Синхронизация позиций в локальный кэш")
if int64(page*perPage) >= total {
break
}
}
report(synced, totalItems, "Локальный кэш синхронизирован")
return nil
}
// List returns all pricelists with pagination
func (h *PricelistHandler) List(c *gin.Context) { func (h *PricelistHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
@@ -184,7 +88,7 @@ func (h *PricelistHandler) List(c *gin.Context) {
}) })
} }
// Get returns a single pricelist by ID // Get returns a single pricelist by ID.
func (h *PricelistHandler) Get(c *gin.Context) { func (h *PricelistHandler) Get(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32) id, err := strconv.ParseUint(idStr, 10, 32)
@@ -211,299 +115,7 @@ func (h *PricelistHandler) Get(c *gin.Context) {
}) })
} }
// Create creates a new pricelist from current prices // GetItems returns items for a pricelist with pagination.
func (h *PricelistHandler) Create(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
var req struct {
Source string `json:"source"`
Items []struct {
LotName string `json:"lot_name"`
Price float64 `json:"price"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
source := string(models.NormalizePricelistSource(req.Source))
// Get the database username as the creator
createdBy := h.localDB.GetDBUser()
if createdBy == "" {
createdBy = "unknown"
}
sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items))
for _, item := range req.Items {
sourceItems = append(sourceItems, pricelist.CreateItemInput{
LotName: item.LotName,
Price: item.Price,
})
}
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Keep local cache consistent for local-first reads (metadata + items).
if err := h.refreshLocalPricelistCacheFromServer(pl.ID, nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "pricelist created on server but failed to refresh local cache: " + err.Error(),
})
return
}
c.JSON(http.StatusCreated, pl)
}
// CreateWithProgress creates a pricelist and streams progress updates over SSE.
func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
var req struct {
Source string `json:"source"`
Items []struct {
LotName string `json:"lot_name"`
Price float64 `json:"price"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
source := string(models.NormalizePricelistSource(req.Source))
createdBy := h.localDB.GetDBUser()
if createdBy == "" {
createdBy = "unknown"
}
sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items))
for _, item := range req.Items {
sourceItems = append(sourceItems, pricelist.CreateItemInput{
LotName: item.LotName,
Price: item.Price,
})
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, pl)
return
}
sendProgress := func(payload gin.H) {
c.SSEvent("progress", payload)
flusher.Flush()
}
sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."})
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, func(p pricelist.CreateProgress) {
// Composite progress: 0-85% server creation, 86-99% local cache sync.
current := int(float64(p.Current) * 0.85)
if p.Status == "completed" {
current = 85
}
status := p.Status
if status == "completed" {
status = "server_completed"
}
sendProgress(gin.H{
"current": current,
"total": p.Total,
"status": status,
"message": p.Message,
"updated": p.Updated,
"errors": p.Errors,
"lot_name": p.LotName,
})
})
if err != nil {
sendProgress(gin.H{
"current": 0,
"total": 4,
"status": "error",
"message": fmt.Sprintf("Ошибка: %v", err),
})
return
}
if err := h.refreshLocalPricelistCacheFromServer(pl.ID, func(synced, total int, message string) {
current := 86
if total > 0 {
progressPart := int(float64(synced) / float64(total) * 13.0) // 86..99
if progressPart > 13 {
progressPart = 13
}
current = 86 + progressPart
}
if current > 99 {
current = 99
}
sendProgress(gin.H{
"current": current,
"total": 100,
"status": "sync_local_cache",
"message": message,
})
}); err != nil {
sendProgress(gin.H{
"current": 4,
"total": 4,
"status": "error",
"message": fmt.Sprintf("Прайслист создан, но локальный кэш не обновлён: %v", err),
})
return
}
sendProgress(gin.H{
"current": 4,
"total": 4,
"status": "completed",
"message": "Готово",
"pricelist": pl,
})
}
// Delete deletes a pricelist by ID
func (h *PricelistHandler) Delete(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
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
}
if err := h.service.Delete(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Local-first UI reads pricelists from SQLite cache. Keep cache in sync right away.
if h.localDB != nil {
if localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id)); err == nil {
if err := h.localDB.DeleteLocalPricelist(localPL.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "pricelist deleted on server but failed to update local cache: " + err.Error(),
})
return
}
}
}
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
}
// SetActive toggles active flag on a pricelist.
func (h *PricelistHandler) SetActive(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
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
}
var req struct {
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.SetActive(uint(id), req.IsActive); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Local-first table stores only active snapshots. Reflect toggles immediately.
if h.localDB != nil {
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
if err == nil {
if req.IsActive {
// Ensure local active row has complete cache (metadata + items).
if h.localDB.CountLocalPricelistItems(localPL.ID) == 0 {
if err := h.refreshLocalPricelistCacheFromServer(uint(id), nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "updated on server but failed to refresh local cache: " + err.Error(),
})
return
}
} else {
localPL.SyncedAt = time.Now()
if saveErr := h.localDB.SaveLocalPricelist(localPL); saveErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "updated on server but failed to update local cache: " + saveErr.Error(),
})
return
}
}
} else {
// Inactive entries should disappear from local active cache list.
if delErr := h.localDB.DeleteLocalPricelist(localPL.ID); delErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "updated on server but failed to update local cache: " + delErr.Error(),
})
return
}
}
} else if req.IsActive {
if err := h.refreshLocalPricelistCacheFromServer(uint(id), nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "updated on server but failed to seed local cache: " + err.Error(),
})
return
}
}
}
c.JSON(http.StatusOK, gin.H{"message": "updated", "is_active": req.IsActive})
}
// GetItems returns items for a pricelist with pagination
func (h *PricelistHandler) GetItems(c *gin.Context) { func (h *PricelistHandler) GetItems(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32) id, err := strconv.ParseUint(idStr, 10, 32)
@@ -598,13 +210,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
}) })
} }
// CanWrite returns whether the current user can create pricelists // GetLatest returns the most recent active pricelist.
func (h *PricelistHandler) CanWrite(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
c.JSON(http.StatusOK, gin.H{"can_write": canWrite, "debug": debugInfo})
}
// GetLatest returns the most recent active pricelist
func (h *PricelistHandler) GetLatest(c *gin.Context) { func (h *PricelistHandler) GetLatest(c *gin.Context) {
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate)) source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
source = string(models.NormalizePricelistSource(source)) source = string(models.NormalizePricelistSource(source))

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
package handlers package handlers
import ( import (
"errors"
"fmt" "fmt"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
stdsync "sync"
"time" "time"
qfassets "git.mchus.pro/mchus/quoteforge" qfassets "git.mchus.pro/mchus/quoteforge"
@@ -24,6 +26,9 @@ type SyncHandler struct {
autoSyncInterval time.Duration autoSyncInterval time.Duration
onlineGraceFactor float64 onlineGraceFactor float64
tmpl *template.Template tmpl *template.Template
readinessMu stdsync.Mutex
readinessCached *sync.SyncReadiness
readinessCachedAt time.Time
} }
// NewSyncHandler creates a new sync handler // NewSyncHandler creates a new sync handler
@@ -53,14 +58,24 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
// SyncStatusResponse represents the sync status // SyncStatusResponse represents the sync status
type SyncStatusResponse struct { type SyncStatusResponse struct {
LastComponentSync *time.Time `json:"last_component_sync"` LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"` LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"` IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"` ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"` PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"` ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"` NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_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 // GetStatus returns current sync status
@@ -90,6 +105,7 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
// Check if component sync is needed (older than 24 hours) // Check if component sync is needed (older than 24 hours)
needComponentSync := h.localDB.NeedComponentSync(24) needComponentSync := h.localDB.NeedComponentSync(24)
readiness := h.getReadinessCached(10 * time.Second)
c.JSON(http.StatusOK, SyncStatusResponse{ c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync, LastComponentSync: lastComponentSync,
@@ -100,9 +116,63 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
ServerPricelists: serverPricelists, ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync, NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync, NeedPricelistSync: needPricelistSync,
Readiness: readiness,
}) })
} }
// GetReadiness returns sync readiness guard status.
// GET /api/sync/readiness
func (h *SyncHandler) GetReadiness(c *gin.Context) {
readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
if readiness == nil {
c.JSON(http.StatusOK, SyncReadinessResponse{Status: sync.ReadinessUnknown, Blocked: false})
return
}
c.JSON(http.StatusOK, SyncReadinessResponse{
Status: readiness.Status,
Blocked: readiness.Blocked,
ReasonCode: readiness.ReasonCode,
ReasonText: readiness.ReasonText,
RequiredMinAppVersion: readiness.RequiredMinAppVersion,
LastCheckedAt: readiness.LastCheckedAt,
})
}
func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool {
readiness, err := h.syncService.EnsureReadinessForSync()
if err == nil {
return true
}
blocked := &sync.SyncBlockedError{}
if errors.As(err, &blocked) {
c.JSON(http.StatusLocked, gin.H{
"success": false,
"error": blocked.Error(),
"reason_code": blocked.Readiness.ReasonCode,
"reason_text": blocked.Readiness.ReasonText,
"required_min_app_version": blocked.Readiness.RequiredMinAppVersion,
"status": blocked.Readiness.Status,
"blocked": true,
"last_checked_at": blocked.Readiness.LastCheckedAt,
})
return false
}
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": err.Error(),
})
_ = readiness
return false
}
// SyncResultResponse represents sync operation result // SyncResultResponse represents sync operation result
type SyncResultResponse struct { type SyncResultResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
@@ -114,11 +184,7 @@ type SyncResultResponse struct {
// SyncComponents syncs components from MariaDB to local SQLite // SyncComponents syncs components from MariaDB to local SQLite
// POST /api/sync/components // POST /api/sync/components
func (h *SyncHandler) SyncComponents(c *gin.Context) { func (h *SyncHandler) SyncComponents(c *gin.Context) {
if !h.checkOnline() { if !h.ensureSyncReadiness(c) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
return return
} }
@@ -153,11 +219,7 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
// SyncPricelists syncs pricelists from MariaDB to local SQLite // SyncPricelists syncs pricelists from MariaDB to local SQLite
// POST /api/sync/pricelists // POST /api/sync/pricelists
func (h *SyncHandler) SyncPricelists(c *gin.Context) { func (h *SyncHandler) SyncPricelists(c *gin.Context) {
if !h.checkOnline() { if !h.ensureSyncReadiness(c) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
return return
} }
@@ -202,11 +264,7 @@ type SyncAllResponse struct {
// - pull components, pricelists, projects, and configurations from server // - pull components, pricelists, projects, and configurations from server
// POST /api/sync/all // POST /api/sync/all
func (h *SyncHandler) SyncAll(c *gin.Context) { func (h *SyncHandler) SyncAll(c *gin.Context) {
if !h.checkOnline() { if !h.ensureSyncReadiness(c) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
return return
} }
@@ -312,11 +370,7 @@ func (h *SyncHandler) checkOnline() bool {
// PushPendingChanges pushes all pending changes to the server // PushPendingChanges pushes all pending changes to the server
// POST /api/sync/push // POST /api/sync/push
func (h *SyncHandler) PushPendingChanges(c *gin.Context) { func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
if !h.checkOnline() { if !h.ensureSyncReadiness(c) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
return return
} }
@@ -377,9 +431,9 @@ type SyncInfoResponse struct {
LastSyncAt *time.Time `json:"last_sync_at"` LastSyncAt *time.Time `json:"last_sync_at"`
// Statistics // Statistics
LotCount int64 `json:"lot_count"` LotCount int64 `json:"lot_count"`
LotLogCount int64 `json:"lot_log_count"` LotLogCount int64 `json:"lot_log_count"`
ConfigCount int64 `json:"config_count"` ConfigCount int64 `json:"config_count"`
ProjectCount int64 `json:"project_count"` ProjectCount int64 `json:"project_count"`
// Pending changes // Pending changes
@@ -388,6 +442,9 @@ type SyncInfoResponse struct {
// Errors // Errors
ErrorCount int `json:"error_count"` ErrorCount int `json:"error_count"`
Errors []SyncError `json:"errors,omitempty"` Errors []SyncError `json:"errors,omitempty"`
// Readiness guard
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
} }
type SyncUsersStatusResponse struct { type SyncUsersStatusResponse struct {
@@ -459,6 +516,8 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
syncErrors = syncErrors[:10] syncErrors = syncErrors[:10]
} }
readiness := h.getReadinessCached(10 * time.Second)
c.JSON(http.StatusOK, SyncInfoResponse{ c.JSON(http.StatusOK, SyncInfoResponse{
DBHost: dbHost, DBHost: dbHost,
DBUser: dbUser, DBUser: dbUser,
@@ -472,6 +531,7 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
PendingChanges: changes, PendingChanges: changes,
ErrorCount: errorCount, ErrorCount: errorCount,
Errors: syncErrors, Errors: syncErrors,
Readiness: readiness,
}) })
} }
@@ -528,12 +588,21 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
// Get pending count // Get pending count
pendingCount := h.localDB.GetPendingCount() pendingCount := h.localDB.GetPendingCount()
readiness := h.getReadinessCached(10 * time.Second)
isBlocked := readiness != nil && readiness.Blocked
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount) slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
data := gin.H{ data := gin.H{
"IsOffline": isOffline, "IsOffline": isOffline,
"PendingCount": pendingCount, "PendingCount": pendingCount,
"IsBlocked": isBlocked,
"BlockedReason": func() string {
if readiness == nil {
return ""
}
return readiness.ReasonText
}(),
} }
c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
@@ -542,3 +611,24 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
c.String(http.StatusInternalServerError, "Template error: "+err.Error()) c.String(http.StatusInternalServerError, "Template error: "+err.Error())
} }
} }
func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness {
h.readinessMu.Lock()
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
cached := *h.readinessCached
h.readinessMu.Unlock()
return &cached
}
h.readinessMu.Unlock()
readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil {
return nil
}
h.readinessMu.Lock()
h.readinessCached = readiness
h.readinessCachedAt = time.Now()
h.readinessMu.Unlock()
return readiness
}

View File

@@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
} }
// Load each page template with base // Load each page template with base
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"} simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html"}
for _, page := range simplePages { for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page) pagePath := filepath.Join(templatesPath, page)
var tmpl *template.Template var tmpl *template.Template
@@ -197,10 +197,6 @@ func (h *WebHandler) ProjectDetail(c *gin.Context) {
}) })
} }
func (h *WebHandler) AdminPricing(c *gin.Context) {
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
}
func (h *WebHandler) Pricelists(c *gin.Context) { func (h *WebHandler) Pricelists(c *gin.Context) {
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"}) h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
} }

View File

@@ -66,6 +66,8 @@ func New(dbPath string) (*LocalDB, error) {
&LocalPricelistItem{}, &LocalPricelistItem{},
&LocalComponent{}, &LocalComponent{},
&AppSetting{}, &AppSetting{},
&LocalRemoteMigrationApplied{},
&LocalSyncGuardState{},
&PendingChange{}, &PendingChange{},
); err != nil { ); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err) return nil, fmt.Errorf("migrating sqlite database: %w", err)
@@ -418,6 +420,37 @@ func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, erro
return &config, err return &config, err
} }
// ListConfigurationsWithFilters returns configurations with DB-level filtering and pagination.
func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, offset, limit int) ([]LocalConfiguration, int64, error) {
query := l.db.Model(&LocalConfiguration{})
switch status {
case "active":
query = query.Where("is_active = ?", true)
case "archived":
query = query.Where("is_active = ?", false)
case "all", "":
// no-op
default:
query = query.Where("is_active = ?", true)
}
search = strings.TrimSpace(search)
if search != "" {
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(search)+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var configs []LocalConfiguration
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
return nil, 0, err
}
return configs, total, nil
}
// DeleteConfiguration deletes a configuration by UUID // DeleteConfiguration deletes a configuration by UUID
func (l *LocalDB) DeleteConfiguration(uuid string) error { func (l *LocalDB) DeleteConfiguration(uuid string) error {
return l.DeactivateConfiguration(uuid) return l.DeactivateConfiguration(uuid)
@@ -772,3 +805,71 @@ func (l *LocalDB) PurgeOrphanConfigurationPendingChanges() (int64, error) {
func (l *LocalDB) GetPendingCount() int64 { func (l *LocalDB) GetPendingCount() int64 {
return l.CountPendingChanges() return l.CountPendingChanges()
} }
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
var migration LocalRemoteMigrationApplied
if err := l.db.Where("id = ?", id).First(&migration).Error; err != nil {
return nil, err
}
return &migration, nil
}
// UpsertRemoteMigrationApplied writes applied migration metadata.
func (l *LocalDB) UpsertRemoteMigrationApplied(id, checksum, appVersion string, appliedAt time.Time) error {
record := &LocalRemoteMigrationApplied{
ID: id,
Checksum: checksum,
AppVersion: appVersion,
AppliedAt: appliedAt,
}
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"checksum": checksum,
"app_version": appVersion,
"applied_at": appliedAt,
}),
}).Create(record).Error
}
// GetLatestAppliedRemoteMigrationID returns last applied remote migration id.
func (l *LocalDB) GetLatestAppliedRemoteMigrationID() (string, error) {
var record LocalRemoteMigrationApplied
if err := l.db.Order("applied_at DESC").First(&record).Error; err != nil {
return "", err
}
return record.ID, nil
}
// GetSyncGuardState returns the latest readiness guard state.
func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) {
var state LocalSyncGuardState
if err := l.db.Order("id DESC").First(&state).Error; err != nil {
return nil, err
}
return &state, nil
}
// SetSyncGuardState upserts readiness guard state (single-row logical table).
func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requiredMinAppVersion *string, checkedAt *time.Time) error {
state := &LocalSyncGuardState{
ID: 1,
Status: status,
ReasonCode: reasonCode,
ReasonText: reasonText,
RequiredMinAppVersion: requiredMinAppVersion,
LastCheckedAt: checkedAt,
}
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"status": status,
"reason_code": reasonCode,
"reason_text": reasonText,
"required_min_app_version": requiredMinAppVersion,
"last_checked_at": checkedAt,
"updated_at": time.Now(),
}),
}).Create(state).Error
}

View File

@@ -8,7 +8,6 @@ import (
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -244,13 +243,6 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
} }
} }
var pl models.Pricelist
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil && pl.Source == string(models.PricelistSourceWarehouse) {
if err := r.enrichWarehouseItems(items); err != nil {
return nil, 0, fmt.Errorf("enriching warehouse items: %w", err)
}
}
return items, total, nil return items, total, nil
} }
@@ -267,42 +259,6 @@ func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
return lotNames, nil return lotNames, nil
} }
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
if len(items) == 0 {
return nil
}
lots := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
lot := strings.TrimSpace(item.LotName)
if lot == "" {
continue
}
if _, ok := seen[lot]; ok {
continue
}
seen[lot] = struct{}{}
lots = append(lots, lot)
}
if len(lots) == 0 {
return nil
}
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
if err != nil {
return err
}
for i := range items {
if qty, ok := qtyByLot[items[i].LotName]; ok {
q := qty
items[i].AvailableQty = &q
}
items[i].Partnumbers = partnumbersByLot[items[i].LotName]
}
return nil
}
// GetPriceForLot returns item price for a lot within a pricelist. // GetPriceForLot returns item price for a lot within a pricelist.
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) { func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
var item models.PricelistItem var item models.PricelistItem

View File

@@ -1,199 +0,0 @@
package alerts
import (
"fmt"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
type Service struct {
alertRepo *repository.AlertRepository
componentRepo *repository.ComponentRepository
priceRepo *repository.PriceRepository
statsRepo *repository.StatsRepository
config config.AlertsConfig
pricingConfig config.PricingConfig
}
func NewService(
alertRepo *repository.AlertRepository,
componentRepo *repository.ComponentRepository,
priceRepo *repository.PriceRepository,
statsRepo *repository.StatsRepository,
alertCfg config.AlertsConfig,
pricingCfg config.PricingConfig,
) *Service {
return &Service{
alertRepo: alertRepo,
componentRepo: componentRepo,
priceRepo: priceRepo,
statsRepo: statsRepo,
config: alertCfg,
pricingConfig: pricingCfg,
}
}
func (s *Service) List(filter repository.AlertFilter, page, perPage int) ([]models.PricingAlert, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
return s.alertRepo.List(filter, offset, perPage)
}
func (s *Service) Acknowledge(id uint) error {
return s.alertRepo.UpdateStatus(id, models.AlertStatusAcknowledged)
}
func (s *Service) Resolve(id uint) error {
return s.alertRepo.UpdateStatus(id, models.AlertStatusResolved)
}
func (s *Service) Ignore(id uint) error {
return s.alertRepo.UpdateStatus(id, models.AlertStatusIgnored)
}
func (s *Service) GetNewAlertsCount() (int64, error) {
return s.alertRepo.CountByStatus(models.AlertStatusNew)
}
// CheckAndGenerateAlerts scans components and creates alerts
func (s *Service) CheckAndGenerateAlerts() error {
if !s.config.Enabled {
return nil
}
// Get top components by usage
topComponents, err := s.statsRepo.GetTopComponents(100)
if err != nil {
return err
}
for _, stats := range topComponents {
component, err := s.componentRepo.GetByLotName(stats.LotName)
if err != nil {
continue
}
// Check high demand + stale price
if err := s.checkHighDemandStalePrice(component, &stats); err != nil {
continue
}
// Check trending without price
if err := s.checkTrendingNoPrice(component, &stats); err != nil {
continue
}
// Check no recent quotes
if err := s.checkNoRecentQuotes(component, &stats); err != nil {
continue
}
}
return nil
}
func (s *Service) checkHighDemandStalePrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
// high_demand_stale_price: >= 5 quotes/month AND price > 60 days old
if stats.QuotesLast30d < s.config.HighDemandThreshold {
return nil
}
if comp.PriceUpdatedAt == nil {
return nil
}
daysSinceUpdate := int(time.Since(*comp.PriceUpdatedAt).Hours() / 24)
if daysSinceUpdate <= s.pricingConfig.FreshnessYellowDays {
return nil
}
// Check if alert already exists
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertHighDemandStalePrice)
if exists {
return nil
}
alert := &models.PricingAlert{
LotName: comp.LotName,
AlertType: models.AlertHighDemandStalePrice,
Severity: models.SeverityCritical,
Message: fmt.Sprintf("Компонент %s: высокий спрос (%d КП/мес), но цена устарела (%d дней)", comp.LotName, stats.QuotesLast30d, daysSinceUpdate),
Details: models.AlertDetails{
"quotes_30d": stats.QuotesLast30d,
"days_since_update": daysSinceUpdate,
},
}
return s.alertRepo.Create(alert)
}
func (s *Service) checkTrendingNoPrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
// trending_no_price: trend > 50% AND no price
if stats.TrendDirection != models.TrendUp || stats.TrendPercent < float64(s.config.TrendingThresholdPercent) {
return nil
}
if comp.CurrentPrice != nil && *comp.CurrentPrice > 0 {
return nil
}
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertTrendingNoPrice)
if exists {
return nil
}
alert := &models.PricingAlert{
LotName: comp.LotName,
AlertType: models.AlertTrendingNoPrice,
Severity: models.SeverityHigh,
Message: fmt.Sprintf("Компонент %s: рост спроса +%.0f%%, но цена не установлена", comp.LotName, stats.TrendPercent),
Details: models.AlertDetails{
"trend_percent": stats.TrendPercent,
},
}
return s.alertRepo.Create(alert)
}
func (s *Service) checkNoRecentQuotes(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
// no_recent_quotes: popular component, no supplier quotes > 90 days
if stats.QuotesLast30d < 3 {
return nil
}
quoteCount, err := s.priceRepo.GetQuoteCount(comp.LotName, s.pricingConfig.FreshnessRedDays)
if err != nil {
return err
}
if quoteCount > 0 {
return nil
}
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertNoRecentQuotes)
if exists {
return nil
}
alert := &models.PricingAlert{
LotName: comp.LotName,
AlertType: models.AlertNoRecentQuotes,
Severity: models.SeverityMedium,
Message: fmt.Sprintf("Компонент %s: популярный (%d КП), но нет новых котировок >%d дней", comp.LotName, stats.QuotesLast30d, s.pricingConfig.FreshnessRedDays),
Details: models.AlertDetails{
"quotes_30d": stats.QuotesLast30d,
"no_quotes_days": s.pricingConfig.FreshnessRedDays,
},
}
return s.alertRepo.Create(alert)
}

View File

@@ -600,26 +600,6 @@ func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configu
// ListAllWithStatus returns configurations filtered by status: active|archived|all. // ListAllWithStatus returns configurations filtered by status: active|archived|all.
func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string, search string) ([]models.Configuration, int64, error) { func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string, search string) ([]models.Configuration, int64, error) {
localConfigs, err := s.localDB.GetConfigurations()
if err != nil {
return nil, 0, err
}
search = strings.ToLower(strings.TrimSpace(search))
configs := make([]models.Configuration, len(localConfigs))
configs = configs[:0]
for _, lc := range localConfigs {
if !matchesConfigStatus(lc.IsActive, status) {
continue
}
if search != "" && !strings.Contains(strings.ToLower(lc.Name), search) {
continue
}
configs = append(configs, *localdb.LocalToConfiguration(&lc))
}
total := int64(len(configs))
// Apply pagination // Apply pagination
if page < 1 { if page < 1 {
page = 1 page = 1
@@ -628,17 +608,15 @@ func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status
perPage = 20 perPage = 20
} }
offset := (page - 1) * perPage offset := (page - 1) * perPage
localConfigs, total, err := s.localDB.ListConfigurationsWithFilters(status, search, offset, perPage)
start := offset if err != nil {
if start > len(configs) { return nil, 0, err
start = len(configs)
} }
end := start + perPage configs := make([]models.Configuration, 0, len(localConfigs))
if end > len(configs) { for _, lc := range localConfigs {
end = len(configs) configs = append(configs, *localdb.LocalToConfiguration(&lc))
} }
return configs, total, nil
return configs[start:end], total, nil
} }
// ListTemplates returns all template configurations // ListTemplates returns all template configurations

View File

@@ -1,371 +0,0 @@
package pricelist
import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
"gorm.io/gorm"
)
type Service struct {
repo *repository.PricelistRepository
componentRepo *repository.ComponentRepository
pricingSvc *pricing.Service
db *gorm.DB
}
type CreateProgress struct {
Current int
Total int
Status string
Message string
Updated int
Errors int
LotName string
}
type CreateItemInput struct {
LotName string
Price float64
PriceMethod string
}
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
return &Service{
repo: repo,
componentRepo: componentRepo,
pricingSvc: pricingSvc,
db: db,
}
}
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
return s.CreateFromCurrentPricesForSource(createdBy, string(models.PricelistSourceEstimate))
}
// CreateFromCurrentPricesForSource creates a new pricelist snapshot for one source.
func (s *Service) CreateFromCurrentPricesForSource(createdBy, source string) (*models.Pricelist, error) {
return s.CreateForSourceWithProgress(createdBy, source, nil, nil)
}
// CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress.
func (s *Service) CreateFromCurrentPricesWithProgress(createdBy, source string, onProgress func(CreateProgress)) (*models.Pricelist, error) {
return s.CreateForSourceWithProgress(createdBy, source, nil, onProgress)
}
// CreateForSourceWithProgress creates a source pricelist from current estimate snapshot or explicit item list.
func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceItems []CreateItemInput, onProgress func(CreateProgress)) (*models.Pricelist, error) {
if s.repo == nil || s.db == nil {
return nil, fmt.Errorf("offline mode: cannot create pricelists")
}
source = string(models.NormalizePricelistSource(source))
report := func(p CreateProgress) {
if onProgress != nil {
onProgress(p)
}
}
report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"})
updated, errs := 0, 0
if source == string(models.PricelistSourceEstimate) && s.pricingSvc != nil {
report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"})
updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) {
if p.Total <= 0 {
return
}
phaseCurrent := 1 + int(float64(p.Current)/float64(p.Total)*90.0)
if phaseCurrent > 91 {
phaseCurrent = 91
}
report(CreateProgress{
Current: phaseCurrent,
Total: 100,
Status: "recalculating",
Message: "Обновление цен компонентов",
Updated: p.Updated,
Errors: p.Errors,
LotName: p.LotName,
})
})
}
report(CreateProgress{Current: 92, Total: 100, Status: "recalculated", Message: "Цены обновлены", Updated: updated, Errors: errs})
report(CreateProgress{Current: 95, Total: 100, Status: "snapshot", Message: "Создание снимка прайслиста"})
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
const maxCreateAttempts = 5
var pricelist *models.Pricelist
for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
version, err := s.repo.GenerateVersionBySource(source)
if err != nil {
return nil, fmt.Errorf("generating version: %w", err)
}
pricelist = &models.Pricelist{
Source: source,
Version: version,
CreatedBy: createdBy,
IsActive: true,
ExpiresAt: &expiresAt,
}
if err := s.repo.Create(pricelist); err != nil {
if isVersionConflictError(err) && attempt < maxCreateAttempts {
slog.Warn("pricelist version conflict, retrying",
"attempt", attempt,
"version", version,
"error", err,
)
time.Sleep(time.Duration(attempt*25) * time.Millisecond)
continue
}
return nil, fmt.Errorf("creating pricelist: %w", err)
}
break
}
items := make([]models.PricelistItem, 0)
if len(sourceItems) == 0 && source == string(models.PricelistSourceWarehouse) {
warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db)
if err != nil {
_ = s.repo.Delete(pricelist.ID)
return nil, fmt.Errorf("building warehouse pricelist from stock_log: %w", err)
}
sourceItems = make([]CreateItemInput, 0, len(warehouseItems))
for _, item := range warehouseItems {
sourceItems = append(sourceItems, CreateItemInput{
LotName: item.LotName,
Price: item.Price,
PriceMethod: item.PriceMethod,
})
}
}
if len(sourceItems) > 0 {
items = make([]models.PricelistItem, 0, len(sourceItems))
for _, srcItem := range sourceItems {
if strings.TrimSpace(srcItem.LotName) == "" || srcItem.Price <= 0 {
continue
}
items = append(items, models.PricelistItem{
PricelistID: pricelist.ID,
LotName: strings.TrimSpace(srcItem.LotName),
Price: srcItem.Price,
PriceMethod: strings.TrimSpace(srcItem.PriceMethod),
})
}
} else {
// Default snapshot source for estimate and backward compatibility.
var metadata []models.LotMetadata
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
return nil, fmt.Errorf("getting lot metadata: %w", err)
}
// Create pricelist items with all price settings
items = make([]models.PricelistItem, 0, len(metadata))
for _, m := range metadata {
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
continue
}
items = append(items, models.PricelistItem{
PricelistID: pricelist.ID,
LotName: m.LotName,
Price: *m.CurrentPrice,
PriceMethod: string(m.PriceMethod),
PricePeriodDays: m.PricePeriodDays,
PriceCoefficient: m.PriceCoefficient,
ManualPrice: m.ManualPrice,
MetaPrices: m.MetaPrices,
})
}
}
if len(items) == 0 {
_ = s.repo.Delete(pricelist.ID)
return nil, fmt.Errorf("cannot create empty pricelist for source %q", source)
}
if err := s.repo.CreateItems(items); err != nil {
// Clean up the pricelist if items creation fails
s.repo.Delete(pricelist.ID)
return nil, fmt.Errorf("creating pricelist items: %w", err)
}
pricelist.ItemCount = len(items)
slog.Info("pricelist created",
"id", pricelist.ID,
"version", pricelist.Version,
"items", len(items),
"created_by", createdBy,
)
report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs})
return pricelist, nil
}
func isVersionConflictError(err error) bool {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "duplicate entry") &&
(strings.Contains(msg, "idx_qt_pricelists_source_version") || strings.Contains(msg, "idx_qt_pricelists_version"))
}
// List returns pricelists with pagination
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
return s.ListBySource(page, perPage, "")
}
// ListBySource returns pricelists with optional source filter.
func (s *Service) ListBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
// If no database connection (offline mode), return empty list
if s.repo == nil {
return []models.PricelistSummary{}, 0, nil
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
offset := (page - 1) * perPage
return s.repo.ListBySource(source, offset, perPage)
}
// ListActive returns active pricelists with pagination.
func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) {
return s.ListActiveBySource(page, perPage, "")
}
// ListActiveBySource returns active pricelists with optional source filter.
func (s *Service) ListActiveBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
if s.repo == nil {
return []models.PricelistSummary{}, 0, nil
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
offset := (page - 1) * perPage
return s.repo.ListActiveBySource(source, offset, perPage)
}
// GetByID returns a pricelist by ID
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
if s.repo == nil {
return nil, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetByID(id)
}
// GetItems returns pricelist items with pagination
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
if s.repo == nil {
return []models.PricelistItem{}, 0, nil
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
offset := (page - 1) * perPage
return s.repo.GetItems(pricelistID, offset, perPage, search)
}
func (s *Service) GetLotNames(pricelistID uint) ([]string, error) {
if s.repo == nil {
return []string{}, nil
}
return s.repo.GetLotNames(pricelistID)
}
// Delete deletes a pricelist by ID
func (s *Service) Delete(id uint) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot delete pricelists")
}
return s.repo.Delete(id)
}
// SetActive toggles active state for a pricelist.
func (s *Service) SetActive(id uint, isActive bool) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot update pricelists")
}
return s.repo.SetActive(id, isActive)
}
// GetPriceForLot returns price by pricelist/lot.
func (s *Service) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
if s.repo == nil {
return 0, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetPriceForLot(pricelistID, lotName)
}
// CanWrite returns true if the user can create pricelists
func (s *Service) CanWrite() bool {
if s.repo == nil {
return false
}
return s.repo.CanWrite()
}
// CanWriteDebug returns write permission status with debug info
func (s *Service) CanWriteDebug() (bool, string) {
if s.repo == nil {
return false, "offline mode"
}
return s.repo.CanWriteDebug()
}
// GetLatestActive returns the most recent active pricelist
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
return s.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
}
// GetLatestActiveBySource returns the latest active pricelist for a source.
func (s *Service) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
if s.repo == nil {
return nil, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetLatestActiveBySource(source)
}
// CleanupExpired deletes expired and unused pricelists
func (s *Service) CleanupExpired() (int, error) {
if s.repo == nil {
return 0, fmt.Errorf("offline mode: cleanup not available")
}
expired, err := s.repo.GetExpiredUnused()
if err != nil {
return 0, err
}
deleted := 0
for _, pl := range expired {
if err := s.repo.Delete(pl.ID); err != nil {
slog.Warn("failed to delete expired pricelist", "id", pl.ID, "error", err)
continue
}
deleted++
}
slog.Info("cleaned up expired pricelists", "deleted", deleted)
return deleted, nil
}

View File

@@ -1,72 +0,0 @@
package pricelist
import (
"math"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestCreateWarehousePricelistFromStockLog(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&models.Pricelist{},
&models.PricelistItem{},
&models.StockLog{},
&models.Lot{},
&models.LotPartnumber{},
); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Create(&models.Lot{LotName: "CPU_X", LotDescription: "CPU"}).Error; err != nil {
t.Fatalf("seed lot: %v", err)
}
if err := db.Create(&models.LotPartnumber{Partnumber: "PN-CPU-X", LotName: "CPU_X"}).Error; err != nil {
t.Fatalf("seed mapping: %v", err)
}
qty1 := 2.0
qty2 := 8.0
now := time.Now()
rows := []models.StockLog{
{Partnumber: "PN-CPU-X", Date: now, Price: 100, Qty: &qty1},
{Partnumber: "PN-CPU-X", Date: now, Price: 200, Qty: &qty2},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("seed stock log: %v", err)
}
repo := repository.NewPricelistRepository(db)
svc := NewService(db, repo, nil, nil)
pl, err := svc.CreateForSourceWithProgress("tester", string(models.PricelistSourceWarehouse), nil, nil)
if err != nil {
t.Fatalf("create warehouse pricelist: %v", err)
}
if pl.Source != string(models.PricelistSourceWarehouse) {
t.Fatalf("unexpected source: %s", pl.Source)
}
var items []models.PricelistItem
if err := db.Where("pricelist_id = ?", pl.ID).Find(&items).Error; err != nil {
t.Fatalf("load pricelist items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].LotName != "CPU_X" {
t.Fatalf("unexpected lot name: %s", items[0].LotName)
}
if math.Abs(items[0].Price-200) > 0.001 {
t.Fatalf("expected weighted median price 200, got %f", items[0].Price)
}
}

View File

@@ -1,121 +0,0 @@
package pricing
import (
"math"
"sort"
"time"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
// CalculateMedian returns the median of prices
func CalculateMedian(prices []float64) float64 {
if len(prices) == 0 {
return 0
}
sorted := make([]float64, len(prices))
copy(sorted, prices)
sort.Float64s(sorted)
n := len(sorted)
if n%2 == 0 {
return (sorted[n/2-1] + sorted[n/2]) / 2
}
return sorted[n/2]
}
// CalculateAverage returns the arithmetic mean of prices
func CalculateAverage(prices []float64) float64 {
if len(prices) == 0 {
return 0
}
var sum float64
for _, p := range prices {
sum += p
}
return sum / float64(len(prices))
}
// CalculateWeightedMedian calculates median with exponential decay weights
// More recent prices have higher weight
func CalculateWeightedMedian(points []repository.PricePoint, decayDays int) float64 {
if len(points) == 0 {
return 0
}
type weightedPrice struct {
price float64
weight float64
}
now := time.Now()
weighted := make([]weightedPrice, len(points))
var totalWeight float64
for i, p := range points {
daysSince := now.Sub(p.Date).Hours() / 24
// weight = e^(-days / decay_days)
weight := math.Exp(-daysSince / float64(decayDays))
weighted[i] = weightedPrice{price: p.Price, weight: weight}
totalWeight += weight
}
// Sort by price
sort.Slice(weighted, func(i, j int) bool {
return weighted[i].price < weighted[j].price
})
// Find weighted median
targetWeight := totalWeight / 2
var cumulativeWeight float64
for _, wp := range weighted {
cumulativeWeight += wp.weight
if cumulativeWeight >= targetWeight {
return wp.price
}
}
return weighted[len(weighted)-1].price
}
// CalculatePercentile calculates the nth percentile of prices
func CalculatePercentile(prices []float64, percentile float64) float64 {
if len(prices) == 0 {
return 0
}
sorted := make([]float64, len(prices))
copy(sorted, prices)
sort.Float64s(sorted)
index := (percentile / 100) * float64(len(sorted)-1)
lower := int(math.Floor(index))
upper := int(math.Ceil(index))
if lower == upper {
return sorted[lower]
}
fraction := index - float64(lower)
return sorted[lower]*(1-fraction) + sorted[upper]*fraction
}
// CalculateStdDev calculates standard deviation
func CalculateStdDev(prices []float64) float64 {
if len(prices) < 2 {
return 0
}
mean := CalculateAverage(prices)
var sumSquares float64
for _, p := range prices {
diff := p - mean
sumSquares += diff * diff
}
return math.Sqrt(sumSquares / float64(len(prices)-1))
}

View File

@@ -1,378 +0,0 @@
package pricing
import (
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"gorm.io/gorm"
)
type Service struct {
componentRepo *repository.ComponentRepository
priceRepo *repository.PriceRepository
config config.PricingConfig
db *gorm.DB
}
type RecalculateProgress struct {
Current int
Total int
LotName string
Updated int
Errors int
}
func NewService(
componentRepo *repository.ComponentRepository,
priceRepo *repository.PriceRepository,
cfg config.PricingConfig,
) *Service {
var db *gorm.DB
if componentRepo != nil {
db = componentRepo.DB()
}
return &Service{
componentRepo: componentRepo,
priceRepo: priceRepo,
config: cfg,
db: db,
}
}
// GetEffectivePrice returns the current effective price for a component
// Priority: active override > calculated price > nil
func (s *Service) GetEffectivePrice(lotName string) (*float64, error) {
// Check for active override first
override, err := s.priceRepo.GetPriceOverride(lotName)
if err == nil && override != nil {
return &override.Price, nil
}
// Get component metadata
component, err := s.componentRepo.GetByLotName(lotName)
if err != nil {
return nil, err
}
return component.CurrentPrice, nil
}
// CalculatePrice calculates price using the specified method
func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, periodDays int) (float64, error) {
if periodDays == 0 {
periodDays = s.config.DefaultPeriodDays
}
points, err := s.priceRepo.GetPriceHistory(lotName, periodDays)
if err != nil {
return 0, err
}
if len(points) == 0 {
return 0, nil
}
prices := make([]float64, len(points))
for i, p := range points {
prices[i] = p.Price
}
switch method {
case models.PriceMethodAverage:
return CalculateAverage(prices), nil
case models.PriceMethodWeightedMedian:
return CalculateWeightedMedian(points, periodDays), nil
case models.PriceMethodMedian:
fallthrough
default:
return CalculateMedian(prices), nil
}
}
// UpdateComponentPrice recalculates and updates the price for a component
func (s *Service) UpdateComponentPrice(lotName string) error {
component, err := s.componentRepo.GetByLotName(lotName)
if err != nil {
return err
}
price, err := s.CalculatePrice(lotName, component.PriceMethod, component.PricePeriodDays)
if err != nil {
return err
}
now := time.Now()
if price > 0 {
component.CurrentPrice = &price
component.PriceUpdatedAt = &now
}
return s.componentRepo.Update(component)
}
// SetManualPrice sets a manual price override
func (s *Service) SetManualPrice(lotName string, price float64, reason string, userID uint) error {
override := &models.PriceOverride{
LotName: lotName,
Price: price,
ValidFrom: time.Now(),
Reason: reason,
CreatedBy: userID,
}
return s.priceRepo.CreatePriceOverride(override)
}
// UpdatePriceMethod changes the pricing method for a component
func (s *Service) UpdatePriceMethod(lotName string, method models.PriceMethod, periodDays int) error {
component, err := s.componentRepo.GetByLotName(lotName)
if err != nil {
return err
}
component.PriceMethod = method
if periodDays > 0 {
component.PricePeriodDays = periodDays
}
if err := s.componentRepo.Update(component); err != nil {
return err
}
return s.UpdateComponentPrice(lotName)
}
// GetPriceStats returns statistics for a component's price history
func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, error) {
if periodDays == 0 {
periodDays = s.config.DefaultPeriodDays
}
points, err := s.priceRepo.GetPriceHistory(lotName, periodDays)
if err != nil {
return nil, err
}
if len(points) == 0 {
return &PriceStats{QuoteCount: 0}, nil
}
prices := make([]float64, len(points))
for i, p := range points {
prices[i] = p.Price
}
return &PriceStats{
QuoteCount: len(points),
MinPrice: CalculatePercentile(prices, 0),
MaxPrice: CalculatePercentile(prices, 100),
MedianPrice: CalculateMedian(prices),
AveragePrice: CalculateAverage(prices),
StdDeviation: CalculateStdDev(prices),
LatestPrice: points[0].Price,
LatestDate: points[0].Date,
OldestDate: points[len(points)-1].Date,
Percentile25: CalculatePercentile(prices, 25),
Percentile75: CalculatePercentile(prices, 75),
}, nil
}
type PriceStats struct {
QuoteCount int `json:"quote_count"`
MinPrice float64 `json:"min_price"`
MaxPrice float64 `json:"max_price"`
MedianPrice float64 `json:"median_price"`
AveragePrice float64 `json:"average_price"`
StdDeviation float64 `json:"std_deviation"`
LatestPrice float64 `json:"latest_price"`
LatestDate time.Time `json:"latest_date"`
OldestDate time.Time `json:"oldest_date"`
Percentile25 float64 `json:"percentile_25"`
Percentile75 float64 `json:"percentile_75"`
}
// RecalculateAllPrices recalculates prices for all components
func (s *Service) RecalculateAllPrices() (updated int, errors int) {
return s.RecalculateAllPricesWithProgress(nil)
}
// RecalculateAllPricesWithProgress recalculates prices and reports progress.
func (s *Service) RecalculateAllPricesWithProgress(onProgress func(RecalculateProgress)) (updated int, errors int) {
if s.db == nil {
return 0, 0
}
// Logic mirrors "Обновить цены" in admin pricing.
var components []models.LotMetadata
if err := s.db.Find(&components).Error; err != nil {
return 0, len(components)
}
total := len(components)
var allLotNames []string
_ = s.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames).Error
type lotDate struct {
Lot string
Date time.Time
}
var latestDates []lotDate
_ = s.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates).Error
lotLatestDate := make(map[string]time.Time, len(latestDates))
for _, ld := range latestDates {
lotLatestDate[ld.Lot] = ld.Date
}
var skipped, manual, unchanged int
now := time.Now()
current := 0
for _, comp := range components {
current++
reportProgress := func() {
if onProgress != nil && (current%10 == 0 || current == total) {
onProgress(RecalculateProgress{
Current: current,
Total: total,
LotName: comp.LotName,
Updated: updated,
Errors: errors,
})
}
}
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
manual++
reportProgress()
continue
}
method := comp.PriceMethod
if method == "" {
method = models.PriceMethodMedian
}
var sourceLots []string
if comp.MetaPrices != "" {
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
} else {
sourceLots = []string{comp.LotName}
}
if len(sourceLots) == 0 {
skipped++
reportProgress()
continue
}
if comp.PriceUpdatedAt != nil {
hasNewData := false
for _, lot := range sourceLots {
if latestDate, ok := lotLatestDate[lot]; ok && latestDate.After(*comp.PriceUpdatedAt) {
hasNewData = true
break
}
}
if !hasNewData {
unchanged++
reportProgress()
continue
}
}
var prices []float64
if comp.PricePeriodDays > 0 {
_ = s.db.Raw(
`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
sourceLots, comp.PricePeriodDays,
).Pluck("price", &prices).Error
} else {
_ = s.db.Raw(
`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
sourceLots,
).Pluck("price", &prices).Error
}
if len(prices) == 0 && comp.PricePeriodDays > 0 {
_ = s.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices).Error
}
if len(prices) == 0 {
skipped++
reportProgress()
continue
}
var basePrice float64
switch method {
case models.PriceMethodAverage:
basePrice = CalculateAverage(prices)
default:
basePrice = CalculateMedian(prices)
}
if basePrice <= 0 {
skipped++
reportProgress()
continue
}
finalPrice := basePrice
if comp.PriceCoefficient != 0 {
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
}
if err := s.db.Model(&models.LotMetadata{}).
Where("lot_name = ?", comp.LotName).
Updates(map[string]interface{}{
"current_price": finalPrice,
"price_updated_at": now,
}).Error; err != nil {
errors++
} else {
updated++
}
reportProgress()
}
if onProgress != nil && total == 0 {
onProgress(RecalculateProgress{
Current: 0,
Total: 0,
LotName: "",
Updated: updated,
Errors: errors,
})
}
return updated, errors
}
func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string {
sources := strings.Split(metaPrices, ",")
var result []string
seen := make(map[string]bool)
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" || source == excludeLot {
continue
}
if strings.HasSuffix(source, "*") {
prefix := strings.TrimSuffix(source, "*")
for _, lot := range allLotNames {
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
result = append(result, lot)
seen[lot] = true
}
}
} else if !seen[source] {
result = append(result, source)
seen[source] = true
}
}
return result
}

View File

@@ -9,7 +9,6 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
) )
var ( var (
@@ -23,18 +22,22 @@ type QuoteService struct {
statsRepo *repository.StatsRepository statsRepo *repository.StatsRepository
pricelistRepo *repository.PricelistRepository pricelistRepo *repository.PricelistRepository
localDB *localdb.LocalDB localDB *localdb.LocalDB
pricingService *pricing.Service pricingService priceResolver
cacheMu sync.RWMutex cacheMu sync.RWMutex
priceCache map[string]cachedLotPrice priceCache map[string]cachedLotPrice
cacheTTL time.Duration cacheTTL time.Duration
} }
type priceResolver interface {
GetEffectivePrice(lotName string) (*float64, error)
}
func NewQuoteService( func NewQuoteService(
componentRepo *repository.ComponentRepository, componentRepo *repository.ComponentRepository,
statsRepo *repository.StatsRepository, statsRepo *repository.StatsRepository,
pricelistRepo *repository.PricelistRepository, pricelistRepo *repository.PricelistRepository,
localDB *localdb.LocalDB, localDB *localdb.LocalDB,
pricingService *pricing.Service, pricingService priceResolver,
) *QuoteService { ) *QuoteService {
return &QuoteService{ return &QuoteService{
componentRepo: componentRepo, componentRepo: componentRepo,
@@ -110,8 +113,53 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
if len(req.Items) == 0 { if len(req.Items) == 0 {
return nil, ErrEmptyQuote return nil, ErrEmptyQuote
} }
// Strict local-first path: calculations use local SQLite snapshot regardless of online status.
if s.localDB != nil {
result := &QuoteValidationResult{
Valid: true,
Items: make([]QuoteItem, 0, len(req.Items)),
Errors: make([]string, 0),
Warnings: make([]string, 0),
}
var total float64
for _, reqItem := range req.Items {
localComp, err := s.localDB.GetLocalComponent(reqItem.LotName)
if err != nil {
result.Valid = false
result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName)
continue
}
item := QuoteItem{
LotName: reqItem.LotName,
Quantity: reqItem.Quantity,
Description: localComp.LotDescription,
Category: localComp.Category,
HasPrice: false,
UnitPrice: 0,
TotalPrice: 0,
}
if localComp.CurrentPrice != nil && *localComp.CurrentPrice > 0 {
item.UnitPrice = *localComp.CurrentPrice
item.TotalPrice = *localComp.CurrentPrice * float64(reqItem.Quantity)
item.HasPrice = true
total += item.TotalPrice
} else {
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
}
result.Items = append(result.Items, item)
}
result.Total = total
return result, nil
}
if s.componentRepo == nil || s.pricingService == nil { if s.componentRepo == nil || s.pricingService == nil {
return nil, errors.New("offline mode: quote calculation not available") return nil, errors.New("quote calculation not available")
} }
result := &QuoteValidationResult{ result := &QuoteValidationResult{

File diff suppressed because it is too large Load Diff

View File

@@ -1,384 +0,0 @@
package services
import (
"archive/zip"
"bytes"
"strings"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestParseMXLRows(t *testing.T) {
content := strings.Join([]string{
`MOXCEL`,
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
`{16,2,{1,1,{"ru","Серверы"}},0},1,`,
`{16,2,{1,1,{"ru","CPU_X"}},0},2,`,
`{16,2,{1,1,{"ru","Процессор"}},0},3,`,
`{16,2,{1,1,{"ru","AMD"}},0},4,`,
`{16,2,{1,1,{"ru","125,50"}},0},5,`,
`{16,2,{1,1,{"ru","10"}},0},6,`,
}, "\n")
rows, err := parseMXLRows([]byte(content))
if err != nil {
t.Fatalf("parseMXLRows: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].Article != "CPU_X" {
t.Fatalf("unexpected article: %s", rows[0].Article)
}
if rows[0].Price != 125.50 {
t.Fatalf("unexpected price: %v", rows[0].Price)
}
if rows[0].Qty != 10 {
t.Fatalf("unexpected qty: %v", rows[0].Qty)
}
}
func TestParseMXLRows_EmptyQtyMarkedInvalid(t *testing.T) {
content := strings.Join([]string{
`MOXCEL`,
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
`{16,2,{1,1,{"ru","Серверы"}},0},1,`,
`{16,2,{1,1,{"ru","CPU_X"}},0},2,`,
`{16,2,{1,1,{"ru","Процессор"}},0},3,`,
`{16,2,{1,1,{"ru","AMD"}},0},4,`,
`{16,2,{1,1,{"ru","125,50"}},0},5,`,
`{16,2,{1,1,{"ru",""}},0},6,`,
}, "\n")
rows, err := parseMXLRows([]byte(content))
if err != nil {
t.Fatalf("parseMXLRows: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if !rows[0].QtyInvalid {
t.Fatalf("expected QtyInvalid=true for empty qty")
}
}
func TestParseXLSXRows(t *testing.T) {
xlsx := buildMinimalXLSX(t, []string{
"Папка", "Артикул", "Описание", "Вендор", "Стоимость", "Свободно",
}, []string{
"Серверы", "CPU_A", "Процессор", "AMD", "99,25", "7",
})
rows, err := parseXLSXRows(xlsx)
if err != nil {
t.Fatalf("parseXLSXRows: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].Article != "CPU_A" {
t.Fatalf("unexpected article: %s", rows[0].Article)
}
if rows[0].Price != 99.25 {
t.Fatalf("unexpected price: %v", rows[0].Price)
}
}
func TestLotResolverPrecedenceAndConflicts(t *testing.T) {
resolver := lotmatch.NewLotResolver(
[]models.LotPartnumber{
{Partnumber: "pn-1", LotName: "LOT_MAPPED"},
{Partnumber: "pn-conflict", LotName: "LOT_A"},
{Partnumber: "pn-conflict", LotName: "LOT_B"},
},
[]models.Lot{
{LotName: "CPU_A_LONG"},
{LotName: "CPU_A"},
{LotName: "ABC "},
{LotName: "ABC\t"},
},
)
lot, typ, err := resolver.Resolve("pn-1")
if err != nil || lot != "LOT_MAPPED" || typ != "mapping_table" {
t.Fatalf("mapping_table mismatch: lot=%s typ=%s err=%v", lot, typ, err)
}
lot, typ, err = resolver.Resolve("cpu_a")
if err != nil || lot != "CPU_A" || typ != "article_exact" {
t.Fatalf("article_exact mismatch: lot=%s typ=%s err=%v", lot, typ, err)
}
lot, typ, err = resolver.Resolve("cpu_a_long_suffix")
if err != nil || lot != "CPU_A_LONG" || typ != "prefix" {
t.Fatalf("prefix mismatch: lot=%s typ=%s err=%v", lot, typ, err)
}
_, _, err = resolver.Resolve("abx")
if err == nil || err != lotmatch.ErrResolveNotFound {
t.Fatalf("expected not found error, got %v", err)
}
_, _, err = resolver.Resolve("pn-conflict")
if err == nil || err != lotmatch.ErrResolveConflict {
t.Fatalf("expected conflict, got %v", err)
}
}
func TestImportNoValidRowsKeepsStockLog(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.StockLog{}); err != nil {
t.Fatalf("automigrate stock_log: %v", err)
}
existing := models.StockLog{
Partnumber: "CPU_A",
Date: time.Now(),
Price: 10,
}
if err := db.Create(&existing).Error; err != nil {
t.Fatalf("seed stock_log: %v", err)
}
svc := NewStockImportService(db, nil)
headerOnly := []byte(strings.Join([]string{
`MOXCEL`,
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
}, "\n"))
if _, err := svc.Import("test.mxl", headerOnly, time.Now(), "tester", nil); err == nil {
t.Fatalf("expected import error")
}
var count int64
if err := db.Model(&models.StockLog{}).Count(&count).Error; err != nil {
t.Fatalf("count stock_log: %v", err)
}
if count != 1 {
t.Fatalf("expected stock_log unchanged, got %d rows", count)
}
}
func TestReplaceStockLogs(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.StockLog{}); err != nil {
t.Fatalf("automigrate stock_log: %v", err)
}
if err := db.Create(&models.StockLog{Partnumber: "OLD", Date: time.Now(), Price: 1}).Error; err != nil {
t.Fatalf("seed old row: %v", err)
}
svc := NewStockImportService(db, nil)
records := []models.StockLog{
{Partnumber: "NEW_1", Date: time.Now(), Price: 2},
{Partnumber: "NEW_2", Date: time.Now(), Price: 3},
}
deleted, inserted, err := svc.replaceStockLogs(records)
if err != nil {
t.Fatalf("replaceStockLogs: %v", err)
}
if deleted != 1 || inserted != 2 {
t.Fatalf("unexpected replace stats deleted=%d inserted=%d", deleted, inserted)
}
var rows []models.StockLog
if err := db.Order("partnumber").Find(&rows).Error; err != nil {
t.Fatalf("read rows: %v", err)
}
if len(rows) != 2 || rows[0].Partnumber != "NEW_1" || rows[1].Partnumber != "NEW_2" {
t.Fatalf("unexpected rows after replace: %#v", rows)
}
}
func TestWeightedMedian(t *testing.T) {
got := weightedMedian([]weightedPricePoint{
{price: 10, weight: 1},
{price: 20, weight: 3},
{price: 50, weight: 1},
})
if got != 20 {
t.Fatalf("expected weighted median 20, got %v", got)
}
}
func TestWeightedMedianFallbackToMedianWhenNoWeights(t *testing.T) {
got := weightedMedian([]weightedPricePoint{
{price: 10, weight: 0},
{price: 20, weight: 0},
{price: 30, weight: 0},
})
if got != 20 {
t.Fatalf("expected fallback median 20, got %v", got)
}
}
func TestBuildWarehousePricelistItems_UsesPrefixResolver(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.StockLog{}, &models.Lot{}, &models.LotPartnumber{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Create(&models.Lot{LotName: "CPU_A"}).Error; err != nil {
t.Fatalf("seed lot: %v", err)
}
qty1 := 3.0
qty2 := 1.0
now := time.Now()
rows := []models.StockLog{
{Partnumber: "CPU_A-001", Date: now, Price: 100, Qty: &qty1},
{Partnumber: "CPU_A-XYZ", Date: now, Price: 120, Qty: &qty2},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("seed stock_log: %v", err)
}
svc := NewStockImportService(db, nil)
items, err := svc.buildWarehousePricelistItems()
if err != nil {
t.Fatalf("buildWarehousePricelistItems: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].LotName != "CPU_A" {
t.Fatalf("expected lot CPU_A, got %s", items[0].LotName)
}
if items[0].Price != 100 {
t.Fatalf("expected weighted median 100, got %v", items[0].Price)
}
}
func TestPartnumberMappings_WildcardMatch(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.LotPartnumber{}, &models.Lot{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
mappings := []models.LotPartnumber{
{Partnumber: "R750*", LotName: "SERVER_R750"},
{Partnumber: "HDD-01", LotName: "HDD_01"},
}
if err := db.Create(&mappings).Error; err != nil {
t.Fatalf("seed mappings: %v", err)
}
if err := db.Create(&models.Lot{LotName: "MEM_DDR5_16G_4800"}).Error; err != nil {
t.Fatalf("seed lot: %v", err)
}
resolver, err := lotmatch.NewMappingMatcherFromDB(db)
if err != nil {
t.Fatalf("NewMappingMatcherFromDB: %v", err)
}
if got := resolver.MatchLots("R750XD"); len(got) != 1 || got[0] != "SERVER_R750" {
t.Fatalf("expected wildcard match SERVER_R750, got %#v", got)
}
if got := resolver.MatchLots("HDD-01"); len(got) != 1 || got[0] != "HDD_01" {
t.Fatalf("expected exact match HDD_01, got %#v", got)
}
if got := resolver.MatchLots("UNKNOWN"); len(got) != 0 {
t.Fatalf("expected no matches, got %#v", got)
}
if got := resolver.MatchLots("MEM_DDR5_16G_4800"); len(got) != 1 || got[0] != "MEM_DDR5_16G_4800" {
t.Fatalf("expected exact lot fallback, got %#v", got)
}
}
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
return db
}
func buildMinimalXLSX(t *testing.T, headers, values []string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
write := func(name, body string) {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create zip entry %s: %v", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("write zip entry %s: %v", name, err)
}
}
write("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>`)
write("_rels/.rels", `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`)
write("xl/workbook.xml", `<?xml version="1.0" encoding="UTF-8"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/>
</sheets>
</workbook>`)
write("xl/_rels/workbook.xml.rels", `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>`)
makeCell := func(ref, value string) string {
escaped := strings.ReplaceAll(value, "&", "&amp;")
escaped = strings.ReplaceAll(escaped, "<", "&lt;")
escaped = strings.ReplaceAll(escaped, ">", "&gt;")
return `<c r="` + ref + `" t="inlineStr"><is><t>` + escaped + `</t></is></c>`
}
cols := []string{"A", "B", "C", "D", "E", "F"}
var headerCells, valueCells strings.Builder
for i := 0; i < len(cols) && i < len(headers); i++ {
headerCells.WriteString(makeCell(cols[i]+"1", headers[i]))
}
for i := 0; i < len(cols) && i < len(values); i++ {
valueCells.WriteString(makeCell(cols[i]+"2", values[i]))
}
write("xl/worksheets/sheet1.xml", `<?xml version="1.0" encoding="UTF-8"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">`+headerCells.String()+`</row>
<row r="2">`+valueCells.String()+`</row>
</sheetData>
</worksheet>`)
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
return buf.Bytes()
}

View File

@@ -71,6 +71,15 @@ func (w *Worker) runSync() {
return return
} }
if readiness, err := w.service.EnsureReadinessForSync(); err != nil {
w.logger.Warn("background sync: blocked by readiness guard",
"error", err,
"reason_code", readiness.ReasonCode,
"reason_text", readiness.ReasonText,
)
return
}
// Push pending changes first // Push pending changes first
pushed, err := w.service.PushPendingChanges() pushed, err := w.service.PushPendingChanges()
if err != nil { if err != nil {

View File

@@ -1,219 +0,0 @@
package warehouse
import (
"sort"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type SnapshotItem struct {
LotName string
Price float64
PriceMethod string
}
type weightedPricePoint struct {
price float64
weight float64
}
// ComputePricelistItemsFromStockLog builds warehouse snapshot items from stock_log.
func ComputePricelistItemsFromStockLog(db *gorm.DB) ([]SnapshotItem, error) {
type stockRow struct {
Partnumber string `gorm:"column:partnumber"`
Price float64 `gorm:"column:price"`
Qty *float64 `gorm:"column:qty"`
}
var rows []stockRow
if err := db.Table(models.StockLog{}.TableName()).Select("partnumber, price, qty").Where("price > 0").Scan(&rows).Error; err != nil {
return nil, err
}
resolver, err := lotmatch.NewLotResolverFromDB(db)
if err != nil {
return nil, err
}
grouped := make(map[string][]weightedPricePoint)
for _, row := range rows {
pn := strings.TrimSpace(row.Partnumber)
if pn == "" || row.Price <= 0 {
continue
}
lot, _, err := resolver.Resolve(pn)
if err != nil || strings.TrimSpace(lot) == "" {
continue
}
weight := 0.0
if row.Qty != nil && *row.Qty > 0 {
weight = *row.Qty
}
grouped[lot] = append(grouped[lot], weightedPricePoint{price: row.Price, weight: weight})
}
items := make([]SnapshotItem, 0, len(grouped))
for lot, values := range grouped {
price := weightedMedian(values)
if price <= 0 {
continue
}
items = append(items, SnapshotItem{LotName: lot, Price: price, PriceMethod: "weighted_median"})
}
sort.Slice(items, func(i, j int) bool { return items[i].LotName < items[j].LotName })
return items, nil
}
// LoadLotMetrics returns stock qty and partnumbers for selected lots.
// If latestOnly is true, qty/partnumbers from stock_log are calculated only for latest import date.
func LoadLotMetrics(db *gorm.DB, lotNames []string, latestOnly bool) (map[string]float64, map[string][]string, error) {
qtyByLot := make(map[string]float64, len(lotNames))
partnumbersByLot := make(map[string][]string, len(lotNames))
if len(lotNames) == 0 {
return qtyByLot, partnumbersByLot, nil
}
lotSet := make(map[string]struct{}, len(lotNames))
for _, lot := range lotNames {
trimmed := strings.TrimSpace(lot)
if trimmed == "" {
continue
}
lotSet[trimmed] = struct{}{}
}
resolver, err := lotmatch.NewLotResolverFromDB(db)
if err != nil {
return nil, nil, err
}
seenPN := make(map[string]map[string]struct{}, len(lotSet))
addPartnumber := func(lotName, partnumber string) {
lotName = strings.TrimSpace(lotName)
partnumber = strings.TrimSpace(partnumber)
if lotName == "" || partnumber == "" {
return
}
if _, ok := lotSet[lotName]; !ok {
return
}
if _, ok := seenPN[lotName]; !ok {
seenPN[lotName] = map[string]struct{}{}
}
key := strings.ToLower(partnumber)
if _, ok := seenPN[lotName][key]; ok {
return
}
seenPN[lotName][key] = struct{}{}
partnumbersByLot[lotName] = append(partnumbersByLot[lotName], partnumber)
}
var mappingRows []models.LotPartnumber
if err := db.Select("partnumber, lot_name").Find(&mappingRows).Error; err != nil {
return nil, nil, err
}
for _, row := range mappingRows {
addPartnumber(row.LotName, row.Partnumber)
}
type stockRow struct {
Partnumber string `gorm:"column:partnumber"`
Qty *float64 `gorm:"column:qty"`
}
var stockRows []stockRow
if latestOnly {
err = db.Raw(`
SELECT sl.partnumber, sl.qty
FROM stock_log sl
INNER JOIN (SELECT MAX(date) AS max_date FROM stock_log) md ON sl.date = md.max_date
`).Scan(&stockRows).Error
} else {
err = db.Table(models.StockLog{}.TableName()).Select("partnumber, qty").Scan(&stockRows).Error
}
if err != nil {
return nil, nil, err
}
for _, row := range stockRows {
pn := strings.TrimSpace(row.Partnumber)
if pn == "" {
continue
}
lot, _, err := resolver.Resolve(pn)
if err != nil {
continue
}
if _, exists := lotSet[lot]; !exists {
continue
}
if row.Qty != nil {
qtyByLot[lot] += *row.Qty
}
addPartnumber(lot, pn)
}
for lot := range partnumbersByLot {
sort.Slice(partnumbersByLot[lot], func(i, j int) bool {
return strings.ToLower(partnumbersByLot[lot][i]) < strings.ToLower(partnumbersByLot[lot][j])
})
}
return qtyByLot, partnumbersByLot, nil
}
func weightedMedian(values []weightedPricePoint) float64 {
if len(values) == 0 {
return 0
}
type pair struct {
price float64
weight float64
}
items := make([]pair, 0, len(values))
totalWeight := 0.0
prices := make([]float64, 0, len(values))
for _, v := range values {
if v.price <= 0 {
continue
}
prices = append(prices, v.price)
if v.weight > 0 {
items = append(items, pair{price: v.price, weight: v.weight})
totalWeight += v.weight
}
}
if totalWeight <= 0 {
return median(prices)
}
sort.Slice(items, func(i, j int) bool {
if items[i].price == items[j].price {
return items[i].weight < items[j].weight
}
return items[i].price < items[j].price
})
threshold := totalWeight / 2.0
acc := 0.0
for _, it := range items {
acc += it.weight
if acc >= threshold {
return it.price
}
}
return items[len(items)-1].price
}
func median(values []float64) float64 {
if len(values) == 0 {
return 0
}
cp := append([]float64(nil), values...)
sort.Float64s(cp)
n := len(cp)
if n%2 == 0 {
return (cp[n/2-1] + cp[n/2]) / 2
}
return cp[n/2]
}

View File

@@ -1,103 +0,0 @@
package warehouse
import (
"math"
"slices"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestComputePricelistItemsFromStockLog(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Create(&models.Lot{LotName: "CPU_X"}).Error; err != nil {
t.Fatalf("seed lot: %v", err)
}
if err := db.Create(&models.LotPartnumber{Partnumber: "PN-CPU-X", LotName: "CPU_X"}).Error; err != nil {
t.Fatalf("seed mapping: %v", err)
}
qtySmall := 1.0
qtyBig := 9.0
now := time.Now()
rows := []models.StockLog{
{Partnumber: "PN CPU X", Date: now, Price: 100, Qty: &qtySmall},
{Partnumber: "CPU_X-EXTRA", Date: now, Price: 200, Qty: &qtyBig},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("seed stock rows: %v", err)
}
items, err := ComputePricelistItemsFromStockLog(db)
if err != nil {
t.Fatalf("ComputePricelistItemsFromStockLog: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].LotName != "CPU_X" {
t.Fatalf("expected lot CPU_X, got %s", items[0].LotName)
}
if math.Abs(items[0].Price-200) > 0.001 {
t.Fatalf("expected weighted median 200, got %f", items[0].Price)
}
}
func TestLoadLotMetricsLatestOnlyIncludesPartnumbers(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Create(&models.Lot{LotName: "CPU_X"}).Error; err != nil {
t.Fatalf("seed lot: %v", err)
}
if err := db.Create(&models.LotPartnumber{Partnumber: "PN-MAPPED", LotName: "CPU_X"}).Error; err != nil {
t.Fatalf("seed mapping: %v", err)
}
oldDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
newDate := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
oldQty := 10.0
newQty := 3.0
rows := []models.StockLog{
{Partnumber: "CPU_X-001", Date: oldDate, Price: 100, Qty: &oldQty},
{Partnumber: "CPU_X-001", Date: newDate, Price: 100, Qty: &newQty},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("seed stock rows: %v", err)
}
qtyByLot, pnsByLot, err := LoadLotMetrics(db, []string{"CPU_X"}, true)
if err != nil {
t.Fatalf("LoadLotMetrics: %v", err)
}
if got := qtyByLot["CPU_X"]; math.Abs(got-3.0) > 0.001 {
t.Fatalf("expected latest qty 3, got %f", got)
}
pns := pnsByLot["CPU_X"]
if !slices.Contains(pns, "PN-MAPPED") {
t.Fatalf("expected mapped PN-MAPPED in partnumbers, got %v", pns)
}
if !slices.Contains(pns, "CPU_X-001") {
t.Fatalf("expected stock CPU_X-001 in partnumbers, got %v", pns)
}
}
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
return db
}

View File

@@ -25,6 +25,25 @@ echo ""
RELEASE_DIR="releases/${VERSION}" RELEASE_DIR="releases/${VERSION}"
mkdir -p "${RELEASE_DIR}" mkdir -p "${RELEASE_DIR}"
# Create release notes template (always include macOS Gatekeeper note)
if [ ! -f "${RELEASE_DIR}/RELEASE_NOTES.md" ]; then
cat > "${RELEASE_DIR}/RELEASE_NOTES.md" <<EOF
# QuoteForge ${VERSION}
Дата релиза: $(date +%Y-%m-%d)
Тег: \`${VERSION}\`
## Что нового
- TODO: опишите ключевые изменения релиза.
## Запуск на macOS
Снимите карантинный атрибут через терминал: \`xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64\`
После этого бинарник запустится без предупреждения Gatekeeper.
EOF
fi
# Build for all platforms # Build for all platforms
echo -e "${YELLOW}→ Building binaries...${NC}" echo -e "${YELLOW}→ Building binaries...${NC}"
make build-all make build-all

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a> <a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
<div class="hidden md:flex space-x-4"> <div class="hidden md:flex space-x-4">
<a href="/projects" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Мои проекты</a> <a href="/projects" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Мои проекты</a>
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Администратор цен</a> <a href="/pricelists" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Прайслисты</a>
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a> <a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
</div> </div>
</div> </div>
@@ -81,6 +81,14 @@
</div> </div>
</div> </div>
<div id="modal-readiness-section" class="hidden">
<h4 class="font-medium text-red-700 mb-2">Почему синхронизация недоступна</h4>
<div class="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm">
<div id="modal-readiness-reason" class="text-red-700"></div>
<div id="modal-readiness-min-version" class="text-red-600 text-xs mt-1 hidden"></div>
</div>
</div>
<!-- Section 2: Statistics --> <!-- Section 2: Statistics -->
<div> <div>
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4> <h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
@@ -176,6 +184,26 @@
document.getElementById('modal-last-sync').textContent = '—'; document.getElementById('modal-last-sync').textContent = '—';
} }
const readinessSection = document.getElementById('modal-readiness-section');
const readinessReason = document.getElementById('modal-readiness-reason');
const readinessMinVersion = document.getElementById('modal-readiness-min-version');
if (data.readiness && data.readiness.blocked) {
readinessSection.classList.remove('hidden');
readinessReason.textContent = data.readiness.reason_text || 'Синхронизация заблокирована preflight-проверкой.';
if (data.readiness.required_min_app_version) {
readinessMinVersion.classList.remove('hidden');
readinessMinVersion.textContent = 'Требуется обновление до версии ' + data.readiness.required_min_app_version;
} else {
readinessMinVersion.classList.add('hidden');
readinessMinVersion.textContent = '';
}
} else {
readinessSection.classList.add('hidden');
readinessReason.textContent = '';
readinessMinVersion.classList.add('hidden');
readinessMinVersion.textContent = '';
}
// Section 2: Statistics // Section 2: Statistics
document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—'; document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—';
document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—'; document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—';
@@ -257,6 +285,11 @@
showToast(successMessage, 'success'); showToast(successMessage, 'success');
// Update last sync time - removed since dropdown is gone // Update last sync time - removed since dropdown is gone
// loadLastSyncTime(); // loadLastSyncTime();
} else if (resp.status === 423) {
const reason = data.reason_text || data.error || 'Синхронизация заблокирована.';
showToast(reason, 'error');
openSyncModal();
loadSyncInfo();
} else { } else {
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error'); showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
} }

View File

@@ -14,7 +14,14 @@
</span> </span>
{{end}} {{end}}
{{if gt .PendingCount 0}} {{if .IsBlocked}}
<span class="bg-red-100 text-red-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.BlockedReason}}" onclick="openSyncModal()">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-7.938 4h15.876c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L2.33 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
!
</span>
{{else if gt .PendingCount 0}}
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" onclick="openSyncModal()"> <span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" onclick="openSyncModal()">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>