Remove admin pricing stack and prepare v1.0.4 release
This commit is contained in:
194
CLAUDE.md
194
CLAUDE.md
@@ -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
|
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
**Server Configuration & Quotation Tool**
|
**Server Configuration & Quotation Tool**
|
||||||
|
|
||||||
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG.
|
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП).
|
||||||
|
Приложение работает в strict local-first режиме: пользовательские операции выполняются через локальную SQLite, MariaDB используется для синхронизации и серверного администрирования прайслистов.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
228
cmd/qfs/main.go
228
cmd/qfs/main.go
@@ -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)
|
||||||
|
|||||||
@@ -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,37 +41,25 @@ 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)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If offline mode (empty result), fallback to local components
|
|
||||||
isOffline := false
|
|
||||||
if v, ok := c.Get("is_offline"); ok {
|
|
||||||
if b, ok := v.(bool); ok {
|
|
||||||
isOffline = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isOffline && result.Total == 0 && h.localDB != nil {
|
|
||||||
localFilter := localdb.ComponentFilter{
|
localFilter := localdb.ComponentFilter{
|
||||||
Category: filter.Category,
|
Category: filter.Category,
|
||||||
Search: filter.Search,
|
Search: filter.Search,
|
||||||
HasPrice: filter.HasPrice,
|
HasPrice: filter.HasPrice,
|
||||||
}
|
}
|
||||||
|
|
||||||
offset := (page - 1) * perPage
|
offset := (page - 1) * perPage
|
||||||
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
||||||
if err == nil && len(localComps) > 0 {
|
if err != nil {
|
||||||
// Convert local components to ComponentView format
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
components := make([]services.ComponentView, len(localComps))
|
components := make([]services.ComponentView, len(localComps))
|
||||||
for i, lc := range localComps {
|
for i, lc := range localComps {
|
||||||
components[i] = services.ComponentView{
|
components[i] = services.ComponentView{
|
||||||
LotName: lc.LotName,
|
LotName: lc.LotName,
|
||||||
Description: lc.LotDescription,
|
Description: lc.LotDescription,
|
||||||
Category: lc.Category,
|
Category: lc.Category,
|
||||||
CategoryName: lc.Category, // No translation in local mode
|
CategoryName: lc.Category,
|
||||||
Model: lc.Model,
|
Model: lc.Model,
|
||||||
CurrentPrice: lc.CurrentPrice,
|
CurrentPrice: lc.CurrentPrice,
|
||||||
}
|
}
|
||||||
@@ -75,31 +71,40 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
Page: page,
|
Page: page,
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -61,6 +66,16 @@ type SyncStatusResponse struct {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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, "&", "&")
|
|
||||||
escaped = strings.ReplaceAll(escaped, "<", "<")
|
|
||||||
escaped = strings.ReplaceAll(escaped, ">", ">")
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user