44 Commits

Author SHA1 Message Date
Mikhail Chusavitin
8fd27d11a7 docs: update v1.2.1 release notes with full changelog
Added comprehensive release notes including:
- Summary of the v1.2.1 patch release
- Bug fix details for configurator component substitution
- API price loading implementation
- Testing verification
- Installation instructions for all platforms
- Migration notes (no DB migration required)

Release notes now provide full context for end users and developers.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 15:45:00 +03:00
Mikhail Chusavitin
600f842b82 docs: add releases/memory directory for changelog tracking
Added structured changelog documentation:
- Created releases/memory/ directory to track changes between tags
- Each version has a .md file (v1.2.1.md, etc.) documenting commits and impact
- Updated CLAUDE.md with release notes reference
- Updated README.md with releases section
- Updated .gitignore to track releases/memory/ while ignoring other release artifacts

This helps reviewers and developers understand changes between versions
before making new updates to the codebase.

Initial entry: v1.2.1.md documenting the pricelist refactor and
configurator component substitution fix.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 15:40:23 +03:00
Mikhail Chusavitin
acf7c8a4da fix: load component prices via API instead of removed current_price field
After the recent refactor that removed CurrentPrice from local_components,
the configurator's autocomplete was filtering out all components because
it checked for the now-removed current_price field.

Instead, now load prices from the API when the user starts typing in a
component search field:
- Added ensurePricesLoaded() to fetch prices via /api/quote/price-levels
- Added componentPricesCache to store loaded prices
- Updated all 3 autocomplete modes (single, multi, section) to load prices
- Changed price checks from c.current_price to hasComponentPrice()
- Updated cart item creation to use cached prices

Components without prices are still filtered out as required, but the check
now uses API data rather than a removed database field.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 15:31:53 +03:00
Mikhail Chusavitin
5984a57a8b refactor: remove CurrentPrice from local_components and transition to pricelist-based pricing
## Overview
Removed the CurrentPrice and SyncedAt fields from local_components, transitioning to a
pricelist-based pricing model where all prices are sourced from local_pricelist_items
based on the configuration's selected pricelist.

## Changes

### Data Model Updates
- **LocalComponent**: Now stores only metadata (LotName, LotDescription, Category, Model)
  - Removed: CurrentPrice, SyncedAt (both redundant)
  - Pricing is now exclusively sourced from local_pricelist_items

- **LocalConfiguration**: Added pricelist selection fields
  - Added: WarehousePricelistID, CompetitorPricelistID
  - These complement the existing PricelistID (Estimate)

### Migrations
- Added migration "drop_component_unused_fields" to remove CurrentPrice and SyncedAt columns
- Added migration "add_warehouse_competitor_pricelists" to add new pricelist fields

### Component Sync
- Removed current_price from MariaDB query
- Removed CurrentPrice assignment in component creation
- SyncComponentPrices now exclusively updates based on pricelist_items via quote calculation

### Quote Calculation
- Added PricelistID field to QuoteRequest
- Updated local-first path to use pricelist_items instead of component.CurrentPrice
- Falls back to latest estimate pricelist if PricelistID not specified
- Maintains offline-first behavior: local queries work without MariaDB

### Configuration Refresh
- Removed fallback on component.CurrentPrice
- Prices are only refreshed from local_pricelist_items
- If price not found in pricelist, original price is preserved

### API Changes
- Removed CurrentPrice from ComponentView
- Components API no longer returns pricing information
- Pricing is accessed via QuoteService or PricelistService

### Code Cleanup
- Removed UpdateComponentPricesFromPricelist() method
- Removed EnsureComponentPricesFromPricelists() method
- Updated UnifiedRepository to remove offline pricing logic
- Updated converters to remove CurrentPrice mapping

## Architecture Impact
- Components = metadata store only
- Prices = managed by pricelist system
- Quote calculation = owns all pricing logic
- Local-first behavior preserved: SQLite queries work offline, no MariaDB dependency

## Testing
- Build successful
- All code compiles without errors
- Ready for migration testing with existing databases

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 14:54:02 +03:00
Mikhail Chusavitin
84dda8cf0a docs: document complete database user permissions for sync support
Add comprehensive database permissions documentation:
- Full list of required tables with their purpose
- Separate sections for: existing user grants, new user creation, and important notes
- Clarifies that sync tables (qt_client_local_migrations, qt_client_schema_state,
  qt_pricelist_sync_status) must be created by DB admin - app doesn't need CREATE TABLE
- Explains read-only vs read-write permissions for each table
- Uses placeholder '<DB_USER>' instead of hardcoded usernames

This helps administrators set up proper permissions without CREATE TABLE requirements,
fixing the sync blockage issue in v1.1.0.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 11:30:09 +03:00
Mikhail Chusavitin
abeb26d82d fix: handle database permission issues in sync migration verification
Sync was blocked because the migration registry table creation required
CREATE TABLE permissions that the database user might not have.

Changes:
- Check if migration registry tables exist before attempting to create them
- Skip creation if table exists and user lacks CREATE permissions
- Use information_schema to reliably check table existence
- Apply same fix to user sync status table creation
- Gracefully handle ALTER TABLE failures for backward compatibility

This allows sync to proceed even if the client is a read-limited database user,
as long as the required tables have already been created by an administrator.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 11:22:33 +03:00
Mikhail Chusavitin
29edd73744 projects: add /all endpoint for unlimited project list
Solve pagination issue where configs reference projects not in the
paginated list (default 10 items, but there could be 50+ projects).

Changes:
- Add GET /api/projects/all endpoint that returns ALL projects without
  pagination as simple {uuid, name} objects
- Update frontend loadProjectsForConfigUI() to use /api/projects/all
  instead of /api/projects?status=all
- Ensures all projects are available in projectNameByUUID for config
  display, regardless of total project count

This fixes cases where project names don't display in /configs page
for configs that reference projects outside the paginated range.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 11:19:49 +03:00
Mikhail Chusavitin
e8d0e28415 export: add project name to CSV filename format
Update filename format to include both project and quotation names:
  YYYY-MM-DD (PROJECT-NAME) QUOTATION-NAME BOM.csv

Changes:
- Add ProjectName field to ExportRequest (optional)
- Update ExportCSV: use project_name if provided, otherwise fall back to name
- Update ExportConfigCSV: use config name for both project and quotation

Example filenames:
  2026-02-09 (OPS-1957) config1 BOM.csv
  2026-02-09 (MyProject) MyQuotation BOM.csv

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 11:02:36 +03:00
Mikhail Chusavitin
08feda9af6 export: use filename from Content-Disposition header in browser
Fix issue where frontend was ignoring server's Content-Disposition
header and using only config name + '.csv' for exported files.

Added getFilenameFromResponse() helper to extract proper filename
from Content-Disposition header and use it for downloaded files.

Applied to both:
- exportCSV() function
- exportCSVWithCustomPrice() function

Now files are downloaded with correct format:
  YYYY-MM-DD (PROJECT-NAME) BOM.csv

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 10:58:01 +03:00
Mikhail Chusavitin
af79b6f3bf export: update CSV filename format to YYYY-MM-DD (PROJECT-NAME) BOM
Change exported CSV filename format from:
  YYYY-MM-DD NAME SPEC.csv
To:
  YYYY-MM-DD (NAME) BOM.csv

Applied to both:
- POST /api/export/csv (direct export)
- GET /api/configs/:uuid/export (config export)

All tests passing.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 10:49:56 +03:00
Mikhail Chusavitin
bca82f9dc0 export: implement streaming CSV with Excel compatibility
Implement Phase 1 CSV Export Optimization:
- Replace buffering with true HTTP streaming (ToCSV writes to io.Writer)
- Add UTF-8 BOM (0xEF 0xBB 0xBF) for correct Cyrillic display in Excel
- Use semicolon (;) delimiter for Russian Excel locale
- Use comma (,) as decimal separator in numbers (100,50 instead of 100.50)
- Add graceful two-phase error handling:
  * Before streaming: return JSON errors for validation failures
  * During streaming: log errors only (HTTP 200 already sent)
- Add backward-compatible ToCSVBytes() helper
- Add GET /api/configs/:uuid/export route for configuration export

New tests (13 total):
- Service layer (7 tests):
  * UTF-8 BOM verification
  * Semicolon delimiter parsing
  * Total row formatting
  * Category sorting
  * Empty data handling
  * Backward compatibility wrapper
  * Writer error handling
- Handler layer (6 tests):
  * Successful CSV export with streaming
  * Invalid request validation
  * Empty items validation
  * Config export with proper headers
  * 404 for missing configs
  * Empty config validation

All tests passing, build verified.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 10:47:10 +03:00
17969277e6 pricing: enrich pricelist items with stock and tighten CORS 2026-02-08 10:27:36 +03:00
0dbfe45353 security: harden secret hygiene and pre-commit scanning 2026-02-08 10:27:23 +03:00
f609d2ce35 Add pricelist type column and commit pending changes 2026-02-08 10:03:24 +03:00
593280de99 sync: clean stale local pricelists and migrate runtime config handling 2026-02-08 10:01:27 +03:00
eb8555c11a Stop tracking ignored release artifacts 2026-02-08 08:55:21 +03:00
7523a7d887 Remove admin pricing stack and prepare v1.0.4 release 2026-02-07 21:23:23 +03:00
95b5f8bf65 refactor lot matching into shared module 2026-02-07 06:22:56 +03:00
b629af9742 Implement warehouse/lot pricing updates and configurator performance fixes 2026-02-07 05:20:35 +03:00
72ff842f5d Fix stock import UI bugs: dead code, fragile data attr, double-click, silent duplicates
- Remove unused stockMappingsCache variable (dead code after selectStockMappingRow removal)
- Move data-description from SVG to button element for reliable access
- Add disabled guard on bulk add/ignore buttons to prevent duplicate requests
- Return explicit error in UpsertIgnoreRule when rule already exists

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 23:30:01 +03:00
Mikhail Chusavitin
5f2969a85a Refine stock import UX with suggestions, ignore rules, and inline mapping controls 2026-02-06 19:58:42 +03:00
Mikhail Chusavitin
eb8ac34d83 Fix stock mappings JSON fields and enable row selection for editing 2026-02-06 19:39:39 +03:00
Mikhail Chusavitin
104a26d907 Add stock pricelist admin flow with mapping placeholders and warehouse details 2026-02-06 19:37:12 +03:00
Mikhail Chusavitin
b965c6bb95 WIP: save current pricing and pricelist changes 2026-02-06 19:07:22 +03:00
Mikhail Chusavitin
29035ddc5a configs: save pending template changes 2026-02-06 16:43:04 +03:00
Mikhail Chusavitin
2f0ac2f6d2 projects: add tracker_url and project create modal 2026-02-06 16:42:32 +03:00
Mikhail Chusavitin
8a8ea10dc2 Add tracker link on project detail page 2026-02-06 16:31:34 +03:00
Mikhail Chusavitin
51e2d1fc83 Fix local pricelist uniqueness and preserve config project on update 2026-02-06 16:00:23 +03:00
Mikhail Chusavitin
3d5ab63970 Make full sync push pending and pull projects/configurations 2026-02-06 15:25:07 +03:00
Mikhail Chusavitin
c02a7eac73 Prepare v1.0.3 release notes 2026-02-06 14:04:06 +03:00
Mikhail Chusavitin
651427e0dd Add projects table controls and sync status tab with app version 2026-02-06 14:02:21 +03:00
Mikhail Chusavitin
f665e9b08c sync: recover missing server config during update push 2026-02-06 13:41:01 +03:00
Mikhail Chusavitin
994eec53e7 Fix MySQL DSN escaping for setup passwords and clarify DB user setup 2026-02-06 13:27:57 +03:00
Mikhail Chusavitin
2f3c20fea6 update stale files list 2026-02-06 13:03:59 +03:00
Mikhail Chusavitin
80ec7bc6b8 Apply remaining pricelist and local-first updates 2026-02-06 13:01:40 +03:00
Mikhail Chusavitin
8e5c4f5a7c Use admin price-refresh logic for pricelist recalculation 2026-02-06 13:00:27 +03:00
Mikhail Chusavitin
1744e6a3b8 fix: skip startup sql migrations when not needed or no permissions 2026-02-06 11:56:55 +03:00
Mikhail Chusavitin
726dccb07c feat: add projects flow and consolidate default project handling 2026-02-06 11:39:12 +03:00
Mikhail Chusavitin
38d7332a38 Update pricelist repository, service, and tests 2026-02-06 10:14:24 +03:00
Mikhail Chusavitin
c0beed021c Enforce pricelist write checks and auto-restart on DB settings change 2026-02-05 15:44:54 +03:00
Mikhail Chusavitin
08b95c293c Purge orphan sync queue entries before push 2026-02-05 15:17:06 +03:00
Mikhail Chusavitin
c418d6cfc3 Handle stale configuration sync events when local row is missing 2026-02-05 15:11:43 +03:00
Mikhail Chusavitin
548a256d04 Drop qt_users dependency for configs and track app version 2026-02-05 15:07:23 +03:00
Mikhail Chusavitin
77c00de97a Добавил шаблон для создания пользователя в БД 2026-02-05 10:55:02 +03:00
76 changed files with 4599 additions and 7485 deletions

5
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
"$repo_root/scripts/check-secrets.sh"

15
.gitignore vendored
View File

@@ -1,5 +1,16 @@
# QuoteForge
config.yaml
.env
.env.*
*.pem
*.key
*.p12
*.pfx
*.crt
id_rsa
id_rsa.*
secrets.yaml
secrets.yml
# Local SQLite database (contains encrypted credentials)
/data/*.db
@@ -63,4 +74,8 @@ Network Trash Folder
Temporary Items
.apdisk
# Release artifacts, but DO track releases/memory/ for changelog
releases/
!releases/
!releases/memory/
!releases/memory/**

204
CLAUDE.md
View File

@@ -1,163 +1,83 @@
# QuoteForge - Claude Code Instructions
## 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
**Local-first architecture:** приложение ВСЕГДА работает с SQLite, MariaDB только для синхронизации.
## Architecture
- Local-first: чтение и запись происходят в SQLite
- MariaDB используется как сервер синхронизации
- Background worker: периодический sync push+pull
**Принцип работы:**
- ВСЕ операции (CRUD) выполняются в SQLite
- При создании конфигурации:
1. Если online → проверить новые прайслисты на сервере → скачать если есть
2. Далее работаем с local_pricelists (и online, и offline одинаково)
- Background sync: push pending_changes → pull updates
## Guardrails
- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
- Runtime-конфиг читается из user state (`config.yaml`) или через `-config` / `QFS_CONFIG_PATH`; не хранить рабочий `config.yaml` в репозитории.
- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
**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`)
## Key SQLite Data
- `connection_settings`
- `local_components`
- `local_pricelists`, `local_pricelist_items`
- `local_configurations`
- `local_projects`
- `pending_changes`
## API Endpoints
| Group | Endpoints |
|-------|-----------|
| Setup | GET/POST /setup, POST /setup/test |
| Components | GET /api/components, /api/categories |
| Pricelists | CRUD /api/pricelists, GET /latest, POST /compare |
| Projects | CRUD /api/projects/:uuid (Phase 3) |
| Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) |
| Configs | POST /:uuid/refresh-prices (обновить цены из local_components) |
| Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict |
| Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export |
| Setup | `GET /setup`, `POST /setup`, `POST /setup/test`, `GET /setup/status` |
| Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` |
| Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` |
| Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` |
| Configs | CRUD + refresh/clone/reactivate/rename/project binding via `/api/configs/*` |
| Projects | CRUD + nested configs via `/api/projects/*` |
| 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 | `POST /api/export/csv` |
## Web Routes
- `/configs`
- `/configurator`
- `/projects`
- `/projects/:uuid`
- `/pricelists`
- `/pricelists/:id`
- `/setup`
## Release Notes & Change Log
Release notes are maintained in `releases/memory/` directory organized by version tags (e.g., `v1.2.1.md`).
Before working on the codebase, review the most recent release notes to understand recent changes.
- Check `releases/memory/` for detailed changelog between tags
- Each release file documents commits, breaking changes, and migration notes
## Commands
```bash
# Development
go run ./cmd/qfs # Dev server
make run # Dev server (via Makefile)
go run ./cmd/qfs
make run
# Production build
make build-release # Optimized build with version (recommended)
VERSION=$(git describe --tags --always --dirty)
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
# Build
make build-release
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
# Cron jobs
go run ./cmd/cron -job=cleanup-pricelists # Remove old unused pricelists
go run ./cmd/cron -job=update-prices # Recalculate all prices
go run ./cmd/cron -job=update-popularity # Update popularity scores
# Check version
./bin/qfs -version
# Verification
go build ./cmd/qfs
go vet ./...
```
## Code Style
- gofmt, structured logging (slog), wrap errors with context
- snake_case files, PascalCase types
- RBAC disabled: DB username = user_id via `models.EnsureDBUser()`
## UI Guidelines
- htmx (hx-get/post/target/swap), Tailwind CDN
- Freshness colors: green (fresh) → yellow → orange → red (critical)
- Sync status + offline indicator in header
- gofmt
- structured logging (`slog`)
- explicit error wrapping with context

View File

@@ -1,178 +0,0 @@
# Local-First Architecture Integration Guide
## Overview
QuoteForge теперь поддерживает local-first архитектуру: приложение ВСЕГДА работает с SQLite (localdb), MariaDB используется только для синхронизации.
## Реализованные компоненты
### 1. Конвертеры моделей (`internal/localdb/converters.go`)
Конвертеры между MariaDB и SQLite моделями:
- `ConfigurationToLocal()` / `LocalToConfiguration()`
- `PricelistToLocal()` / `LocalToPricelist()`
- `ComponentToLocal()` / `LocalToComponent()`
### 2. LocalDB методы (`internal/localdb/localdb.go`)
Добавлены методы для работы с pending changes:
- `MarkChangesSynced(ids []int64)` - помечает изменения как синхронизированные
- `GetPendingCount()` - возвращает количество несинхронизированных изменений
### 3. Sync Service расширения (`internal/services/sync/service.go`)
Новые методы:
- `SyncPricelistsIfNeeded()` - проверяет и скачивает новые прайслисты при необходимости
- `PushPendingChanges()` - отправляет все pending changes на сервер
- `pushSingleChange()` - обрабатывает один pending change
- `pushConfigurationCreate/Update/Delete()` - специфичные методы для конфигураций
**ВАЖНО**: Конструктор изменен - теперь требует `ConfigurationRepository`:
```go
syncService := sync.NewService(pricelistRepo, configRepo, local)
```
### 4. LocalConfigurationService (`internal/services/local_configuration.go`)
Новый сервис для работы с конфигурациями в local-first режиме:
- Все операции CRUD работают через SQLite
- Автоматически добавляет изменения в pending_changes
- При создании конфигурации (если online) проверяет новые прайслисты
```go
localConfigService := services.NewLocalConfigurationService(
localDB,
syncService,
quoteService,
isOnlineFunc,
)
```
### 5. Sync Handler расширения (`internal/handlers/sync.go`)
Новые endpoints:
- `POST /api/sync/push` - отправить pending changes на сервер
- `GET /api/sync/pending/count` - получить количество pending changes
- `GET /api/sync/pending` - получить список pending changes
## Интеграция
### Шаг 1: Обновить main.go
```go
// В cmd/qfs/main.go
syncService := sync.NewService(pricelistRepo, configRepo, local)
// Создать isOnline функцию
isOnlineFunc := func() bool {
sqlDB, err := db.DB()
if err != nil {
return false
}
return sqlDB.Ping() == nil
}
// Создать LocalConfigurationService
localConfigService := services.NewLocalConfigurationService(
local,
syncService,
quoteService,
isOnlineFunc,
)
```
### Шаг 2: Обновить ConfigurationHandler
Заменить `ConfigurationService` на `LocalConfigurationService` в handlers:
```go
// Было:
configHandler := handlers.NewConfigurationHandler(configService, exportService)
// Стало:
configHandler := handlers.NewConfigurationHandler(localConfigService, exportService)
```
### Шаг 3: Добавить endpoints для sync
В роутере добавить:
```go
syncGroup := router.Group("/api/sync")
{
syncGroup.POST("/push", syncHandler.PushPendingChanges)
syncGroup.GET("/pending/count", syncHandler.GetPendingCount)
syncGroup.GET("/pending", syncHandler.GetPendingChanges)
}
```
## Как это работает
### Создание конфигурации
1. Пользователь создает конфигурацию
2. `LocalConfigurationService.Create()`:
- Если online → `SyncPricelistsIfNeeded()` проверяет новые прайслисты
- Сохраняет конфигурацию в SQLite
- Добавляет в `pending_changes` с operation="create"
3. Конфигурация доступна локально сразу
### Синхронизация с сервером
**Manual sync:**
```bash
POST /api/sync/push
```
**Background sync (TODO):**
- Периодический worker вызывает `syncService.PushPendingChanges()`
- Проверяет online статус
- Отправляет все pending changes на сервер
- Удаляет успешно синхронизированные записи
### Offline режим
1. Все операции работают нормально через SQLite
2. Изменения копятся в `pending_changes`
3. При восстановлении соединения автоматически синхронизируются
## Pending Changes Queue
Таблица `pending_changes`:
```go
type PendingChange struct {
ID int64 // Auto-increment
EntityType string // "configuration", "project", "specification"
EntityUUID string // UUID сущности
Operation string // "create", "update", "delete"
Payload string // JSON snapshot сущности
CreatedAt time.Time
Attempts int // Счетчик попыток синхронизации
LastError string // Последняя ошибка синхронизации
}
```
## TODO для Phase 2.5
- [ ] Background sync worker (автоматическая синхронизация каждые N минут)
- [ ] Conflict resolution (при конфликтах обновления)
- [ ] UI: pending counter в header
- [ ] UI: manual sync button
- [ ] UI: conflict alerts
- [ ] Retry logic для failed pending changes
- [ ] RefreshPrices для local mode (через local_components)
## Testing
```bash
# Compile
go build ./cmd/qfs
# Run
./quoteforge
# Check pending changes
curl http://localhost:8080/api/sync/pending/count
# Manual sync
curl -X POST http://localhost:8080/api/sync/push
```

View File

@@ -1,121 +0,0 @@
# Миграция: Функционал пересчета цен в конфигураторе
## Описание изменений
Добавлен функционал автоматического обновления цен компонентов в сохраненных конфигурациях.
### Новые возможности
1. **Кнопка "Пересчитать цену"** на странице конфигуратора
- Обновляет цены всех компонентов в конфигурации до актуальных значений из базы данных
- Сохраняет количество компонентов, обновляя только цены
- Отображает время последнего обновления цен
2. **Поле `price_updated_at`** в таблице конфигураций
- Хранит дату и время последнего обновления цен
- Отображается на странице конфигуратора в удобном формате ("5 мин. назад", "2 ч. назад" и т.д.)
### Изменения в базе данных
Добавлено новое поле в таблицу `qt_configurations`:
```sql
ALTER TABLE qt_configurations
ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL
AFTER server_count;
```
### Новый API endpoint
```
POST /api/configs/:uuid/refresh-prices
```
**Требования:**
- Авторизация: Bearer Token
- Роль: editor или выше
**Ответ:**
```json
{
"id": 1,
"uuid": "...",
"name": "Конфигурация 1",
"items": [
{
"lot_name": "CPU_AMD_9654",
"quantity": 2,
"unit_price": 11500.00
}
],
"total_price": 23000.00,
"price_updated_at": "2026-01-31T12:34:56Z",
...
}
```
## Применение изменений
### 1. Обновление базы данных
Запустите сервер с флагом миграции:
```bash
./quoteforge -migrate -config config.yaml
```
Или выполните SQL миграцию вручную:
```bash
mysql -u user -p RFQ_LOG < migrations/004_add_price_updated_at.sql
```
### 2. Перезапуск сервера
После применения миграции перезапустите сервер:
```bash
./quoteforge -config config.yaml
```
## Использование
1. Откройте любую сохраненную конфигурацию в конфигураторе
2. Нажмите кнопку **"Пересчитать цену"** рядом с кнопкой "Сохранить"
3. Все цены компонентов будут обновлены до актуальных значений
4. Конфигурация автоматически сохраняется с обновленными ценами
5. Под кнопками отображается время последнего обновления цен
## Технические детали
### Измененные файлы
- `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt`
- `internal/services/configuration.go` - добавлен метод `RefreshPrices()`
- `internal/handlers/configuration.go` - добавлен обработчик `RefreshPrices()`
- `cmd/qfs/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
- `web/templates/index.html` - добавлена кнопка и JavaScript функции
- `migrations/004_add_price_updated_at.sql` - SQL миграция
- `CLAUDE.md` - обновлена документация
### Логика обновления цен
1. Получение конфигурации по UUID
2. Проверка прав доступа (пользователь должен быть владельцем)
3. Для каждого компонента в конфигурации:
- Получение актуальной цены из `qt_lot_metadata.current_price`
- Обновление `unit_price` в items
4. Пересчет `total_price` с учетом `server_count`
5. Установка `price_updated_at` на текущее время
6. Сохранение конфигурации
### Обработка ошибок
- Если компонент не найден или у него нет цены - сохраняется старая цена
- При ошибках доступа возвращается 403 Forbidden
- При отсутствии конфигурации возвращается 404 Not Found
## Отмена изменений (Rollback)
Для отмены миграции выполните:
```sql
ALTER TABLE qt_configurations DROP COLUMN price_updated_at;
```
**Внимание:** После отмены миграции функционал пересчета цен перестанет работать корректно.

View File

@@ -1,4 +1,4 @@
.PHONY: build build-release clean test run version
.PHONY: build build-release clean test run version install-hooks
# Get version from git
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -72,6 +72,12 @@ deps:
go mod download
go mod tidy
# Install local git hooks
install-hooks:
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit scripts/check-secrets.sh
@echo "Installed git hooks from .githooks/"
# Help
help:
@echo "QuoteForge Server (qfs) - Build Commands"
@@ -92,6 +98,7 @@ help:
@echo " run Run development server"
@echo " watch Run with auto-restart (requires entr)"
@echo " deps Install/update dependencies"
@echo " install-hooks Install local git hooks (secret scan on commit)"
@echo " help Show this help"
@echo ""
@echo "Current version: $(VERSION)"

259
README.md
View File

@@ -2,7 +2,8 @@
**Server Configuration & Quotation Tool**
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG.
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП).
Приложение работает в strict local-first режиме: пользовательские операции выполняются через локальную SQLite, MariaDB используется для синхронизации и серверного администрирования прайслистов.
![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat&logo=go)
![License](https://img.shields.io/badge/License-Proprietary-red)
@@ -16,6 +17,8 @@ QuoteForge — корпоративный инструмент для конфи
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
- 🔌 **Полная офлайн-работа** — можно продолжать работу без сети и синхронизировать позже
- 🛡️ **Защищенная синхронизация** — sync блокируется preflight-проверкой, если локальная схема не готова
### Для ценовых администраторов
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
@@ -35,7 +38,7 @@ QuoteForge — корпоративный инструмент для конфи
- **Backend:** Go 1.22+, Gin, GORM
- **Frontend:** HTML, Tailwind CSS, htmx
- **Database:** MariaDB 11+
- **Database:** SQLite (runtime/local-first), MariaDB 11+ (sync + server admin)
- **Export:** excelize (XLSX), encoding/csv
## Требования
@@ -53,13 +56,13 @@ git clone https://github.com/your-company/quoteforge.git
cd quoteforge
```
### 2. Настройка конфигурации
### 2. Настройка runtime-конфига (опционально)
```bash
cp config.example.yaml config.yaml
```
`config.yaml` создаётся автоматически при первом старте в той же user-state папке, где находится `qfs.db`.
Если найден старый формат, приложение автоматически мигрирует файл в актуальный runtime-формат
(оставляя только используемые секции `server` и `logging`).
Отредактируйте `config.yaml`:
При необходимости можно создать/отредактировать файл вручную:
```yaml
server:
@@ -67,16 +70,10 @@ server:
port: 8080
mode: "release"
database:
host: "localhost"
port: 3306
name: "RFQ_LOG"
user: "quoteforge"
password: "your-secure-password"
auth:
jwt_secret: "your-jwt-secret-min-32-chars"
token_expiry: "24h"
logging:
level: "info"
format: "json"
output: "stdout"
```
### 3. Миграции базы данных
@@ -93,73 +90,100 @@ go run ./cmd/qfs -migrate
Сначала всегда смотрите preview:
```bash
go run ./cmd/migrate_ops_projects -config config.yaml
go run ./cmd/migrate_ops_projects
```
Применение изменений:
```bash
go run ./cmd/migrate_ops_projects -config config.yaml -apply
go run ./cmd/migrate_ops_projects -apply
```
Без интерактивного подтверждения:
```bash
go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
go run ./cmd/migrate_ops_projects -apply -yes
```
### Минимальные права БД для пользователя квотаций
### Права БД для пользователя приложения
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
#### Полный набор прав для обычного пользователя
Чтобы выдать существующему пользователю все необходимые права (без переоздания):
```sql
-- 1) Создать пользователя (если его ещё нет)
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
-- Справочные таблицы (только чтение)
GRANT SELECT ON RFQ_LOG.lot TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
-- 2) Если пользователь уже существовал, принудительно обновить пароль
ALTER USER 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
-- Таблицы конфигураций и проектов (чтение и запись)
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
-- 3) (Опционально, но рекомендуется) удалить дубли пользователя с другими host,
-- чтобы не возникало конфликтов вида user@localhost vs user@'%'
DROP USER IF EXISTS 'quote_user'@'localhost';
DROP USER IF EXISTS 'quote_user'@'127.0.0.1';
DROP USER IF EXISTS 'quote_user'@'::1';
-- 4) Сбросить лишние права
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
-- 5) Чтение данных для конфигуратора и синка
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
-- 6) Работа с конфигурациями
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
-- Таблицы синхронизации (только чтение для миграций, чтение+запись для статуса)
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
-- Применить изменения
FLUSH PRIVILEGES;
SHOW GRANTS FOR 'quote_user'@'%';
SHOW CREATE USER 'quote_user'@'%';
-- Проверка выданных прав
SHOW GRANTS FOR '<DB_USER>'@'%';
```
Полный набор прав для пользователя квотаций:
#### Таблицы и их назначение
| Таблица | Назначение | Права | Примечание |
|---------|-----------|-------|-----------|
| `lot` | Справочник компонентов | SELECT | Существующая таблица |
| `qt_lot_metadata` | Расширенные данные компонентов | SELECT | Метаданные компонентов |
| `qt_categories` | Категории компонентов | SELECT | Справочник |
| `qt_pricelists` | Прайслисты | SELECT | Управляется сервером |
| `qt_pricelist_items` | Позиции прайслистов | SELECT | Управляется сервером |
| `qt_configurations` | Сохранённые конфигурации | SELECT, INSERT, UPDATE | Основная таблица работы |
| `qt_projects` | Проекты | SELECT, INSERT, UPDATE | Для группировки конфигураций |
| `qt_client_local_migrations` | Справочник миграций БД | SELECT | Только чтение (управляется админом) |
| `qt_client_schema_state` | Состояние локальной схемы | SELECT, INSERT, UPDATE | Отслеживание примененных миграций |
| `qt_pricelist_sync_status` | Статус синхронизации | SELECT, INSERT, UPDATE | Отслеживание активности синхронизации |
#### При создании нового пользователя
Если нужно создать нового пользователя с нуля:
```sql
GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
-- 1) Создать пользователя
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
-- 2) Выдать все необходимые права
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
-- 3) Применить изменения
FLUSH PRIVILEGES;
-- 4) Проверить права
SHOW GRANTS FOR 'quote_user'@'%';
```
Важно:
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
- если видите ошибку `Access denied for user ...@'<ip>'`, проверьте, что не осталось других записей `quote_user@host` кроме `quote_user@'%'`;
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
#### Важные замечания
- **Таблицы синхронизации** должны быть созданы администратором БД один раз. Приложение не требует прав CREATE TABLE.
- **Прайслисты** (`qt_pricelists`, `qt_pricelist_items`) — справочные таблицы, управляются сервером, пользователь имеет только SELECT.
- **Конфигурации и проекты** — таблицы, в которые пишет само приложение (INSERT, UPDATE при сохранении изменений).
- **Таблицы миграций** нужны для синхронизации: приложение читает список миграций и отчитывается о применённых.
- Если видите ошибку `Access denied for user ...@'<ip>'`, проверьте наличие конфликтующих записей пользователя с разными хостами (user@localhost vs user@'%').
### 4. Импорт метаданных компонентов
@@ -190,6 +214,7 @@ make build-all # Сборка для всех платформ (Linux, mac
make build-windows # Только для Windows
make run # Запуск dev сервера
make test # Запуск тестов
make install-hooks # Установить git hooks (блокировка коммита с секретами)
make clean # Очистка bin/
make help # Показать все команды
```
@@ -207,6 +232,56 @@ make help # Показать все команды
Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`.
#### Sync readiness guard
Перед `push/pull` выполняется preflight-проверка:
- доступен ли сервер (MariaDB);
- можно ли проверить и применить централизованные миграции локальной БД;
- подходит ли версия приложения под `min_app_version` миграций.
Если проверка не пройдена:
- локальная работа (CRUD) продолжается;
- sync API возвращает `423 Locked` с `reason_code` и `reason_text`;
- в UI показывается красный индикатор и причина блокировки в модалке синхронизации.
#### Схема потоков данных синхронизации
```text
[ SERVER / MariaDB ]
┌───────────────────────────┐
│ qt_projects │
│ qt_configurations │
│ qt_pricelists │
│ qt_pricelist_items │
│ qt_pricelist_sync_status │
└─────────────┬─────────────┘
pull (projects/configs/pricelists)
┌──────────────────┴──────────────────┐
│ │
[ CLIENT A / local SQLite ] [ CLIENT B / local SQLite ]
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ local_projects │ │ local_projects │
│ local_configurations │ │ local_configurations │
│ local_pricelists │ │ local_pricelists │
│ local_pricelist_items │ │ local_pricelist_items │
│ pending_changes (proj/config) │ │ pending_changes (proj/config) │
└───────────────┬───────────────┘ └───────────────┬───────────────┘
│ │
push (projects/configurations only) push (projects/configurations only)
│ │
└──────────────────┬────────────────────┘
[ SERVER / MariaDB ]
```
По сущностям:
- Конфигурации: `Client <-> Server <-> Other Clients`
- Проекты: `Client <-> Server <-> Other Clients`
- Прайслисты: `Server -> Clients only` (локальный push отсутствует)
- Локальная очистка прайслистов на клиенте: удаляются записи, которых нет на сервере и которые не используются активными локальными конфигурациями
### Версионность конфигураций (local-first)
Для `local_configurations` используется append-only versioning через полные snapshot-версии:
@@ -240,6 +315,7 @@ POST /api/configs/:uuid/rollback
### Локальный config.yaml
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
Если файла нет, он создаётся автоматически. Если формат устарел, он автоматически мигрируется в runtime-формат (`server` + `logging`).
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
## Docker
@@ -270,12 +346,23 @@ quoteforge/
│ ├── templates/ # HTML шаблоны
│ └── static/ # CSS, JS, изображения
├── migrations/ # SQL миграции
├── config.yaml # Конфигурация
├── Dockerfile
├── docker-compose.yml
├── config.example.yaml # Пример конфигурации
├── releases/
│ └── memory/ # Changelog между тегами (v1.2.1.md, v1.2.2.md, ...)
└── go.mod
```
## Releases & Changelog
Change log между версиями хранится в `releases/memory/` каталоге в файлах вида `v{major}.{minor}.{patch}.md`.
Каждый файл содержит:
- Список коммитов между версиями
- Описание изменений и их влияния
- Breaking changes и заметки о миграции
**Перед работой над кодом проверьте последний файл в этой папке, чтобы понять текущее состояние проекта.**
## Роли пользователей
| Роль | Описание |
@@ -301,8 +388,26 @@ GET /api/configs/:uuid/versions # Список версий конф
GET /api/configs/:uuid/versions/:version # Получить конкретную версию
POST /api/configs/:uuid/rollback # Rollback на указанную версию
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 API
| Endpoint | Назначение | Поток |
|----------|------------|-------|
| `POST /api/sync/push` | Отправить локальные pending-изменения | `SQLite -> MariaDB` |
| `POST /api/sync/components` | Подтянуть справочник компонентов | `MariaDB -> SQLite` |
| `POST /api/sync/pricelists` | Подтянуть прайслисты и позиции | `MariaDB -> SQLite` |
| `POST /api/sync/all` | Полный цикл: push + pull + импорт проектов/конфигураций | `двунаправленно` |
| `GET /api/sync/readiness` | Статус preflight/readiness | `read-only` |
| `GET /api/sync/status` / `GET /api/sync/info` | Сводка статуса и данных синхронизации | `read-only` |
#### Sync payload для versioning
События в `pending_changes` для конфигураций содержат:
@@ -314,50 +419,6 @@ POST /api/configs/:uuid/reactivate # Вернуть архивную к
Это позволяет push-слою отправлять на сервер актуальное состояние и готовит основу для будущего conflict resolution.
## Cron Jobs
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
### Docker Compose Setup
The Docker setup includes a dedicated cron service that runs the following jobs:
- **Alerts check**: Every hour (0 * * * *)
- **Price updates**: Daily at 2 AM (0 2 * * *)
- **Usage counter reset**: Weekly on Sunday at 1 AM (0 1 * * 0)
- **Popularity score updates**: Daily at 3 AM (0 3 * * *)
To enable cron jobs in Docker, run:
```bash
docker-compose up -d
```
### Manual Cron Job Execution
You can also run cron jobs manually using the quoteforge-cron binary:
```bash
# Check and generate alerts
go run ./cmd/cron -job=alerts
# Recalculate all prices
go run ./cmd/cron -job=update-prices
# Reset usage counters
go run ./cmd/cron -job=reset-counters
# Update popularity scores
go run ./cmd/cron -job=update-popularity
```
### Cron Job Details
- **Alerts check**: Generates alerts for components with high demand and stale prices, trending components without prices, and components with no recent quotes
- **Price updates**: Recalculates prices for all components using configured methods (median, weighted median, average)
- **Usage counter reset**: Resets weekly and monthly usage counters for components
- **Popularity score updates**: Recalculates popularity scores based on supplier quote activity
## Разработка
```bash

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/driver/mysql"
@@ -16,7 +15,6 @@ import (
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
defaultLocalDBPath, err := appstate.ResolveDBPath("")
if err != nil {
log.Fatalf("Failed to resolve default local SQLite path: %v", err)
@@ -28,22 +26,6 @@ func main() {
log.Println("QuoteForge Configuration Migration Tool")
log.Println("========================================")
// Load config for MariaDB connection
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Connect to MariaDB
log.Printf("Connecting to MariaDB at %s:%d...", cfg.Database.Host, cfg.Database.Port)
mariaDB, 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 MariaDB: %v", err)
}
log.Println("Connected to MariaDB")
// Initialize local SQLite
log.Printf("Opening local SQLite at %s...", *localDBPath)
local, err := localdb.New(*localDBPath)
@@ -51,6 +33,28 @@ func main() {
log.Fatalf("Failed to initialize local database: %v", err)
}
log.Println("Local SQLite initialized")
if !local.HasSettings() {
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
}
settings, err := local.GetSettings()
if err != nil {
log.Fatalf("Failed to load SQLite connection settings: %v", err)
}
dsn, err := local.GetDSN()
if err != nil {
log.Fatalf("Failed to build DSN from SQLite settings: %v", err)
}
// Connect to MariaDB
log.Printf("Connecting to MariaDB at %s:%d...", settings.Host, settings.Port)
mariaDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("Failed to connect to MariaDB: %v", err)
}
log.Println("Connected to MariaDB")
// Count configurations in MariaDB
var serverCount int64
@@ -149,23 +153,7 @@ func main() {
log.Printf(" Skipped: %d", skipped)
log.Printf(" Errors: %d", errors)
// Save connection settings to local SQLite if not exists
if !local.HasSettings() {
log.Println("\nSaving connection settings to local SQLite...")
if err := local.SaveSettings(
cfg.Database.Host,
cfg.Database.Port,
cfg.Database.Name,
cfg.Database.User,
cfg.Database.Password,
); err != nil {
log.Printf("Warning: Failed to save settings: %v", err)
} else {
log.Println("Connection settings saved")
}
}
fmt.Println("\nDone! You can now run the server with: go run ./cmd/server")
fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs")
}
func derefUint(v *uint) uint {

View File

@@ -10,7 +10,8 @@ import (
"sort"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/google/uuid"
"gorm.io/driver/mysql"
@@ -38,17 +39,29 @@ type migrationAction struct {
}
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
defaultLocalDBPath, err := appstate.ResolveDBPath("")
if err != nil {
log.Fatalf("failed to resolve default local SQLite path: %v", err)
}
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
apply := flag.Bool("apply", false, "apply migration (default is preview only)")
yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)")
flag.Parse()
cfg, err := config.Load(*configPath)
local, err := localdb.New(*localDBPath)
if err != nil {
log.Fatalf("failed to load config: %v", err)
log.Fatalf("failed to initialize local database: %v", err)
}
if !local.HasSettings() {
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
}
dsn, err := local.GetDSN()
if err != nil {
log.Fatalf("failed to build DSN from SQLite settings: %v", err)
}
dbUser := strings.TrimSpace(local.GetDBUser())
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
@@ -59,7 +72,7 @@ func main() {
log.Fatalf("precheck failed: %v", err)
}
actions, existingProjects, err := buildPlan(db, cfg.Database.User)
actions, existingProjects, err := buildPlan(db, dbUser)
if err != nil {
log.Fatalf("failed to build migration plan: %v", err)
}

View File

@@ -0,0 +1,66 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"git.mchus.pro/mchus/quoteforge/internal/config"
)
func TestMigrateConfigFileToRuntimeShapeDropsDeprecatedSections(t *testing.T) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
legacy := `server:
host: "0.0.0.0"
port: 9191
database:
host: "legacy-db"
port: 3306
name: "RFQ_LOG"
user: "old"
password: "REDACTED_TEST_PASSWORD"
pricing:
default_method: "median"
logging:
level: "debug"
format: "text"
output: "stdout"
`
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
t.Fatalf("write legacy config: %v", err)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("load legacy config: %v", err)
}
setConfigDefaults(cfg)
if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil {
t.Fatalf("migrate config: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read migrated config: %v", err)
}
text := string(got)
if strings.Contains(text, "database:") {
t.Fatalf("migrated config still contains deprecated database section:\n%s", text)
}
if strings.Contains(text, "pricing:") {
t.Fatalf("migrated config still contains deprecated pricing section:\n%s", text)
}
if !strings.Contains(text, "server:") || !strings.Contains(text, "logging:") {
t.Fatalf("migrated config missing required sections:\n%s", text)
}
if !strings.Contains(text, "port: 9191") {
t.Fatalf("migrated config did not preserve server port:\n%s", text)
}
if !strings.Contains(text, "level: debug") {
t.Fatalf("migrated config did not preserve logging level:\n%s", text)
}
}

View File

@@ -1,6 +1,7 @@
package main
import (
"bytes"
"context"
"errors"
"flag"
@@ -17,6 +18,7 @@ import (
"sort"
"strconv"
"strings"
syncpkg "sync"
"syscall"
"time"
@@ -31,11 +33,9 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"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"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -45,6 +45,7 @@ import (
var Version = "dev"
const backgroundSyncInterval = 5 * time.Minute
const onDemandPullCooldown = 30 * time.Second
func main() {
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
@@ -63,18 +64,18 @@ func main() {
slog.Info("starting qfs", "version", Version, "executable", exePath)
appmeta.SetVersion(Version)
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
if err != nil {
slog.Error("failed to resolve config path", "error", err)
os.Exit(1)
}
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
if err != nil {
slog.Error("failed to resolve local database path", "error", err)
os.Exit(1)
}
resolvedConfigPath, err := appstate.ResolveConfigPathNearDB(*configPath, resolvedLocalDBPath)
if err != nil {
slog.Error("failed to resolve config path", "error", err)
os.Exit(1)
}
// Migrate legacy project-local config path to the user state directory when using defaults.
if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" {
migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"})
@@ -114,6 +115,10 @@ func main() {
}
// Load config for server settings (optional)
if err := ensureDefaultConfigFile(resolvedConfigPath); err != nil {
slog.Error("failed to ensure default config file", "path", resolvedConfigPath, "error", err)
os.Exit(1)
}
cfg, err := config.Load(resolvedConfigPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
@@ -126,6 +131,10 @@ func main() {
}
}
setConfigDefaults(cfg)
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
os.Exit(1)
}
slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath)
setupLogger(cfg.Logging)
@@ -207,6 +216,15 @@ func main() {
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)
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
@@ -308,6 +326,96 @@ func setConfigDefaults(cfg *config.Config) {
}
}
func ensureDefaultConfigFile(configPath string) error {
if strings.TrimSpace(configPath) == "" {
return fmt.Errorf("config path is empty")
}
if _, err := os.Stat(configPath); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return err
}
const defaultConfigYAML = `server:
host: "127.0.0.1"
port: 8080
mode: "release"
read_timeout: 30s
write_timeout: 30s
logging:
level: "info"
format: "json"
output: "stdout"
`
if err := os.WriteFile(configPath, []byte(defaultConfigYAML), 0644); err != nil {
return err
}
slog.Info("created default config file", "path", configPath)
return nil
}
type runtimeServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Mode string `yaml:"mode"`
ReadTimeout time.Duration `yaml:"read_timeout"`
WriteTimeout time.Duration `yaml:"write_timeout"`
}
type runtimeLoggingConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
Output string `yaml:"output"`
}
type runtimeConfigFile struct {
Server runtimeServerConfig `yaml:"server"`
Logging runtimeLoggingConfig `yaml:"logging"`
}
// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
// Deprecated sections from legacy configs are intentionally dropped.
func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
runtimeCfg := runtimeConfigFile{
Server: runtimeServerConfig{
Host: cfg.Server.Host,
Port: cfg.Server.Port,
Mode: cfg.Server.Mode,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
},
Logging: runtimeLoggingConfig{
Level: cfg.Logging.Level,
Format: cfg.Logging.Format,
Output: cfg.Logging.Output,
},
}
rendered, err := yaml.Marshal(&runtimeCfg)
if err != nil {
return fmt.Errorf("marshal runtime config: %w", err)
}
current, err := os.ReadFile(configPath)
if err == nil && bytes.Equal(bytes.TrimSpace(current), bytes.TrimSpace(rendered)) {
return nil
}
if err := os.WriteFile(configPath, rendered, 0644); err != nil {
return fmt.Errorf("write runtime config: %w", err)
}
slog.Info("migrated config.yaml to runtime format", "path", configPath)
return nil
}
// runSetupMode starts a minimal server that only serves the setup page
func runSetupMode(local *localdb.LocalDB) {
restartSig := make(chan struct{}, 1)
@@ -446,8 +554,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Repositories
var componentRepo *repository.ComponentRepository
var categoryRepo *repository.CategoryRepository
var priceRepo *repository.PriceRepository
var alertRepo *repository.AlertRepository
var statsRepo *repository.StatsRepository
var pricelistRepo *repository.PricelistRepository
@@ -455,8 +561,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if mariaDB != nil {
componentRepo = repository.NewComponentRepository(mariaDB)
categoryRepo = repository.NewCategoryRepository(mariaDB)
priceRepo = repository.NewPriceRepository(mariaDB)
alertRepo = repository.NewAlertRepository(mariaDB)
statsRepo = repository.NewStatsRepository(mariaDB)
pricelistRepo = repository.NewPricelistRepository(mariaDB)
} else {
@@ -465,13 +569,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
// Services
var pricingService *pricing.Service
var componentService *services.ComponentService
var quoteService *services.QuoteService
var exportService *services.ExportService
var alertService *alerts.Service
var pricelistService *pricelist.Service
var stockImportService *services.StockImportService
var syncService *sync.Service
var projectService *services.ProjectService
@@ -479,22 +579,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
syncService = sync.NewService(connMgr, local)
if mariaDB != nil {
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
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)
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService)
stockImportService = services.NewStockImportService(mariaDB, pricelistService)
} else {
// In offline mode, we still need to create services that don't require DB
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
// In offline mode, we still need to create services that don't require DB.
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)
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
@@ -526,20 +618,75 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
}
syncProjectsFromServer := func() {
if !connMgr.IsOnline() {
type pullState struct {
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
}
if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) {
slog.Warn("failed to sync projects from server", "error", err)
if !state.lastStarted.IsZero() && time.Since(state.lastStarted) < onDemandPullCooldown {
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() {
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
@@ -549,17 +696,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(
mariaDB,
pricingService,
alertService,
componentRepo,
priceRepo,
statsRepo,
stockImportService,
local.GetDBUser(),
)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
pricelistHandler := handlers.NewPricelistHandler(local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
@@ -615,28 +752,39 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) {
var lotCount, lotLogCount, metadataCount int64
var dbOK bool = false
var dbOK bool
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()
if status.IsConnected {
// Already connected, safe to use
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
dbOK = status.IsConnected
if !status.IsConnected {
dbError = "Database not connected (offline mode)"
if 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{
"connected": dbOK,
"error": dbError,
@@ -667,12 +815,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.GET("/configurator", webHandler.Configurator)
router.GET("/projects", webHandler.Projects)
router.GET("/projects/:uuid", webHandler.ProjectDetail)
router.GET("/pricelists", func(c *gin.Context) {
// Redirect to admin/pricing with pricelists tab
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
})
router.GET("/pricelists", webHandler.Pricelists)
router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/admin/pricing", webHandler.AdminPricing)
// htmx partials
partials := router.Group("/partials")
@@ -716,21 +860,17 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricelists := api.Group("/pricelists")
{
pricelists.GET("", pricelistHandler.List)
pricelists.GET("/can-write", pricelistHandler.CanWrite)
pricelists.GET("/latest", pricelistHandler.GetLatest)
pricelists.GET("/:id", pricelistHandler.Get)
pricelists.GET("/:id/items", pricelistHandler.GetItems)
pricelists.POST("", pricelistHandler.Create)
pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress)
pricelists.PATCH("/:id/active", pricelistHandler.SetActive)
pricelists.DELETE("/:id", pricelistHandler.Delete)
pricelists.GET("/:id/lots", pricelistHandler.GetLotNames)
}
// Configurations (public - RBAC disabled)
configs := api.Group("/configs")
{
configs.GET("", func(c *gin.Context) {
syncConfigurationsFromServer()
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
@@ -1012,19 +1152,22 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
"current_version": currentVersion,
})
})
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
}
projects := api.Group("/projects")
{
projects.GET("", func(c *gin.Context) {
syncProjectsFromServer()
syncConfigurationsFromServer()
triggerPull("projects", &projectsPullState, syncProjectsFromServer)
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
status := c.DefaultQuery("status", "active")
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
author := strings.ToLower(strings.TrimSpace(c.Query("author")))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "10"))
// Return all projects by default (set high limit for configs to reference)
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "1000"))
sortField := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sort", "created_at")))
sortDir := strings.ToLower(strings.TrimSpace(c.DefaultQuery("dir", "desc")))
if status != "active" && status != "archived" && status != "all" {
@@ -1127,17 +1270,26 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
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))
for i := range paged {
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{
"id": p.ID,
"uuid": p.UUID,
@@ -1148,8 +1300,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
"is_system": p.IsSystem,
"created_at": p.CreatedAt,
"updated_at": p.UpdatedAt,
"config_count": len(configs.Configs),
"total": configs.Total,
"config_count": projectConfigCount[p.UUID],
"total": projectConfigTotal[p.UUID],
})
}
@@ -1167,6 +1319,32 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
})
})
// GET /api/projects/all - Returns all projects without pagination for UI dropdowns
projects.GET("/all", func(c *gin.Context) {
allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return simplified list of all projects (UUID + Name only)
type ProjectSimple struct {
UUID string `json:"uuid"`
Name string `json:"name"`
}
simplified := make([]ProjectSimple, 0, len(allProjects))
for _, p := range allProjects {
simplified = append(simplified, ProjectSimple{
UUID: p.UUID,
Name: p.Name,
})
}
c.JSON(http.StatusOK, simplified)
})
projects.POST("", func(c *gin.Context) {
var req services.CreateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -1257,7 +1435,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
})
projects.GET("/:uuid/configs", func(c *gin.Context) {
syncConfigurationsFromServer()
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
status := c.DefaultQuery("status", "active")
if status != "active" && status != "archived" && status != "all" {
@@ -1317,34 +1495,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.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)
syncAPI := api.Group("/sync")
{
syncAPI.GET("/status", syncHandler.GetStatus)
syncAPI.GET("/readiness", syncHandler.GetReadiness)
syncAPI.GET("/info", syncHandler.GetInfo)
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
syncAPI.POST("/components", syncHandler.SyncComponents)

15
crontab
View File

@@ -1,15 +0,0 @@
# Cron jobs for QuoteForge
# Run alerts check every hour
0 * * * * /app/quoteforge-cron -job=alerts
# Run price updates daily at 2 AM
0 2 * * * /app/quoteforge-cron -job=update-prices
# Reset weekly counters every Sunday at 1 AM
0 1 * * 0 /app/quoteforge-cron -job=reset-counters
# Update popularity scores daily at 3 AM
0 3 * * * /app/quoteforge-cron -job=update-popularity
# Log rotation (optional)
# 0 0 * * * /usr/bin/logrotate /etc/logrotate.conf

297
csv_export.md Normal file
View File

@@ -0,0 +1,297 @@
# CSV Export Pattern (Go + GORM)
## Архитектура (3-слойная)
### 1. Handler Layer (HTTP)
**Задачи**: Обработка HTTP-запроса, установка заголовков, инициация экспорта
```go
func (h *PricelistHandler) ExportCSV(c *gin.Context) {
// 1. Валидация параметров
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
// 2. Получение метаданных для формирования имени файла
pl, err := h.service.GetByID(uint(id))
// 3. Установка HTTP-заголовков для скачивания
filename := fmt.Sprintf("pricelist_%s.csv", pl.Version)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
// 4. UTF-8 BOM для Excel-совместимости
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
// 5. Настройка CSV writer
writer := csv.NewWriter(c.Writer)
writer.Comma = ';' // Точка с запятой для Excel
defer writer.Flush()
// 6. Динамические заголовки (зависят от типа данных)
isWarehouse := strings.ToLower(pl.Source) == "warehouse"
var header []string
if isWarehouse {
header = []string{"Артикул", "Категория", "Описание", "Доступно", "Partnumbers", "Цена, $", "Настройки"}
} else {
header = []string{"Артикул", "Категория", "Описание", "Цена, $", "Настройки"}
}
writer.Write(header)
// 7. Streaming в batches через callback
err = h.service.StreamItemsForExport(uint(id), 500, func(items []models.PricelistItem) error {
for _, item := range items {
row := buildRow(item, isWarehouse)
if err := writer.Write(row); err != nil {
return err
}
}
writer.Flush() // Flush после каждого batch
return nil
})
}
```
### 2. Service Layer
**Задачи**: Оркестрация, делегирование в репозиторий
```go
func (s *Service) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot stream pricelist items")
}
return s.repo.StreamItemsForExport(pricelistID, batchSize, callback)
}
```
### 3. Repository Layer (Критичный)
**Задачи**: Batch-загрузка из БД, оптимизация запросов, enrichment
```go
func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
if batchSize <= 0 {
batchSize = 500 // Default batch size
}
// Проверка типа pricelist для conditional enrichment
var pl models.Pricelist
isWarehouse := false
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil {
isWarehouse = pl.Source == string(models.PricelistSourceWarehouse)
}
offset := 0
for {
var items []models.PricelistItem
// ⚡ КЛЮЧЕВОЙ МОМЕНТ: JOIN для избежания N+1 запросов
err := r.db.Table("qt_pricelist_items AS pi").
Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category").
Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name").
Where("pi.pricelist_id = ?", pricelistID).
Order("pi.lot_name").
Offset(offset).
Limit(batchSize).
Scan(&items).Error
if err != nil || len(items) == 0 {
break
}
// Conditional enrichment для warehouse данных
if isWarehouse {
r.enrichWarehouseItems(items) // Добавление qty, partnumbers
}
// Вызов callback для обработки batch
if err := callback(items); err != nil {
return err
}
if len(items) < batchSize {
break // Последний batch
}
offset += batchSize
}
return nil
}
```
## Ключевые паттерны
### 1. Streaming (не загружать все в память)
```go
// ❌ НЕ ТАК:
var allItems []Item
db.Find(&allItems) // Может упасть на миллионах записей
// ✅ ТАК:
for offset := 0; ; offset += batchSize {
var batch []Item
db.Offset(offset).Limit(batchSize).Find(&batch)
processBatch(batch)
if len(batch) < batchSize {
break
}
}
```
### 2. Callback Pattern для гибкости
```go
// Service не знает о CSV - может использоваться для любого экспорта
func StreamItems(callback func([]Item) error) error
```
### 3. JOIN для избежания N+1
```go
// ❌ N+1 problem:
items := getItems()
for _, item := range items {
description := getLotDescription(item.LotName) // N запросов
}
// ✅ JOIN:
db.Table("items AS i").
Select("i.*, COALESCE(l.description, '') AS description").
Joins("LEFT JOIN lots AS l ON l.name = i.lot_name")
```
### 4. UTF-8 BOM для Excel
```go
// Excel на Windows требует BOM для корректного отображения UTF-8
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
```
### 5. Точка с запятой для Excel
```go
writer := csv.NewWriter(c.Writer)
writer.Comma = ';' // Excel в русской локали использует ;
```
### 6. Graceful Error Handling
```go
// После начала streaming нельзя вернуть JSON
if err != nil {
// Уже начали писать CSV, поэтому пишем текст
c.String(http.StatusInternalServerError, "Export failed: %v", err)
return
}
```
## Conditional Enrichment Pattern
```go
// Для warehouse прайслистов добавляем дополнительные поля
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
// 1. Собрать уникальные lot_names
lots := make([]string, 0, len(items))
seen := make(map[string]struct{})
for _, item := range items {
if _, ok := seen[item.LotName]; !ok {
lots = append(lots, item.LotName)
seen[item.LotName] = struct{}{}
}
}
// 2. Batch-загрузка метрик (qty, partnumbers)
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
// 3. Обогащение items
for i := range items {
if qty, ok := qtyByLot[items[i].LotName]; ok {
items[i].AvailableQty = &qty
}
items[i].Partnumbers = partnumbersByLot[items[i].LotName]
}
return nil
}
```
## Virtual Fields Pattern
```go
type PricelistItem struct {
// Stored fields
ID uint `gorm:"primaryKey"`
LotName string `gorm:"size:255"`
Price float64 `gorm:"type:decimal(12,2)"`
// Virtual fields (populated via JOIN or programmatically)
LotDescription string `gorm:"-:migration" json:"lot_description,omitempty"`
Category string `gorm:"-:migration" json:"category,omitempty"`
AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"`
Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"`
}
```
- `gorm:"-:migration"` - не создавать колонку в БД, но маппить при SELECT
- `gorm:"-"` - полностью игнорировать при БД операциях
## Checklist для CSV Export
- [ ] HTTP заголовки: Content-Type, Content-Disposition
- [ ] UTF-8 BOM для Excel (0xEF, 0xBB, 0xBF)
- [ ] Разделитель (`;` для русской локали Excel)
- [ ] Streaming с batch processing (не загружать всё в память)
- [ ] JOIN для избежания N+1 запросов
- [ ] Flush после каждого batch
- [ ] Graceful error handling (нельзя JSON после начала streaming)
- [ ] Динамические заголовки (если нужно)
- [ ] Conditional enrichment (если данные зависят от типа)
## Когда использовать этот паттерн
**Используй когда:**
- Экспорт больших датасетов (>1000 записей)
- Нужна Excel-совместимость
- Связанные данные из нескольких таблиц
- Conditional логика enrichment
**Не нужен когда:**
- Малые датасеты (<100 записей) - можно загрузить всё сразу
- Экспорт JSON/XML - другие подходы
- Нет связанных данных - можно упростить
## Пример роутинга (Gin)
```go
// В файле роутера
func SetupRoutes(router *gin.Engine, handler *PricelistHandler) {
api := router.Group("/api")
{
pricelists := api.Group("/pricelists")
{
pricelists.GET("/:id/export", handler.ExportCSV)
}
}
}
```
## Импорты
```go
import (
"encoding/csv"
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
```
## Performance Notes
1. **Batch Size**: 500-1000 оптимально для большинства случаев
2. **JOIN vs N+1**: JOIN на порядки быстрее при >100 записях
3. **Memory**: Streaming позволяет экспортировать миллионы записей с минимальной памятью
4. **Indexes**: Убедись что есть индексы на JOIN колонках
## Источник
Реализовано в проекте PriceForge:
- Handler: `internal/handlers/pricelist.go:245-346`
- Service: `internal/services/pricelist/service.go:373-379`
- Repository: `internal/repository/pricelist.go:475-533`
- Models: `internal/models/pricelist.go`

BIN
dist/qfs-darwin-amd64 vendored Executable file

Binary file not shown.

BIN
dist/qfs-darwin-arm64 vendored Executable file

Binary file not shown.

BIN
dist/qfs-linux-amd64 vendored Executable file

Binary file not shown.

BIN
dist/qfs-windows-amd64.exe vendored Executable file

Binary file not shown.

View File

@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
)
const (
@@ -55,6 +56,25 @@ func ResolveConfigPath(explicitPath string) (string, error) {
return filepath.Join(dir, defaultCfg), nil
}
// ResolveConfigPathNearDB returns config path using priority:
// explicit CLI path > QFS_CONFIG_PATH > directory of resolved local DB path.
// Falls back to ResolveConfigPath when dbPath is empty.
func ResolveConfigPathNearDB(explicitPath, dbPath string) (string, error) {
if explicitPath != "" {
return filepath.Clean(explicitPath), nil
}
if fromEnv := os.Getenv(envCfgPath); fromEnv != "" {
return filepath.Clean(fromEnv), nil
}
if strings.TrimSpace(dbPath) != "" {
return filepath.Join(filepath.Dir(filepath.Clean(dbPath)), defaultCfg), nil
}
return ResolveConfigPath("")
}
// MigrateLegacyDB copies an existing legacy DB (and optional SQLite sidecars)
// to targetPath if targetPath does not already exist.
// Returns source path if migration happened.

View File

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

View File

@@ -29,8 +29,9 @@ func NewExportHandler(
}
type ExportRequest struct {
Name string `json:"name" binding:"required"`
Items []struct {
Name string `json:"name" binding:"required"`
ProjectName string `json:"project_name"`
Items []struct {
LotName string `json:"lot_name" binding:"required"`
Quantity int `json:"quantity" binding:"required,min=1"`
UnitPrice float64 `json:"unit_price"`
@@ -47,15 +48,26 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
data := h.buildExportData(&req)
csvData, err := h.exportService.ToCSV(data)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
// Validate before streaming (can return JSON error)
if len(data.Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
return
}
filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name)
// Set headers before streaming
projectName := req.ProjectName
if projectName == "" {
projectName = req.Name
}
filename := fmt.Sprintf("%s (%s) %s BOM.csv", time.Now().Format("2006-01-02"), projectName, req.Name)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
// Stream CSV (cannot return JSON after this point)
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
c.Error(err) // Log only
return
}
}
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
@@ -101,6 +113,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
// Get config before streaming (can return JSON error)
config, err := h.configService.GetByUUID(uuid, username)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
@@ -109,13 +122,21 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
data := h.exportService.ConfigToExportData(config, h.componentService)
csvData, err := h.exportService.ToCSV(data)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
// Validate before streaming (can return JSON error)
if len(data.Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
return
}
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name)
// Set headers before streaming
// For config export, use config name for both project and quotation name
filename := fmt.Sprintf("%s (%s) %s BOM.csv", config.CreatedAt.Format("2006-01-02"), config.Name, config.Name)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
// Stream CSV (cannot return JSON after this point)
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
c.Error(err) // Log only
return
}
}

View File

@@ -0,0 +1,308 @@
package handlers
import (
"bytes"
"encoding/csv"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
// Mock services for testing
type mockConfigService struct {
config *models.Configuration
err error
}
func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
return m.config, m.err
}
func TestExportCSV_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
// Create a basic mock component service that doesn't panic
mockComponentService := &services.ComponentService{}
// Create handler with mocks
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{},
mockComponentService,
)
// Create JSON request body
jsonBody := `{
"name": "Test Export",
"items": [
{
"lot_name": "LOT-001",
"quantity": 2,
"unit_price": 100.50
}
],
"notes": "Test notes"
}`
// Create HTTP request
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(jsonBody))
req.Header.Set("Content-Type", "application/json")
// Create response recorder
w := httptest.NewRecorder()
// Create Gin context
c, _ := gin.CreateTestContext(w)
c.Request = req
// Call handler
handler.ExportCSV(c)
// Check status code
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check Content-Type header
contentType := w.Header().Get("Content-Type")
if contentType != "text/csv; charset=utf-8" {
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
}
// Check for BOM
responseBody := w.Body.Bytes()
if len(responseBody) < 3 {
t.Fatalf("Response too short to contain BOM")
}
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
actualBOM := responseBody[:3]
if bytes.Compare(actualBOM, expectedBOM) != 0 {
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
}
// Check semicolon delimiter in CSV
reader := csv.NewReader(bytes.NewReader(responseBody[3:]))
reader.Comma = ';'
header, err := reader.Read()
if err != nil {
t.Errorf("Failed to parse CSV header: %v", err)
}
if len(header) != 6 {
t.Errorf("Expected 6 columns, got %d", len(header))
}
}
func TestExportCSV_InvalidRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{},
&services.ComponentService{},
)
// Create invalid request (missing required field)
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name": "Test"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
handler.ExportCSV(c)
// Should return 400 Bad Request
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
// Should return JSON error
var errResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &errResp)
if _, hasError := errResp["error"]; !hasError {
t.Errorf("Expected error in JSON response")
}
}
func TestExportCSV_EmptyItems(t *testing.T) {
gin.SetMode(gin.TestMode)
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{},
&services.ComponentService{},
)
// Create request with empty items array - should fail binding validation
req, _ := http.NewRequest("POST", "/api/export/csv", bytes.NewBufferString(`{"name":"Empty Export","items":[],"notes":""}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
handler.ExportCSV(c)
// Should return 400 Bad Request (validation error from gin binding)
if w.Code != http.StatusBadRequest {
t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
}
}
func TestExportConfigCSV_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
// Mock configuration
mockConfig := &models.Configuration{
UUID: "test-uuid",
Name: "Test Config",
OwnerUsername: "testuser",
Items: models.ConfigItems{
{
LotName: "LOT-001",
Quantity: 1,
UnitPrice: 100.0,
},
},
CreatedAt: time.Now(),
}
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{config: mockConfig},
&services.ComponentService{},
)
// Create HTTP request
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Params = gin.Params{
{Key: "uuid", Value: "test-uuid"},
}
// Mock middleware.GetUsername
c.Set("username", "testuser")
handler.ExportConfigCSV(c)
// Check status code
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check Content-Type header
contentType := w.Header().Get("Content-Type")
if contentType != "text/csv; charset=utf-8" {
t.Errorf("Expected Content-Type 'text/csv; charset=utf-8', got %q", contentType)
}
// Check for BOM
responseBody := w.Body.Bytes()
if len(responseBody) < 3 {
t.Fatalf("Response too short to contain BOM")
}
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
actualBOM := responseBody[:3]
if bytes.Compare(actualBOM, expectedBOM) != 0 {
t.Errorf("UTF-8 BOM mismatch")
}
}
func TestExportConfigCSV_NotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{err: errors.New("config not found")},
&services.ComponentService{},
)
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Params = gin.Params{
{Key: "uuid", Value: "nonexistent-uuid"},
}
c.Set("username", "testuser")
handler.ExportConfigCSV(c)
// Should return 404 Not Found
if w.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", w.Code)
}
// Should return JSON error
var errResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &errResp)
if _, hasError := errResp["error"]; !hasError {
t.Errorf("Expected error in JSON response")
}
}
func TestExportConfigCSV_EmptyItems(t *testing.T) {
gin.SetMode(gin.TestMode)
// Mock configuration with empty items
mockConfig := &models.Configuration{
UUID: "test-uuid",
Name: "Empty Config",
OwnerUsername: "testuser",
Items: models.ConfigItems{},
CreatedAt: time.Now(),
}
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{config: mockConfig},
&services.ComponentService{},
)
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Params = gin.Params{
{Key: "uuid", Value: "test-uuid"},
}
c.Set("username", "testuser")
handler.ExportConfigCSV(c)
// Should return 400 Bad Request
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
// Should return JSON error
var errResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &errResp)
if _, hasError := errResp["error"]; !hasError {
t.Errorf("Expected error in JSON response")
}
}

View File

@@ -1,99 +1,94 @@
package handlers
import (
"errors"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"github.com/gin-gonic/gin"
)
type PricelistHandler struct {
service *pricelist.Service
localDB *localdb.LocalDB
}
func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *PricelistHandler {
return &PricelistHandler{service: service, localDB: localDB}
func NewPricelistHandler(localDB *localdb.LocalDB) *PricelistHandler {
return &PricelistHandler{localDB: localDB}
}
// List returns all pricelists with pagination
// List returns all pricelists with pagination.
func (h *PricelistHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
activeOnly := c.DefaultQuery("active_only", "false") == "true"
source := c.Query("source")
var (
pricelists any
total int64
err error
)
if activeOnly {
pricelists, total, err = h.service.ListActiveBySource(page, perPage, source)
} else {
pricelists, total, err = h.service.ListBySource(page, perPage, source)
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
source := c.Query("source")
activeOnly := c.DefaultQuery("active_only", "false") == "true"
localPLs, err := h.localDB.GetLocalPricelists()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// If offline (empty list), fallback to local pricelists
if total == 0 && h.localDB != nil {
localPLs, err := h.localDB.GetLocalPricelists()
if err == nil && len(localPLs) > 0 {
if source != "" {
filtered := localPLs[:0]
for _, lpl := range localPLs {
if lpl.Source == source {
filtered = append(filtered, lpl)
}
}
localPLs = filtered
if source != "" {
filtered := localPLs[:0]
for _, lpl := range localPLs {
if strings.EqualFold(lpl.Source, source) {
filtered = append(filtered, lpl)
}
// Convert to PricelistSummary format
summaries := make([]map[string]interface{}, len(localPLs))
for i, lpl := range localPLs {
summaries[i] = map[string]interface{}{
"id": lpl.ServerID,
"source": lpl.Source,
"version": lpl.Version,
"created_by": "sync",
"item_count": 0, // Not tracked
"usage_count": 0, // Not tracked in local
"is_active": true,
"created_at": lpl.CreatedAt,
"synced_from": "local",
}
}
c.JSON(http.StatusOK, gin.H{
"pricelists": summaries,
"total": len(summaries),
"page": page,
"per_page": perPage,
"offline": true,
})
return
}
localPLs = filtered
}
if activeOnly {
// Local cache stores only active snapshots for normal operations.
}
sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) })
total := len(localPLs)
start := (page - 1) * perPage
if start > total {
start = total
}
end := start + perPage
if end > total {
end = total
}
pageSlice := localPLs[start:end]
summaries := make([]map[string]interface{}, 0, len(pageSlice))
for _, lpl := range pageSlice {
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
usageCount := 0
if lpl.IsUsed {
usageCount = 1
}
summaries = append(summaries, map[string]interface{}{
"id": lpl.ServerID,
"source": lpl.Source,
"version": lpl.Version,
"created_by": "sync",
"item_count": itemCount,
"usage_count": usageCount,
"is_active": true,
"created_at": lpl.CreatedAt,
"synced_from": "local",
})
}
c.JSON(http.StatusOK, gin.H{
"pricelists": pricelists,
"pricelists": summaries,
"total": total,
"page": page,
"per_page": perPage,
})
}
// Get returns a single pricelist by ID
// Get returns a single pricelist by ID.
func (h *PricelistHandler) Get(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
@@ -102,210 +97,25 @@ func (h *PricelistHandler) Get(c *gin.Context) {
return
}
pl, err := h.service.GetByID(uint(id))
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
return
}
c.JSON(http.StatusOK, pl)
}
// Create creates a new pricelist from current prices
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
}
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) {
sendProgress(gin.H{
"current": p.Current,
"total": p.Total,
"status": p.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
}
sendProgress(gin.H{
"current": 4,
"total": 4,
"status": "completed",
"message": "Готово",
"pricelist": pl,
c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID,
"source": localPL.Source,
"version": localPL.Version,
"created_by": "sync",
"item_count": h.localDB.CountLocalPricelistItems(localPL.ID),
"is_active": true,
"created_at": localPL.CreatedAt,
"synced_from": "local",
})
}
// 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
}
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
}
c.JSON(http.StatusOK, gin.H{"message": "updated", "is_active": req.IsActive})
}
// GetItems returns items for a pricelist with pagination
// GetItems returns items for a pricelist with pagination.
func (h *PricelistHandler) GetItems(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
@@ -318,67 +128,106 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
search := c.Query("search")
items, total, err := h.service.GetItems(uint(id), page, perPage, search)
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
return
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
var items []localdb.LocalPricelistItem
dbq := h.localDB.DB().Model(&localdb.LocalPricelistItem{}).Where("pricelist_id = ?", localPL.ID)
if strings.TrimSpace(search) != "" {
dbq = dbq.Where("lot_name LIKE ?", "%"+strings.TrimSpace(search)+"%")
}
var total int64
if err := dbq.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
pl, _ := h.service.GetByID(uint(id))
source := ""
if pl != nil {
source = pl.Source
offset := (page - 1) * perPage
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resultItems := make([]gin.H, 0, len(items))
for _, item := range items {
category := ""
if parts := strings.SplitN(item.LotName, "_", 2); len(parts) > 0 {
category = parts[0]
}
resultItems = append(resultItems, gin.H{
"id": item.ID,
"lot_name": item.LotName,
"price": item.Price,
"category": category,
"available_qty": item.AvailableQty,
"partnumbers": []string(item.Partnumbers),
})
}
c.JSON(http.StatusOK, gin.H{
"source": source,
"items": items,
"source": localPL.Source,
"items": resultItems,
"total": total,
"page": page,
"per_page": perPage,
})
}
// CanWrite returns whether the current user can create pricelists
func (h *PricelistHandler) CanWrite(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
c.JSON(http.StatusOK, gin.H{"can_write": canWrite, "debug": debugInfo})
func (h *PricelistHandler) GetLotNames(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
return
}
items, err := h.localDB.GetLocalPricelistItems(localPL.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
lotNames := make([]string, 0, len(items))
for _, item := range items {
lotNames = append(lotNames, item.LotName)
}
sort.Strings(lotNames)
c.JSON(http.StatusOK, gin.H{
"lot_names": lotNames,
"total": len(lotNames),
})
}
// GetLatest returns the most recent active pricelist
// GetLatest returns the most recent active pricelist.
func (h *PricelistHandler) GetLatest(c *gin.Context) {
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
source = string(models.NormalizePricelistSource(source))
// Try to get from server first
pl, err := h.service.GetLatestActiveBySource(source)
localPL, err := h.localDB.GetLatestLocalPricelistBySource(source)
if err != nil {
// If offline or no server pricelists, try to get from local cache
if h.localDB == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
return
}
localPL, localErr := h.localDB.GetLatestLocalPricelistBySource(source)
if localErr != nil {
// No local pricelists either
c.JSON(http.StatusNotFound, gin.H{
"error": "no pricelists available",
"local_error": localErr.Error(),
})
return
}
// Return local pricelist
c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID,
"source": localPL.Source,
"version": localPL.Version,
"created_by": "sync",
"item_count": 0, // Not tracked in local pricelists
"is_active": true,
"created_at": localPL.CreatedAt,
"synced_from": "local",
})
c.JSON(http.StatusNotFound, gin.H{"error": "no pricelists available"})
return
}
c.JSON(http.StatusOK, pl)
c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID,
"source": localPL.Source,
"version": localPL.Version,
"created_by": "sync",
"item_count": h.localDB.CountLocalPricelistItems(localPL.ID),
"is_active": true,
"created_at": localPL.CreatedAt,
"synced_from": "local",
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,14 @@
package handlers
import (
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
"os"
"path/filepath"
stdsync "sync"
"time"
qfassets "git.mchus.pro/mchus/quoteforge"
@@ -23,6 +26,9 @@ type SyncHandler struct {
autoSyncInterval time.Duration
onlineGraceFactor float64
tmpl *template.Template
readinessMu stdsync.Mutex
readinessCached *sync.SyncReadiness
readinessCachedAt time.Time
}
// NewSyncHandler creates a new sync handler
@@ -52,14 +58,24 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
// SyncStatusResponse represents the sync status
type SyncStatusResponse struct {
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
}
type SyncReadinessResponse struct {
Status string `json:"status"`
Blocked bool `json:"blocked"`
ReasonCode string `json:"reason_code,omitempty"`
ReasonText string `json:"reason_text,omitempty"`
RequiredMinAppVersion *string `json:"required_min_app_version,omitempty"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
}
// GetStatus returns current sync status
@@ -89,6 +105,7 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
// Check if component sync is needed (older than 24 hours)
needComponentSync := h.localDB.NeedComponentSync(24)
readiness := h.getReadinessCached(10 * time.Second)
c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync,
@@ -99,9 +116,63 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync,
Readiness: readiness,
})
}
// GetReadiness returns sync readiness guard status.
// GET /api/sync/readiness
func (h *SyncHandler) GetReadiness(c *gin.Context) {
readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
if readiness == nil {
c.JSON(http.StatusOK, SyncReadinessResponse{Status: sync.ReadinessUnknown, Blocked: false})
return
}
c.JSON(http.StatusOK, SyncReadinessResponse{
Status: readiness.Status,
Blocked: readiness.Blocked,
ReasonCode: readiness.ReasonCode,
ReasonText: readiness.ReasonText,
RequiredMinAppVersion: readiness.RequiredMinAppVersion,
LastCheckedAt: readiness.LastCheckedAt,
})
}
func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool {
readiness, err := h.syncService.EnsureReadinessForSync()
if err == nil {
return true
}
blocked := &sync.SyncBlockedError{}
if errors.As(err, &blocked) {
c.JSON(http.StatusLocked, gin.H{
"success": false,
"error": blocked.Error(),
"reason_code": blocked.Readiness.ReasonCode,
"reason_text": blocked.Readiness.ReasonText,
"required_min_app_version": blocked.Readiness.RequiredMinAppVersion,
"status": blocked.Readiness.Status,
"blocked": true,
"last_checked_at": blocked.Readiness.LastCheckedAt,
})
return false
}
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": err.Error(),
})
_ = readiness
return false
}
// SyncResultResponse represents sync operation result
type SyncResultResponse struct {
Success bool `json:"success"`
@@ -113,11 +184,7 @@ type SyncResultResponse struct {
// SyncComponents syncs components from MariaDB to local SQLite
// POST /api/sync/components
func (h *SyncHandler) SyncComponents(c *gin.Context) {
if !h.checkOnline() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
if !h.ensureSyncReadiness(c) {
return
}
@@ -152,11 +219,7 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
// SyncPricelists syncs pricelists from MariaDB to local SQLite
// POST /api/sync/pricelists
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
if !h.checkOnline() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
if !h.ensureSyncReadiness(c) {
return
}
@@ -201,11 +264,7 @@ type SyncAllResponse struct {
// - pull components, pricelists, projects, and configurations from server
// POST /api/sync/all
func (h *SyncHandler) SyncAll(c *gin.Context) {
if !h.checkOnline() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
if !h.ensureSyncReadiness(c) {
return
}
@@ -311,11 +370,7 @@ func (h *SyncHandler) checkOnline() bool {
// PushPendingChanges pushes all pending changes to the server
// POST /api/sync/push
func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
if !h.checkOnline() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
if !h.ensureSyncReadiness(c) {
return
}
@@ -364,12 +419,32 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
})
}
// SyncInfoResponse represents sync information
// SyncInfoResponse represents sync information for the modal
type SyncInfoResponse struct {
LastSyncAt *time.Time `json:"last_sync_at"`
IsOnline bool `json:"is_online"`
// Connection
DBHost string `json:"db_host"`
DBUser string `json:"db_user"`
DBName string `json:"db_name"`
// Status
IsOnline bool `json:"is_online"`
LastSyncAt *time.Time `json:"last_sync_at"`
// Statistics
LotCount int64 `json:"lot_count"`
LotLogCount int64 `json:"lot_log_count"`
ConfigCount int64 `json:"config_count"`
ProjectCount int64 `json:"project_count"`
// Pending changes
PendingChanges []localdb.PendingChange `json:"pending_changes"`
// Errors
ErrorCount int `json:"error_count"`
Errors []SyncError `json:"errors,omitempty"`
// Readiness guard
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
}
type SyncUsersStatusResponse struct {
@@ -392,31 +467,44 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
// Check online status by pinging MariaDB
isOnline := h.checkOnline()
// Get DB connection info
var dbHost, dbUser, dbName string
if settings, err := h.localDB.GetSettings(); err == nil {
dbHost = settings.Host + ":" + fmt.Sprintf("%d", settings.Port)
dbUser = settings.User
dbName = settings.Database
}
// Get sync times
lastPricelistSync := h.localDB.GetLastSyncTime()
// Get MariaDB counts (if online)
var lotCount, lotLogCount int64
if isOnline {
if mariaDB, err := h.connMgr.GetDB(); err == nil {
mariaDB.Table("lot").Count(&lotCount)
mariaDB.Table("lot_log").Count(&lotLogCount)
}
}
// Get local counts
configCount := h.localDB.CountConfigurations()
projectCount := h.localDB.CountProjects()
// Get error count (only changes with LastError != "")
errorCount := int(h.localDB.CountErroredChanges())
// Get recent errors (last 10)
// Get pending changes
changes, err := h.localDB.GetPendingChanges()
if err != nil {
slog.Error("failed to get pending changes for sync info", "error", err)
// Even if we can't get changes, we can still return the error count
c.JSON(http.StatusOK, SyncInfoResponse{
LastSyncAt: lastPricelistSync,
IsOnline: isOnline,
ErrorCount: errorCount,
Errors: []SyncError{}, // Return empty errors list
})
return
changes = []localdb.PendingChange{}
}
var errors []SyncError
var syncErrors []SyncError
for _, change := range changes {
// Check if there's a last error and it's not empty
if change.LastError != "" {
errors = append(errors, SyncError{
syncErrors = append(syncErrors, SyncError{
Timestamp: change.CreatedAt,
Message: change.LastError,
})
@@ -424,15 +512,26 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
}
// Limit to last 10 errors
if len(errors) > 10 {
errors = errors[:10]
if len(syncErrors) > 10 {
syncErrors = syncErrors[:10]
}
readiness := h.getReadinessCached(10 * time.Second)
c.JSON(http.StatusOK, SyncInfoResponse{
LastSyncAt: lastPricelistSync,
IsOnline: isOnline,
ErrorCount: errorCount,
Errors: errors,
DBHost: dbHost,
DBUser: dbUser,
DBName: dbName,
IsOnline: isOnline,
LastSyncAt: lastPricelistSync,
LotCount: lotCount,
LotLogCount: lotLogCount,
ConfigCount: configCount,
ProjectCount: projectCount,
PendingChanges: changes,
ErrorCount: errorCount,
Errors: syncErrors,
Readiness: readiness,
})
}
@@ -489,12 +588,21 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
// Get pending count
pendingCount := h.localDB.GetPendingCount()
readiness := h.getReadinessCached(10 * time.Second)
isBlocked := readiness != nil && readiness.Blocked
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount)
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
data := gin.H{
"IsOffline": isOffline,
"PendingCount": pendingCount,
"IsBlocked": isBlocked,
"BlockedReason": func() string {
if readiness == nil {
return ""
}
return readiness.ReasonText
}(),
}
c.Header("Content-Type", "text/html; charset=utf-8")
@@ -503,3 +611,24 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
c.String(http.StatusInternalServerError, "Template error: "+err.Error())
}
}
func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness {
h.readinessMu.Lock()
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
cached := *h.readinessCached
h.readinessMu.Unlock()
return &cached
}
h.readinessMu.Unlock()
readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil {
return nil
}
h.readinessMu.Lock()
h.readinessCached = readiness
h.readinessCachedAt = time.Now()
h.readinessMu.Unlock()
return readiness
}

View File

@@ -0,0 +1,64 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
)
func TestSyncReadinessOfflineBlocked(t *testing.T) {
gin.SetMode(gin.TestMode)
dir := t.TempDir()
local, err := localdb.New(filepath.Join(dir, "qfs.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
service := syncsvc.NewService(nil, local)
h, err := NewSyncHandler(local, service, nil, filepath.Join("web", "templates"), 5*time.Minute)
if err != nil {
t.Fatalf("new sync handler: %v", err)
}
router := gin.New()
router.GET("/api/sync/readiness", h.GetReadiness)
router.POST("/api/sync/push", h.PushPendingChanges)
readinessResp := httptest.NewRecorder()
readinessReq, _ := http.NewRequest(http.MethodGet, "/api/sync/readiness", nil)
router.ServeHTTP(readinessResp, readinessReq)
if readinessResp.Code != http.StatusOK {
t.Fatalf("unexpected readiness status: %d", readinessResp.Code)
}
var readinessBody map[string]any
if err := json.Unmarshal(readinessResp.Body.Bytes(), &readinessBody); err != nil {
t.Fatalf("decode readiness body: %v", err)
}
if blocked, _ := readinessBody["blocked"].(bool); !blocked {
t.Fatalf("expected blocked readiness, got %v", readinessBody["blocked"])
}
pushResp := httptest.NewRecorder()
pushReq, _ := http.NewRequest(http.MethodPost, "/api/sync/push", nil)
router.ServeHTTP(pushResp, pushReq)
if pushResp.Code != http.StatusLocked {
t.Fatalf("expected 423 for blocked sync push, got %d body=%s", pushResp.Code, pushResp.Body.String())
}
var pushBody map[string]any
if err := json.Unmarshal(pushResp.Body.Bytes(), &pushBody); err != nil {
t.Fatalf("decode push body: %v", err)
}
if pushBody["reason_text"] == nil || pushBody["reason_text"] == "" {
t.Fatalf("expected reason_text in blocked response, got %v", pushBody)
}
}

View File

@@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
}
// 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 {
pagePath := filepath.Join(templatesPath, page)
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) {
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
}

View File

@@ -28,14 +28,13 @@ type ComponentSyncResult struct {
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
startTime := time.Now()
// Query to join lot with qt_lot_metadata
// Query to join lot with qt_lot_metadata (metadata only, no pricing)
// Use LEFT JOIN to include lots without metadata
type componentRow struct {
LotName string
LotDescription string
Category *string
Model *string
CurrentPrice *float64
}
var rows []componentRow
@@ -44,8 +43,7 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
l.lot_name,
l.lot_description,
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
m.model,
m.current_price
m.model
FROM lot l
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
LEFT JOIN qt_categories c ON m.category_id = c.id
@@ -100,8 +98,6 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
LotDescription: row.LotDescription,
Category: category,
Model: model,
CurrentPrice: row.CurrentPrice,
SyncedAt: syncTime,
}
components = append(components, comp)
@@ -221,11 +217,6 @@ func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]L
)
}
// Apply price filter
if filter.HasPrice {
db = db.Where("current_price IS NOT NULL")
}
// Get total count
var total int64
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
@@ -312,98 +303,3 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
}
// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items
// This allows offline price updates using synced pricelists without MariaDB connection
func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) {
// Get all items from the specified pricelist
var items []LocalPricelistItem
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
return 0, fmt.Errorf("fetching pricelist items: %w", err)
}
if len(items) == 0 {
slog.Warn("no items found in pricelist", "pricelist_id", pricelistID)
return 0, nil
}
// Update current_price for each component
updated := 0
err := l.db.Transaction(func(tx *gorm.DB) error {
for _, item := range items {
result := tx.Model(&LocalComponent{}).
Where("lot_name = ?", item.LotName).
Update("current_price", item.Price)
if result.Error != nil {
return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error)
}
if result.RowsAffected > 0 {
updated++
}
}
return nil
})
if err != nil {
return 0, err
}
slog.Info("updated component prices from pricelist",
"pricelist_id", pricelistID,
"total_items", len(items),
"updated_components", updated)
return updated, nil
}
// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components
// if no components exist or all current prices are NULL
func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
// Check if we have any components with prices
var count int64
if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil {
return fmt.Errorf("checking component prices: %w", err)
}
// If we have components with prices, don't load from pricelists
if count > 0 {
return nil
}
// Check if we have any components at all
var totalComponents int64
if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil {
return fmt.Errorf("counting components: %w", err)
}
// If we have no components, we need to load them from pricelists
if totalComponents == 0 {
slog.Info("no components found in local database, loading from latest pricelist")
// This would typically be called from the sync service or setup process
// For now, we'll just return nil to indicate no action needed
return nil
}
// If we have components but no prices, load from latest estimate pricelist.
var latestPricelist LocalPricelist
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&latestPricelist).Error; err != nil {
if err == gorm.ErrRecordNotFound {
slog.Warn("no pricelists found in local database")
return nil
}
return fmt.Errorf("finding latest pricelist: %w", err)
}
// Update prices from the latest pricelist
updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID)
if err != nil {
return fmt.Errorf("updating component prices from pricelist: %w", err)
}
slog.Info("loaded component prices from latest pricelist",
"pricelist_id", latestPricelist.ID,
"updated_components", updated)
return nil
}

View File

@@ -29,6 +29,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
PricelistID: cfg.PricelistID,
OnlyInStock: cfg.OnlyInStock,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
@@ -72,6 +73,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount,
PricelistID: local.PricelistID,
OnlyInStock: local.OnlyInStock,
PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt,
}
@@ -162,20 +164,28 @@ func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
partnumbers := make(LocalStringList, 0, len(item.Partnumbers))
partnumbers = append(partnumbers, item.Partnumbers...)
return &LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
}
}
// LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem
func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem {
partnumbers := make([]string, 0, len(local.Partnumbers))
partnumbers = append(partnumbers, local.Partnumbers...)
return &models.PricelistItem{
ID: local.ID,
PricelistID: serverPricelistID,
LotName: local.LotName,
Price: local.Price,
ID: local.ID,
PricelistID: serverPricelistID,
LotName: local.LotName,
Price: local.Price,
AvailableQty: local.AvailableQty,
Partnumbers: partnumbers,
}
}
@@ -203,17 +213,14 @@ func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
LotDescription: lotDesc,
Category: category,
Model: meta.Model,
CurrentPrice: meta.CurrentPrice,
SyncedAt: time.Now(),
}
}
// LocalToComponent converts LocalComponent to models.LotMetadata
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
return &models.LotMetadata{
LotName: local.LotName,
Model: local.Model,
CurrentPrice: local.CurrentPrice,
LotName: local.LotName,
Model: local.Model,
Lot: &models.Lot{
LotName: local.LotName,
LotDescription: local.LotDescription,

View File

@@ -66,6 +66,8 @@ func New(dbPath string) (*LocalDB, error) {
&LocalPricelistItem{},
&LocalComponent{},
&AppSetting{},
&LocalRemoteMigrationApplied{},
&LocalSyncGuardState{},
&PendingChange{},
); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err)
@@ -418,6 +420,37 @@ func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, erro
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
func (l *LocalDB) DeleteConfiguration(uuid string) error {
return l.DeactivateConfiguration(uuid)
@@ -483,6 +516,13 @@ func (l *LocalDB) CountConfigurations() int64 {
return count
}
// CountProjects returns the number of local projects
func (l *LocalDB) CountProjects() int64 {
var count int64
l.db.Model(&LocalProject{}).Count(&count)
return count
}
// Pricelist methods
// GetLastSyncTime returns the last sync timestamp
@@ -679,6 +719,47 @@ func (l *LocalDB) DeleteLocalPricelist(id uint) error {
return l.db.Delete(&LocalPricelist{}, id).Error
}
// DeleteUnusedLocalPricelistsMissingOnServer removes local pricelists that are absent on server
// and not referenced by active local configurations.
func (l *LocalDB) DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs []uint) (int, error) {
returned := 0
err := l.db.Transaction(func(tx *gorm.DB) error {
var candidates []LocalPricelist
query := tx.Model(&LocalPricelist{})
if len(serverPricelistIDs) > 0 {
query = query.Where("server_id NOT IN ?", serverPricelistIDs)
}
if err := query.Find(&candidates).Error; err != nil {
return err
}
for i := range candidates {
pl := candidates[i]
var refs int64
if err := tx.Model(&LocalConfiguration{}).
Where("pricelist_id = ? AND is_active = 1", pl.ServerID).
Count(&refs).Error; err != nil {
return err
}
if refs > 0 {
continue
}
if err := tx.Where("pricelist_id = ?", pl.ID).Delete(&LocalPricelistItem{}).Error; err != nil {
return err
}
if err := tx.Delete(&LocalPricelist{}, pl.ID).Error; err != nil {
return err
}
returned++
}
return nil
})
if err != nil {
return 0, err
}
return returned, nil
}
// PendingChange methods
// AddPendingChange adds a change to the sync queue
@@ -765,3 +846,71 @@ func (l *LocalDB) PurgeOrphanConfigurationPendingChanges() (int64, error) {
func (l *LocalDB) GetPendingCount() int64 {
return l.CountPendingChanges()
}
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
var migration LocalRemoteMigrationApplied
if err := l.db.Where("id = ?", id).First(&migration).Error; err != nil {
return nil, err
}
return &migration, nil
}
// UpsertRemoteMigrationApplied writes applied migration metadata.
func (l *LocalDB) UpsertRemoteMigrationApplied(id, checksum, appVersion string, appliedAt time.Time) error {
record := &LocalRemoteMigrationApplied{
ID: id,
Checksum: checksum,
AppVersion: appVersion,
AppliedAt: appliedAt,
}
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"checksum": checksum,
"app_version": appVersion,
"applied_at": appliedAt,
}),
}).Create(record).Error
}
// GetLatestAppliedRemoteMigrationID returns last applied remote migration id.
func (l *LocalDB) GetLatestAppliedRemoteMigrationID() (string, error) {
var record LocalRemoteMigrationApplied
if err := l.db.Order("applied_at DESC").First(&record).Error; err != nil {
return "", err
}
return record.ID, nil
}
// GetSyncGuardState returns the latest readiness guard state.
func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) {
var state LocalSyncGuardState
if err := l.db.Order("id DESC").First(&state).Error; err != nil {
return nil, err
}
return &state, nil
}
// SetSyncGuardState upserts readiness guard state (single-row logical table).
func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requiredMinAppVersion *string, checkedAt *time.Time) error {
state := &LocalSyncGuardState{
ID: 1,
Status: status,
ReasonCode: reasonCode,
ReasonText: reasonText,
RequiredMinAppVersion: requiredMinAppVersion,
LastCheckedAt: checkedAt,
}
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"status": status,
"reason_code": reasonCode,
"reason_text": reasonText,
"required_min_app_version": requiredMinAppVersion,
"last_checked_at": checkedAt,
"updated_at": time.Now(),
}),
}).Create(state).Error
}

View File

@@ -58,6 +58,16 @@ var localMigrations = []localMigration{
name: "Backfill source for local pricelists and create source indexes",
run: backfillLocalPricelistSource,
},
{
id: "2026_02_09_drop_component_unused_fields",
name: "Remove current_price and synced_at from local_components (unused fields)",
run: dropComponentUnusedFields,
},
{
id: "2026_02_09_add_warehouse_competitor_pricelists",
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
run: addWarehouseCompetitorPriceLists,
},
}
func runLocalMigrations(db *gorm.DB) error {
@@ -316,3 +326,113 @@ func backfillLocalPricelistSource(tx *gorm.DB) error {
return nil
}
func dropComponentUnusedFields(tx *gorm.DB) error {
// Check if columns exist
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_components')
WHERE name IN ('current_price', 'synced_at')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check columns existence: %w", err)
}
if len(columns) == 0 {
slog.Info("unused fields already removed from local_components")
return nil
}
// SQLite: recreate table without current_price and synced_at
if err := tx.Exec(`
CREATE TABLE local_components_new (
lot_name TEXT PRIMARY KEY,
lot_description TEXT,
category TEXT,
model TEXT
)
`).Error; err != nil {
return fmt.Errorf("create new local_components table: %w", err)
}
if err := tx.Exec(`
INSERT INTO local_components_new (lot_name, lot_description, category, model)
SELECT lot_name, lot_description, category, model
FROM local_components
`).Error; err != nil {
return fmt.Errorf("copy data to new table: %w", err)
}
if err := tx.Exec(`DROP TABLE local_components`).Error; err != nil {
return fmt.Errorf("drop old table: %w", err)
}
if err := tx.Exec(`ALTER TABLE local_components_new RENAME TO local_components`).Error; err != nil {
return fmt.Errorf("rename new table: %w", err)
}
slog.Info("dropped current_price and synced_at columns from local_components")
return nil
}
func addWarehouseCompetitorPriceLists(tx *gorm.DB) error {
// Check if columns exist
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_configurations')
WHERE name IN ('warehouse_pricelist_id', 'competitor_pricelist_id')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check columns existence: %w", err)
}
if len(columns) == 2 {
slog.Info("warehouse and competitor pricelist columns already exist")
return nil
}
// Add columns if they don't exist
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN warehouse_pricelist_id INTEGER
`).Error; err != nil {
// Column might already exist, ignore
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("add warehouse_pricelist_id column: %w", err)
}
}
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN competitor_pricelist_id INTEGER
`).Error; err != nil {
// Column might already exist, ignore
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("add competitor_pricelist_id column: %w", err)
}
}
// Create indexes
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_configurations_warehouse_pricelist
ON local_configurations(warehouse_pricelist_id)
`).Error; err != nil {
return fmt.Errorf("create warehouse pricelist index: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_configurations_competitor_pricelist
ON local_configurations(competitor_pricelist_id)
`).Error; err != nil {
return fmt.Errorf("create competitor pricelist index: %w", err)
}
slog.Info("added warehouse and competitor pricelist fields to local_configurations")
return nil
}

View File

@@ -57,6 +57,30 @@ func (c LocalConfigItems) Total() float64 {
return total
}
// LocalStringList is a JSON-encoded list of strings stored as TEXT in SQLite.
type LocalStringList []string
func (s LocalStringList) Value() (driver.Value, error) {
return json.Marshal(s)
}
func (s *LocalStringList) Scan(value interface{}) error {
if value == nil {
*s = make(LocalStringList, 0)
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New("type assertion failed for LocalStringList")
}
return json.Unmarshal(bytes, s)
}
// LocalConfiguration stores configurations in local SQLite
type LocalConfiguration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
@@ -72,7 +96,10 @@ type LocalConfiguration struct {
Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -142,30 +169,58 @@ func (LocalPricelist) TableName() string {
// LocalPricelistItem stores pricelist items
type LocalPricelistItem struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
LotName string `gorm:"not null" json:"lot_name"`
Price float64 `gorm:"not null" json:"price"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
LotName string `gorm:"not null" json:"lot_name"`
Price float64 `gorm:"not null" json:"price"`
AvailableQty *float64 `json:"available_qty,omitempty"`
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`
}
func (LocalPricelistItem) TableName() string {
return "local_pricelist_items"
}
// LocalComponent stores cached components for offline search
// LocalComponent stores cached components for offline search (metadata only)
// All pricing is now sourced from local_pricelist_items based on configuration pricelist selection
type LocalComponent struct {
LotName string `gorm:"primaryKey" json:"lot_name"`
LotDescription string `json:"lot_description"`
Category string `json:"category"`
Model string `json:"model"`
CurrentPrice *float64 `json:"current_price"`
SyncedAt time.Time `json:"synced_at"`
LotName string `gorm:"primaryKey" json:"lot_name"`
LotDescription string `json:"lot_description"`
Category string `json:"category"`
Model string `json:"model"`
}
func (LocalComponent) TableName() string {
return "local_components"
}
// LocalRemoteMigrationApplied tracks remote SQLite migrations received from server and applied locally.
type LocalRemoteMigrationApplied struct {
ID string `gorm:"primaryKey;size:128" json:"id"`
Checksum string `gorm:"size:128;not null" json:"checksum"`
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
AppliedAt time.Time `gorm:"not null" json:"applied_at"`
}
func (LocalRemoteMigrationApplied) TableName() string {
return "local_remote_migrations_applied"
}
// LocalSyncGuardState stores latest sync readiness decision for UI and preflight checks.
type LocalSyncGuardState struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Status string `gorm:"size:32;not null;index" json:"status"` // ready|blocked|unknown
ReasonCode string `gorm:"size:128" json:"reason_code,omitempty"`
ReasonText string `gorm:"type:text" json:"reason_text,omitempty"`
RequiredMinAppVersion *string `gorm:"size:64" json:"required_min_app_version,omitempty"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
func (LocalSyncGuardState) TableName() string {
return "local_sync_guard_state"
}
// PendingChange stores changes that need to be synced to the server
type PendingChange struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`

View File

@@ -23,6 +23,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"is_template": localCfg.IsTemplate,
"server_count": localCfg.ServerCount,
"pricelist_id": localCfg.PricelistID,
"only_in_stock": localCfg.OnlyInStock,
"price_updated_at": localCfg.PriceUpdatedAt,
"created_at": localCfg.CreatedAt,
"updated_at": localCfg.UpdatedAt,
@@ -52,6 +53,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
PricelistID *uint `json:"pricelist_id"`
OnlyInStock bool `json:"only_in_stock"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
OriginalUserID uint `json:"original_user_id"`
OriginalUsername string `json:"original_username"`
@@ -77,6 +79,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
IsTemplate: snapshot.IsTemplate,
ServerCount: snapshot.ServerCount,
PricelistID: snapshot.PricelistID,
OnlyInStock: snapshot.OnlyInStock,
PriceUpdatedAt: snapshot.PriceUpdatedAt,
OriginalUserID: snapshot.OriginalUserID,
OriginalUsername: snapshot.OriginalUsername,

View File

@@ -0,0 +1,238 @@
package lotmatch
import (
"errors"
"regexp"
"sort"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
var (
ErrResolveConflict = errors.New("multiple lot matches")
ErrResolveNotFound = errors.New("lot not found")
)
type LotResolver struct {
partnumberToLots map[string][]string
exactLots map[string]string
allLots []string
}
type MappingMatcher struct {
exact map[string][]string
exactLot map[string]string
wildcard []wildcardMapping
}
type wildcardMapping struct {
lotName string
re *regexp.Regexp
}
func NewLotResolverFromDB(db *gorm.DB) (*LotResolver, error) {
mappings, lots, err := loadMappingsAndLots(db)
if err != nil {
return nil, err
}
return NewLotResolver(mappings, lots), nil
}
func NewMappingMatcherFromDB(db *gorm.DB) (*MappingMatcher, error) {
mappings, lots, err := loadMappingsAndLots(db)
if err != nil {
return nil, err
}
return NewMappingMatcher(mappings, lots), nil
}
func NewLotResolver(mappings []models.LotPartnumber, lots []models.Lot) *LotResolver {
partnumberToLots := make(map[string][]string, len(mappings))
for _, m := range mappings {
pn := NormalizeKey(m.Partnumber)
lot := strings.TrimSpace(m.LotName)
if pn == "" || lot == "" {
continue
}
partnumberToLots[pn] = append(partnumberToLots[pn], lot)
}
for key := range partnumberToLots {
partnumberToLots[key] = uniqueCaseInsensitive(partnumberToLots[key])
}
exactLots := make(map[string]string, len(lots))
allLots := make([]string, 0, len(lots))
for _, l := range lots {
name := strings.TrimSpace(l.LotName)
if name == "" {
continue
}
exactLots[NormalizeKey(name)] = name
allLots = append(allLots, name)
}
sort.Slice(allLots, func(i, j int) bool {
li := len([]rune(allLots[i]))
lj := len([]rune(allLots[j]))
if li == lj {
return allLots[i] < allLots[j]
}
return li > lj
})
return &LotResolver{
partnumberToLots: partnumberToLots,
exactLots: exactLots,
allLots: allLots,
}
}
func NewMappingMatcher(mappings []models.LotPartnumber, lots []models.Lot) *MappingMatcher {
exact := make(map[string][]string, len(mappings))
wildcards := make([]wildcardMapping, 0, len(mappings))
for _, m := range mappings {
pn := NormalizeKey(m.Partnumber)
lot := strings.TrimSpace(m.LotName)
if pn == "" || lot == "" {
continue
}
if strings.Contains(pn, "*") {
pattern := "^" + regexp.QuoteMeta(pn) + "$"
pattern = strings.ReplaceAll(pattern, "\\*", ".*")
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
wildcards = append(wildcards, wildcardMapping{lotName: lot, re: re})
continue
}
exact[pn] = append(exact[pn], lot)
}
for key := range exact {
exact[key] = uniqueCaseInsensitive(exact[key])
}
exactLot := make(map[string]string, len(lots))
for _, l := range lots {
name := strings.TrimSpace(l.LotName)
if name == "" {
continue
}
exactLot[NormalizeKey(name)] = name
}
return &MappingMatcher{
exact: exact,
exactLot: exactLot,
wildcard: wildcards,
}
}
func (r *LotResolver) Resolve(partnumber string) (string, string, error) {
key := NormalizeKey(partnumber)
if key == "" {
return "", "", ErrResolveNotFound
}
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
if len(mapped) == 1 {
return mapped[0], "mapping_table", nil
}
return "", "", ErrResolveConflict
}
if exact, ok := r.exactLots[key]; ok {
return exact, "article_exact", nil
}
best := ""
bestLen := -1
tie := false
for _, lot := range r.allLots {
lotKey := NormalizeKey(lot)
if lotKey == "" {
continue
}
if strings.HasPrefix(key, lotKey) {
l := len([]rune(lotKey))
if l > bestLen {
best = lot
bestLen = l
tie = false
} else if l == bestLen && !strings.EqualFold(best, lot) {
tie = true
}
}
}
if best == "" {
return "", "", ErrResolveNotFound
}
if tie {
return "", "", ErrResolveConflict
}
return best, "prefix", nil
}
func (m *MappingMatcher) MatchLots(partnumber string) []string {
if m == nil {
return nil
}
key := NormalizeKey(partnumber)
if key == "" {
return nil
}
lots := make([]string, 0, 2)
if exact := m.exact[key]; len(exact) > 0 {
lots = append(lots, exact...)
}
for _, wc := range m.wildcard {
if wc.re == nil || !wc.re.MatchString(key) {
continue
}
lots = append(lots, wc.lotName)
}
if lot, ok := m.exactLot[key]; ok && strings.TrimSpace(lot) != "" {
lots = append(lots, lot)
}
return uniqueCaseInsensitive(lots)
}
func NormalizeKey(v string) string {
s := strings.ToLower(strings.TrimSpace(v))
replacer := strings.NewReplacer(" ", "", "-", "", "_", "", ".", "", "/", "", "\\", "", "\"", "", "'", "", "(", "", ")", "")
return replacer.Replace(s)
}
func loadMappingsAndLots(db *gorm.DB) ([]models.LotPartnumber, []models.Lot, error) {
var mappings []models.LotPartnumber
if err := db.Find(&mappings).Error; err != nil {
return nil, nil, err
}
var lots []models.Lot
if err := db.Select("lot_name").Find(&lots).Error; err != nil {
return nil, nil, err
}
return mappings, lots, nil
}
func uniqueCaseInsensitive(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, trimmed)
}
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i]) < strings.ToLower(out[j])
})
return out
}

View File

@@ -0,0 +1,62 @@
package lotmatch
import (
"testing"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func TestLotResolverPrecedence(t *testing.T) {
resolver := NewLotResolver(
[]models.LotPartnumber{
{Partnumber: "PN-1", LotName: "LOT_A"},
},
[]models.Lot{
{LotName: "CPU_X_LONG"},
{LotName: "CPU_X"},
},
)
lot, by, err := resolver.Resolve("PN-1")
if err != nil || lot != "LOT_A" || by != "mapping_table" {
t.Fatalf("expected mapping_table LOT_A, got lot=%s by=%s err=%v", lot, by, err)
}
lot, by, err = resolver.Resolve("CPU_X")
if err != nil || lot != "CPU_X" || by != "article_exact" {
t.Fatalf("expected article_exact CPU_X, got lot=%s by=%s err=%v", lot, by, err)
}
lot, by, err = resolver.Resolve("CPU_X_LONG_001")
if err != nil || lot != "CPU_X_LONG" || by != "prefix" {
t.Fatalf("expected prefix CPU_X_LONG, got lot=%s by=%s err=%v", lot, by, err)
}
}
func TestMappingMatcherWildcardAndExactLot(t *testing.T) {
matcher := NewMappingMatcher(
[]models.LotPartnumber{
{Partnumber: "R750*", LotName: "SERVER_R750"},
{Partnumber: "HDD-01", LotName: "HDD_01"},
},
[]models.Lot{
{LotName: "MEM_DDR5_16G_4800"},
},
)
check := func(partnumber string, want string) {
t.Helper()
got := matcher.MatchLots(partnumber)
if len(got) != 1 || got[0] != want {
t.Fatalf("partnumber %s: expected [%s], got %#v", partnumber, want, got)
}
}
check("R750XD", "SERVER_R750")
check("HDD-01", "HDD_01")
check("MEM_DDR5_16G_4800", "MEM_DDR5_16G_4800")
if got := matcher.MatchLots("UNKNOWN"); len(got) != 0 {
t.Fatalf("expected no matches for UNKNOWN, got %#v", got)
}
}

View File

@@ -1,22 +1,55 @@
package middleware
import (
"net"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
)
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
c.Header("Access-Control-Max-Age", "86400")
origin := strings.TrimSpace(c.GetHeader("Origin"))
if origin != "" {
if isLoopbackOrigin(origin) {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Disposition")
c.Header("Access-Control-Max-Age", "86400")
} else if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
func isLoopbackOrigin(origin string) bool {
u, err := url.Parse(origin)
if err != nil {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
host := strings.TrimSpace(u.Hostname())
if host == "" {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}

View File

@@ -40,25 +40,26 @@ func (c ConfigItems) Total() float64 {
}
type Configuration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"`
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"`
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}

View File

@@ -41,7 +41,7 @@ func (Supplier) TableName() string {
// StockLog stores warehouse stock snapshots imported from external files.
type StockLog struct {
StockLogID uint `gorm:"column:stock_log_id;primaryKey;autoIncrement"`
Lot string `gorm:"column:lot;size:255;not null"`
Partnumber string `gorm:"column:partnumber;size:255;not null"`
Supplier *string `gorm:"column:supplier;size:255"`
Date time.Time `gorm:"column:date;type:date;not null"`
Price float64 `gorm:"column:price;not null"`

View File

@@ -3,10 +3,12 @@ package repository
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
@@ -26,7 +28,8 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
// ListBySource returns pricelists filtered by source when provided.
func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
query := r.db.Model(&models.Pricelist{})
query := r.db.Model(&models.Pricelist{}).
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)")
if source != "" {
query = query.Where("source = ?", source)
}
@@ -51,7 +54,9 @@ func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistS
// ListActiveBySource returns active pricelists filtered by source when provided.
func (r *PricelistRepository) ListActiveBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
query := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true)
query := r.db.Model(&models.Pricelist{}).
Where("is_active = ?", true).
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)")
if source != "" {
query = query.Where("source = ?", source)
}
@@ -240,86 +245,104 @@ 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)
}
if err := r.enrichItemsWithStock(items); err != nil {
return nil, 0, fmt.Errorf("enriching pricelist items with stock: %w", err)
}
return items, total, nil
}
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
func (r *PricelistRepository) enrichItemsWithStock(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
}
type lotQty struct {
Lot string
Qty float64
}
var qtyRows []lotQty
if err := r.db.Model(&models.StockLog{}).
Select("lot, COALESCE(SUM(qty), 0) AS qty").
Where("lot IN ?", lots).
Group("lot").
Scan(&qtyRows).Error; err != nil {
resolver, err := lotmatch.NewLotResolverFromDB(r.db)
if err != nil {
return err
}
qtyByLot := make(map[string]float64, len(qtyRows))
for _, row := range qtyRows {
qtyByLot[row.Lot] = row.Qty
}
var mappings []models.LotPartnumber
if err := r.db.Where("lot_name IN ? AND TRIM(lot_name) <> ''", lots).
Order("partnumber ASC").
Find(&mappings).Error; err != nil {
type stockRow struct {
Partnumber string `gorm:"column:partnumber"`
Qty *float64 `gorm:"column:qty"`
}
rows := make([]stockRow, 0)
if err := r.db.Raw(`
SELECT s.partnumber, s.qty
FROM stock_log s
INNER JOIN (
SELECT partnumber, MAX(date) AS max_date
FROM stock_log
GROUP BY partnumber
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
WHERE s.qty IS NOT NULL
`).Scan(&rows).Error; err != nil {
return err
}
partnumbersByLot := make(map[string][]string, len(lots))
seenPair := make(map[string]struct{}, len(mappings))
for _, m := range mappings {
lot := strings.TrimSpace(m.LotName)
pn := strings.TrimSpace(m.Partnumber)
if lot == "" || pn == "" {
lotTotals := make(map[string]float64, len(items))
lotPartnumbers := make(map[string][]string, len(items))
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
for i := range rows {
row := rows[i]
if strings.TrimSpace(row.Partnumber) == "" {
continue
}
key := lot + "\x00" + strings.ToLower(pn)
if _, ok := seenPair[key]; ok {
lotName, _, resolveErr := resolver.Resolve(row.Partnumber)
if resolveErr != nil || strings.TrimSpace(lotName) == "" {
continue
}
seenPair[key] = struct{}{}
partnumbersByLot[lot] = append(partnumbersByLot[lot], pn)
if row.Qty != nil {
lotTotals[lotName] += *row.Qty
}
pn := strings.TrimSpace(row.Partnumber)
if pn == "" {
continue
}
if _, ok := seenPartnumbers[lotName]; !ok {
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
}
key := strings.ToLower(pn)
if _, exists := seenPartnumbers[lotName][key]; exists {
continue
}
seenPartnumbers[lotName][key] = struct{}{}
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
}
for i := range items {
if qty, ok := qtyByLot[items[i].LotName]; ok {
q := qty
items[i].AvailableQty = &q
lotName := items[i].LotName
if qty, ok := lotTotals[lotName]; ok {
qtyCopy := qty
items[i].AvailableQty = &qtyCopy
}
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
sort.Slice(partnumbers, func(a, b int) bool {
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
})
items[i].Partnumbers = partnumbers
}
items[i].Partnumbers = partnumbersByLot[items[i].LotName]
}
return nil
}
// GetLotNames returns distinct lot names from pricelist items.
func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
var lotNames []string
if err := r.db.Model(&models.PricelistItem{}).
Where("pricelist_id = ?", pricelistID).
Distinct("lot_name").
Order("lot_name ASC").
Pluck("lot_name", &lotNames).Error; err != nil {
return nil, fmt.Errorf("listing pricelist lot names: %w", err)
}
return lotNames, nil
}
// GetPriceForLot returns item price for a lot within a pricelist.
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
var item models.PricelistItem
@@ -329,6 +352,28 @@ func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (
return item.Price, nil
}
// GetPricesForLots returns price map for given lots within a pricelist.
func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
result := make(map[string]float64, len(lotNames))
if pricelistID == 0 || len(lotNames) == 0 {
return result, nil
}
var rows []models.PricelistItem
if err := r.db.Select("lot_name, price").
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Price > 0 {
result[row.LotName] = row.Price
}
}
return result, nil
}
// SetActive toggles active flag on a pricelist.
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).Error

View File

@@ -75,6 +75,57 @@ func TestGenerateVersion_IsolatedBySource(t *testing.T) {
}
}
func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) {
repo := newTestPricelistRepository(t)
db := repo.db
warehouse := models.Pricelist{
Source: string(models.PricelistSourceWarehouse),
Version: "S-2026-02-07-001",
CreatedBy: "test",
IsActive: true,
}
if err := db.Create(&warehouse).Error; err != nil {
t.Fatalf("create pricelist: %v", err)
}
if err := db.Create(&models.PricelistItem{
PricelistID: warehouse.ID,
LotName: "SSD_NVME_03.2T",
Price: 100,
}).Error; err != nil {
t.Fatalf("create pricelist item: %v", err)
}
if err := db.Create(&models.Lot{LotName: "SSD_NVME_03.2T"}).Error; err != nil {
t.Fatalf("create lot: %v", err)
}
qty := 5.0
if err := db.Create(&models.StockLog{
Partnumber: "SSD_NVME_03.2T_GEN3_P4610",
Date: time.Now(),
Price: 200,
Qty: &qty,
}).Error; err != nil {
t.Fatalf("create stock log: %v", err)
}
items, total, err := repo.GetItems(warehouse.ID, 0, 20, "")
if err != nil {
t.Fatalf("GetItems: %v", err)
}
if total != 1 {
t.Fatalf("expected total=1, got %d", total)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].AvailableQty == nil {
t.Fatalf("expected available qty to be set")
}
if *items[0].AvailableQty != 5 {
t.Fatalf("expected available qty=5, got %v", *items[0].AvailableQty)
}
}
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
t.Helper()
@@ -82,7 +133,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.Pricelist{}); err != nil {
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewPricelistRepository(db)

View File

@@ -83,10 +83,6 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
search := "%" + filter.Search + "%"
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
}
if filter.HasPrice {
query = query.Where("current_price IS NOT NULL AND current_price > 0")
}
var total int64
query.Count(&total)
@@ -96,8 +92,6 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
sortDir = "DESC"
}
switch filter.SortField {
case "current_price":
query = query.Order("current_price " + sortDir)
case "lot_name":
query = query.Order("lot_name " + sortDir)
default:
@@ -112,9 +106,8 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
result := make([]models.LotMetadata, len(components))
for i, comp := range components {
result[i] = models.LotMetadata{
LotName: comp.LotName,
Model: comp.Model,
CurrentPrice: comp.CurrentPrice,
LotName: comp.LotName,
Model: comp.Model,
Lot: &models.Lot{
LotName: comp.LotName,
LotDescription: comp.LotDescription,
@@ -138,9 +131,8 @@ func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error)
}
return &models.LotMetadata{
LotName: comp.LotName,
Model: comp.Model,
CurrentPrice: comp.CurrentPrice,
LotName: comp.LotName,
Model: comp.Model,
Lot: &models.Lot{
LotName: comp.LotName,
LotDescription: comp.LotDescription,

View File

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

View File

@@ -53,7 +53,6 @@ type ComponentView struct {
Category string `json:"category"`
CategoryName string `json:"category_name"`
Model string `json:"model"`
CurrentPrice *float64 `json:"current_price"`
PriceFreshness models.PriceFreshness `json:"price_freshness"`
PopularityScore float64 `json:"popularity_score"`
Specs models.Specs `json:"specs,omitempty"`
@@ -92,7 +91,6 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
view := ComponentView{
LotName: c.LotName,
Model: c.Model,
CurrentPrice: c.CurrentPrice,
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
PopularityScore: c.PopularityScore,
Specs: c.Specs,
@@ -134,7 +132,6 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
view := &ComponentView{
LotName: c.LotName,
Model: c.Model,
CurrentPrice: c.CurrentPrice,
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
PopularityScore: c.PopularityScore,
Specs: c.Specs,

View File

@@ -53,6 +53,7 @@ type CreateConfigRequest struct {
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
PricelistID *uint `json:"pricelist_id,omitempty"`
OnlyInStock bool `json:"only_in_stock"`
}
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
@@ -84,6 +85,7 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock,
}
if err := s.configRepo.Create(config); err != nil {
@@ -145,6 +147,7 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.PricelistID = pricelistID
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil {
return nil, err
@@ -222,6 +225,7 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
}
if err := s.configRepo.Create(clone); err != nil {
@@ -295,6 +299,7 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.PricelistID = pricelistID
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil {
return nil, err
@@ -362,6 +367,7 @@ func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName s
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
}
if err := s.configRepo.Create(clone); err != nil {

View File

@@ -4,6 +4,8 @@ import (
"bytes"
"encoding/csv"
"fmt"
"io"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
@@ -40,14 +42,21 @@ type ExportItem struct {
TotalPrice float64
}
func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
var buf bytes.Buffer
w := csv.NewWriter(&buf)
func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
// Write UTF-8 BOM for Excel compatibility
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return fmt.Errorf("failed to write BOM: %w", err)
}
csvWriter := csv.NewWriter(w)
// Use semicolon as delimiter for Russian Excel locale
csvWriter.Comma = ';'
defer csvWriter.Flush()
// Header
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
if err := w.Write(headers); err != nil {
return nil, err
if err := csvWriter.Write(headers); err != nil {
return fmt.Errorf("failed to write header: %w", err)
}
// Get category hierarchy for sorting
@@ -90,21 +99,35 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
item.Description,
item.Category,
fmt.Sprintf("%d", item.Quantity),
fmt.Sprintf("%.2f", item.UnitPrice),
fmt.Sprintf("%.2f", item.TotalPrice),
strings.ReplaceAll(fmt.Sprintf("%.2f", item.UnitPrice), ".", ","),
strings.ReplaceAll(fmt.Sprintf("%.2f", item.TotalPrice), ".", ","),
}
if err := w.Write(row); err != nil {
return nil, err
if err := csvWriter.Write(row); err != nil {
return fmt.Errorf("failed to write row: %w", err)
}
}
// Total row
if err := w.Write([]string{"", "", "", "", "ИТОГО:", fmt.Sprintf("%.2f", data.Total)}); err != nil {
return nil, err
totalStr := strings.ReplaceAll(fmt.Sprintf("%.2f", data.Total), ".", ",")
if err := csvWriter.Write([]string{"", "", "", "", "ИТОГО:", totalStr}); err != nil {
return fmt.Errorf("failed to write total row: %w", err)
}
w.Flush()
return buf.Bytes(), w.Error()
csvWriter.Flush()
if err := csvWriter.Error(); err != nil {
return fmt.Errorf("csv writer error: %w", err)
}
return nil
}
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes
func (s *ExportService) ToCSVBytes(data *ExportData) ([]byte, error) {
var buf bytes.Buffer
if err := s.ToCSV(&buf, data); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {

View File

@@ -0,0 +1,343 @@
package services
import (
"bytes"
"encoding/csv"
"io"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
)
func TestToCSV_UTF8BOM(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
data := &ExportData{
Name: "Test",
Items: []ExportItem{
{
LotName: "LOT-001",
Description: "Test Item",
Category: "CAT",
Quantity: 1,
UnitPrice: 100.0,
TotalPrice: 100.0,
},
},
Total: 100.0,
CreatedAt: time.Now(),
}
var buf bytes.Buffer
if err := svc.ToCSV(&buf, data); err != nil {
t.Fatalf("ToCSV failed: %v", err)
}
csvBytes := buf.Bytes()
if len(csvBytes) < 3 {
t.Fatalf("CSV too short to contain BOM")
}
// Check UTF-8 BOM: 0xEF 0xBB 0xBF
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
actualBOM := csvBytes[:3]
if bytes.Compare(actualBOM, expectedBOM) != 0 {
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
}
}
func TestToCSV_SemicolonDelimiter(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
data := &ExportData{
Name: "Test",
Items: []ExportItem{
{
LotName: "LOT-001",
Description: "Test Item",
Category: "CAT",
Quantity: 2,
UnitPrice: 100.50,
TotalPrice: 201.00,
},
},
Total: 201.00,
CreatedAt: time.Now(),
}
var buf bytes.Buffer
if err := svc.ToCSV(&buf, data); err != nil {
t.Fatalf("ToCSV failed: %v", err)
}
// Skip BOM and read CSV with semicolon delimiter
csvBytes := buf.Bytes()
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
reader.Comma = ';'
// Read header
header, err := reader.Read()
if err != nil {
t.Fatalf("Failed to read header: %v", err)
}
if len(header) != 6 {
t.Errorf("Expected 6 columns, got %d", len(header))
}
expectedHeader := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
for i, col := range expectedHeader {
if i < len(header) && header[i] != col {
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
}
}
// Read item row
itemRow, err := reader.Read()
if err != nil {
t.Fatalf("Failed to read item row: %v", err)
}
if itemRow[0] != "LOT-001" {
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[0])
}
if itemRow[3] != "2" {
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[3])
}
if itemRow[4] != "100,50" {
t.Errorf("Unit price mismatch: expected 100,50, got %s", itemRow[4])
}
}
func TestToCSV_TotalRow(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
data := &ExportData{
Name: "Test",
Items: []ExportItem{
{
LotName: "LOT-001",
Description: "Item 1",
Category: "CAT",
Quantity: 1,
UnitPrice: 100.0,
TotalPrice: 100.0,
},
{
LotName: "LOT-002",
Description: "Item 2",
Category: "CAT",
Quantity: 2,
UnitPrice: 50.0,
TotalPrice: 100.0,
},
},
Total: 200.0,
CreatedAt: time.Now(),
}
var buf bytes.Buffer
if err := svc.ToCSV(&buf, data); err != nil {
t.Fatalf("ToCSV failed: %v", err)
}
csvBytes := buf.Bytes()
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
reader.Comma = ';'
// Skip header and item rows
reader.Read()
reader.Read()
reader.Read()
// Read total row
totalRow, err := reader.Read()
if err != nil {
t.Fatalf("Failed to read total row: %v", err)
}
// Total row should have "ИТОГО:" in position 4 and total value in position 5
if totalRow[4] != "ИТОГО:" {
t.Errorf("Expected 'ИТОГО:' in column 4, got %q", totalRow[4])
}
if totalRow[5] != "200,00" {
t.Errorf("Expected total 200,00, got %s", totalRow[5])
}
}
func TestToCSV_CategorySorting(t *testing.T) {
// Test category sorting without category repo (items maintain original order)
svc := NewExportService(config.ExportConfig{}, nil)
data := &ExportData{
Name: "Test",
Items: []ExportItem{
{
LotName: "LOT-001",
Category: "CAT-A",
Quantity: 1,
UnitPrice: 100.0,
TotalPrice: 100.0,
},
{
LotName: "LOT-002",
Category: "CAT-C",
Quantity: 1,
UnitPrice: 100.0,
TotalPrice: 100.0,
},
{
LotName: "LOT-003",
Category: "CAT-B",
Quantity: 1,
UnitPrice: 100.0,
TotalPrice: 100.0,
},
},
Total: 300.0,
CreatedAt: time.Now(),
}
var buf bytes.Buffer
if err := svc.ToCSV(&buf, data); err != nil {
t.Fatalf("ToCSV failed: %v", err)
}
csvBytes := buf.Bytes()
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
reader.Comma = ';'
// Skip header
reader.Read()
// Without category repo, items maintain original order
row1, _ := reader.Read()
if row1[0] != "LOT-001" {
t.Errorf("Expected LOT-001 first, got %s", row1[0])
}
row2, _ := reader.Read()
if row2[0] != "LOT-002" {
t.Errorf("Expected LOT-002 second, got %s", row2[0])
}
row3, _ := reader.Read()
if row3[0] != "LOT-003" {
t.Errorf("Expected LOT-003 third, got %s", row3[0])
}
}
func TestToCSV_EmptyData(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
data := &ExportData{
Name: "Test",
Items: []ExportItem{},
Total: 0.0,
CreatedAt: time.Now(),
}
var buf bytes.Buffer
if err := svc.ToCSV(&buf, data); err != nil {
t.Fatalf("ToCSV failed: %v", err)
}
csvBytes := buf.Bytes()
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
reader.Comma = ';'
// Should have header and total row
header, err := reader.Read()
if err != nil {
t.Fatalf("Failed to read header: %v", err)
}
if len(header) != 6 {
t.Errorf("Expected 6 columns, got %d", len(header))
}
totalRow, err := reader.Read()
if err != nil {
t.Fatalf("Failed to read total row: %v", err)
}
if totalRow[4] != "ИТОГО:" {
t.Errorf("Expected ИТОГО: in total row, got %s", totalRow[4])
}
}
func TestToCSVBytes_BackwardCompat(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
data := &ExportData{
Name: "Test",
Items: []ExportItem{
{
LotName: "LOT-001",
Description: "Test Item",
Category: "CAT",
Quantity: 1,
UnitPrice: 100.0,
TotalPrice: 100.0,
},
},
Total: 100.0,
CreatedAt: time.Now(),
}
csvBytes, err := svc.ToCSVBytes(data)
if err != nil {
t.Fatalf("ToCSVBytes failed: %v", err)
}
if len(csvBytes) < 3 {
t.Fatalf("CSV bytes too short")
}
// Verify BOM is present
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
actualBOM := csvBytes[:3]
if bytes.Compare(actualBOM, expectedBOM) != 0 {
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
}
}
func TestToCSV_WriterError(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
data := &ExportData{
Name: "Test",
Items: []ExportItem{
{
LotName: "LOT-001",
Description: "Test",
Category: "CAT",
Quantity: 1,
UnitPrice: 100.0,
TotalPrice: 100.0,
},
},
Total: 100.0,
CreatedAt: time.Now(),
}
// Use a failing writer
failingWriter := &failingWriter{}
if err := svc.ToCSV(failingWriter, data); err == nil {
t.Errorf("Expected error from failing writer, got nil")
}
}
// failingWriter always returns an error
type failingWriter struct{}
func (fw *failingWriter) Write(p []byte) (int, error) {
return 0, io.EOF
}

View File

@@ -81,6 +81,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock,
CreatedAt: time.Now(),
}
@@ -163,6 +164,7 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount
localCfg.PricelistID = pricelistID
localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
@@ -268,6 +270,7 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
}
@@ -344,7 +347,7 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
}
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Update prices for all items
// Update prices for all items from pricelist
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
if latestErr == nil && latestPricelist != nil {
@@ -359,20 +362,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
}
}
// Fallback to current component price from local cache
component, err := s.localDB.GetLocalComponent(item.LotName)
if err != nil || component.CurrentPrice == nil {
// Keep original item if component not found or no price available
updatedItems[i] = item
continue
}
// Update item with current price from local cache
updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: *component.CurrentPrice,
}
// Keep original item if price not found in pricelist
updatedItems[i] = item
}
// Update configuration
@@ -454,6 +445,7 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount
localCfg.PricelistID = pricelistID
localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
@@ -546,6 +538,7 @@ func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newN
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
}
@@ -595,26 +588,6 @@ func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configu
// ListAllWithStatus returns configurations filtered by status: active|archived|all.
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
if page < 1 {
page = 1
@@ -623,17 +596,15 @@ func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status
perPage = 20
}
offset := (page - 1) * perPage
start := offset
if start > len(configs) {
start = len(configs)
localConfigs, total, err := s.localDB.ListConfigurationsWithFilters(status, search, offset, perPage)
if err != nil {
return nil, 0, err
}
end := start + perPage
if end > len(configs) {
end = len(configs)
configs := make([]models.Configuration, 0, len(localConfigs))
for _, lc := range localConfigs {
configs = append(configs, *localdb.LocalToConfiguration(&lc))
}
return configs[start:end], total, nil
return configs, total, nil
}
// ListTemplates returns all template configurations
@@ -689,7 +660,7 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
}
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Update prices for all items
// Update prices for all items from pricelist
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
if latestErr == nil && latestPricelist != nil {
@@ -704,20 +675,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
}
}
// Fallback to current component price from local cache
component, err := s.localDB.GetLocalComponent(item.LotName)
if err != nil || component.CurrentPrice == nil {
// Keep original item if component not found or no price available
updatedItems[i] = item
continue
}
// Update item with current price from local cache
updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: *component.CurrentPrice,
}
// Keep original item if price not found in pricelist
updatedItems[i] = item
}
// Update configuration
@@ -1029,6 +988,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
current.IsTemplate = rollbackData.IsTemplate
current.ServerCount = rollbackData.ServerCount
current.PricelistID = rollbackData.PricelistID
current.OnlyInStock = rollbackData.OnlyInStock
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
current.UpdatedAt = time.Now()
current.SyncStatus = "pending"

View File

@@ -1,340 +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"
"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
}
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 {
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,
})
}
} 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 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)
}
// Delete deletes a pricelist by ID
func (s *Service) Delete(id uint) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot delete pricelists")
}
return s.repo.Delete(id)
}
// SetActive toggles active state for a pricelist.
func (s *Service) SetActive(id uint, isActive bool) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot update pricelists")
}
return s.repo.SetActive(id, isActive)
}
// GetPriceForLot returns price by pricelist/lot.
func (s *Service) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
if s.repo == nil {
return 0, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetPriceForLot(pricelistID, lotName)
}
// CanWrite returns true if the user can create pricelists
func (s *Service) CanWrite() bool {
if s.repo == nil {
return false
}
return s.repo.CanWrite()
}
// CanWriteDebug returns write permission status with debug info
func (s *Service) CanWriteDebug() (bool, string) {
if s.repo == nil {
return false, "offline mode"
}
return s.repo.CanWriteDebug()
}
// GetLatestActive returns the most recent active pricelist
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
return s.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
}
// GetLatestActiveBySource returns the latest active pricelist for a source.
func (s *Service) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
if s.repo == nil {
return nil, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetLatestActiveBySource(source)
}
// CleanupExpired deletes expired and unused pricelists
func (s *Service) CleanupExpired() (int, error) {
if s.repo == nil {
return 0, fmt.Errorf("offline mode: cleanup not available")
}
expired, err := s.repo.GetExpiredUnused()
if err != nil {
return 0, err
}
deleted := 0
for _, pl := range expired {
if err := s.repo.Delete(pl.ID); err != nil {
slog.Warn("failed to delete expired pricelist", "id", pl.ID, "error", err)
continue
}
deleted++
}
slog.Info("cleaned up expired pricelists", "deleted", deleted)
return deleted, nil
}

View File

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

View File

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

View File

@@ -2,11 +2,13 @@ package services
import (
"errors"
"fmt"
"sync"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
)
var (
@@ -20,7 +22,14 @@ type QuoteService struct {
statsRepo *repository.StatsRepository
pricelistRepo *repository.PricelistRepository
localDB *localdb.LocalDB
pricingService *pricing.Service
pricingService priceResolver
cacheMu sync.RWMutex
priceCache map[string]cachedLotPrice
cacheTTL time.Duration
}
type priceResolver interface {
GetEffectivePrice(lotName string) (*float64, error)
}
func NewQuoteService(
@@ -28,7 +37,7 @@ func NewQuoteService(
statsRepo *repository.StatsRepository,
pricelistRepo *repository.PricelistRepository,
localDB *localdb.LocalDB,
pricingService *pricing.Service,
pricingService priceResolver,
) *QuoteService {
return &QuoteService{
componentRepo: componentRepo,
@@ -36,9 +45,16 @@ func NewQuoteService(
pricelistRepo: pricelistRepo,
localDB: localDB,
pricingService: pricingService,
priceCache: make(map[string]cachedLotPrice, 4096),
cacheTTL: 10 * time.Second,
}
}
type cachedLotPrice struct {
price *float64
expiresAt time.Time
}
type QuoteItem struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
@@ -62,6 +78,7 @@ type QuoteRequest struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
} `json:"items"`
PricelistID *uint `json:"pricelist_id,omitempty"` // Optional: use specific pricelist for pricing
}
type PriceLevelsRequest struct {
@@ -70,6 +87,7 @@ type PriceLevelsRequest struct {
Quantity int `json:"quantity"`
} `json:"items"`
PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"`
NoCache bool `json:"no_cache,omitempty"`
}
type PriceLevelsItem struct {
@@ -96,8 +114,69 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
if len(req.Items) == 0 {
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),
}
// Determine which pricelist to use for pricing
pricelistID := req.PricelistID
if pricelistID == nil || *pricelistID == 0 {
// By default, use latest estimate pricelist
latestPricelist, err := s.localDB.GetLatestLocalPricelistBySource("estimate")
if err == nil && latestPricelist != nil {
pricelistID = &latestPricelist.ServerID
}
}
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,
}
// Get price from pricelist_items
if pricelistID != nil {
price, found := s.lookupPriceByPricelistID(*pricelistID, reqItem.LotName)
if found && price > 0 {
item.UnitPrice = price
item.TotalPrice = price * float64(reqItem.Quantity)
item.HasPrice = true
total += item.TotalPrice
} else {
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
}
} else {
result.Warnings = append(result.Warnings, "No pricelist available for: "+reqItem.LotName)
}
result.Items = append(result.Items, item)
}
result.Total = total
return result, 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{
@@ -170,11 +249,55 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
return nil, ErrEmptyQuote
}
lotNames := make([]string, 0, len(req.Items))
seenLots := make(map[string]struct{}, len(req.Items))
for _, reqItem := range req.Items {
if _, ok := seenLots[reqItem.LotName]; ok {
continue
}
seenLots[reqItem.LotName] = struct{}{}
lotNames = append(lotNames, reqItem.LotName)
}
result := &PriceLevelsResult{
Items: make([]PriceLevelsItem, 0, len(req.Items)),
ResolvedPricelistIDs: map[string]uint{},
}
type levelState struct {
id uint
prices map[string]float64
}
levelBySource := map[models.PricelistSource]*levelState{
models.PricelistSourceEstimate: {prices: map[string]float64{}},
models.PricelistSourceWarehouse: {prices: map[string]float64{}},
models.PricelistSourceCompetitor: {prices: map[string]float64{}},
}
for source, st := range levelBySource {
sourceKey := string(source)
if req.PricelistIDs != nil {
if explicitID, ok := req.PricelistIDs[sourceKey]; ok && explicitID > 0 {
st.id = explicitID
result.ResolvedPricelistIDs[sourceKey] = explicitID
}
}
if st.id == 0 && s.pricelistRepo != nil {
latest, err := s.pricelistRepo.GetLatestActiveBySource(sourceKey)
if err == nil {
st.id = latest.ID
result.ResolvedPricelistIDs[sourceKey] = latest.ID
}
}
if st.id == 0 {
continue
}
prices, err := s.lookupPricesByPricelistID(st.id, lotNames, req.NoCache)
if err == nil {
st.prices = prices
}
}
for _, reqItem := range req.Items {
item := PriceLevelsItem{
LotName: reqItem.LotName,
@@ -182,22 +305,17 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
PriceMissing: make([]string, 0, 3),
}
estimatePrice, estimateID := s.lookupLevelPrice(models.PricelistSourceEstimate, reqItem.LotName, req.PricelistIDs)
warehousePrice, warehouseID := s.lookupLevelPrice(models.PricelistSourceWarehouse, reqItem.LotName, req.PricelistIDs)
competitorPrice, competitorID := s.lookupLevelPrice(models.PricelistSourceCompetitor, reqItem.LotName, req.PricelistIDs)
item.EstimatePrice = estimatePrice
item.WarehousePrice = warehousePrice
item.CompetitorPrice = competitorPrice
if estimateID != 0 {
result.ResolvedPricelistIDs[string(models.PricelistSourceEstimate)] = estimateID
if p, ok := levelBySource[models.PricelistSourceEstimate].prices[reqItem.LotName]; ok && p > 0 {
price := p
item.EstimatePrice = &price
}
if warehouseID != 0 {
result.ResolvedPricelistIDs[string(models.PricelistSourceWarehouse)] = warehouseID
if p, ok := levelBySource[models.PricelistSourceWarehouse].prices[reqItem.LotName]; ok && p > 0 {
price := p
item.WarehousePrice = &price
}
if competitorID != 0 {
result.ResolvedPricelistIDs[string(models.PricelistSourceCompetitor)] = competitorID
if p, ok := levelBySource[models.PricelistSourceCompetitor].prices[reqItem.LotName]; ok && p > 0 {
price := p
item.CompetitorPrice = &price
}
if item.EstimatePrice == nil {
@@ -220,6 +338,93 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
return result, nil
}
func (s *QuoteService) lookupPricesByPricelistID(pricelistID uint, lotNames []string, noCache bool) (map[string]float64, error) {
result := make(map[string]float64, len(lotNames))
if pricelistID == 0 || len(lotNames) == 0 {
return result, nil
}
missing := make([]string, 0, len(lotNames))
if noCache {
missing = append(missing, lotNames...)
} else {
now := time.Now()
s.cacheMu.RLock()
for _, lotName := range lotNames {
if entry, ok := s.priceCache[s.cacheKey(pricelistID, lotName)]; ok && entry.expiresAt.After(now) {
if entry.price != nil && *entry.price > 0 {
result[lotName] = *entry.price
}
continue
}
missing = append(missing, lotName)
}
s.cacheMu.RUnlock()
}
if len(missing) == 0 {
return result, nil
}
loaded := make(map[string]float64, len(missing))
if s.pricelistRepo != nil {
prices, err := s.pricelistRepo.GetPricesForLots(pricelistID, missing)
if err == nil {
for lotName, price := range prices {
if price > 0 {
result[lotName] = price
loaded[lotName] = price
}
}
s.updateCache(pricelistID, missing, loaded)
return result, nil
}
}
// Fallback path (usually offline): local per-lot lookup.
if s.localDB != nil {
for _, lotName := range missing {
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
if found && price > 0 {
result[lotName] = price
loaded[lotName] = price
}
}
s.updateCache(pricelistID, missing, loaded)
return result, nil
}
return result, fmt.Errorf("price lookup unavailable for pricelist %d", pricelistID)
}
func (s *QuoteService) updateCache(pricelistID uint, requested []string, loaded map[string]float64) {
if len(requested) == 0 {
return
}
expiresAt := time.Now().Add(s.cacheTTL)
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
for _, lotName := range requested {
if price, ok := loaded[lotName]; ok && price > 0 {
priceCopy := price
s.priceCache[s.cacheKey(pricelistID, lotName)] = cachedLotPrice{
price: &priceCopy,
expiresAt: expiresAt,
}
continue
}
s.priceCache[s.cacheKey(pricelistID, lotName)] = cachedLotPrice{
price: nil,
expiresAt: expiresAt,
}
}
}
func (s *QuoteService) cacheKey(pricelistID uint, lotName string) string {
return fmt.Sprintf("%d|%s", pricelistID, lotName)
}
func calculateDelta(target, base *float64) (*float64, *float64) {
if target == nil || base == nil {
return nil, nil

File diff suppressed because it is too large Load Diff

View File

@@ -1,256 +0,0 @@
package services
import (
"archive/zip"
"bytes"
"strings"
"testing"
"time"
"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 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) {
r := &lotResolver{
partnumberToLots: map[string][]string{
"pn-1": {"LOT_MAPPED"},
"pn-conflict": {"LOT_A", "LOT_B"},
},
exactLots: map[string]string{
"cpu_a": "CPU_A",
},
allLots: []string{"CPU_A_LONG", "CPU_A", "ABC ", "ABC\t"},
}
lot, typ, err := r.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 = r.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 = r.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 = r.resolve("abx")
if err == nil {
t.Fatalf("expected not found error")
}
_, _, err = r.resolve("pn-conflict")
if err == nil || err != 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{
Lot: "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{Lot: "OLD", Date: time.Now(), Price: 1}).Error; err != nil {
t.Fatalf("seed old row: %v", err)
}
svc := NewStockImportService(db, nil)
records := []models.StockLog{
{Lot: "NEW_1", Date: time.Now(), Price: 2},
{Lot: "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("lot").Find(&rows).Error; err != nil {
t.Fatalf("read rows: %v", err)
}
if len(rows) != 2 || rows[0].Lot != "NEW_1" || rows[1].Lot != "NEW_2" {
t.Fatalf("unexpected rows after replace: %#v", rows)
}
}
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
return db
}
func buildMinimalXLSX(t *testing.T, headers, values []string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
write := func(name, body string) {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create zip entry %s: %v", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("write zip entry %s: %v", name, err)
}
}
write("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>`)
write("_rels/.rels", `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`)
write("xl/workbook.xml", `<?xml version="1.0" encoding="UTF-8"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/>
</sheets>
</workbook>`)
write("xl/_rels/workbook.xml.rels", `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>`)
makeCell := func(ref, value string) string {
escaped := strings.ReplaceAll(value, "&", "&amp;")
escaped = strings.ReplaceAll(escaped, "<", "&lt;")
escaped = strings.ReplaceAll(escaped, ">", "&gt;")
return `<c r="` + ref + `" t="inlineStr"><is><t>` + escaped + `</t></is></c>`
}
cols := []string{"A", "B", "C", "D", "E", "F"}
var headerCells, valueCells strings.Builder
for i := 0; i < len(cols) && i < len(headers); i++ {
headerCells.WriteString(makeCell(cols[i]+"1", headers[i]))
}
for i := 0; i < len(cols) && i < len(values); i++ {
valueCells.WriteString(makeCell(cols[i]+"2", values[i]))
}
write("xl/worksheets/sheet1.xml", `<?xml version="1.0" encoding="UTF-8"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">`+headerCells.String()+`</row>
<row r="2">`+valueCells.String()+`</row>
</sheetData>
</worksheet>`)
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
return buf.Bytes()
}

View File

@@ -0,0 +1,410 @@
package sync
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"gorm.io/gorm"
)
const (
ReadinessReady = "ready"
ReadinessBlocked = "blocked"
ReadinessUnknown = "unknown"
)
var ErrSyncBlockedByReadiness = errors.New("sync blocked by readiness guard")
type SyncReadiness 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"`
}
type SyncBlockedError struct {
Readiness SyncReadiness
}
func (e *SyncBlockedError) Error() string {
if e == nil {
return ErrSyncBlockedByReadiness.Error()
}
if strings.TrimSpace(e.Readiness.ReasonText) != "" {
return e.Readiness.ReasonText
}
return ErrSyncBlockedByReadiness.Error()
}
func (s *Service) EnsureReadinessForSync() (*SyncReadiness, error) {
readiness, err := s.GetReadiness()
if err != nil {
return nil, err
}
if readiness.Blocked {
return readiness, &SyncBlockedError{Readiness: *readiness}
}
return readiness, nil
}
func (s *Service) GetReadiness() (*SyncReadiness, error) {
now := time.Now().UTC()
if !s.isOnline() {
return s.blockedReadiness(
now,
"OFFLINE_UNVERIFIED_SCHEMA",
"Синхронизация недоступна: нет соединения с сервером и нельзя проверить миграции локальной БД.",
nil,
)
}
mariaDB, err := s.getDB()
if err != nil || mariaDB == nil {
return s.blockedReadiness(
now,
"OFFLINE_UNVERIFIED_SCHEMA",
"Синхронизация недоступна: нет соединения с сервером и нельзя проверить миграции локальной БД.",
nil,
)
}
migrations, err := listActiveClientMigrations(mariaDB)
if err != nil {
return s.blockedReadiness(
now,
"REMOTE_MIGRATION_REGISTRY_UNAVAILABLE",
"Синхронизация заблокирована: не удалось проверить централизованные миграции локальной БД.",
nil,
)
}
for i := range migrations {
m := migrations[i]
if strings.TrimSpace(m.MinAppVersion) != "" {
if compareVersions(appmeta.Version(), m.MinAppVersion) < 0 {
min := m.MinAppVersion
return s.blockedReadiness(
now,
"MIN_APP_VERSION_REQUIRED",
fmt.Sprintf("Требуется обновление приложения до версии %s для безопасной синхронизации.", m.MinAppVersion),
&min,
)
}
}
}
if err := s.applyMissingRemoteMigrations(migrations); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "checksum") {
return s.blockedReadiness(
now,
"REMOTE_MIGRATION_CHECKSUM_MISMATCH",
"Синхронизация заблокирована: контрольная сумма миграции не совпадает.",
nil,
)
}
return s.blockedReadiness(
now,
"LOCAL_MIGRATION_APPLY_FAILED",
"Синхронизация заблокирована: не удалось применить миграции локальной БД.",
nil,
)
}
if err := s.reportClientSchemaState(mariaDB, now); err != nil {
slog.Warn("failed to report client schema state", "error", err)
}
ready := &SyncReadiness{Status: ReadinessReady, Blocked: false, LastCheckedAt: &now}
if setErr := s.localDB.SetSyncGuardState(ReadinessReady, "", "", nil, &now); setErr != nil {
slog.Warn("failed to persist sync guard state", "error", setErr)
}
return ready, nil
}
func (s *Service) blockedReadiness(now time.Time, code, text string, minVersion *string) (*SyncReadiness, error) {
readiness := &SyncReadiness{
Status: ReadinessBlocked,
Blocked: true,
ReasonCode: code,
ReasonText: text,
RequiredMinAppVersion: minVersion,
LastCheckedAt: &now,
}
if err := s.localDB.SetSyncGuardState(ReadinessBlocked, code, text, minVersion, &now); err != nil {
slog.Warn("failed to persist blocked sync guard state", "error", err)
}
return readiness, nil
}
func (s *Service) isOnline() bool {
if s.directDB != nil {
return true
}
if s.connMgr == nil {
return false
}
return s.connMgr.IsOnline()
}
type clientLocalMigration struct {
ID string `gorm:"column:id"`
Name string `gorm:"column:name"`
SQLText string `gorm:"column:sql_text"`
Checksum string `gorm:"column:checksum"`
MinAppVersion string `gorm:"column:min_app_version"`
OrderNo int `gorm:"column:order_no"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func listActiveClientMigrations(db *gorm.DB) ([]clientLocalMigration, error) {
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
return []clientLocalMigration{}, nil
}
if err := ensureClientMigrationRegistryTable(db); err != nil {
return nil, err
}
rows := make([]clientLocalMigration, 0)
if err := db.Raw(`
SELECT id, name, sql_text, checksum, COALESCE(min_app_version, '') AS min_app_version, order_no, created_at
FROM qt_client_local_migrations
WHERE is_active = 1
ORDER BY order_no ASC, created_at ASC, id ASC
`).Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("load client local migrations: %w", err)
}
return rows, nil
}
func ensureClientMigrationRegistryTable(db *gorm.DB) error {
// Check if table exists instead of trying to create (avoids permission issues)
if !tableExists(db, "qt_client_local_migrations") {
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
id VARCHAR(128) NOT NULL,
name VARCHAR(255) NOT NULL,
sql_text LONGTEXT NOT NULL,
checksum VARCHAR(128) NOT NULL,
min_app_version VARCHAR(64) NULL,
order_no INT NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
)
`).Error; err != nil {
return fmt.Errorf("create qt_client_local_migrations table: %w", err)
}
}
if !tableExists(db, "qt_client_schema_state") {
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
username VARCHAR(100) NOT NULL,
last_applied_migration_id VARCHAR(128) NULL,
app_version VARCHAR(64) NULL,
last_checked_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (username),
INDEX idx_qt_client_schema_state_checked (last_checked_at)
)
`).Error; err != nil {
return fmt.Errorf("create qt_client_schema_state table: %w", err)
}
}
return nil
}
func tableExists(db *gorm.DB, tableName string) bool {
var count int64
// For MariaDB/MySQL, check information_schema
if err := db.Raw(`
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
`, tableName).Scan(&count).Error; err != nil {
return false
}
return count > 0
}
func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {
for i := range migrations {
m := migrations[i]
computedChecksum := digestSQL(m.SQLText)
checksum := strings.TrimSpace(m.Checksum)
if checksum == "" {
checksum = computedChecksum
} else if !strings.EqualFold(checksum, computedChecksum) {
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
}
applied, err := s.localDB.GetRemoteMigrationApplied(m.ID)
if err == nil {
if strings.TrimSpace(applied.Checksum) != checksum {
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
}
continue
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("check local applied migration %s: %w", m.ID, err)
}
if strings.TrimSpace(m.SQLText) == "" {
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
return fmt.Errorf("mark empty migration %s as applied: %w", m.ID, err)
}
continue
}
statements := splitSQLStatementsLite(m.SQLText)
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
for _, stmt := range statements {
if err := tx.Exec(stmt).Error; err != nil {
return fmt.Errorf("apply migration %s statement %q: %w", m.ID, stmt, err)
}
}
return nil
}); err != nil {
return err
}
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
return fmt.Errorf("record applied migration %s: %w", m.ID, err)
}
}
return nil
}
func splitSQLStatementsLite(script string) []string {
scanner := bufio.NewScanner(strings.NewReader(script))
scanner.Buffer(make([]byte, 1024), 1024*1024)
lines := make([]string, 0, 64)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "--") {
continue
}
lines = append(lines, scanner.Text())
}
combined := strings.Join(lines, "\n")
raw := strings.Split(combined, ";")
stmts := make([]string, 0, len(raw))
for _, stmt := range raw {
trimmed := strings.TrimSpace(stmt)
if trimmed == "" {
continue
}
stmts = append(stmts, trimmed)
}
return stmts
}
func digestSQL(sqlText string) string {
hash := sha256.Sum256([]byte(sqlText))
return hex.EncodeToString(hash[:])
}
func compareVersions(left, right string) int {
leftParts := normalizeVersionParts(left)
rightParts := normalizeVersionParts(right)
maxLen := len(leftParts)
if len(rightParts) > maxLen {
maxLen = len(rightParts)
}
for i := 0; i < maxLen; i++ {
lv := 0
rv := 0
if i < len(leftParts) {
lv = leftParts[i]
}
if i < len(rightParts) {
rv = rightParts[i]
}
if lv < rv {
return -1
}
if lv > rv {
return 1
}
}
return 0
}
func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) error {
if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
return nil
}
username := strings.TrimSpace(s.localDB.GetDBUser())
if username == "" {
return nil
}
lastMigrationID := ""
if id, err := s.localDB.GetLatestAppliedRemoteMigrationID(); err == nil {
lastMigrationID = id
}
return mariaDB.Exec(`
INSERT INTO qt_client_schema_state (username, last_applied_migration_id, app_version, last_checked_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_applied_migration_id = VALUES(last_applied_migration_id),
app_version = VALUES(app_version),
last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at)
`, username, lastMigrationID, appmeta.Version(), checkedAt, checkedAt).Error
}
func normalizeVersionParts(v string) []int {
trimmed := strings.TrimSpace(v)
trimmed = strings.TrimPrefix(trimmed, "v")
chunks := strings.Split(trimmed, ".")
parts := make([]int, 0, len(chunks))
for _, chunk := range chunks {
clean := strings.TrimSpace(chunk)
if clean == "" {
parts = append(parts, 0)
continue
}
n := 0
for i := 0; i < len(clean); i++ {
if clean[i] < '0' || clean[i] > '9' {
clean = clean[:i]
break
}
}
if clean != "" {
if parsed, err := strconv.Atoi(clean); err == nil {
n = parsed
}
}
parts = append(parts, n)
}
return parts
}
func toReadinessFromState(state *localdb.LocalSyncGuardState) *SyncReadiness {
if state == nil {
return nil
}
blocked := state.Status == ReadinessBlocked
return &SyncReadiness{
Status: state.Status,
Blocked: blocked,
ReasonCode: state.ReasonCode,
ReasonText: state.ReasonText,
RequiredMinAppVersion: state.RequiredMinAppVersion,
LastCheckedAt: state.LastCheckedAt,
}
}

View File

@@ -322,6 +322,9 @@ func (s *Service) NeedSync() (bool, error) {
// SyncPricelists synchronizes all active pricelists from server to local SQLite
func (s *Service) SyncPricelists() (int, error) {
slog.Info("starting pricelist sync")
if _, err := s.EnsureReadinessForSync(); err != nil {
return 0, err
}
// Get database connection
mariaDB, err := s.getDB()
@@ -337,19 +340,16 @@ func (s *Service) SyncPricelists() (int, error) {
if err != nil {
return 0, fmt.Errorf("getting active server pricelists: %w", err)
}
serverPricelistIDs := make([]uint, 0, len(serverPricelists))
for i := range serverPricelists {
serverPricelistIDs = append(serverPricelistIDs, serverPricelists[i].ID)
}
synced := 0
var latestEstimateLocalID uint
var latestEstimateCreatedAt time.Time
for _, pl := range serverPricelists {
// Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
if existing != nil {
// Track latest estimate pricelist by created_at for component refresh.
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
latestEstimateCreatedAt = pl.CreatedAt
latestEstimateLocalID = existing.ID
}
continue
}
@@ -378,21 +378,14 @@ func (s *Service) SyncPricelists() (int, error) {
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
}
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
latestEstimateCreatedAt = pl.CreatedAt
latestEstimateLocalID = localPL.ID
}
synced++
}
// Update component prices from latest estimate pricelist only.
if latestEstimateLocalID > 0 {
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID)
if err != nil {
slog.Warn("failed to update component prices from pricelist", "error", err)
} else {
slog.Info("updated component prices from latest pricelist", "updated", updated)
}
removed, err := s.localDB.DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs)
if err != nil {
slog.Warn("failed to cleanup stale local pricelists", "error", err)
} else if removed > 0 {
slog.Info("deleted stale local pricelists", "deleted", removed)
}
// Update last sync time
@@ -539,24 +532,34 @@ func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, e
}
func ensureUserSyncStatusTable(db *gorm.DB) error {
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
username VARCHAR(100) NOT NULL,
last_sync_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
app_version VARCHAR(64) NULL,
PRIMARY KEY (username),
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
)
`).Error; err != nil {
return err
// Check if table exists instead of trying to create (avoids permission issues)
if !tableExists(db, "qt_pricelist_sync_status") {
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
username VARCHAR(100) NOT NULL,
last_sync_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
app_version VARCHAR(64) NULL,
PRIMARY KEY (username),
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
)
`).Error; err != nil {
return fmt.Errorf("create qt_pricelist_sync_status table: %w", err)
}
}
// Backward compatibility for environments where table was created without app_version.
return db.Exec(`
ALTER TABLE qt_pricelist_sync_status
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
`).Error
// Only try to add column if table exists.
if tableExists(db, "qt_pricelist_sync_status") {
if err := db.Exec(`
ALTER TABLE qt_pricelist_sync_status
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
`).Error; err != nil {
// Log but don't fail if alter fails (column might already exist)
slog.Debug("failed to add app_version column", "error", err)
}
}
return nil
}
// SyncPricelistItems synchronizes items for a specific pricelist
@@ -592,10 +595,14 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
// Convert and save locally
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i, item := range serverItems {
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
partnumbers = append(partnumbers, item.Partnumbers...)
localItems[i] = localdb.LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
}
}
@@ -672,6 +679,10 @@ func (s *Service) SyncPricelistsIfNeeded() error {
// PushPendingChanges pushes all pending changes to the server
func (s *Service) PushPendingChanges() (int, error) {
if _, err := s.EnsureReadinessForSync(); err != nil {
return 0, err
}
removed, err := s.localDB.PurgeOrphanConfigurationPendingChanges()
if err != nil {
slog.Warn("failed to purge orphan configuration pending changes", "error", err)

View File

@@ -0,0 +1,85 @@
package sync_test
import (
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
)
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
t.Fatalf("migrate server pricelist tables: %v", err)
}
serverPL := models.Pricelist{
Source: "estimate",
Version: "2026-01-01-001",
Notification: "server",
CreatedBy: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-1 * time.Hour),
}
if err := serverDB.Create(&serverPL).Error; err != nil {
t.Fatalf("create server pricelist: %v", err)
}
if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil {
t.Fatalf("create server pricelist item: %v", err)
}
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 9991,
Source: "estimate",
Version: "old-unused",
Name: "old-unused",
CreatedAt: time.Now().Add(-2 * time.Hour),
SyncedAt: time.Now().Add(-2 * time.Hour),
IsUsed: false,
}); err != nil {
t.Fatalf("seed local missing pricelist: %v", err)
}
missingUsed := &localdb.LocalPricelist{
ServerID: 9992,
Source: "estimate",
Version: "old-used",
Name: "old-used",
CreatedAt: time.Now().Add(-2 * time.Hour),
SyncedAt: time.Now().Add(-2 * time.Hour),
IsUsed: false,
}
if err := local.SaveLocalPricelist(missingUsed); err != nil {
t.Fatalf("seed local referenced pricelist: %v", err)
}
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
UUID: "cfg-1",
OriginalUsername: "tester",
Name: "cfg",
Items: localdb.LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1}},
IsActive: true,
PricelistID: &missingUsed.ServerID,
SyncStatus: "synced",
CreatedAt: time.Now().Add(-30 * time.Minute),
UpdatedAt: time.Now().Add(-30 * time.Minute),
}); err != nil {
t.Fatalf("seed local configuration with pricelist ref: %v", err)
}
svc := syncsvc.NewServiceWithDB(serverDB, local)
if _, err := svc.SyncPricelists(); err != nil {
t.Fatalf("sync pricelists: %v", err)
}
if _, err := local.GetLocalPricelistByServerID(9991); err == nil {
t.Fatalf("expected unused missing local pricelist to be deleted")
}
if _, err := local.GetLocalPricelistByServerID(9992); err != nil {
t.Fatalf("expected local pricelist referenced by active config to stay: %v", err)
}
if _, err := local.GetLocalPricelistByServerID(serverPL.ID); err != nil {
t.Fatalf("expected server pricelist to be synced locally: %v", err)
}
}

View File

@@ -349,6 +349,10 @@ CREATE TABLE qt_configurations (
is_template INTEGER NOT NULL DEFAULT 0,
server_count INTEGER NOT NULL DEFAULT 1,
pricelist_id INTEGER NULL,
warehouse_pricelist_id INTEGER NULL,
competitor_pricelist_id INTEGER NULL,
disable_price_refresh INTEGER NOT NULL DEFAULT 0,
only_in_stock INTEGER NOT NULL DEFAULT 0,
price_updated_at DATETIME NULL,
created_at DATETIME
);`).Error; err != nil {

View File

@@ -71,6 +71,15 @@ func (w *Worker) runSync() {
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
pushed, err := w.service.PushPendingChanges()
if err != nil {

View File

@@ -0,0 +1,2 @@
ALTER TABLE stock_log
CHANGE COLUMN lot partnumber VARCHAR(255) NOT NULL;

View File

@@ -0,0 +1,3 @@
-- Add only_in_stock toggle to configuration settings persisted in MariaDB.
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS only_in_stock BOOLEAN NOT NULL DEFAULT FALSE AFTER disable_price_refresh;

View File

@@ -0,0 +1,19 @@
-- Ensure fast lookup for /api/quote/price-levels batched queries:
-- SELECT ... FROM qt_pricelist_items WHERE pricelist_id = ? AND lot_name IN (...)
SET @has_idx := (
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'qt_pricelist_items'
AND index_name IN ('idx_qt_pricelist_items_pricelist_lot', 'idx_pricelist_lot')
);
SET @ddl := IF(
@has_idx = 0,
'ALTER TABLE qt_pricelist_items ADD INDEX idx_qt_pricelist_items_pricelist_lot (pricelist_id, lot_name)',
'SELECT ''idx_qt_pricelist_items_pricelist_lot already exists, skip'''
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

BIN
qfs Executable file

Binary file not shown.

72
releases/memory/v1.2.1.md Normal file
View File

@@ -0,0 +1,72 @@
# v1.2.1 Release Notes
**Date:** 2026-02-09
**Changes since v1.2.0:** 2 commits
## Summary
Fixed configurator component substitution by updating to work with new pricelist-based pricing model. Addresses regression from v1.2.0 refactor that removed `CurrentPrice` field from components.
## Commits
### 1. Refactor: Remove CurrentPrice from local_components (5984a57)
**Type:** Refactor
**Files Changed:** 11 files, +167 insertions, -194 deletions
#### Overview
Transitioned from component-based pricing to pricelist-based pricing model:
- Removed `CurrentPrice` and `SyncedAt` from LocalComponent (metadata-only now)
- Added `WarehousePricelistID` and `CompetitorPricelistID` to LocalConfiguration
- Removed 2 unused methods: UpdateComponentPricesFromPricelist, EnsureComponentPricesFromPricelists
#### Key Changes
- **Data Model:**
- LocalComponent: now stores only metadata (LotName, LotDescription, Category, Model)
- LocalConfiguration: added warehouse and competitor pricelist references
- **Migrations:**
- drop_component_unused_fields - removes CurrentPrice, SyncedAt columns
- add_warehouse_competitor_pricelists - adds new pricelist fields
- **Quote Calculation:**
- Updated to use pricelist_items instead of component.CurrentPrice
- Added PricelistID field to QuoteRequest
- Maintains offline-first behavior
- **API:**
- Removed CurrentPrice from ComponentView
- Components API no longer returns pricing
### 2. Fix: Load component prices via API (acf7c8a)
**Type:** Bug Fix
**Files Changed:** 1 file (web/templates/index.html), +66 insertions, -12 deletions
#### Problem
After v1.2.0 refactor, the configurator's autocomplete was filtering out all components because it checked for the removed `current_price` field on component objects.
#### Solution
Implemented on-demand price loading via API:
- Added `ensurePricesLoaded()` function to fetch prices from `/api/quote/price-levels`
- Added `componentPricesCache` to cache loaded prices in memory
- Updated all 3 autocomplete modes (single, multi, section) to load prices when input is focused
- Changed price validation from `c.current_price` to `hasComponentPrice(lot_name)`
- Updated cart item creation to use cached API prices
#### Impact
- Components without prices are still filtered out (as required)
- Price checks now use API data instead of removed database field
- Frontend loads prices on-demand for better performance
## Testing Notes
- ✅ Configurator component substitution now works
- ✅ Prices load correctly from pricelist
- ✅ Offline mode still supported (prices cached after initial load)
- ✅ Multi-pricelist support functional (estimate/warehouse/competitor)
## Known Issues
None
## Migration Path
No database migration needed from v1.2.0 - migrations were applied in v1.2.0 release.
## Breaking Changes
None for end users. Internal: `ComponentView` no longer includes `CurrentPrice` in API responses.

View File

@@ -1,51 +0,0 @@
# QuoteForge v1.0.3
Дата релиза: 2026-02-06
Тег: `v1.0.3`
Диапазон изменений: `v1.0.2..v1.0.3`
## Что нового
- Добавлена страница управления проектами `/projects` с:
- датой и временем создания проекта;
- сортировкой по названию и дате создания;
- серверной пагинацией;
- фильтром по автору в заголовке таблицы.
- Добавлена отдельная вкладка `Статус синхронизации` на уровне `Алерты / Компоненты / Прайслисты`.
- Во вкладке статуса синхронизации отображаются:
- пользователь;
- версия приложения;
- статус (`онлайн` или относительное время последней синхронизации).
## Изменения синхронизации
- Реализован heartbeat синхронизации пользователей в MariaDB: `qt_pricelist_sync_status`.
- Добавлен API `GET /api/sync/users-status` для UI статуса синхронизации.
- Логика онлайн-статуса рассчитана от интервала фоновой синхронизации: `5 минут + 10%`.
- В heartbeat фиксируется версия приложения (`app_version`).
## Важные исправления
- Исправлено восстановление отсутствующей серверной конфигурации при push обновлений.
- Исправлено экранирование паролей в MySQL DSN в setup.
- Улучшена логика запуска SQL-миграций на старте при отсутствии прав/необходимости.
- Обновлена логика пересчета прайслистов через админский price-refresh.
## Миграции и совместимость
Добавлены SQL-миграции:
- `migrations/010_add_pricelist_sync_status.sql`
- `migrations/011_add_app_version_to_pricelist_sync_status.sql`
Релиз совместим с предыдущей веткой `v1.0.x`; новая таблица синхронизации создается автоматически.
## Коммиты в релизе
- `b1b50ce` Add projects table controls and sync status tab with app version
- `6ab1e98` sync: recover missing server config during update push
- `a1d2192` Fix MySQL DSN escaping for setup passwords and clarify DB user setup
- `a90c07c` update stale files list
- `e9307c4` Apply remaining pricelist and local-first updates
- `1b48401` Use admin price-refresh logic for pricelist recalculation
- `4a86f7b` fix: skip startup sql migrations when not needed or no permissions

View File

@@ -0,0 +1,89 @@
# QuoteForge v1.2.1
**Дата релиза:** 2026-02-09
**Тег:** `v1.2.1`
**GitHub:** https://git.mchus.pro/mchus/QuoteForge/releases/tag/v1.2.1
## Резюме
Быстрый патч-релиз, исправляющий регрессию в конфигураторе после рефактора v1.2.0. После удаления поля `CurrentPrice` из компонентов, autocomplete перестал показывать компоненты. Теперь используется на-demand загрузка цен через API.
## Что исправлено
### 🐛 Configurator Component Substitution (acf7c8a)
- **Проблема:** После рефактора в v1.2.0, autocomplete фильтровал ВСЕ компоненты, потому что проверял удаленное поле `current_price`
- **Решение:** Загрузка цен на-demand через `/api/quote/price-levels`
- Добавлен `componentPricesCache` для кэширования цен в памяти
- Функция `ensurePricesLoaded()` загружает цены при фокусе на поле поиска
- Все 3 режима autocomplete (single, multi, section) обновлены
- Компоненты без цен по-прежнему фильтруются (как требуется), но проверка использует API
- **Затронутые файлы:** `web/templates/index.html` (+66 строк, -12 строк)
## История v1.2.0 → v1.2.1
Всего коммитов: **2**
| Хеш | Автор | Сообщение |
|-----|-------|-----------|
| `acf7c8a` | Claude | fix: load component prices via API instead of removed current_price field |
| `5984a57` | Claude | refactor: remove CurrentPrice from local_components and transition to pricelist-based pricing |
## Тестирование
✅ Configurator component substitution работает
✅ Цены загружаются корректно из pricelist
✅ Offline режим поддерживается (цены кэшируются после первой загрузки)
✅ Multi-pricelist поддержка функциональна (estimate/warehouse/competitor)
## Breaking Changes
Нет критических изменений для конечных пользователей.
⚠️ **Для разработчиков:** `ComponentView` API больше не возвращает `CurrentPrice`.
## Миграция
Не требуется миграция БД — все миграции были применены в v1.2.0.
## Установка
### macOS
```bash
# Скачать и распаковать
tar xzf qfs-v1.2.1-darwin-arm64.tar.gz # для Apple Silicon
# или
tar xzf qfs-v1.2.1-darwin-amd64.tar.gz # для Intel Mac
# Снять ограничение Gatekeeper (если требуется)
xattr -d com.apple.quarantine ./qfs
# Запустить
./qfs
```
### Linux
```bash
tar xzf qfs-v1.2.1-linux-amd64.tar.gz
./qfs
```
### Windows
```bash
# Распаковать qfs-v1.2.1-windows-amd64.zip
# Запустить qfs.exe
```
## Известные проблемы
Нет известных проблем на момент релиза.
## Поддержка
По вопросам обращайтесь: [@mchus](https://git.mchus.pro/mchus)
---
*Отправлено с ❤️ через Claude Code*

56
scripts/check-secrets.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "Not inside a git repository."
exit 1
fi
if ! command -v rg >/dev/null 2>&1; then
echo "ripgrep (rg) is required for secret scanning."
exit 1
fi
staged_files=()
while IFS= read -r file; do
staged_files+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMRTUXB)
if [ "${#staged_files[@]}" -eq 0 ]; then
exit 0
fi
secret_pattern='AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|-----BEGIN (RSA|OPENSSH|EC|DSA|PRIVATE) KEY-----|(?i)(password|passwd|pwd|secret|token|api[_-]?key|jwt_secret)\s*[:=]\s*["'"'"'][^"'"'"'\s]{8,}["'"'"']'
allow_pattern='CHANGE_ME|REDACTED|PLACEHOLDER|EXAMPLE|example|<[^>]+>'
found=0
for file in "${staged_files[@]}"; do
case "$file" in
dist/*|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.pdf|*.zip|*.gz|*.exe|*.dll|*.so|*.dylib)
continue
;;
esac
if ! content="$(git show ":$file" 2>/dev/null)"; then
continue
fi
hits="$(printf '%s' "$content" | rg -n --no-heading -e "$secret_pattern" || true)"
if [ -n "$hits" ]; then
filtered="$(printf '%s\n' "$hits" | rg -v -e "$allow_pattern" || true)"
if [ -n "$filtered" ]; then
echo "Potential secret found in staged file: $file"
printf '%s\n' "$filtered"
found=1
fi
fi
done
if [ "$found" -ne 0 ]; then
echo
echo "Commit blocked: remove or redact secrets before committing."
exit 1
fi
exit 0

View File

@@ -25,6 +25,25 @@ echo ""
RELEASE_DIR="releases/${VERSION}"
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
echo -e "${YELLOW}→ Building binaries...${NC}"
make build-all

78
todo.md Normal file
View File

@@ -0,0 +1,78 @@
# QuoteForge — План очистки (удаление admin pricing)
Цель: убрать всё, что связано с администрированием цен, складскими справками, алертами.
Оставить: конфигуратор, проекты, read-only просмотр прайслистов, sync, offline-first.
---
## 1. Удалить файлы
- [x] `internal/handlers/pricing.go` (40.6KB) — весь admin pricing UI
- [x] `internal/services/pricing/` — весь пакет расчёта цен
- [x] `internal/services/pricelist/` — весь пакет управления прайслистами
- [x] `internal/services/stock_import.go` — импорт складских справок
- [x] `internal/services/alerts/` — весь пакет алертов
- [x] `internal/warehouse/` — алгоритмы расчёта цен по складу
- [x] `web/templates/admin_pricing.html` (109KB) — страница admin pricing
- [x] `cmd/cron/` — cron jobs (cleanup-pricelists, update-prices, update-popularity)
- [x] `cmd/importer/` — утилита импорта данных
## 2. Упростить `internal/handlers/pricelist.go` (read-only)
Read-only методы (List, Get, GetItems, GetLotNames, GetLatest) уже работают
только через `h.localDB` (SQLite) без `pricelist.Service`.
- [x] Убрать поле `service *pricelist.Service` из структуры `PricelistHandler`
- [x] Изменить конструктор: `NewPricelistHandler(localDB *localdb.LocalDB)`
- [x] Удалить write-методы: `Create()`, `CreateWithProgress()`, `Delete()`, `SetActive()`, `CanWrite()`
- [x] Удалить метод `refreshLocalPricelistCacheFromServer()` (зависит от service)
- [x] Удалить import `pricelist` пакета
- [x] Оставить: `List()`, `Get()`, `GetItems()`, `GetLotNames()`, `GetLatest()`
## 3. Упростить `cmd/qfs/main.go`
- [x] Удалить создание сервисов: `pricingService`, `alertService`, `pricelistService`, `stockImportService`
- [x] Удалить хэндлер: `pricingHandler`
- [x] Изменить создание `pricelistHandler`: `NewPricelistHandler(local)` (без service)
- [x] Удалить repositories: `priceRepo`, `alertRepo` (statsRepo оставить — nil-safe)
- [x] Удалить все routes `/api/admin/pricing/*` (строки ~1407-1430)
- [x] Из `/api/pricelists/*` оставить только read-only:
- `GET ""` (List), `GET "/latest"`, `GET "/:id"`, `GET "/:id/items"`, `GET "/:id/lots"`
- [x] Удалить write routes: `POST ""`, `POST "/create-with-progress"`, `PATCH "/:id/active"`, `DELETE "/:id"`, `GET "/can-write"`
- [x] Удалить web page `/admin/pricing`
- [x] Исправить `/pricelists` — вместо redirect на admin/pricing сделать страницу
- [x] В `QuoteService` конструкторе: передавать `nil` для `pricingService`
- [x] Удалить imports: `pricing`, `pricelist`, `alerts` пакеты
## 4. Упростить `handlers/web.go`
- [x] Удалить из `simplePages`: `admin_pricing.html`
- [x] Удалить метод: `AdminPricing()`
- [x] Оставить все остальные методы включая `Pricelists()` и `PricelistDetail()`
## 5. Упростить `base.html` (навигация)
- [x] Убрать ссылку "Администратор цен"
- [x] Добавить ссылку "Прайслисты" (на `/pricelists`)
- [x] Оставить: "Мои проекты", "Прайслисты", sync indicator
## 6. Sync — оставить полностью
- Background worker: pull компоненты + прайслисты, push конфигурации
- Все `/api/sync/*` endpoints остаются
- Это ядро offline-first архитектуры
## 7. Верификация
- [x] `go build ./cmd/qfs` — компилируется
- [x] `go vet ./...` — без ошибок
- [ ] Запуск → `/configs` работает
- [ ] `/pricelists` — read-only список работает
- [ ] `/pricelists/:id` — детали работают
- [ ] Sync с сервером работает
- [ ] Нет ссылок на admin pricing в UI
## 8. Обновить CLAUDE.md
- [x] Убрать разделы про admin pricing, stock import, alerts, cron
- [x] Обновить API endpoints список
- [x] Обновить описание приложения

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
<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 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>
</div>
</div>
@@ -38,7 +38,7 @@
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 pb-12">
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{{template "content" .}}
</main>
@@ -46,7 +46,7 @@
<!-- Sync Info Modal -->
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
@@ -57,28 +57,72 @@
</button>
</div>
<div class="space-y-4">
<div class="space-y-5">
<!-- Section 1: DB Connection -->
<div>
<h4 class="font-medium text-gray-900">Статус БД</h4>
<p id="modal-db-status" class="text-sm text-gray-600">Проверка...</p>
</div>
<div>
<h4 class="font-medium text-gray-900">Количество ошибок</h4>
<p id="modal-error-count" class="text-sm text-gray-600">0</p>
</div>
<div>
<h4 class="font-medium text-gray-900">Последняя синхронизация</h4>
<p id="modal-last-sync" class="text-sm text-gray-600">-</p>
</div>
<div>
<h4 class="font-medium text-gray-900">Список ошибок</h4>
<div id="modal-errors-list" class="mt-2 text-sm text-gray-600 max-h-40 overflow-y-auto">
<p>Нет ошибок</p>
<h4 class="font-medium text-gray-900 mb-2">Подключение к БД</h4>
<div class="text-sm space-y-1">
<div class="flex justify-between">
<span class="text-gray-500">Адрес:</span>
<span id="modal-db-host" class="text-gray-700 font-mono"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Пользователь:</span>
<span id="modal-db-user" class="text-gray-700"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Статус:</span>
<span id="modal-db-status" class="text-gray-700">Проверка...</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Последняя синхронизация:</span>
<span id="modal-last-sync" class="text-gray-700"></span>
</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 -->
<div>
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
<span class="text-gray-500">Компоненты (lot):</span>
<span id="modal-lot-count" class="font-medium text-gray-700"></span>
</div>
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
<span class="text-gray-500">Котировки:</span>
<span id="modal-lotlog-count" class="font-medium text-gray-700"></span>
</div>
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
<span class="text-gray-500">Конфигурации:</span>
<span id="modal-config-count" class="font-medium text-gray-700"></span>
</div>
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
<span class="text-gray-500">Проекты:</span>
<span id="modal-project-count" class="font-medium text-gray-700"></span>
</div>
</div>
</div>
<!-- Section 3: Pending Changes (shown only if any) -->
<div id="modal-pending-section" class="hidden">
<h4 class="font-medium text-gray-900 mb-2">Ожидающие синхронизации</h4>
<div id="modal-pending-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
</div>
<!-- Section 4: Errors (shown only if any) -->
<div id="modal-errors-section" class="hidden">
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
</div>
</div>
<div class="mt-6 flex justify-end">
@@ -90,13 +134,6 @@
</div>
</div>
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
<div class="max-w-7xl mx-auto flex justify-between">
<span id="db-status">БД: проверка...</span>
<span id="db-counts"></span>
</div>
</footer>
<script>
function showToast(msg, type) {
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
@@ -129,37 +166,95 @@
const resp = await fetch('/api/sync/info');
const data = await resp.json();
document.getElementById('modal-db-status').textContent = data.is_online ? 'Подключено' : 'Отключено';
document.getElementById('modal-error-count').textContent = data.error_count;
// Section 1: DB Connection
document.getElementById('modal-db-host').textContent = data.db_host ? data.db_host + '/' + data.db_name : '—';
document.getElementById('modal-db-user').textContent = data.db_user || '—';
const statusEl = document.getElementById('modal-db-status');
if (data.is_online) {
statusEl.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-1"></span>Online';
} else {
statusEl.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-red-500 mr-1"></span>Offline';
}
if (data.last_sync_at) {
const date = new Date(data.last_sync_at);
document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU');
} else {
document.getElementById('modal-last-sync').textContent = 'Нет данных';
document.getElementById('modal-last-sync').textContent = '';
}
// Load error list
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
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-config-count').textContent = data.config_count.toLocaleString();
document.getElementById('modal-project-count').textContent = data.project_count.toLocaleString();
// Section 3: Pending changes
const pendingSection = document.getElementById('modal-pending-section');
const pendingList = document.getElementById('modal-pending-list');
if (data.pending_changes && data.pending_changes.length > 0) {
pendingSection.classList.remove('hidden');
pendingList.innerHTML = data.pending_changes.map(ch => {
const shortUUID = ch.entity_uuid.substring(0, 8);
const time = new Date(ch.created_at).toLocaleString('ru-RU');
const hasError = ch.last_error ? ' border-l-2 border-red-400 pl-2' : '';
const errorLine = ch.last_error ? `<div class="text-red-500 text-xs mt-0.5">${ch.last_error}</div>` : '';
return `<div class="bg-gray-50 rounded px-3 py-1.5${hasError}">
<span class="font-medium">${ch.operation}</span>
<span class="text-gray-500">${ch.entity_type}</span>
<span class="font-mono text-xs text-gray-400">${shortUUID}</span>
<span class="text-gray-400 text-xs ml-1">${time}</span>
${errorLine}
</div>`;
}).join('');
} else {
pendingSection.classList.add('hidden');
}
// Section 4: Errors
const errorsSection = document.getElementById('modal-errors-section');
const errorsList = document.getElementById('modal-errors-list');
if (data.errors && data.errors.length > 0) {
errorsList.innerHTML = data.errors.map(error =>
`<div class="mb-1"><span class="font-medium">${error.timestamp}</span>: ${error.message}</div>`
).join('');
errorsSection.classList.remove('hidden');
errorsList.innerHTML = data.errors.map(error => {
const time = new Date(error.timestamp).toLocaleString('ru-RU');
return `<div class="bg-red-50 text-red-700 rounded px-3 py-1.5">
<span class="text-xs text-red-400">${time}</span>: ${error.message}
</div>`;
}).join('');
} else {
errorsList.innerHTML = '<p>Нет ошибок</p>';
errorsSection.classList.add('hidden');
}
} catch(e) {
console.error('Failed to load sync info:', e);
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
document.getElementById('modal-error-count').textContent = '0';
document.getElementById('modal-last-sync').textContent = '-';
document.getElementById('modal-errors-list').innerHTML = '<p>Ошибка загрузки данных</p>';
}
}
// Event delegation for sync dropdown and actions
document.addEventListener('DOMContentLoaded', function() {
checkDbStatus();
loadDBUser();
checkWritePermission();
});
@@ -190,6 +285,11 @@
showToast(successMessage, 'success');
// Update last sync time - removed since dropdown is gone
// loadLastSyncTime();
} else if (resp.status === 423) {
const reason = data.reason_text || data.error || 'Синхронизация заблокирована.';
showToast(reason, 'error');
openSyncModal();
loadSyncInfo();
} else {
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
}
@@ -214,26 +314,16 @@
syncAction('/api/sync/all', 'Полная синхронизация завершена', button, originalHTML);
}
async function checkDbStatus() {
async function loadDBUser() {
try {
const resp = await fetch('/api/db-status');
const data = await resp.json();
const statusEl = document.getElementById('db-status');
const countsEl = document.getElementById('db-counts');
const userEl = document.getElementById('db-user');
if (data.connected) {
statusEl.innerHTML = '<span class="text-green-400">БД: подключено</span>';
if (data.db_user) {
userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
}
} else {
statusEl.innerHTML = '<span class="text-red-400">БД: ошибка - ' + data.error + '</span>';
if (data.connected && data.db_user) {
userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
}
countsEl.textContent = 'lot: ' + data.lot_count + ' | lot_log: ' + data.lot_log_count + ' | metadata: ' + data.metadata_count;
} catch(e) {
document.getElementById('db-status').innerHTML = '<span class="text-red-400">БД: нет связи</span>';
// ignore
}
}
@@ -262,7 +352,7 @@
// Call functions immediately to ensure they run even before DOMContentLoaded
// This ensures username and admin link are visible ASAP
checkDbStatus();
loadDBUser();
checkWritePermission();
// Load last sync time - removed since dropdown is gone

View File

@@ -835,12 +835,17 @@ async function loadProjectsForConfigUI() {
projectsCache = [];
projectNameByUUID = {};
try {
const resp = await fetch('/api/projects?status=all');
// Use /api/projects/all to get all projects without pagination
const resp = await fetch('/api/projects/all');
if (!resp.ok) return;
const data = await resp.json();
projectsCache = (data.projects || []);
// data is now a simple array of {uuid, name} objects
const allProjects = Array.isArray(data) ? data : (data.projects || []);
projectsCache.forEach(project => {
// For compatibility with rest of code, populate projectsCache but mainly use projectNameByUUID
projectsCache = allProjects;
allProjects.forEach(project => {
projectNameByUUID[project.uuid] = project.name;
});

View File

@@ -246,6 +246,10 @@
<input id="settings-disable-price-refresh" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span>Не обновлять цены</span>
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input id="settings-only-in-stock" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span>Только наличие</span>
</label>
</div>
<div class="px-5 py-4 border-t flex justify-end gap-2">
<button type="button" onclick="closePriceSettingsModal()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Отмена</button>
@@ -334,12 +338,21 @@ let selectedPricelistIds = {
competitor: null
};
let disablePriceRefresh = false;
let onlyInStock = false;
let activePricelistsBySource = {
estimate: [],
warehouse: [],
competitor: []
};
let activePricelistsLoadedAt = 0;
let activePricelistsLoadPromise = null;
let priceLevelsRequestSeq = 0;
let priceLevelsRefreshTimer = null;
let warehouseStockLotsByPricelist = new Map();
let warehouseStockLoadSeq = 0;
let warehouseStockLoadsByPricelist = new Map();
let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via API
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
// Autocomplete state
let autocompleteInput = null;
@@ -389,8 +402,10 @@ function formatDelta(abs, pct) {
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
}
async function refreshPriceLevels() {
if (!configUUID || cart.length === 0 || disablePriceRefresh) {
async function refreshPriceLevels(options = {}) {
const force = options.force === true;
const noCache = options.noCache === true;
if (!configUUID || cart.length === 0 || (disablePriceRefresh && !force)) {
return;
}
@@ -401,6 +416,7 @@ async function refreshPriceLevels() {
lot_name: item.lot_name,
quantity: item.quantity
})),
no_cache: noCache,
pricelist_ids: Object.fromEntries(
Object.entries(selectedPricelistIds)
.filter(([, id]) => typeof id === 'number' && id > 0)
@@ -443,6 +459,99 @@ async function refreshPriceLevels() {
}
}
function schedulePriceLevelsRefresh(options = {}) {
const delay = Number.isFinite(options.delay) ? options.delay : 120;
const rerender = options.rerender !== false;
const autosave = options.autosave === true;
const noCache = options.noCache === true;
const force = options.force === true;
if (priceLevelsRefreshTimer) {
clearTimeout(priceLevelsRefreshTimer);
priceLevelsRefreshTimer = null;
}
priceLevelsRefreshTimer = setTimeout(async () => {
priceLevelsRefreshTimer = null;
await refreshPriceLevels({ noCache, force });
if (rerender) {
renderTab();
updateCartUI();
}
if (autosave) {
triggerAutoSave();
}
}, Math.max(0, delay));
}
function currentWarehousePricelistID() {
const id = selectedPricelistIds.warehouse;
if (Number.isFinite(id) && id > 0) return Number(id);
const fallback = activePricelistsBySource.warehouse?.[0]?.id;
if (Number.isFinite(fallback) && fallback > 0) return Number(fallback);
return null;
}
async function loadWarehouseInStockLots() {
const pricelistID = currentWarehousePricelistID();
if (!pricelistID) return new Set();
if (warehouseStockLotsByPricelist.has(pricelistID)) {
return warehouseStockLotsByPricelist.get(pricelistID);
}
const existingLoad = warehouseStockLoadsByPricelist.get(pricelistID);
if (existingLoad) {
return existingLoad;
}
const loadPromise = (async () => {
const seq = ++warehouseStockLoadSeq;
const result = new Set();
const resp = await fetch(`/api/pricelists/${pricelistID}/lots`);
if (!resp.ok) {
throw new Error(`warehouse lots request failed: ${resp.status}`);
}
const data = await resp.json();
const lotNames = Array.isArray(data.lot_names) ? data.lot_names : [];
lotNames.forEach(lot => {
if (typeof lot === 'string' && lot.trim() !== '') {
result.add(lot);
}
});
if (seq === warehouseStockLoadSeq) {
warehouseStockLotsByPricelist.set(pricelistID, result);
}
return result;
})();
warehouseStockLoadsByPricelist.set(pricelistID, loadPromise);
try {
return await loadPromise;
} finally {
warehouseStockLoadsByPricelist.delete(pricelistID);
}
}
async function ensureWarehouseStockFilterLoaded() {
if (!onlyInStock) return;
try {
await loadWarehouseInStockLots();
} catch (e) {
console.error('Failed to load warehouse availability filter', e);
showToast('Не удалось загрузить наличие склада', 'error');
}
}
function isComponentAllowedByStockFilter(comp) {
if (!onlyInStock) return true;
const pricelistID = currentWarehousePricelistID();
if (!pricelistID) return false;
const availableLots = warehouseStockLotsByPricelist.get(pricelistID);
// Don't block UI while stock set is being loaded.
if (!availableLots) return true;
return availableLots.has(comp.lot_name);
}
// Load categories from API and update tab configuration
async function loadCategoriesFromAPI() {
try {
@@ -486,8 +595,8 @@ document.addEventListener('DOMContentLoaded', async function() {
return;
}
// Load categories first
await loadCategoriesFromAPI();
// Load categories in background (defaults are usable immediately).
const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
try {
const resp = await fetch('/api/configs/' + configUUID);
@@ -508,6 +617,7 @@ document.addEventListener('DOMContentLoaded', async function() {
document.getElementById('server-count').value = serverCount;
document.getElementById('total-server-count').textContent = serverCount;
selectedPricelistIds.estimate = config.pricelist_id || null;
onlyInStock = Boolean(config.only_in_stock);
if (config.items && config.items.length > 0) {
cart = config.items.map(item => ({
@@ -538,14 +648,21 @@ document.addEventListener('DOMContentLoaded', async function() {
}
restoreLocalPriceSettings();
await loadActivePricelists();
await Promise.all([
loadActivePricelists(),
loadAllComponents(),
categoriesPromise,
]);
syncPriceSettingsControls();
renderPricelistSettingsSummary();
updateRefreshPricesButtonState();
await loadAllComponents();
await refreshPriceLevels();
renderTab();
updateCartUI();
ensureWarehouseStockFilterLoaded().then(() => {
renderTab();
updateCartUI();
});
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: false });
// Close autocomplete on outside click
document.addEventListener('click', function(e) {
@@ -582,25 +699,44 @@ function updateServerCount() {
triggerAutoSave();
}
async function loadActivePricelists() {
async function loadActivePricelists(force = false) {
const now = Date.now();
const isFresh = (now - activePricelistsLoadedAt) < 15000;
if (!force && isFresh) {
return;
}
if (activePricelistsLoadPromise) {
await activePricelistsLoadPromise;
return;
}
const sources = ['estimate', 'warehouse', 'competitor'];
await Promise.all(sources.map(async source => {
try {
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
const data = await resp.json();
activePricelistsBySource[source] = data.pricelists || [];
const existing = selectedPricelistIds[source];
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
return;
activePricelistsLoadPromise = (async () => {
await Promise.all(sources.map(async source => {
try {
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
const data = await resp.json();
activePricelistsBySource[source] = data.pricelists || [];
const existing = selectedPricelistIds[source];
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
return;
}
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
? Number(activePricelistsBySource[source][0].id)
: null;
} catch (e) {
activePricelistsBySource[source] = [];
selectedPricelistIds[source] = null;
}
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
? Number(activePricelistsBySource[source][0].id)
: null;
} catch (e) {
activePricelistsBySource[source] = [];
selectedPricelistIds[source] = null;
}
}));
}));
activePricelistsLoadedAt = Date.now();
})();
try {
await activePricelistsLoadPromise;
} finally {
activePricelistsLoadPromise = null;
}
}
function renderPricelistSelectOptions(selectId, source) {
@@ -627,6 +763,10 @@ function syncPriceSettingsControls() {
if (disableCheckbox) {
disableCheckbox.checked = disablePriceRefresh;
}
const inStockCheckbox = document.getElementById('settings-only-in-stock');
if (inStockCheckbox) {
inStockCheckbox.checked = onlyInStock;
}
}
function getPricelistVersionById(source, id) {
@@ -642,7 +782,8 @@ function renderPricelistSettingsSummary() {
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}`;
const stockFilterState = onlyInStock ? ' | Только наличие: вкл' : '';
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}${stockFilterState}`;
}
function updateRefreshPricesButtonState() {
@@ -693,11 +834,14 @@ function restoreLocalPriceSettings() {
}
}
async function openPriceSettingsModal() {
await loadActivePricelists();
function openPriceSettingsModal() {
syncPriceSettingsControls();
renderPricelistSettingsSummary();
document.getElementById('price-settings-modal')?.classList.remove('hidden');
loadActivePricelists().then(() => {
syncPriceSettingsControls();
renderPricelistSettingsSummary();
});
}
function closePriceSettingsModal() {
@@ -709,22 +853,31 @@ function applyPriceSettings() {
const warehouseVal = parseInt(document.getElementById('settings-pricelist-warehouse')?.value || '');
const competitorVal = parseInt(document.getElementById('settings-pricelist-competitor')?.value || '');
const disableVal = Boolean(document.getElementById('settings-disable-price-refresh')?.checked);
const inStockVal = Boolean(document.getElementById('settings-only-in-stock')?.checked);
const prevWarehouseID = currentWarehousePricelistID();
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
disablePriceRefresh = disableVal;
onlyInStock = inStockVal;
const nextWarehouseID = currentWarehousePricelistID();
if (Number.isFinite(prevWarehouseID) && prevWarehouseID > 0 && prevWarehouseID !== nextWarehouseID) {
warehouseStockLotsByPricelist.delete(prevWarehouseID);
}
if (onlyInStock) {
ensureWarehouseStockFilterLoaded().then(() => {
renderTab();
updateCartUI();
});
}
updateRefreshPricesButtonState();
renderPricelistSettingsSummary();
persistLocalPriceSettings();
closePriceSettingsModal();
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave();
});
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true });
}
function getCategoryFromLotName(lotName) {
@@ -1050,12 +1203,54 @@ function renderMultiSelectTabWithSections(sections) {
document.getElementById('tab-content').innerHTML = html;
}
// Load prices for components in a category/tab via API
async function ensurePricesLoaded(components) {
if (!components || components.length === 0) return;
// Filter out components that already have prices cached
const toLoad = components.filter(c => !(c.lot_name in componentPricesCache));
if (toLoad.length === 0) return;
try {
// Use quote/price-levels API to get prices for these components
const resp = await fetch('/api/quote/price-levels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: toLoad.map(c => ({ lot_name: c.lot_name, quantity: 1 })),
pricelist_ids: Object.fromEntries(
Object.entries(selectedPricelistIds)
.filter(([, id]) => typeof id === 'number' && id > 0)
)
})
});
if (resp.ok) {
const data = await resp.json();
if (data.items) {
data.items.forEach(item => {
// Cache the estimate price (or 0 if not found)
componentPricesCache[item.lot_name] = item.estimate_price || 0;
});
}
}
} catch (e) {
console.error('Failed to load component prices', e);
}
}
function hasComponentPrice(lotName) {
return lotName in componentPricesCache && componentPricesCache[lotName] > 0;
}
// Autocomplete for single select (Base tab)
function showAutocomplete(category, input) {
async function showAutocomplete(category, input) {
autocompleteInput = input;
autocompleteCategory = category;
autocompleteMode = 'single';
autocompleteIndex = -1;
const components = getComponentsForCategory(category);
await ensurePricesLoaded(components);
filterAutocomplete(category, input.value);
}
@@ -1064,7 +1259,8 @@ function filterAutocomplete(category, search) {
const searchLower = search.toLowerCase();
autocompleteFiltered = components.filter(c => {
if (!c.current_price) return false;
if (!hasComponentPrice(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
@@ -1146,12 +1342,13 @@ function selectAutocompleteItem(index) {
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: comp.current_price,
estimate_price: comp.current_price,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
@@ -1166,11 +1363,10 @@ function selectAutocompleteItem(index) {
});
hideAutocomplete();
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave();
});
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
function hideAutocomplete() {
@@ -1182,11 +1378,13 @@ function hideAutocomplete() {
}
// Autocomplete for multi select tabs
function showAutocompleteMulti(input) {
async function showAutocompleteMulti(input) {
autocompleteInput = input;
autocompleteCategory = null;
autocompleteMode = 'multi';
autocompleteIndex = -1;
const components = getComponentsForTab(currentTab);
await ensurePricesLoaded(components);
filterAutocompleteMulti(input.value);
}
@@ -1198,8 +1396,9 @@ function filterAutocompleteMulti(search) {
const addedLots = new Set(cart.map(i => i.lot_name));
autocompleteFiltered = components.filter(c => {
if (!c.current_price) return false;
if (!hasComponentPrice(c.lot_name)) return false;
if (addedLots.has(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
@@ -1238,12 +1437,13 @@ function selectAutocompleteItemMulti(index) {
const qtyInput = document.getElementById('new-qty');
const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: comp.current_price,
estimate_price: comp.current_price,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
@@ -1258,19 +1458,23 @@ function selectAutocompleteItemMulti(index) {
});
hideAutocomplete();
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave();
});
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
function showAutocompleteSection(sectionId, input) {
async function showAutocompleteSection(sectionId, input) {
autocompleteInput = input;
autocompleteCategory = sectionId; // Store section ID
autocompleteMode = 'section';
autocompleteIndex = -1;
// Load prices for tab components
const components = getComponentsForTab(currentTab);
await ensurePricesLoaded(components);
filterAutocompleteSection(sectionId, input.value, input);
}
@@ -1297,8 +1501,9 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
const addedLots = new Set(cart.map(i => i.lot_name));
autocompleteFiltered = sectionComponents.filter(c => {
if (!c.current_price) return false;
if (!hasComponentPrice(c.lot_name)) return false;
if (addedLots.has(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
@@ -1337,12 +1542,13 @@ function selectAutocompleteItemSection(index, sectionId) {
const qtyInput = document.getElementById('new-qty-' + sectionId);
const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: comp.current_price,
estimate_price: comp.current_price,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
@@ -1364,12 +1570,10 @@ function selectAutocompleteItemSection(index, sectionId) {
// Reset quantity to 1
if (qtyInput) qtyInput.value = '1';
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave();
});
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
function clearSingleSelect(category) {
@@ -1517,6 +1721,12 @@ function triggerAutoSave() {
async function saveConfig(showNotification = true) {
// RBAC disabled - no token check required
if (!configUUID) return;
if (priceLevelsRefreshTimer) {
clearTimeout(priceLevelsRefreshTimer);
priceLevelsRefreshTimer = null;
}
await refreshPriceLevels({ force: true, noCache: true });
// Get custom price if set
const customPriceInput = document.getElementById('custom-price-input');
@@ -1538,7 +1748,8 @@ async function saveConfig(showNotification = true) {
custom_price: customPrice,
notes: '',
server_count: serverCountValue,
pricelist_id: selectedPricelistIds.estimate
pricelist_id: selectedPricelistIds.estimate,
only_in_stock: onlyInStock
})
});
@@ -1559,21 +1770,39 @@ async function saveConfig(showNotification = true) {
}
}
// Helper function to extract filename from Content-Disposition header
function getFilenameFromResponse(resp) {
const contentDisposition = resp.headers.get('content-disposition');
if (!contentDisposition) return null;
const matches = contentDisposition.match(/filename="?([^"]+)"?/);
return matches && matches[1] ? matches[1] : null;
}
async function exportCSV() {
if (cart.length === 0) return;
try {
if (priceLevelsRefreshTimer) {
clearTimeout(priceLevelsRefreshTimer);
priceLevelsRefreshTimer = null;
}
await refreshPriceLevels({ force: true, noCache: true });
const exportItems = cart.map(item => ({
...item,
unit_price: getDisplayPrice(item),
}));
const resp = await fetch('/api/export/csv', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: cart, name: configName})
body: JSON.stringify({items: exportItems, name: configName})
});
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (configName || 'config') + '.csv';
a.download = getFilenameFromResponse(resp) || (configName || 'config') + '.csv';
a.click();
window.URL.revokeObjectURL(url);
} catch(e) {
@@ -1793,6 +2022,11 @@ function clearCustomPrice() {
async function exportCSVWithCustomPrice() {
if (cart.length === 0) return;
if (priceLevelsRefreshTimer) {
clearTimeout(priceLevelsRefreshTimer);
priceLevelsRefreshTimer = null;
}
await refreshPriceLevels({ force: true, noCache: true });
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
@@ -1821,7 +2055,7 @@ async function exportCSVWithCustomPrice() {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (configName || 'config') + '.csv';
a.download = getFilenameFromResponse(resp) || (configName || 'config') + '.csv';
a.click();
window.URL.revokeObjectURL(url);
} catch(e) {

View File

@@ -14,7 +14,14 @@
</span>
{{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()">
<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>

View File

@@ -169,6 +169,16 @@
return qty.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 3 });
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatPriceSettings(item) {
// Format price settings to match admin pricing interface style
let settings = [];

View File

@@ -12,6 +12,7 @@
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Тип</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th>
@@ -22,7 +23,7 @@
</thead>
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
<td colspan="8" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
</tr>
</tbody>
</table>
@@ -87,7 +88,7 @@
} catch (e) {
document.getElementById('pricelists-body').innerHTML = `
<tr>
<td colspan="7" class="px-6 py-4 text-center text-red-500">
<td colspan="8" class="px-6 py-4 text-center text-red-500">
Ошибка загрузки: ${e.message}
</td>
</tr>
@@ -99,7 +100,7 @@
if (pricelists.length === 0) {
document.getElementById('pricelists-body').innerHTML = `
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
<td colspan="8" class="px-6 py-4 text-center text-gray-500">
Прайслисты не найдены. ${canWrite ? 'Создайте первый прайслист.' : ''}
</td>
</tr>
@@ -109,6 +110,12 @@
const html = pricelists.map(pl => {
const date = new Date(pl.created_at).toLocaleDateString('ru-RU');
const sourceToType = {
estimate: 'estimate',
warehouse: 'stock',
competitor: 'b2b'
};
const pricelistType = sourceToType[pl.source] || pl.source || '-';
const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
@@ -122,6 +129,7 @@
<td class="px-6 py-4 whitespace-nowrap">
<span class="font-mono text-sm">${pl.version}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pricelistType}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td>