24 Commits

Author SHA1 Message Date
Mikhail Chusavitin
9b5d57902d Add project variants and UI updates 2026-02-13 19:27:48 +03:00
Mikhail Chusavitin
4e1a46bd71 Fix project selection and add project settings UI 2026-02-13 12:51:53 +03:00
Mikhail Chusavitin
857ec7a0e5 Fix article category fallback for pricelist gaps 2026-02-12 16:47:49 +03:00
Mikhail Chusavitin
01f21fa5ac Document backup implementation guide 2026-02-11 19:50:35 +03:00
Mikhail Chusavitin
a1edca3be9 Add scheduled rotating local backups 2026-02-11 19:48:40 +03:00
Mikhail Chusavitin
7fbf813952 docs: add release notes for v1.3.0 2026-02-11 19:27:16 +03:00
Mikhail Chusavitin
e58fd35ee4 Refine article compression and simplify generator 2026-02-11 19:24:25 +03:00
Mikhail Chusavitin
e3559035f7 Allow cross-user project updates 2026-02-11 19:24:16 +03:00
Mikhail Chusavitin
5edffe822b Add article generation and pricelist categories 2026-02-11 19:16:01 +03:00
Mikhail Chusavitin
99fd80bca7 feat: unify sync functionality with event-driven UI updates
- Refactored navbar sync button to dispatch 'sync-completed' event
- Configs page: removed duplicate 'Импорт с сервера' button, added auto-refresh on sync
- Projects page: wrapped initialization in DOMContentLoaded, added auto-refresh on sync
- Pricelists page: added auto-refresh on sync completion
- Consistent UX: all lists update automatically after 'Синхронизация' button click
- Removed code duplication: importConfigsFromServer() function no longer needed
- Event-driven architecture enables easy extension to other pages

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-10 11:11:10 +03:00
Mikhail Chusavitin
d8edd5d5f0 chore: exclude qfs binary and update release notes for v1.2.2
- Add qfs binary to gitignore (compiled executable from build)
- Update UI labels in configuration form for clarity
- Add release notes documenting v1.2.2 changes

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 17:50:58 +03:00
Mikhail Chusavitin
9cb17ee03f chore: simplify gitignore rules for releases binaries
- Ignore all files in releases/ directory (binaries, archives, checksums)
- Preserve releases/memory/ for changelog tracking
- Changed from 'releases/' to 'releases/*' for clearer intent

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 17:41:41 +03:00
Mikhail Chusavitin
8f596cec68 fix: standardize CSV export filename format to use project name
Unified export filename format across both ExportCSV and ExportConfigCSV:
- Format: YYYY-MM-DD (project_name) config_name BOM.csv
- Use PriceUpdatedAt if available, otherwise CreatedAt
- Extract project name from ProjectUUID for ExportCSV via projectService
- Pass project_uuid from frontend to backend in export request
- Add projectUUID and projectName state variables to track project context

This ensures consistent naming whether exporting from form or project view,
and uses most recent price update timestamp in filename.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-09 17:22:51 +03:00
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
68 changed files with 5824 additions and 543 deletions

6
.gitignore vendored
View File

@@ -23,6 +23,7 @@ secrets.yml
/importer /importer
/cron /cron
/bin/ /bin/
qfs
# Local Go build cache used in sandboxed runs # Local Go build cache used in sandboxed runs
.gocache/ .gocache/
@@ -74,4 +75,7 @@ Network Trash Folder
Temporary Items Temporary Items
.apdisk .apdisk
releases/ # Release artifacts (binaries, archives, checksums), but DO track releases/memory/ for changelog
releases/*
!releases/memory/
!releases/memory/**

View File

@@ -56,6 +56,12 @@
- `/pricelists/:id` - `/pricelists/:id`
- `/setup` - `/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 ## Commands
```bash ```bash
# Development # Development

106
README.md
View File

@@ -105,58 +105,85 @@ go run ./cmd/migrate_ops_projects -apply
go run ./cmd/migrate_ops_projects -apply -yes go run ./cmd/migrate_ops_projects -apply -yes
``` ```
### Минимальные права БД для пользователя квотаций ### Права БД для пользователя приложения
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты: #### Полный набор прав для обычного пользователя
Чтобы выдать существующему пользователю все необходимые права (без переоздания):
```sql ```sql
-- 1) Создать пользователя (если его ещё нет) -- Справочные таблицы (только чтение)
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>'; 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 '<DB_PASSWORD>'; 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@'%' GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
DROP USER IF EXISTS 'quote_user'@'localhost'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
DROP USER IF EXISTS 'quote_user'@'127.0.0.1'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
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'@'%';
-- Применить изменения
FLUSH PRIVILEGES; 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 ```sql
GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>'; -- 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.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata 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_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.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_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@'%'`; - **Таблицы синхронизации** должны быть созданы администратором БД один раз. Приложение не требует прав CREATE TABLE.
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя. - **Прайслисты** (`qt_pricelists`, `qt_pricelist_items`) — справочные таблицы, управляются сервером, пользователь имеет только SELECT.
- **Конфигурации и проекты** — таблицы, в которые пишет само приложение (INSERT, UPDATE при сохранении изменений).
- **Таблицы миграций** нужны для синхронизации: приложение читает список миграций и отчитывается о применённых.
- Если видите ошибку `Access denied for user ...@'<ip>'`, проверьте наличие конфликтующих записей пользователя с разными хостами (user@localhost vs user@'%').
### 4. Импорт метаданных компонентов ### 4. Импорт метаданных компонентов
@@ -320,9 +347,22 @@ quoteforge/
│ └── static/ # CSS, JS, изображения │ └── static/ # CSS, JS, изображения
├── migrations/ # SQL миграции ├── migrations/ # SQL миграции
├── config.example.yaml # Пример конфигурации ├── config.example.yaml # Пример конфигурации
├── releases/
│ └── memory/ # Changelog между тегами (v1.2.1.md, v1.2.2.md, ...)
└── go.mod └── go.mod
``` ```
## Releases & Changelog
Change log между версиями хранится в `releases/memory/` каталоге в файлах вида `v{major}.{minor}.{patch}.md`.
Каждый файл содержит:
- Список коммитов между версиями
- Описание изменений и их влияния
- Breaking changes и заметки о миграции
**Перед работой над кодом проверьте последний файл в этой папке, чтобы понять текущее состояние проекта.**
## Роли пользователей ## Роли пользователей
| Роль | Описание | | Роль | Описание |
@@ -406,6 +446,8 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
| `QFS_DB_PATH` | Полный путь к локальной SQLite БД | OS-specific user state dir | | `QFS_DB_PATH` | Полный путь к локальной SQLite БД | OS-specific user state dir |
| `QFS_STATE_DIR` | Каталог state (если `QFS_DB_PATH` не задан) | OS-specific user state dir | | `QFS_STATE_DIR` | Каталог state (если `QFS_DB_PATH` не задан) | OS-specific user state dir |
| `QFS_CONFIG_PATH` | Полный путь к `config.yaml` | OS-specific user state dir | | `QFS_CONFIG_PATH` | Полный путь к `config.yaml` | OS-specific user state dir |
| `QFS_BACKUP_DIR` | Каталог для ротационных бэкапов локальных данных | `<db dir>/backups` |
| `QFS_BACKUP_DISABLE` | Отключить автоматические бэкапы (`1/true/yes`) | — |
## Интеграция с существующей БД ## Интеграция с существующей БД

View File

@@ -163,7 +163,7 @@ func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string
} }
for i := range projects { for i := range projects {
p := projects[i] p := projects[i]
existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p existingProjects[projectKey(p.OwnerUsername, derefString(p.Name))] = &p
} }
} }
@@ -253,12 +253,13 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
for _, action := range actions { for _, action := range actions {
key := projectKey(action.OwnerUsername, action.TargetProjectName) key := projectKey(action.OwnerUsername, action.TargetProjectName)
project := projectCache[key] project := projectCache[key]
if project == nil { if project == nil {
project = &models.Project{ project = &models.Project{
UUID: uuid.NewString(), UUID: uuid.NewString(),
OwnerUsername: action.OwnerUsername, OwnerUsername: action.OwnerUsername,
Name: action.TargetProjectName, Code: action.TargetProjectName,
Name: ptrString(action.TargetProjectName),
IsActive: true, IsActive: true,
IsSystem: false, IsSystem: false,
} }
@@ -268,7 +269,7 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
projectCache[key] = project projectCache[key] = project
} else if !project.IsActive { } else if !project.IsActive {
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil { if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
return fmt.Errorf("reactivate project %s (%s): %w", project.Name, project.UUID, err) return fmt.Errorf("reactivate project %s (%s): %w", derefString(project.Name), project.UUID, err)
} }
project.IsActive = true project.IsActive = true
} }
@@ -294,3 +295,14 @@ func setKeys(set map[string]struct{}) []string {
func projectKey(owner, name string) string { func projectKey(owner, name string) string {
return owner + "||" + name return owner + "||" + name
} }
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func ptrString(value string) *string {
return &value
}

View File

@@ -50,6 +50,7 @@ const onDemandPullCooldown = 30 * time.Second
func main() { func main() {
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)") configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)") localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
migrate := flag.Bool("migrate", false, "run database migrations") migrate := flag.Bool("migrate", false, "run database migrations")
version := flag.Bool("version", false, "show version information") version := flag.Bool("version", false, "show version information")
flag.Parse() flag.Parse()
@@ -100,6 +101,13 @@ func main() {
} }
} }
if shouldResetLocalDB(*resetLocalDB) {
if err := localdb.ResetData(resolvedLocalDBPath); err != nil {
slog.Error("failed to reset local database", "error", err)
os.Exit(1)
}
}
// Initialize local SQLite database (always used) // Initialize local SQLite database (always used)
local, err := localdb.New(resolvedLocalDBPath) local, err := localdb.New(resolvedLocalDBPath)
if err != nil { if err != nil {
@@ -232,6 +240,10 @@ func main() {
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval) syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
go syncWorker.Start(workerCtx) go syncWorker.Start(workerCtx)
backupCtx, backupCancel := context.WithCancel(context.Background())
defer backupCancel()
go startBackupScheduler(backupCtx, cfg, resolvedLocalDBPath, resolvedConfigPath)
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Address(), Addr: cfg.Address(),
Handler: router, Handler: router,
@@ -274,6 +286,7 @@ func main() {
// Stop background sync worker first // Stop background sync worker first
syncWorker.Stop() syncWorker.Stop()
workerCancel() workerCancel()
backupCancel()
// Then shutdown HTTP server // Then shutdown HTTP server
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -290,6 +303,31 @@ func main() {
} }
} }
func shouldResetLocalDB(flagValue bool) bool {
if flagValue {
return true
}
value := strings.TrimSpace(os.Getenv("QFS_RESET_LOCAL_DB"))
if value == "" {
return false
}
switch strings.ToLower(value) {
case "1", "true", "yes", "y":
return true
default:
return false
}
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func setConfigDefaults(cfg *config.Config) { func setConfigDefaults(cfg *config.Config) {
if cfg.Server.Host == "" { if cfg.Server.Host == "" {
cfg.Server.Host = "127.0.0.1" cfg.Server.Host = "127.0.0.1"
@@ -324,6 +362,9 @@ func setConfigDefaults(cfg *config.Config) {
if cfg.Pricing.MinQuotesForMedian == 0 { if cfg.Pricing.MinQuotesForMedian == 0 {
cfg.Pricing.MinQuotesForMedian = 3 cfg.Pricing.MinQuotesForMedian = 3
} }
if cfg.Backup.Time == "" {
cfg.Backup.Time = "00:00"
}
} }
func ensureDefaultConfigFile(configPath string) error { func ensureDefaultConfigFile(configPath string) error {
@@ -347,6 +388,9 @@ func ensureDefaultConfigFile(configPath string) error {
read_timeout: 30s read_timeout: 30s
write_timeout: 30s write_timeout: 30s
backup:
time: "00:00"
logging: logging:
level: "info" level: "info"
format: "json" format: "json"
@@ -373,9 +417,14 @@ type runtimeLoggingConfig struct {
Output string `yaml:"output"` Output string `yaml:"output"`
} }
type runtimeBackupConfig struct {
Time string `yaml:"time"`
}
type runtimeConfigFile struct { type runtimeConfigFile struct {
Server runtimeServerConfig `yaml:"server"` Server runtimeServerConfig `yaml:"server"`
Logging runtimeLoggingConfig `yaml:"logging"` Logging runtimeLoggingConfig `yaml:"logging"`
Backup runtimeBackupConfig `yaml:"backup"`
} }
// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format. // migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
@@ -398,6 +447,9 @@ func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) erro
Format: cfg.Logging.Format, Format: cfg.Logging.Format,
Output: cfg.Logging.Output, Output: cfg.Logging.Output,
}, },
Backup: runtimeBackupConfig{
Time: cfg.Backup.Time,
},
} }
rendered, err := yaml.Marshal(&runtimeCfg) rendered, err := yaml.Marshal(&runtimeCfg)
@@ -416,6 +468,69 @@ func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) erro
return nil return nil
} }
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
if cfg == nil {
return
}
hour, minute, err := parseBackupTime(cfg.Backup.Time)
if err != nil {
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
hour = 0
minute = 0
}
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
slog.Error("local backup failed", "error", backupErr)
} else if len(created) > 0 {
for _, path := range created {
slog.Info("local backup completed", "archive", path)
}
}
for {
next := nextBackupTime(time.Now(), hour, minute)
timer := time.NewTimer(time.Until(next))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
start := time.Now()
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
duration := time.Since(start)
if backupErr != nil {
slog.Error("local backup failed", "error", backupErr, "duration", duration)
} else {
for _, path := range created {
slog.Info("local backup completed", "archive", path, "duration", duration)
}
}
}
}
}
func parseBackupTime(value string) (int, int, error) {
if strings.TrimSpace(value) == "" {
return 0, 0, fmt.Errorf("empty backup time")
}
parsed, err := time.Parse("15:04", value)
if err != nil {
return 0, 0, err
}
return parsed.Hour(), parsed.Minute(), nil
}
func nextBackupTime(now time.Time, hour, minute int) time.Time {
location := now.Location()
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location)
if !now.Before(target) {
target = target.Add(24 * time.Hour)
}
return target
}
// runSetupMode starts a minimal server that only serves the setup page // runSetupMode starts a minimal server that only serves the setup page
func runSetupMode(local *localdb.LocalDB) { func runSetupMode(local *localdb.LocalDB) {
restartSig := make(chan struct{}, 1) restartSig := make(chan struct{}, 1)
@@ -695,7 +810,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Handlers // Handlers
componentHandler := handlers.NewComponentHandler(componentService, local) componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService) quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService, projectService)
pricelistHandler := handlers.NewPricelistHandler(local) pricelistHandler := handlers.NewPricelistHandler(local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil { if err != nil {
@@ -926,6 +1041,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusCreated, config) c.JSON(http.StatusCreated, config)
}) })
configs.POST("/preview-article", func(c *gin.Context) {
var req services.ArticlePreviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result, err := configService.BuildArticlePreview(&req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"article": result.Article,
"warnings": result.Warnings,
})
})
configs.GET("/:uuid", func(c *gin.Context) { configs.GET("/:uuid", func(c *gin.Context) {
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := configService.GetByUUIDNoAuth(uuid) config, err := configService.GetByUUIDNoAuth(uuid)
@@ -1152,6 +1284,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
"current_version": currentVersion, "current_version": currentVersion,
}) })
}) })
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
} }
projects := api.Group("/projects") projects := api.Group("/projects")
@@ -1164,7 +1298,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
search := strings.ToLower(strings.TrimSpace(c.Query("search"))) search := strings.ToLower(strings.TrimSpace(c.Query("search")))
author := strings.ToLower(strings.TrimSpace(c.Query("author"))) author := strings.ToLower(strings.TrimSpace(c.Query("author")))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) 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"))) sortField := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sort", "created_at")))
sortDir := strings.ToLower(strings.TrimSpace(c.DefaultQuery("dir", "desc"))) sortDir := strings.ToLower(strings.TrimSpace(c.DefaultQuery("dir", "desc")))
if status != "active" && status != "archived" && status != "all" { if status != "active" && status != "archived" && status != "all" {
@@ -1204,7 +1339,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if status == "archived" && p.IsActive { if status == "archived" && p.IsActive {
continue continue
} }
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) { if search != "" &&
!strings.Contains(strings.ToLower(derefString(p.Name)), search) &&
!strings.Contains(strings.ToLower(p.Code), search) &&
!strings.Contains(strings.ToLower(p.Variant), search) {
continue continue
} }
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) { if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
@@ -1217,8 +1355,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
left := filtered[i] left := filtered[i]
right := filtered[j] right := filtered[j]
if sortField == "name" { if sortField == "name" {
leftName := strings.ToLower(strings.TrimSpace(left.Name)) leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
rightName := strings.ToLower(strings.TrimSpace(right.Name)) rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
if leftName == rightName { if leftName == rightName {
if sortDir == "asc" { if sortDir == "asc" {
return left.CreatedAt.Before(right.CreatedAt) return left.CreatedAt.Before(right.CreatedAt)
@@ -1231,8 +1369,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
return leftName > rightName return leftName > rightName
} }
if left.CreatedAt.Equal(right.CreatedAt) { if left.CreatedAt.Equal(right.CreatedAt) {
leftName := strings.ToLower(strings.TrimSpace(left.Name)) leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
rightName := strings.ToLower(strings.TrimSpace(right.Name)) rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
if sortDir == "asc" { if sortDir == "asc" {
return leftName < rightName return leftName < rightName
} }
@@ -1291,6 +1429,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
"id": p.ID, "id": p.ID,
"uuid": p.UUID, "uuid": p.UUID,
"owner_username": p.OwnerUsername, "owner_username": p.OwnerUsername,
"code": p.Code,
"variant": p.Variant,
"name": p.Name, "name": p.Name,
"tracker_url": p.TrackerURL, "tracker_url": p.TrackerURL,
"is_active": p.IsActive, "is_active": p.IsActive,
@@ -1316,19 +1456,55 @@ 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"`
Code string `json:"code"`
Variant string `json:"variant"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
}
simplified := make([]ProjectSimple, 0, len(allProjects))
for _, p := range allProjects {
simplified = append(simplified, ProjectSimple{
UUID: p.UUID,
Code: p.Code,
Variant: p.Variant,
Name: derefString(p.Name),
IsActive: p.IsActive,
})
}
c.JSON(http.StatusOK, simplified)
})
projects.POST("", func(c *gin.Context) { projects.POST("", func(c *gin.Context) {
var req services.CreateProjectRequest var req services.CreateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if strings.TrimSpace(req.Name) == "" { if strings.TrimSpace(req.Code) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"})
return return
} }
project, err := projectService.Create(dbUsername, &req) project, err := projectService.Create(dbUsername, &req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) switch {
case errors.Is(err, services.ErrProjectCodeExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return return
} }
c.JSON(http.StatusCreated, project) c.JSON(http.StatusCreated, project)
@@ -1356,13 +1532,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if strings.TrimSpace(req.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
return
}
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req) project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectCodeExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):

View File

@@ -149,7 +149,7 @@ func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
t.Fatalf("setup router: %v", err) t.Fatalf("setup router: %v", err)
} }
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1"}`))) createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1","code":"P1"}`)))
createProjectReq.Header.Set("Content-Type", "application/json") createProjectReq.Header.Set("Content-Type", "application/json")
createProjectRec := httptest.NewRecorder() createProjectRec := httptest.NewRecorder()
router.ServeHTTP(createProjectRec, createProjectReq) router.ServeHTTP(createProjectRec, createProjectReq)
@@ -243,7 +243,7 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
t.Fatalf("setup router: %v", err) t.Fatalf("setup router: %v", err)
} }
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project"}`))) createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project","code":"MOVE"}`)))
createProjectReq.Header.Set("Content-Type", "application/json") createProjectReq.Header.Set("Content-Type", "application/json")
createProjectRec := httptest.NewRecorder() createProjectRec := httptest.NewRecorder()
router.ServeHTTP(createProjectRec, createProjectReq) router.ServeHTTP(createProjectRec, createProjectReq)

View File

@@ -37,6 +37,9 @@ export:
max_file_age: "1h" max_file_age: "1h"
company_name: "Your Company Name" company_name: "Your Company Name"
backup:
time: "00:00"
alerts: alerts:
enabled: true enabled: true
check_interval: "1h" check_interval: "1h"

273
internal/appstate/backup.go Normal file
View File

@@ -0,0 +1,273 @@
package appstate
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type backupPeriod struct {
name string
retention int
key func(time.Time) string
date func(time.Time) string
}
var backupPeriods = []backupPeriod{
{
name: "daily",
retention: 7,
key: func(t time.Time) string {
return t.Format("2006-01-02")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "weekly",
retention: 4,
key: func(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", y, w)
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "monthly",
retention: 12,
key: func(t time.Time) string {
return t.Format("2006-01")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "yearly",
retention: 10,
key: func(t time.Time) string {
return t.Format("2006")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
}
const (
envBackupDisable = "QFS_BACKUP_DISABLE"
envBackupDir = "QFS_BACKUP_DIR"
)
var backupNow = time.Now
// EnsureRotatingLocalBackup creates or refreshes daily/weekly/monthly/yearly backups
// for the local database and config. It keeps a limited number per period.
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
if isBackupDisabled() {
return nil, nil
}
if dbPath == "" {
return nil, nil
}
if _, err := os.Stat(dbPath); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("stat db: %w", err)
}
root := resolveBackupRoot(dbPath)
now := backupNow()
created := make([]string, 0)
for _, period := range backupPeriods {
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
if err != nil {
return created, err
}
if len(newFiles) > 0 {
created = append(created, newFiles...)
}
}
return created, nil
}
func resolveBackupRoot(dbPath string) string {
if fromEnv := strings.TrimSpace(os.Getenv(envBackupDir)); fromEnv != "" {
return filepath.Clean(fromEnv)
}
return filepath.Join(filepath.Dir(dbPath), "backups")
}
func isBackupDisabled() bool {
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
return val == "1" || val == "true" || val == "yes"
}
func ensurePeriodBackup(root string, period backupPeriod, now time.Time, dbPath, configPath string) ([]string, error) {
key := period.key(now)
periodDir := filepath.Join(root, period.name)
if err := os.MkdirAll(periodDir, 0755); err != nil {
return nil, fmt.Errorf("create %s backup dir: %w", period.name, err)
}
if hasBackupForKey(periodDir, key) {
return nil, nil
}
archiveName := fmt.Sprintf("qfs-backp-%s.zip", period.date(now))
archivePath := filepath.Join(periodDir, archiveName)
if err := createBackupArchive(archivePath, dbPath, configPath); err != nil {
return nil, fmt.Errorf("create %s backup archive: %w", period.name, err)
}
if err := writePeriodMarker(periodDir, key); err != nil {
return []string{archivePath}, err
}
if err := pruneOldBackups(periodDir, period.retention); err != nil {
return []string{archivePath}, err
}
return []string{archivePath}, nil
}
func hasBackupForKey(periodDir, key string) bool {
marker := periodMarker{Key: ""}
data, err := os.ReadFile(periodMarkerPath(periodDir))
if err != nil {
return false
}
if err := json.Unmarshal(data, &marker); err != nil {
return false
}
return marker.Key == key
}
type periodMarker struct {
Key string `json:"key"`
}
func periodMarkerPath(periodDir string) string {
return filepath.Join(periodDir, ".period.json")
}
func writePeriodMarker(periodDir, key string) error {
data, err := json.MarshalIndent(periodMarker{Key: key}, "", " ")
if err != nil {
return err
}
return os.WriteFile(periodMarkerPath(periodDir), data, 0644)
}
func pruneOldBackups(periodDir string, keep int) error {
entries, err := os.ReadDir(periodDir)
if err != nil {
return fmt.Errorf("read backups dir: %w", err)
}
files := make([]os.DirEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.HasSuffix(entry.Name(), ".zip") {
files = append(files, entry)
}
}
if len(files) <= keep {
return nil
}
sort.Slice(files, func(i, j int) bool {
infoI, errI := files[i].Info()
infoJ, errJ := files[j].Info()
if errI != nil || errJ != nil {
return files[i].Name() < files[j].Name()
}
return infoI.ModTime().Before(infoJ.ModTime())
})
for i := 0; i < len(files)-keep; i++ {
path := filepath.Join(periodDir, files[i].Name())
if err := os.Remove(path); err != nil {
return fmt.Errorf("remove old backup %s: %w", path, err)
}
}
return nil
}
func createBackupArchive(destPath, dbPath, configPath string) error {
file, err := os.Create(destPath)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
if err := addZipFile(zipWriter, dbPath); err != nil {
_ = zipWriter.Close()
return err
}
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
if strings.TrimSpace(configPath) != "" {
_ = addZipOptionalFile(zipWriter, configPath)
}
if err := zipWriter.Close(); err != nil {
return err
}
return file.Sync()
}
func addZipOptionalFile(writer *zip.Writer, path string) error {
if _, err := os.Stat(path); err != nil {
return nil
}
return addZipFile(writer, path)
}
func addZipFile(writer *zip.Writer, path string) error {
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = filepath.Base(path)
header.Method = zip.Deflate
out, err := writer.CreateHeader(header)
if err != nil {
return err
}
_, err = io.Copy(out, in)
return err
}

View File

@@ -0,0 +1,83 @@
package appstate
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
temp := t.TempDir()
dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatalf("write db: %v", err)
}
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
prevNow := backupNow
defer func() { backupNow = prevNow }()
backupNow = func() time.Time { return time.Date(2026, 2, 11, 10, 0, 0, 0, time.UTC) }
created, err := EnsureRotatingLocalBackup(dbPath, cfgPath)
if err != nil {
t.Fatalf("backup: %v", err)
}
if len(created) == 0 {
t.Fatalf("expected backup to be created")
}
dailyArchive := filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-11.zip")
if _, err := os.Stat(dailyArchive); err != nil {
t.Fatalf("daily archive missing: %v", err)
}
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
if err != nil {
t.Fatalf("backup rotate: %v", err)
}
if len(created) == 0 {
t.Fatalf("expected backup to be created for new day")
}
dailyArchive = filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-12.zip")
if _, err := os.Stat(dailyArchive); err != nil {
t.Fatalf("daily archive missing after rotate: %v", err)
}
}
func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
temp := t.TempDir()
dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatalf("write db: %v", err)
}
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
backupRoot := filepath.Join(temp, "custom_backups")
t.Setenv(envBackupDir, backupRoot)
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
t.Fatalf("backup with env: %v", err)
}
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
t.Fatalf("expected backup in custom dir: %v", err)
}
t.Setenv(envBackupDisable, "1")
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
t.Fatalf("backup disabled: %v", err)
}
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
t.Fatalf("backup should remain from previous run: %v", err)
}
}

View File

@@ -0,0 +1,124 @@
package article
import (
"errors"
"fmt"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
)
// ErrMissingCategoryForLot is returned when a lot has no category in local_pricelist_items.lot_category.
var ErrMissingCategoryForLot = errors.New("missing_category_for_lot")
type MissingCategoryForLotError struct {
LotName string
}
func (e *MissingCategoryForLotError) Error() string {
if e == nil || strings.TrimSpace(e.LotName) == "" {
return ErrMissingCategoryForLot.Error()
}
return fmt.Sprintf("%s: %s", ErrMissingCategoryForLot.Error(), e.LotName)
}
func (e *MissingCategoryForLotError) Unwrap() error {
return ErrMissingCategoryForLot
}
type Group string
const (
GroupCPU Group = "CPU"
GroupMEM Group = "MEM"
GroupGPU Group = "GPU"
GroupDISK Group = "DISK"
GroupNET Group = "NET"
GroupPSU Group = "PSU"
)
// GroupForLotCategory maps pricelist lot_category codes into article groups.
// Unknown/unrelated categories return ok=false.
func GroupForLotCategory(cat string) (group Group, ok bool) {
c := strings.ToUpper(strings.TrimSpace(cat))
switch c {
case "CPU":
return GroupCPU, true
case "MEM":
return GroupMEM, true
case "GPU":
return GroupGPU, true
case "M2", "SSD", "HDD", "EDSFF", "HHHL":
return GroupDISK, true
case "NIC", "HCA", "DPU":
return GroupNET, true
case "HBA":
return GroupNET, true
case "PSU", "PS":
return GroupPSU, true
default:
return "", false
}
}
// ResolveLotCategoriesStrict resolves categories for lotNames using local_pricelist_items.lot_category
// for a given server pricelist id. If any lot is missing or has empty category, returns an error.
func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
if local == nil {
return nil, fmt.Errorf("local db is nil")
}
cats, err := local.GetLocalLotCategoriesByServerPricelistID(serverPricelistID, lotNames)
if err != nil {
return nil, err
}
missing := make([]string, 0)
for _, lot := range lotNames {
cat := strings.TrimSpace(cats[lot])
if cat == "" {
missing = append(missing, lot)
continue
}
cats[lot] = cat
}
if len(missing) > 0 {
fallback, err := local.GetLocalComponentCategoriesByLotNames(missing)
if err != nil {
return nil, err
}
for _, lot := range missing {
if cat := strings.TrimSpace(fallback[lot]); cat != "" {
cats[lot] = cat
}
}
for _, lot := range missing {
if strings.TrimSpace(cats[lot]) == "" {
return nil, &MissingCategoryForLotError{LotName: lot}
}
}
}
return cats, nil
}
// NormalizeServerModel produces a stable article segment for the server model.
func NormalizeServerModel(model string) string {
trimmed := strings.TrimSpace(model)
if trimmed == "" {
return ""
}
upper := strings.ToUpper(trimmed)
var b strings.Builder
for _, r := range upper {
if r >= 'A' && r <= 'Z' {
b.WriteRune(r)
continue
}
if r >= '0' && r <= '9' {
b.WriteRune(r)
continue
}
if r == '.' {
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -0,0 +1,98 @@
package article
import (
"errors"
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
)
func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 1,
Source: "estimate",
Version: "S-2026-02-11-001",
Name: "test",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(1)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: localPL.ID, LotName: "CPU_A", LotCategory: "", Price: 10},
}); err != nil {
t.Fatalf("save local items: %v", err)
}
_, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"})
if err == nil {
t.Fatalf("expected error")
}
if !errors.Is(err, ErrMissingCategoryForLot) {
t.Fatalf("expected ErrMissingCategoryForLot, got %v", err)
}
}
func TestResolveLotCategoriesStrict_FallbackToLocalComponents(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 2,
Source: "estimate",
Version: "S-2026-02-11-002",
Name: "test",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(2)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: localPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10},
}); err != nil {
t.Fatalf("save local items: %v", err)
}
if err := local.DB().Create(&localdb.LocalComponent{
LotName: "CPU_B",
Category: "CPU",
LotDescription: "cpu",
}).Error; err != nil {
t.Fatalf("save local components: %v", err)
}
cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"})
if err != nil {
t.Fatalf("expected fallback, got error: %v", err)
}
if cats["CPU_B"] != "CPU" {
t.Fatalf("expected CPU, got %q", cats["CPU_B"])
}
}
func TestGroupForLotCategory(t *testing.T) {
if g, ok := GroupForLotCategory("cpu"); !ok || g != GroupCPU {
t.Fatalf("expected cpu -> GroupCPU")
}
if g, ok := GroupForLotCategory("SFP"); ok || g != "" {
t.Fatalf("expected SFP to be excluded")
}
}

View File

@@ -0,0 +1,602 @@
package article
import (
"fmt"
"regexp"
"sort"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
type BuildOptions struct {
ServerModel string
SupportCode string
ServerPricelist *uint
}
type BuildResult struct {
Article string
Warnings []string
}
var (
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
)
func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) {
segments := make([]string, 0, 8)
warnings := make([]string, 0)
model := NormalizeServerModel(opts.ServerModel)
if model == "" {
return BuildResult{}, fmt.Errorf("server_model required")
}
segments = append(segments, model)
lotNames := make([]string, 0, len(items))
for _, it := range items {
lotNames = append(lotNames, it.LotName)
}
if opts.ServerPricelist == nil || *opts.ServerPricelist == 0 {
return BuildResult{}, fmt.Errorf("pricelist_id required for article")
}
cats, err := ResolveLotCategoriesStrict(local, *opts.ServerPricelist, lotNames)
if err != nil {
return BuildResult{}, err
}
cpuSeg := buildCPUSegment(items, cats)
if cpuSeg != "" {
segments = append(segments, cpuSeg)
}
memSeg, memWarn := buildMemSegment(items, cats)
if memWarn != "" {
warnings = append(warnings, memWarn)
}
if memSeg != "" {
segments = append(segments, memSeg)
}
gpuSeg := buildGPUSegment(items, cats)
if gpuSeg != "" {
segments = append(segments, gpuSeg)
}
diskSeg, diskWarn := buildDiskSegment(items, cats)
if diskWarn != "" {
warnings = append(warnings, diskWarn)
}
if diskSeg != "" {
segments = append(segments, diskSeg)
}
netSeg, netWarn := buildNetSegment(items, cats)
if netWarn != "" {
warnings = append(warnings, netWarn)
}
if netSeg != "" {
segments = append(segments, netSeg)
}
psuSeg, psuWarn := buildPSUSegment(items, cats)
if psuWarn != "" {
warnings = append(warnings, psuWarn)
}
if psuSeg != "" {
segments = append(segments, psuSeg)
}
if strings.TrimSpace(opts.SupportCode) != "" {
code := strings.TrimSpace(opts.SupportCode)
if !isSupportCodeValid(code) {
return BuildResult{}, fmt.Errorf("invalid_support_code")
}
segments = append(segments, code)
}
article := strings.Join(segments, "-")
if len([]rune(article)) > 80 {
article = compressArticle(segments)
warnings = append(warnings, "compressed")
}
if len([]rune(article)) > 80 {
return BuildResult{}, fmt.Errorf("article_overflow")
}
return BuildResult{Article: article, Warnings: warnings}, nil
}
func isSupportCodeValid(code string) bool {
if len(code) < 3 {
return false
}
if !strings.Contains(code, "y") {
return false
}
parts := strings.Split(code, "y")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return false
}
for _, r := range parts[0] {
if r < '0' || r > '9' {
return false
}
}
switch parts[1] {
case "W", "B", "S", "P":
return true
default:
return false
}
}
func buildCPUSegment(items []models.ConfigItem, cats map[string]string) string {
type agg struct {
qty int
}
models := map[string]*agg{}
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupCPU {
continue
}
model := parseCPUModel(it.LotName)
if model == "" {
model = "UNK"
}
if _, ok := models[model]; !ok {
models[model] = &agg{}
}
models[model].qty += it.Quantity
}
if len(models) == 0 {
return ""
}
parts := make([]string, 0, len(models))
for model, a := range models {
parts = append(parts, fmt.Sprintf("%dx%s", a.qty, model))
}
sort.Strings(parts)
return strings.Join(parts, "+")
}
func buildMemSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
totalGiB := 0
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupMEM {
continue
}
per := parseMemGiB(it.LotName)
if per <= 0 {
return "", "mem_unknown"
}
totalGiB += per * it.Quantity
}
if totalGiB == 0 {
return "", ""
}
if totalGiB%1024 == 0 {
return fmt.Sprintf("%dT", totalGiB/1024), ""
}
return fmt.Sprintf("%dG", totalGiB), ""
}
func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
models := map[string]int{}
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupGPU {
continue
}
model := parseGPUModel(it.LotName)
if model == "" {
model = "UNK"
}
models[model] += it.Quantity
}
if len(models) == 0 {
return ""
}
parts := make([]string, 0, len(models))
for model, qty := range models {
parts = append(parts, fmt.Sprintf("%dx%s", qty, model))
}
sort.Strings(parts)
return strings.Join(parts, "+")
}
func buildDiskSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
type key struct {
t string
c string
}
groupQty := map[key]int{}
warn := ""
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupDISK {
continue
}
capToken := parseCapacity(it.LotName)
if capToken == "" {
warn = "disk_unknown"
}
typeCode := diskTypeCode(cats[it.LotName], it.LotName)
k := key{t: typeCode, c: capToken}
groupQty[k] += it.Quantity
}
if len(groupQty) == 0 {
return "", ""
}
parts := make([]string, 0, len(groupQty))
for k, qty := range groupQty {
if k.c == "" {
parts = append(parts, fmt.Sprintf("%dx%s", qty, k.t))
} else {
parts = append(parts, fmt.Sprintf("%dx%s%s", qty, k.c, k.t))
}
}
sort.Strings(parts)
return strings.Join(parts, "+"), warn
}
func buildNetSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
groupQty := map[string]int{}
warn := ""
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupNET {
continue
}
profile := parsePortSpeed(it.LotName)
if profile == "" {
profile = "UNKNET"
warn = "net_unknown"
}
groupQty[profile] += it.Quantity
}
if len(groupQty) == 0 {
return "", ""
}
parts := make([]string, 0, len(groupQty))
for profile, qty := range groupQty {
parts = append(parts, fmt.Sprintf("%dx%s", qty, profile))
}
sort.Strings(parts)
return strings.Join(parts, "+"), warn
}
func buildPSUSegment(items []models.ConfigItem, cats map[string]string) (string, string) {
groupQty := map[string]int{}
warn := ""
for _, it := range items {
group, ok := GroupForLotCategory(cats[it.LotName])
if !ok || group != GroupPSU {
continue
}
rating := parseWatts(it.LotName)
if rating == "" {
rating = "UNKPSU"
warn = "psu_unknown"
}
groupQty[rating] += it.Quantity
}
if len(groupQty) == 0 {
return "", ""
}
parts := make([]string, 0, len(groupQty))
for rating, qty := range groupQty {
parts = append(parts, fmt.Sprintf("%dx%s", qty, rating))
}
sort.Strings(parts)
return strings.Join(parts, "+"), warn
}
func normalizeModelToken(lotName string) string {
if idx := strings.Index(lotName, "_"); idx >= 0 && idx+1 < len(lotName) {
lotName = lotName[idx+1:]
}
parts := strings.Split(lotName, "_")
token := parts[len(parts)-1]
return strings.ToUpper(strings.TrimSpace(token))
}
func parseCPUModel(lotName string) string {
parts := strings.Split(lotName, "_")
if len(parts) >= 2 {
last := strings.ToUpper(strings.TrimSpace(parts[len(parts)-1]))
if last != "" {
return last
}
}
return normalizeModelToken(lotName)
}
func parseGPUModel(lotName string) string {
upper := strings.ToUpper(lotName)
if idx := strings.Index(upper, "GPU_"); idx >= 0 {
upper = upper[idx+4:]
}
parts := strings.Split(upper, "_")
model := ""
mem := ""
for i, p := range parts {
if p == "" {
continue
}
switch p {
case "NV", "NVIDIA", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
continue
default:
if strings.Contains(p, "GB") {
mem = p
continue
}
if model == "" && (i > 0) {
model = p
}
}
}
if model != "" && mem != "" {
return model + "_" + mem
}
if model != "" {
return model
}
return normalizeModelToken(lotName)
}
func parseMemGiB(lotName string) int {
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
return atoi(m[1]) * 1024
}
if m := reMemGiB.FindStringSubmatch(lotName); len(m) == 3 {
return atoi(m[1])
}
return 0
}
func parseCapacity(lotName string) string {
if m := reCapacityT.FindStringSubmatch(lotName); len(m) == 2 {
return normalizeTToken(strings.ReplaceAll(m[1], ",", ".")) + "T"
}
if m := reCapacityG.FindStringSubmatch(lotName); len(m) == 2 {
return normalizeNumberToken(strings.ReplaceAll(m[1], ",", ".")) + "G"
}
return ""
}
func diskTypeCode(cat string, lotName string) string {
c := strings.ToUpper(strings.TrimSpace(cat))
if c == "M2" {
return "M2"
}
upper := strings.ToUpper(lotName)
if strings.Contains(upper, "NVME") {
return "NV"
}
if strings.Contains(upper, "SAS") {
return "SAS"
}
if strings.Contains(upper, "SATA") {
return "SAT"
}
return c
}
func parsePortSpeed(lotName string) string {
if m := rePortSpeed.FindStringSubmatch(lotName); len(m) == 4 {
return fmt.Sprintf("%sp%sG", m[1], m[2])
}
if m := rePortFC.FindStringSubmatch(lotName); len(m) == 3 {
return fmt.Sprintf("%spFC%s", m[1], m[2])
}
return ""
}
func parseWatts(lotName string) string {
if m := reWatts.FindStringSubmatch(lotName); len(m) == 2 {
w := atoi(m[1])
if w >= 1000 {
kw := fmt.Sprintf("%.1f", float64(w)/1000.0)
kw = strings.TrimSuffix(kw, ".0")
return fmt.Sprintf("%skW", kw)
}
return fmt.Sprintf("%dW", w)
}
return ""
}
func normalizeNumberToken(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.TrimLeft(raw, "0")
if raw == "" || raw[0] == '.' {
raw = "0" + raw
}
return raw
}
func normalizeTToken(raw string) string {
raw = normalizeNumberToken(raw)
parts := strings.SplitN(raw, ".", 2)
intPart := parts[0]
frac := ""
if len(parts) == 2 {
frac = parts[1]
}
if frac == "" {
frac = "0"
}
if len(intPart) >= 2 {
return intPart + "." + frac
}
if len(frac) > 1 {
frac = frac[:1]
}
return intPart + "." + frac
}
func atoi(v string) int {
n := 0
for _, r := range v {
if r < '0' || r > '9' {
continue
}
n = n*10 + int(r-'0')
}
return n
}
func compressArticle(segments []string) string {
if len(segments) == 0 {
return ""
}
normalized := make([]string, 0, len(segments))
for _, s := range segments {
normalized = append(normalized, strings.ReplaceAll(s, "GbE", "G"))
}
segments = normalized
article := strings.Join(segments, "-")
if len([]rune(article)) <= 80 {
return article
}
// segment order: model, cpu, mem, gpu, disk, net, psu, support
index := func(i int) (int, bool) {
if i >= 0 && i < len(segments) {
return i, true
}
return -1, false
}
// 1) remove PSU
if i, ok := index(6); ok {
segments = append(segments[:i], segments[i+1:]...)
article = strings.Join(segments, "-")
if len([]rune(article)) <= 80 {
return article
}
}
// 2) compress NET/HBA/HCA
if i, ok := index(5); ok {
segments[i] = compressNetSegment(segments[i])
article = strings.Join(segments, "-")
if len([]rune(article)) <= 80 {
return article
}
}
// 3) compress DISK
if i, ok := index(4); ok {
segments[i] = compressDiskSegment(segments[i])
article = strings.Join(segments, "-")
if len([]rune(article)) <= 80 {
return article
}
}
// 4) compress GPU to vendor only (GPU_NV)
if i, ok := index(3); ok {
segments[i] = compressGPUSegment(segments[i])
}
return strings.Join(segments, "-")
}
func compressNetSegment(seg string) string {
if seg == "" {
return seg
}
parts := strings.Split(seg, "+")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
qty := "1"
profile := p
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
qty = x[0]
profile = x[1]
}
upper := strings.ToUpper(profile)
label := "NIC"
if strings.Contains(upper, "FC") {
label = "HBA"
} else if strings.Contains(upper, "HCA") || strings.Contains(upper, "IB") {
label = "HCA"
}
out = append(out, fmt.Sprintf("%sx%s", qty, label))
}
if len(out) == 0 {
return seg
}
sort.Strings(out)
return strings.Join(out, "+")
}
func compressDiskSegment(seg string) string {
if seg == "" {
return seg
}
parts := strings.Split(seg, "+")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
qty := "1"
spec := p
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
qty = x[0]
spec = x[1]
}
upper := strings.ToUpper(spec)
label := "DSK"
for _, t := range []string{"M2", "NV", "SAS", "SAT", "SSD", "HDD", "EDS", "HHH"} {
if strings.Contains(upper, t) {
label = t
break
}
}
out = append(out, fmt.Sprintf("%sx%s", qty, label))
}
if len(out) == 0 {
return seg
}
sort.Strings(out)
return strings.Join(out, "+")
}
func compressGPUSegment(seg string) string {
if seg == "" {
return seg
}
parts := strings.Split(seg, "+")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
qty := "1"
if x := strings.SplitN(p, "x", 2); len(x) == 2 {
qty = x[0]
}
out = append(out, fmt.Sprintf("%sxGPU_NV", qty))
}
if len(out) == 0 {
return seg
}
sort.Strings(out)
return strings.Join(out, "+")
}

View File

@@ -0,0 +1,66 @@
package article
import (
"path/filepath"
"strings"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func TestBuild_ParsesNetAndPSU(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 1,
Source: "estimate",
Version: "S-2026-02-11-001",
Name: "test",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(1)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: localPL.ID, LotName: "NIC_2p25G_MCX512A-AC", LotCategory: "NIC", Price: 1},
{PricelistID: localPL.ID, LotName: "HBA_2pFC32_Gen6", LotCategory: "HBA", Price: 1},
{PricelistID: localPL.ID, LotName: "PS_1000W_Platinum", LotCategory: "PS", Price: 1},
}); err != nil {
t.Fatalf("save local items: %v", err)
}
items := models.ConfigItems{
{LotName: "NIC_2p25G_MCX512A-AC", Quantity: 1},
{LotName: "HBA_2pFC32_Gen6", Quantity: 1},
{LotName: "PS_1000W_Platinum", Quantity: 2},
}
result, err := Build(local, items, BuildOptions{
ServerModel: "DL380GEN11",
SupportCode: "1yW",
ServerPricelist: &localPL.ServerID,
})
if err != nil {
t.Fatalf("build article: %v", err)
}
if result.Article == "" {
t.Fatalf("expected article to be non-empty")
}
if contains(result.Article, "UNKNET") || contains(result.Article, "UNKPSU") {
t.Fatalf("unexpected UNK in article: %s", result.Article)
}
}
func contains(s, sub string) bool {
return strings.Contains(s, sub)
}

View File

@@ -20,6 +20,7 @@ type Config struct {
Alerts AlertsConfig `yaml:"alerts"` Alerts AlertsConfig `yaml:"alerts"`
Notifications NotificationsConfig `yaml:"notifications"` Notifications NotificationsConfig `yaml:"notifications"`
Logging LoggingConfig `yaml:"logging"` Logging LoggingConfig `yaml:"logging"`
Backup BackupConfig `yaml:"backup"`
} }
type ServerConfig struct { type ServerConfig struct {
@@ -101,6 +102,10 @@ type LoggingConfig struct {
FilePath string `yaml:"file_path"` FilePath string `yaml:"file_path"`
} }
type BackupConfig struct {
Time string `yaml:"time"`
}
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -182,6 +187,10 @@ func (c *Config) setDefaults() {
if c.Logging.Output == "" { if c.Logging.Output == "" {
c.Logging.Output = "stdout" c.Logging.Output = "stdout"
} }
if c.Backup.Time == "" {
c.Backup.Time = "00:00"
}
} }
func (c *Config) Address() string { func (c *Config) Address() string {

View File

@@ -61,7 +61,6 @@ func (h *ComponentHandler) List(c *gin.Context) {
Category: lc.Category, Category: lc.Category,
CategoryName: lc.Category, CategoryName: lc.Category,
Model: lc.Model, Model: lc.Model,
CurrentPrice: lc.CurrentPrice,
} }
} }
@@ -87,7 +86,6 @@ func (h *ComponentHandler) Get(c *gin.Context) {
Category: component.Category, Category: component.Category,
CategoryName: component.Category, CategoryName: component.Category,
Model: component.Model, Model: component.Model,
CurrentPrice: component.CurrentPrice,
}) })
} }

View File

@@ -3,6 +3,7 @@ package handlers
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/middleware" "git.mchus.pro/mchus/quoteforge/internal/middleware"
@@ -14,23 +15,29 @@ type ExportHandler struct {
exportService *services.ExportService exportService *services.ExportService
configService services.ConfigurationGetter configService services.ConfigurationGetter
componentService *services.ComponentService componentService *services.ComponentService
projectService *services.ProjectService
} }
func NewExportHandler( func NewExportHandler(
exportService *services.ExportService, exportService *services.ExportService,
configService services.ConfigurationGetter, configService services.ConfigurationGetter,
componentService *services.ComponentService, componentService *services.ComponentService,
projectService *services.ProjectService,
) *ExportHandler { ) *ExportHandler {
return &ExportHandler{ return &ExportHandler{
exportService: exportService, exportService: exportService,
configService: configService, configService: configService,
componentService: componentService, componentService: componentService,
projectService: projectService,
} }
} }
type ExportRequest struct { type ExportRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Items []struct { ProjectName string `json:"project_name"`
ProjectUUID string `json:"project_uuid"`
Article string `json:"article"`
Items []struct {
LotName string `json:"lot_name" binding:"required"` LotName string `json:"lot_name" binding:"required"`
Quantity int `json:"quantity" binding:"required,min=1"` Quantity int `json:"quantity" binding:"required,min=1"`
UnitPrice float64 `json:"unit_price"` UnitPrice float64 `json:"unit_price"`
@@ -47,15 +54,47 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
data := h.buildExportData(&req) data := h.buildExportData(&req)
csvData, err := h.exportService.ToCSV(data) // Validate before streaming (can return JSON error)
if err != nil { if len(data.Items) == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
return return
} }
filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name) // Get project name if available
projectName := req.ProjectName
if projectName == "" && req.ProjectUUID != "" {
// Try to load project name from database
username := middleware.GetUsername(c)
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
projectName = derefString(project.Name)
}
}
if projectName == "" {
projectName = req.Name
}
// Set headers before streaming
exportDate := data.CreatedAt
articleSegment := sanitizeFilenameSegment(req.Article)
if articleSegment == "" {
articleSegment = "BOM"
}
filename := fmt.Sprintf("%s (%s) %s %s.csv", exportDate.Format("2006-01-02"), projectName, req.Name, articleSegment)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 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 derefString(value *string) string {
if value == nil {
return ""
}
return *value
} }
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData { func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
@@ -90,6 +129,7 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
return &services.ExportData{ return &services.ExportData{
Name: req.Name, Name: req.Name,
Article: req.Article,
Items: items, Items: items,
Total: total, Total: total,
Notes: req.Notes, Notes: req.Notes,
@@ -97,10 +137,29 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
} }
} }
func sanitizeFilenameSegment(value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
replacer := strings.NewReplacer(
"/", "_",
"\\", "_",
":", "_",
"*", "_",
"?", "_",
"\"", "_",
"<", "_",
">", "_",
"|", "_",
)
return strings.TrimSpace(replacer.Replace(value))
}
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) { func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
username := middleware.GetUsername(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
// Get config before streaming (can return JSON error)
config, err := h.configService.GetByUUID(uuid, username) config, err := h.configService.GetByUUID(uuid, username)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
@@ -109,13 +168,33 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
data := h.exportService.ConfigToExportData(config, h.componentService) data := h.exportService.ConfigToExportData(config, h.componentService)
csvData, err := h.exportService.ToCSV(data) // Validate before streaming (can return JSON error)
if err != nil { if len(data.Items) == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
return return
} }
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name) // Get project name if configuration belongs to a project
projectName := config.Name // fallback: use config name if no project
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
projectName = derefString(project.Name)
}
}
// Set headers before streaming
// Use price update time if available, otherwise creation time
exportDate := config.CreatedAt
if config.PriceUpdatedAt != nil {
exportDate = *config.PriceUpdatedAt
}
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, config.Name)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 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,314 @@
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,
nil,
)
// 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{},
nil,
)
// 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{},
nil,
)
// 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{},
nil,
)
// 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{},
nil,
)
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{},
nil,
)
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

@@ -157,15 +157,11 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
} }
resultItems := make([]gin.H, 0, len(items)) resultItems := make([]gin.H, 0, len(items))
for _, item := range items { for _, item := range items {
category := ""
if parts := strings.SplitN(item.LotName, "_", 2); len(parts) > 0 {
category = parts[0]
}
resultItems = append(resultItems, gin.H{ resultItems = append(resultItems, gin.H{
"id": item.ID, "id": item.ID,
"lot_name": item.LotName, "lot_name": item.LotName,
"price": item.Price, "price": item.Price,
"category": category, "category": item.LotCategory,
"available_qty": item.AvailableQty, "available_qty": item.AvailableQty,
"partnumbers": []string(item.Partnumbers), "partnumbers": []string(item.Partnumbers),
}) })

View File

@@ -0,0 +1,84 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"github.com/gin-gonic/gin"
)
func TestPricelistGetItems_ReturnsLotCategoryFromLocalPricelistItems(t *testing.T) {
gin.SetMode(gin.TestMode)
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 1,
Source: "estimate",
Version: "S-2026-02-11-001",
Name: "test",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
IsUsed: false,
}); err != nil {
t.Fatalf("save local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(1)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{
PricelistID: localPL.ID,
LotName: "NO_UNDERSCORE_NAME",
LotCategory: "CPU",
Price: 10,
},
}); err != nil {
t.Fatalf("save local pricelist items: %v", err)
}
h := NewPricelistHandler(local)
req, _ := http.NewRequest("GET", "/api/pricelists/1/items?page=1&per_page=50", nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Params = gin.Params{{Key: "id", Value: "1"}}
h.GetItems(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Items []struct {
LotName string `json:"lot_name"`
Category string `json:"category"`
UnitPrice any `json:"price"`
} `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if len(resp.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(resp.Items))
}
if resp.Items[0].LotName != "NO_UNDERSCORE_NAME" {
t.Fatalf("expected lot_name NO_UNDERSCORE_NAME, got %q", resp.Items[0].LotName)
}
if resp.Items[0].Category != "CPU" {
t.Fatalf("expected category CPU, got %q", resp.Items[0].Category)
}
}

View File

@@ -147,8 +147,8 @@ func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
} }
func (h *WebHandler) Index(c *gin.Context) { func (h *WebHandler) Index(c *gin.Context) {
// Redirect to configs page - configurator is accessed via /configurator?uuid=... // Redirect to projects page - configurator is accessed via /configurator?uuid=...
c.Redirect(302, "/configs") c.Redirect(302, "/projects")
} }
func (h *WebHandler) Configurator(c *gin.Context) { func (h *WebHandler) Configurator(c *gin.Context) {

View File

@@ -28,14 +28,13 @@ type ComponentSyncResult struct {
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) { func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
startTime := time.Now() 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 // Use LEFT JOIN to include lots without metadata
type componentRow struct { type componentRow struct {
LotName string LotName string
LotDescription string LotDescription string
Category *string Category *string
Model *string Model *string
CurrentPrice *float64
} }
var rows []componentRow var rows []componentRow
@@ -44,8 +43,7 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
l.lot_name, l.lot_name,
l.lot_description, l.lot_description,
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category, COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
m.model, m.model
m.current_price
FROM lot l FROM lot l
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
LEFT JOIN qt_categories c ON m.category_id = c.id 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, LotDescription: row.LotDescription,
Category: category, Category: category,
Model: model, Model: model,
CurrentPrice: row.CurrentPrice,
SyncedAt: syncTime,
} }
components = append(components, comp) 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 // Get total count
var total int64 var total int64
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil { if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
@@ -251,6 +242,31 @@ func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
return &component, nil return &component, nil
} }
// GetLocalComponentCategoriesByLotNames returns category for each lot_name in the local component cache.
// Missing lots are not included in the map; caller is responsible for strict validation.
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
result := make(map[string]string, len(lotNames))
if len(lotNames) == 0 {
return result, nil
}
type row struct {
LotName string `gorm:"column:lot_name"`
Category string `gorm:"column:category"`
}
var rows []row
if err := l.db.Model(&LocalComponent{}).
Select("lot_name, category").
Where("lot_name IN ?", lotNames).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.LotName] = r.Category
}
return result, nil
}
// GetLocalComponentCategories returns distinct categories from local components // GetLocalComponentCategories returns distinct categories from local components
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) { func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
var categories []string var categories []string
@@ -311,99 +327,3 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
} }
return time.Since(*syncTime).Hours() > float64(maxAgeHours) 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

@@ -28,6 +28,9 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
Notes: cfg.Notes, Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate, IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount, ServerCount: cfg.ServerCount,
ServerModel: cfg.ServerModel,
SupportCode: cfg.SupportCode,
Article: cfg.Article,
PricelistID: cfg.PricelistID, PricelistID: cfg.PricelistID,
OnlyInStock: cfg.OnlyInStock, OnlyInStock: cfg.OnlyInStock,
PriceUpdatedAt: cfg.PriceUpdatedAt, PriceUpdatedAt: cfg.PriceUpdatedAt,
@@ -72,6 +75,9 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
Notes: local.Notes, Notes: local.Notes,
IsTemplate: local.IsTemplate, IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount, ServerCount: local.ServerCount,
ServerModel: local.ServerModel,
SupportCode: local.SupportCode,
Article: local.Article,
PricelistID: local.PricelistID, PricelistID: local.PricelistID,
OnlyInStock: local.OnlyInStock, OnlyInStock: local.OnlyInStock,
PriceUpdatedAt: local.PriceUpdatedAt, PriceUpdatedAt: local.PriceUpdatedAt,
@@ -100,6 +106,8 @@ func ProjectToLocal(project *models.Project) *LocalProject {
local := &LocalProject{ local := &LocalProject{
UUID: project.UUID, UUID: project.UUID,
OwnerUsername: project.OwnerUsername, OwnerUsername: project.OwnerUsername,
Code: project.Code,
Variant: project.Variant,
Name: project.Name, Name: project.Name,
TrackerURL: project.TrackerURL, TrackerURL: project.TrackerURL,
IsActive: project.IsActive, IsActive: project.IsActive,
@@ -119,6 +127,8 @@ func LocalToProject(local *LocalProject) *models.Project {
project := &models.Project{ project := &models.Project{
UUID: local.UUID, UUID: local.UUID,
OwnerUsername: local.OwnerUsername, OwnerUsername: local.OwnerUsername,
Code: local.Code,
Variant: local.Variant,
Name: local.Name, Name: local.Name,
TrackerURL: local.TrackerURL, TrackerURL: local.TrackerURL,
IsActive: local.IsActive, IsActive: local.IsActive,
@@ -169,6 +179,7 @@ func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *Lo
return &LocalPricelistItem{ return &LocalPricelistItem{
PricelistID: localPricelistID, PricelistID: localPricelistID,
LotName: item.LotName, LotName: item.LotName,
LotCategory: item.LotCategory,
Price: item.Price, Price: item.Price,
AvailableQty: item.AvailableQty, AvailableQty: item.AvailableQty,
Partnumbers: partnumbers, Partnumbers: partnumbers,
@@ -183,6 +194,7 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
ID: local.ID, ID: local.ID,
PricelistID: serverPricelistID, PricelistID: serverPricelistID,
LotName: local.LotName, LotName: local.LotName,
LotCategory: local.LotCategory,
Price: local.Price, Price: local.Price,
AvailableQty: local.AvailableQty, AvailableQty: local.AvailableQty,
Partnumbers: partnumbers, Partnumbers: partnumbers,
@@ -213,17 +225,14 @@ func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
LotDescription: lotDesc, LotDescription: lotDesc,
Category: category, Category: category,
Model: meta.Model, Model: meta.Model,
CurrentPrice: meta.CurrentPrice,
SyncedAt: time.Now(),
} }
} }
// LocalToComponent converts LocalComponent to models.LotMetadata // LocalToComponent converts LocalComponent to models.LotMetadata
func LocalToComponent(local *LocalComponent) *models.LotMetadata { func LocalToComponent(local *LocalComponent) *models.LotMetadata {
return &models.LotMetadata{ return &models.LotMetadata{
LotName: local.LotName, LotName: local.LotName,
Model: local.Model, Model: local.Model,
CurrentPrice: local.CurrentPrice,
Lot: &models.Lot{ Lot: &models.Lot{
LotName: local.LotName, LotName: local.LotName,
LotDescription: local.LotDescription, LotDescription: local.LotDescription,

View File

@@ -0,0 +1,34 @@
package localdb
import (
"testing"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
item := &models.PricelistItem{
LotName: "CPU_A",
LotCategory: "CPU",
Price: 10,
}
local := PricelistItemToLocal(item, 123)
if local.LotCategory != "CPU" {
t.Fatalf("expected LotCategory=CPU, got %q", local.LotCategory)
}
}
func TestLocalToPricelistItem_PreservesLotCategory(t *testing.T) {
local := &LocalPricelistItem{
LotName: "CPU_A",
LotCategory: "CPU",
Price: 10,
}
item := LocalToPricelistItem(local, 456)
if item.LotCategory != "CPU" {
t.Fatalf("expected LotCategory=CPU, got %q", item.LotCategory)
}
}

View File

@@ -12,6 +12,7 @@ import (
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta" "git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
mysqlDriver "github.com/go-sql-driver/mysql" mysqlDriver "github.com/go-sql-driver/mysql"
uuidpkg "github.com/google/uuid" uuidpkg "github.com/google/uuid"
@@ -41,6 +42,49 @@ type LocalDB struct {
path string path string
} }
// ResetData clears local data tables while keeping connection settings.
// It does not drop schema or connection_settings.
func ResetData(dbPath string) error {
if strings.TrimSpace(dbPath) == "" {
return nil
}
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("stat local db: %w", err)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return fmt.Errorf("opening sqlite database: %w", err)
}
// Order does not matter because we use DELETEs without FK constraints in SQLite.
tables := []string{
"local_projects",
"local_configurations",
"local_configuration_versions",
"local_pricelists",
"local_pricelist_items",
"local_components",
"local_remote_migrations_applied",
"local_sync_guard_state",
"pending_changes",
"app_settings",
}
for _, table := range tables {
if err := db.Exec("DELETE FROM " + table).Error; err != nil {
return fmt.Errorf("clear %s: %w", table, err)
}
}
slog.Info("local database data reset", "path", dbPath)
return nil
}
// New creates a new LocalDB instance // New creates a new LocalDB instance
func New(dbPath string) (*LocalDB, error) { func New(dbPath string) (*LocalDB, error) {
// Ensure directory exists // Ensure directory exists
@@ -49,6 +93,14 @@ func New(dbPath string) (*LocalDB, error) {
return nil, fmt.Errorf("creating data directory: %w", err) return nil, fmt.Errorf("creating data directory: %w", err)
} }
if cfgPath, err := appstate.ResolveConfigPathNearDB("", dbPath); err == nil {
if _, err := appstate.EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
return nil, fmt.Errorf("backup local data: %w", err)
}
} else {
return nil, fmt.Errorf("resolve config path: %w", err)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent), Logger: logger.Default.LogMode(logger.Silent),
}) })
@@ -56,10 +108,31 @@ func New(dbPath string) (*LocalDB, error) {
return nil, fmt.Errorf("opening sqlite database: %w", err) return nil, fmt.Errorf("opening sqlite database: %w", err)
} }
if err := ensureLocalProjectsTable(db); err != nil {
return nil, fmt.Errorf("ensure local_projects table: %w", err)
}
// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
if db.Migrator().HasTable(&LocalProject{}) {
if !db.Migrator().HasColumn(&LocalProject{}, "uuid") {
if err := db.Exec(`ALTER TABLE local_projects ADD COLUMN uuid TEXT`).Error; err != nil {
return nil, fmt.Errorf("adding local_projects.uuid: %w", err)
}
}
var ids []uint
if err := db.Raw(`SELECT id FROM local_projects WHERE uuid IS NULL OR uuid = ''`).Scan(&ids).Error; err != nil {
return nil, fmt.Errorf("finding local_projects without uuid: %w", err)
}
for _, id := range ids {
if err := db.Exec(`UPDATE local_projects SET uuid = ? WHERE id = ?`, uuidpkg.New().String(), id).Error; err != nil {
return nil, fmt.Errorf("backfilling local_projects.uuid: %w", err)
}
}
}
// Auto-migrate all local tables // Auto-migrate all local tables
if err := db.AutoMigrate( if err := db.AutoMigrate(
&ConnectionSettings{}, &ConnectionSettings{},
&LocalProject{},
&LocalConfiguration{}, &LocalConfiguration{},
&LocalConfigurationVersion{}, &LocalConfigurationVersion{},
&LocalPricelist{}, &LocalPricelist{},
@@ -84,6 +157,38 @@ func New(dbPath string) (*LocalDB, error) {
}, nil }, nil
} }
func ensureLocalProjectsTable(db *gorm.DB) error {
if db.Migrator().HasTable(&LocalProject{}) {
return nil
}
if err := db.Exec(`
CREATE TABLE local_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
server_id INTEGER NULL,
owner_username TEXT NOT NULL,
code TEXT NOT NULL,
variant TEXT NOT NULL DEFAULT '',
name TEXT NULL,
tracker_url TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
is_system INTEGER NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
synced_at DATETIME NULL,
sync_status TEXT DEFAULT 'local'
)`).Error; err != nil {
return err
}
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
_ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
return nil
}
// HasSettings returns true if connection settings exist // HasSettings returns true if connection settings exist
func (l *LocalDB) HasSettings() bool { func (l *LocalDB) HasSettings() bool {
var count int64 var count int64
@@ -258,7 +363,8 @@ func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, err
project = &LocalProject{ project = &LocalProject{
UUID: uuidpkg.NewString(), UUID: uuidpkg.NewString(),
OwnerUsername: "", OwnerUsername: "",
Name: "Без проекта", Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true, IsActive: true,
IsSystem: true, IsSystem: true,
CreatedAt: now, CreatedAt: now,
@@ -286,7 +392,8 @@ func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
canonical = LocalProject{ canonical = LocalProject{
UUID: uuidpkg.NewString(), UUID: uuidpkg.NewString(),
OwnerUsername: "", OwnerUsername: "",
Name: "Без проекта", Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true, IsActive: true,
IsSystem: true, IsSystem: true,
CreatedAt: now, CreatedAt: now,
@@ -367,6 +474,10 @@ WHERE (
return tx.RowsAffected, tx.Error return tx.RowsAffected, tx.Error
} }
func ptrString(value string) *string {
return &value
}
// BackfillConfigurationProjects ensures every configuration has project_uuid set. // BackfillConfigurationProjects ensures every configuration has project_uuid set.
// If missing, it assigns system project "Без проекта" for configuration owner. // If missing, it assigns system project "Без проекта" for configuration owner.
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error { func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
@@ -425,18 +536,36 @@ func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, of
query := l.db.Model(&LocalConfiguration{}) query := l.db.Model(&LocalConfiguration{})
switch status { switch status {
case "active": case "active":
query = query.Where("is_active = ?", true) query = query.Where("local_configurations.is_active = ?", true)
case "archived": case "archived":
query = query.Where("is_active = ?", false) query = query.Where("local_configurations.is_active = ?", false)
case "all", "": case "all", "":
// no-op // no-op
default: default:
query = query.Where("is_active = ?", true) query = query.Where("local_configurations.is_active = ?", true)
} }
search = strings.TrimSpace(search) search = strings.TrimSpace(search)
if search != "" { if search != "" {
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(search)+"%") needle := "%" + strings.ToLower(search) + "%"
hasProjectsTable := l.db.Migrator().HasTable(&LocalProject{})
hasServerModel := l.db.Migrator().HasColumn(&LocalConfiguration{}, "server_model")
conditions := []string{"LOWER(local_configurations.name) LIKE ?"}
args := []interface{}{needle}
if hasProjectsTable {
query = query.Joins("LEFT JOIN local_projects lp ON lp.uuid = local_configurations.project_uuid")
conditions = append(conditions, "LOWER(COALESCE(lp.name, '')) LIKE ?")
args = append(args, needle)
}
if hasServerModel {
conditions = append(conditions, "LOWER(COALESCE(local_configurations.server_model, '')) LIKE ?")
args = append(args, needle)
}
query = query.Where(strings.Join(conditions, " OR "), args...)
} }
var total int64 var total int64
@@ -445,7 +574,7 @@ func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, of
} }
var configs []LocalConfiguration var configs []LocalConfiguration
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil { if err := query.Order("local_configurations.created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
return configs, total, nil return configs, total, nil
@@ -645,6 +774,17 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
return count return count
} }
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
var count int64
if err := l.db.Model(&LocalPricelistItem{}).
Where("pricelist_id = ? AND (lot_category IS NULL OR TRIM(lot_category) = '')", pricelistID).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// SaveLocalPricelistItems saves pricelist items to local SQLite // SaveLocalPricelistItems saves pricelist items to local SQLite
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error { func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
if len(items) == 0 { if len(items) == 0 {
@@ -665,6 +805,30 @@ func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
return nil return nil
} }
// ReplaceLocalPricelistItems atomically replaces all items for a pricelist.
func (l *LocalDB) ReplaceLocalPricelistItems(pricelistID uint, items []LocalPricelistItem) error {
return l.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&LocalPricelistItem{}).Error; err != nil {
return err
}
if len(items) == 0 {
return nil
}
batchSize := 500
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
if err := tx.CreateInBatches(items[i:end], batchSize).Error; err != nil {
return err
}
}
return nil
})
}
// GetLocalPricelistItems returns items for a local pricelist // GetLocalPricelistItems returns items for a local pricelist
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) { func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
var items []LocalPricelistItem var items []LocalPricelistItem
@@ -684,6 +848,36 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
return item.Price, nil return item.Price, nil
} }
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
// Missing lots are not included in the map; caller is responsible for strict validation.
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
result := make(map[string]string, len(lotNames))
if serverPricelistID == 0 || len(lotNames) == 0 {
return result, nil
}
localPL, err := l.GetLocalPricelistByServerID(serverPricelistID)
if err != nil {
return nil, err
}
type row struct {
LotName string `gorm:"column:lot_name"`
LotCategory string `gorm:"column:lot_category"`
}
var rows []row
if err := l.db.Model(&LocalPricelistItem{}).
Select("lot_name, lot_category").
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.LotName] = r.LotCategory
}
return result, nil
}
// MarkPricelistAsUsed marks a pricelist as used by a configuration // MarkPricelistAsUsed marks a pricelist as used by a configuration
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error { func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID). return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).

View File

@@ -51,8 +51,8 @@ func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("get system project: %v", err) t.Fatalf("get system project: %v", err)
} }
if project.Name != "Без проекта" { if project.Name == nil || *project.Name != "Без проекта" {
t.Fatalf("expected system project name, got %q", project.Name) t.Fatalf("expected system project name, got %v", project.Name)
} }
if !project.IsSystem { if !project.IsSystem {
t.Fatalf("expected system project flag") t.Fatalf("expected system project flag")

View File

@@ -58,6 +58,51 @@ var localMigrations = []localMigration{
name: "Backfill source for local pricelists and create source indexes", name: "Backfill source for local pricelists and create source indexes",
run: backfillLocalPricelistSource, 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,
},
{
id: "2026_02_11_local_pricelist_item_category",
name: "Add lot_category to local_pricelist_items and create indexes",
run: addLocalPricelistItemCategoryAndIndexes,
},
{
id: "2026_02_11_local_config_article",
name: "Add article to local_configurations",
run: addLocalConfigurationArticle,
},
{
id: "2026_02_11_local_config_server_model",
name: "Add server_model to local_configurations",
run: addLocalConfigurationServerModel,
},
{
id: "2026_02_11_local_config_support_code",
name: "Add support_code to local_configurations",
run: addLocalConfigurationSupportCode,
},
{
id: "2026_02_13_local_project_code",
name: "Add project code to local_projects and backfill",
run: addLocalProjectCode,
},
{
id: "2026_02_13_local_project_variant",
name: "Add project variant to local_projects and backfill",
run: addLocalProjectVariant,
},
{
id: "2026_02_13_local_project_name_nullable",
name: "Allow NULL project names in local_projects",
run: allowLocalProjectNameNull,
},
} }
func runLocalMigrations(db *gorm.DB) error { func runLocalMigrations(db *gorm.DB) error {
@@ -194,7 +239,8 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
project = LocalProject{ project = LocalProject{
UUID: uuid.NewString(), UUID: uuid.NewString(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
Name: "Без проекта", Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true, IsActive: true,
IsSystem: true, IsSystem: true,
CreatedAt: now, CreatedAt: now,
@@ -208,6 +254,139 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
return &project, nil return &project, nil
} }
func addLocalProjectCode(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN code TEXT`).Error; err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
!strings.Contains(strings.ToLower(err.Error()), "exists") {
return err
}
}
// Drop unique index if it already exists to allow de-duplication updates.
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
return err
}
// Copy code from current project name.
if err := tx.Exec(`
UPDATE local_projects
SET code = TRIM(COALESCE(name, ''))`).Error; err != nil {
return err
}
// Ensure any remaining blanks have a unique fallback.
if err := tx.Exec(`
UPDATE local_projects
SET code = 'P-' || uuid
WHERE code IS NULL OR TRIM(code) = ''`).Error; err != nil {
return err
}
// De-duplicate codes: OPS-1948-2, OPS-1948-3...
if err := tx.Exec(`
WITH ranked AS (
SELECT id, code,
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
FROM local_projects
)
UPDATE local_projects
SET code = code || '-' || (SELECT rn FROM ranked WHERE ranked.id = local_projects.id)
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)`).Error; err != nil {
return err
}
// Create unique index for project codes (ignore if exists).
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code ON local_projects(code)`).Error; err != nil {
return err
}
return nil
}
func addLocalProjectVariant(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN variant TEXT NOT NULL DEFAULT ''`).Error; err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
!strings.Contains(strings.ToLower(err.Error()), "exists") {
return err
}
}
// Drop legacy code index if present.
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
return err
}
// Reset code from name and clear variant.
if err := tx.Exec(`
UPDATE local_projects
SET code = TRIM(COALESCE(name, '')),
variant = ''`).Error; err != nil {
return err
}
// De-duplicate by assigning variant numbers: 2,3...
if err := tx.Exec(`
WITH ranked AS (
SELECT id, code,
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
FROM local_projects
)
UPDATE local_projects
SET variant = CASE
WHEN (SELECT rn FROM ranked WHERE ranked.id = local_projects.id) = 1 THEN ''
ELSE '-' || CAST((SELECT rn FROM ranked WHERE ranked.id = local_projects.id) AS TEXT)
END`).Error; err != nil {
return err
}
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error; err != nil {
return err
}
return nil
}
func allowLocalProjectNameNull(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE local_projects RENAME TO local_projects_old`).Error; err != nil {
return err
}
if err := tx.Exec(`
CREATE TABLE local_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
server_id INTEGER NULL,
owner_username TEXT NOT NULL,
code TEXT NOT NULL,
variant TEXT NOT NULL DEFAULT '',
name TEXT NULL,
tracker_url TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
is_system INTEGER NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
synced_at DATETIME NULL,
sync_status TEXT DEFAULT 'local'
)`).Error; err != nil {
return err
}
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
_ = tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
if err := tx.Exec(`
INSERT INTO local_projects (id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status)
SELECT id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status
FROM local_projects_old`).Error; err != nil {
return err
}
_ = tx.Exec(`DROP TABLE local_projects_old`).Error
return nil
}
func backfillConfigurationPricelists(tx *gorm.DB) error { func backfillConfigurationPricelists(tx *gorm.DB) error {
var latest LocalPricelist var latest LocalPricelist
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil { if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
@@ -249,6 +428,7 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
return candidate return candidate
} }
func fixLocalPricelistIndexes(tx *gorm.DB) error { func fixLocalPricelistIndexes(tx *gorm.DB) error {
type indexRow struct { type indexRow struct {
Name string `gorm:"column:name"` Name string `gorm:"column:name"`
@@ -316,3 +496,222 @@ func backfillLocalPricelistSource(tx *gorm.DB) error {
return nil 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
}
func addLocalPricelistItemCategoryAndIndexes(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_pricelist_items')
WHERE name IN ('lot_category')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_pricelist_items(lot_category) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_pricelist_items
ADD COLUMN lot_category TEXT
`).Error; err != nil {
return fmt.Errorf("add local_pricelist_items.lot_category: %w", err)
}
slog.Info("added lot_category to local_pricelist_items")
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot
ON local_pricelist_items(pricelist_id, lot_name)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_pricelist_items_pricelist_lot: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_lot_category
ON local_pricelist_items(lot_category)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_pricelist_items_lot_category: %w", err)
}
return nil
}
func addLocalConfigurationArticle(tx *gorm.DB) error {
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 ('article')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(article) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN article TEXT
`).Error; err != nil {
return fmt.Errorf("add local_configurations.article: %w", err)
}
slog.Info("added article to local_configurations")
}
return nil
}
func addLocalConfigurationServerModel(tx *gorm.DB) error {
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 ('server_model')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(server_model) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN server_model TEXT
`).Error; err != nil {
return fmt.Errorf("add local_configurations.server_model: %w", err)
}
slog.Info("added server_model to local_configurations")
}
return nil
}
func addLocalConfigurationSupportCode(tx *gorm.DB) error {
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 ('support_code')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(support_code) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN support_code TEXT
`).Error; err != nil {
return fmt.Errorf("add local_configurations.support_code: %w", err)
}
slog.Info("added support_code to local_configurations")
}
return nil
}

View File

@@ -96,8 +96,13 @@ type LocalConfiguration struct {
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"` IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"` ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"` ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"` SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
Article string `gorm:"size:80" json:"article,omitempty"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -118,7 +123,9 @@ type LocalProject struct {
UUID string `gorm:"uniqueIndex;not null" json:"uuid"` UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id,omitempty"` ServerID *uint `json:"server_id,omitempty"`
OwnerUsername string `gorm:"not null;index" json:"owner_username"` OwnerUsername string `gorm:"not null;index" json:"owner_username"`
Name string `gorm:"not null" json:"name"` Code string `gorm:"not null;index:idx_local_projects_code_variant,priority:1" json:"code"`
Variant string `gorm:"default:'';index:idx_local_projects_code_variant,priority:2" json:"variant"`
Name *string `json:"name,omitempty"`
TrackerURL string `json:"tracker_url"` TrackerURL string `json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"` IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"` IsSystem bool `gorm:"default:false;index" json:"is_system"`
@@ -170,6 +177,7 @@ type LocalPricelistItem struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PricelistID uint `gorm:"not null;index" json:"pricelist_id"` PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
LotName string `gorm:"not null" json:"lot_name"` LotName string `gorm:"not null" json:"lot_name"`
LotCategory string `gorm:"column:lot_category" json:"lot_category,omitempty"`
Price float64 `gorm:"not null" json:"price"` Price float64 `gorm:"not null" json:"price"`
AvailableQty *float64 `json:"available_qty,omitempty"` AvailableQty *float64 `json:"available_qty,omitempty"`
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"` Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`
@@ -179,14 +187,13 @@ func (LocalPricelistItem) TableName() string {
return "local_pricelist_items" 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 { type LocalComponent struct {
LotName string `gorm:"primaryKey" json:"lot_name"` LotName string `gorm:"primaryKey" json:"lot_name"`
LotDescription string `json:"lot_description"` LotDescription string `json:"lot_description"`
Category string `json:"category"` Category string `json:"category"`
Model string `json:"model"` Model string `json:"model"`
CurrentPrice *float64 `json:"current_price"`
SyncedAt time.Time `json:"synced_at"`
} }
func (LocalComponent) TableName() string { func (LocalComponent) TableName() string {

View File

@@ -22,6 +22,9 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"notes": localCfg.Notes, "notes": localCfg.Notes,
"is_template": localCfg.IsTemplate, "is_template": localCfg.IsTemplate,
"server_count": localCfg.ServerCount, "server_count": localCfg.ServerCount,
"server_model": localCfg.ServerModel,
"support_code": localCfg.SupportCode,
"article": localCfg.Article,
"pricelist_id": localCfg.PricelistID, "pricelist_id": localCfg.PricelistID,
"only_in_stock": localCfg.OnlyInStock, "only_in_stock": localCfg.OnlyInStock,
"price_updated_at": localCfg.PriceUpdatedAt, "price_updated_at": localCfg.PriceUpdatedAt,
@@ -52,6 +55,9 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `json:"is_template"` IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
ServerModel string `json:"server_model"`
SupportCode string `json:"support_code"`
Article string `json:"article"`
PricelistID *uint `json:"pricelist_id"` PricelistID *uint `json:"pricelist_id"`
OnlyInStock bool `json:"only_in_stock"` OnlyInStock bool `json:"only_in_stock"`
PriceUpdatedAt *time.Time `json:"price_updated_at"` PriceUpdatedAt *time.Time `json:"price_updated_at"`
@@ -78,6 +84,9 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Notes: snapshot.Notes, Notes: snapshot.Notes,
IsTemplate: snapshot.IsTemplate, IsTemplate: snapshot.IsTemplate,
ServerCount: snapshot.ServerCount, ServerCount: snapshot.ServerCount,
ServerModel: snapshot.ServerModel,
SupportCode: snapshot.SupportCode,
Article: snapshot.Article,
PricelistID: snapshot.PricelistID, PricelistID: snapshot.PricelistID,
OnlyInStock: snapshot.OnlyInStock, OnlyInStock: snapshot.OnlyInStock,
PriceUpdatedAt: snapshot.PriceUpdatedAt, PriceUpdatedAt: snapshot.PriceUpdatedAt,

View File

@@ -53,6 +53,9 @@ type Configuration struct {
Notes string `gorm:"type:text" json:"notes"` Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"` IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"` ServerCount int `gorm:"default:1" json:"server_count"`
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
Article string `gorm:"size:80" json:"article,omitempty"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"` PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"` WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"` CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`

View File

@@ -55,6 +55,7 @@ type PricelistItem struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"` PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"` LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
LotCategory string `gorm:"column:lot_category;size:50" json:"lot_category,omitempty"`
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"` Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
PriceMethod string `gorm:"size:20" json:"price_method"` PriceMethod string `gorm:"size:20" json:"price_method"`

View File

@@ -6,7 +6,9 @@ type Project struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"` OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
Name string `gorm:"size:200;not null" json:"name"` Code string `gorm:"size:100;not null;index:idx_qt_projects_code_variant,priority:1" json:"code"`
Variant string `gorm:"size:100;not null;default:'';index:idx_qt_projects_code_variant,priority:2" json:"variant"`
Name *string `gorm:"size:200" json:"name,omitempty"`
TrackerURL string `gorm:"size:500" json:"tracker_url"` TrackerURL string `gorm:"size:500" json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"` IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"` IsSystem bool `gorm:"default:false;index" json:"is_system"`

View File

@@ -238,11 +238,7 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil { if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
items[i].LotDescription = lot.LotDescription items[i].LotDescription = lot.LotDescription
} }
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU") items[i].Category = strings.TrimSpace(items[i].LotCategory)
parts := strings.SplitN(items[i].LotName, "_", 2)
if len(parts) >= 1 {
items[i].Category = parts[0]
}
} }
if err := r.enrichItemsWithStock(items); err != nil { if err := r.enrichItemsWithStock(items); err != nil {

View File

@@ -27,6 +27,8 @@ func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
Columns: []clause.Column{{Name: "uuid"}}, Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.AssignmentColumns([]string{ DoUpdates: clause.AssignmentColumns([]string{
"owner_username", "owner_username",
"code",
"variant",
"name", "name",
"tracker_url", "tracker_url",
"is_active", "is_active",

View File

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

View File

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

View File

@@ -52,10 +52,20 @@ type CreateConfigRequest struct {
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `json:"is_template"` IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
ServerModel string `json:"server_model,omitempty"`
SupportCode string `json:"support_code,omitempty"`
Article string `json:"article,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"` PricelistID *uint `json:"pricelist_id,omitempty"`
OnlyInStock bool `json:"only_in_stock"` OnlyInStock bool `json:"only_in_stock"`
} }
type ArticlePreviewRequest struct {
Items models.ConfigItems `json:"items"`
ServerModel string `json:"server_model"`
SupportCode string `json:"support_code,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
}
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) { func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID) projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
if err != nil { if err != nil {
@@ -84,6 +94,9 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
Notes: req.Notes, Notes: req.Notes,
IsTemplate: req.IsTemplate, IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount, ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
PricelistID: pricelistID, PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock, OnlyInStock: req.OnlyInStock,
} }
@@ -146,6 +159,9 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
config.Notes = req.Notes config.Notes = req.Notes
config.IsTemplate = req.IsTemplate config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount config.ServerCount = req.ServerCount
config.ServerModel = req.ServerModel
config.SupportCode = req.SupportCode
config.Article = req.Article
config.PricelistID = pricelistID config.PricelistID = pricelistID
config.OnlyInStock = req.OnlyInStock config.OnlyInStock = req.OnlyInStock

View File

@@ -4,6 +4,8 @@ import (
"bytes" "bytes"
"encoding/csv" "encoding/csv"
"fmt" "fmt"
"io"
"strings"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/config"
@@ -25,6 +27,7 @@ func NewExportService(cfg config.ExportConfig, categoryRepo *repository.Category
type ExportData struct { type ExportData struct {
Name string Name string
Article string
Items []ExportItem Items []ExportItem
Total float64 Total float64
Notes string Notes string
@@ -40,14 +43,21 @@ type ExportItem struct {
TotalPrice float64 TotalPrice float64
} }
func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) { func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
var buf bytes.Buffer // Write UTF-8 BOM for Excel compatibility
w := csv.NewWriter(&buf) 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 // Header
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"} headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
if err := w.Write(headers); err != nil { if err := csvWriter.Write(headers); err != nil {
return nil, err return fmt.Errorf("failed to write header: %w", err)
} }
// Get category hierarchy for sorting // Get category hierarchy for sorting
@@ -90,21 +100,35 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
item.Description, item.Description,
item.Category, item.Category,
fmt.Sprintf("%d", item.Quantity), fmt.Sprintf("%d", item.Quantity),
fmt.Sprintf("%.2f", item.UnitPrice), strings.ReplaceAll(fmt.Sprintf("%.2f", item.UnitPrice), ".", ","),
fmt.Sprintf("%.2f", item.TotalPrice), strings.ReplaceAll(fmt.Sprintf("%.2f", item.TotalPrice), ".", ","),
} }
if err := w.Write(row); err != nil { if err := csvWriter.Write(row); err != nil {
return nil, err return fmt.Errorf("failed to write row: %w", err)
} }
} }
// Total row // Total row
if err := w.Write([]string{"", "", "", "", "ИТОГО:", fmt.Sprintf("%.2f", data.Total)}); err != nil { totalStr := strings.ReplaceAll(fmt.Sprintf("%.2f", data.Total), ".", ",")
return nil, err if err := csvWriter.Write([]string{data.Article, "", "", "", "ИТОГО:", totalStr}); err != nil {
return fmt.Errorf("failed to write total row: %w", err)
} }
w.Flush() csvWriter.Flush()
return buf.Bytes(), w.Error() 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 { func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
@@ -139,6 +163,7 @@ func (s *ExportService) ConfigToExportData(config *models.Configuration, compone
return &ExportData{ return &ExportData{
Name: config.Name, Name: config.Name,
Article: "",
Items: items, Items: items,
Total: total, Total: total,
Notes: config.Notes, Notes: config.Notes,

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

@@ -8,6 +8,7 @@ import (
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta" "git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/article"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
@@ -64,6 +65,18 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
return nil, err return nil, err
} }
if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID,
})
if articleErr != nil {
return nil, articleErr
}
req.Article = articleResult.Article
}
total := req.Items.Total() total := req.Items.Total()
if req.ServerCount > 1 { if req.ServerCount > 1 {
total *= float64(req.ServerCount) total *= float64(req.ServerCount)
@@ -80,6 +93,9 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
Notes: req.Notes, Notes: req.Notes,
IsTemplate: req.IsTemplate, IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount, ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
PricelistID: pricelistID, PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock, OnlyInStock: req.OnlyInStock,
CreatedAt: time.Now(), CreatedAt: time.Now(),
@@ -142,6 +158,18 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
return nil, err return nil, err
} }
if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID,
})
if articleErr != nil {
return nil, articleErr
}
req.Article = articleResult.Article
}
total := req.Items.Total() total := req.Items.Total()
if req.ServerCount > 1 { if req.ServerCount > 1 {
total *= float64(req.ServerCount) total *= float64(req.ServerCount)
@@ -163,6 +191,9 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
localCfg.Notes = req.Notes localCfg.Notes = req.Notes
localCfg.IsTemplate = req.IsTemplate localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount localCfg.ServerCount = req.ServerCount
localCfg.ServerModel = req.ServerModel
localCfg.SupportCode = req.SupportCode
localCfg.Article = req.Article
localCfg.PricelistID = pricelistID localCfg.PricelistID = pricelistID
localCfg.OnlyInStock = req.OnlyInStock localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now() localCfg.UpdatedAt = time.Now()
@@ -176,6 +207,19 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
return cfg, nil return cfg, nil
} }
// BuildArticlePreview generates server article based on current items and server_model/support_code.
func (s *LocalConfigurationService) BuildArticlePreview(req *ArticlePreviewRequest) (article.BuildResult, error) {
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return article.BuildResult{}, err
}
return article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID,
})
}
// Delete deletes a configuration from local SQLite and queues it for sync // Delete deletes a configuration from local SQLite and queues it for sync
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error { func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
@@ -269,6 +313,9 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
Notes: original.Notes, Notes: original.Notes,
IsTemplate: false, IsTemplate: false,
ServerCount: original.ServerCount, ServerCount: original.ServerCount,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID, PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock, OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(), CreatedAt: time.Now(),
@@ -347,7 +394,7 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
} }
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist() latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Update prices for all items // Update prices for all items from pricelist
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items { for i, item := range localCfg.Items {
if latestErr == nil && latestPricelist != nil { if latestErr == nil && latestPricelist != nil {
@@ -362,20 +409,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
} }
} }
// Fallback to current component price from local cache // Keep original item if price not found in pricelist
component, err := s.localDB.GetLocalComponent(item.LotName) updatedItems[i] = item
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,
}
} }
// Update configuration // Update configuration
@@ -436,6 +471,18 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
return nil, err return nil, err
} }
if strings.TrimSpace(req.ServerModel) != "" {
articleResult, articleErr := article.Build(s.localDB, req.Items, article.BuildOptions{
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
ServerPricelist: pricelistID,
})
if articleErr != nil {
return nil, articleErr
}
req.Article = articleResult.Article
}
total := req.Items.Total() total := req.Items.Total()
if req.ServerCount > 1 { if req.ServerCount > 1 {
total *= float64(req.ServerCount) total *= float64(req.ServerCount)
@@ -456,6 +503,9 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
localCfg.Notes = req.Notes localCfg.Notes = req.Notes
localCfg.IsTemplate = req.IsTemplate localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount localCfg.ServerCount = req.ServerCount
localCfg.ServerModel = req.ServerModel
localCfg.SupportCode = req.SupportCode
localCfg.Article = req.Article
localCfg.PricelistID = pricelistID localCfg.PricelistID = pricelistID
localCfg.OnlyInStock = req.OnlyInStock localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now() localCfg.UpdatedAt = time.Now()
@@ -672,7 +722,7 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
} }
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist() latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Update prices for all items // Update prices for all items from pricelist
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items { for i, item := range localCfg.Items {
if latestErr == nil && latestPricelist != nil { if latestErr == nil && latestPricelist != nil {
@@ -687,20 +737,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
} }
} }
// Fallback to current component price from local cache // Keep original item if price not found in pricelist
component, err := s.localDB.GetLocalComponent(item.LotName) updatedItems[i] = item
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,
}
} }
// Update configuration // Update configuration

View File

@@ -191,7 +191,8 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
project := &localdb.LocalProject{ project := &localdb.LocalProject{
UUID: "project-keep", UUID: "project-keep",
OwnerUsername: "tester", OwnerUsername: "tester",
Name: "Keep Project", Code: "TEST-KEEP",
Name: ptrString("Keep Project"),
IsActive: true, IsActive: true,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
@@ -227,6 +228,10 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
} }
} }
func ptrString(value string) *string {
return &value
}
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) { func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
t.Helper() t.Helper()

View File

@@ -16,8 +16,9 @@ import (
) )
var ( var (
ErrProjectNotFound = errors.New("project not found") ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden") ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
) )
type ProjectService struct { type ProjectService struct {
@@ -29,12 +30,16 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
} }
type CreateProjectRequest struct { type CreateProjectRequest struct {
Name string `json:"name"` Code string `json:"code"`
Variant string `json:"variant,omitempty"`
Name *string `json:"name,omitempty"`
TrackerURL string `json:"tracker_url"` TrackerURL string `json:"tracker_url"`
} }
type UpdateProjectRequest struct { type UpdateProjectRequest struct {
Name string `json:"name"` Code *string `json:"code,omitempty"`
Variant *string `json:"variant,omitempty"`
Name *string `json:"name,omitempty"`
TrackerURL *string `json:"tracker_url,omitempty"` TrackerURL *string `json:"tracker_url,omitempty"`
} }
@@ -45,17 +50,30 @@ type ProjectConfigurationsResult struct {
} }
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) { func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
name := strings.TrimSpace(req.Name) var namePtr *string
if name == "" { if req.Name != nil {
return nil, fmt.Errorf("project name is required") name := strings.TrimSpace(*req.Name)
if name != "" {
namePtr = &name
}
}
code := strings.TrimSpace(req.Code)
if code == "" {
return nil, fmt.Errorf("project code is required")
}
variant := strings.TrimSpace(req.Variant)
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
return nil, err
} }
now := time.Now() now := time.Now()
localProject := &localdb.LocalProject{ localProject := &localdb.LocalProject{
UUID: uuid.NewString(), UUID: uuid.NewString(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
Name: name, Code: code,
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL), Variant: variant,
Name: namePtr,
TrackerURL: normalizeProjectTrackerURL(code, req.TrackerURL),
IsActive: true, IsActive: true,
IsSystem: false, IsSystem: false,
CreatedAt: now, CreatedAt: now,
@@ -76,20 +94,33 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
if err != nil { if err != nil {
return nil, ErrProjectNotFound return nil, ErrProjectNotFound
} }
if localProject.OwnerUsername != ownerUsername {
return nil, ErrProjectForbidden if req.Code != nil {
code := strings.TrimSpace(*req.Code)
if code == "" {
return nil, fmt.Errorf("project code is required")
}
localProject.Code = code
}
if req.Variant != nil {
localProject.Variant = strings.TrimSpace(*req.Variant)
}
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
return nil, err
} }
name := strings.TrimSpace(req.Name) if req.Name != nil {
if name == "" { name := strings.TrimSpace(*req.Name)
return nil, fmt.Errorf("project name is required") if name == "" {
localProject.Name = nil
} else {
localProject.Name = &name
}
} }
localProject.Name = name
if req.TrackerURL != nil { if req.TrackerURL != nil {
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL) localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, *req.TrackerURL)
} else if strings.TrimSpace(localProject.TrackerURL) == "" { } else if strings.TrimSpace(localProject.TrackerURL) == "" {
localProject.TrackerURL = normalizeProjectTrackerURL(name, "") localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "")
} }
localProject.UpdatedAt = time.Now() localProject.UpdatedAt = time.Now()
localProject.SyncStatus = "pending" localProject.SyncStatus = "pending"
@@ -102,6 +133,38 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
return localdb.LocalToProject(localProject), nil return localdb.LocalToProject(localProject), nil
} }
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
normalizedCode := normalizeProjectCode(code)
normalizedVariant := normalizeProjectVariant(variant)
if normalizedCode == "" {
return fmt.Errorf("project code is required")
}
projects, err := s.localDB.GetAllProjects(true)
if err != nil {
return err
}
for i := range projects {
project := projects[i]
if excludeUUID != "" && project.UUID == excludeUUID {
continue
}
if normalizeProjectCode(project.Code) == normalizedCode &&
normalizeProjectVariant(project.Variant) == normalizedVariant {
return ErrProjectCodeExists
}
}
return nil
}
func normalizeProjectCode(code string) string {
return strings.ToLower(strings.TrimSpace(code))
}
func normalizeProjectVariant(variant string) string {
return strings.ToLower(strings.TrimSpace(variant))
}
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error { func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
return s.setProjectActive(projectUUID, ownerUsername, false) return s.setProjectActive(projectUUID, ownerUsername, false)
} }
@@ -116,9 +179,6 @@ func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isA
if err := tx.Where("uuid = ?", projectUUID).First(&project).Error; err != nil { if err := tx.Where("uuid = ?", projectUUID).First(&project).Error; err != nil {
return ErrProjectNotFound return ErrProjectNotFound
} }
if project.OwnerUsername != ownerUsername {
return ErrProjectForbidden
}
if project.IsActive == isActive { if project.IsActive == isActive {
return nil return nil
} }

View File

@@ -78,6 +78,7 @@ type QuoteRequest struct {
LotName string `json:"lot_name"` LotName string `json:"lot_name"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
} `json:"items"` } `json:"items"`
PricelistID *uint `json:"pricelist_id,omitempty"` // Optional: use specific pricelist for pricing
} }
type PriceLevelsRequest struct { type PriceLevelsRequest struct {
@@ -123,6 +124,16 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
Warnings: 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 var total float64
for _, reqItem := range req.Items { for _, reqItem := range req.Items {
localComp, err := s.localDB.GetLocalComponent(reqItem.LotName) localComp, err := s.localDB.GetLocalComponent(reqItem.LotName)
@@ -142,13 +153,19 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
TotalPrice: 0, TotalPrice: 0,
} }
if localComp.CurrentPrice != nil && *localComp.CurrentPrice > 0 { // Get price from pricelist_items
item.UnitPrice = *localComp.CurrentPrice if pricelistID != nil {
item.TotalPrice = *localComp.CurrentPrice * float64(reqItem.Quantity) price, found := s.lookupPriceByPricelistID(*pricelistID, reqItem.LotName)
item.HasPrice = true if found && price > 0 {
total += item.TotalPrice 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 { } else {
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName) result.Warnings = append(result.Warnings, "No pricelist available for: "+reqItem.LotName)
} }
result.Items = append(result.Items, item) result.Items = append(result.Items, item)

View File

@@ -189,33 +189,54 @@ func listActiveClientMigrations(db *gorm.DB) ([]clientLocalMigration, error) {
} }
func ensureClientMigrationRegistryTable(db *gorm.DB) error { func ensureClientMigrationRegistryTable(db *gorm.DB) error {
if err := db.Exec(` // Check if table exists instead of trying to create (avoids permission issues)
CREATE TABLE IF NOT EXISTS qt_client_local_migrations ( if !tableExists(db, "qt_client_local_migrations") {
id VARCHAR(128) NOT NULL, if err := db.Exec(`
name VARCHAR(255) NOT NULL, CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
sql_text LONGTEXT NOT NULL, id VARCHAR(128) NOT NULL,
checksum VARCHAR(128) NOT NULL, name VARCHAR(255) NOT NULL,
min_app_version VARCHAR(64) NULL, sql_text LONGTEXT NOT NULL,
order_no INT NOT NULL DEFAULT 0, checksum VARCHAR(128) NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1, min_app_version VARCHAR(64) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, order_no INT NOT NULL DEFAULT 0,
PRIMARY KEY (id), is_active TINYINT(1) NOT NULL DEFAULT 1,
INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at) created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
) PRIMARY KEY (id),
`).Error; err != nil { INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
return err )
`).Error; err != nil {
return fmt.Errorf("create qt_client_local_migrations table: %w", err)
}
} }
return db.Exec(`
CREATE TABLE IF NOT EXISTS qt_client_schema_state ( if !tableExists(db, "qt_client_schema_state") {
username VARCHAR(100) NOT NULL, if err := db.Exec(`
last_applied_migration_id VARCHAR(128) NULL, CREATE TABLE IF NOT EXISTS qt_client_schema_state (
app_version VARCHAR(64) NULL, username VARCHAR(100) NOT NULL,
last_checked_at DATETIME NOT NULL, last_applied_migration_id VARCHAR(128) NULL,
updated_at DATETIME NOT NULL, app_version VARCHAR(64) NULL,
PRIMARY KEY (username), last_checked_at DATETIME NOT NULL,
INDEX idx_qt_client_schema_state_checked (last_checked_at) updated_at DATETIME NOT NULL,
) PRIMARY KEY (username),
`).Error 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 { func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {

View File

@@ -200,6 +200,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
} }
existing.OwnerUsername = project.OwnerUsername existing.OwnerUsername = project.OwnerUsername
existing.Code = project.Code
existing.Name = project.Name existing.Name = project.Name
existing.TrackerURL = project.TrackerURL existing.TrackerURL = project.TrackerURL
existing.IsActive = project.IsActive existing.IsActive = project.IsActive
@@ -346,17 +347,10 @@ func (s *Service) SyncPricelists() (int, error) {
} }
synced := 0 synced := 0
var latestEstimateLocalID uint
var latestEstimateCreatedAt time.Time
for _, pl := range serverPricelists { for _, pl := range serverPricelists {
// Check if pricelist already exists locally // Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
if existing != nil { 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 continue
} }
@@ -385,10 +379,6 @@ func (s *Service) SyncPricelists() (int, error) {
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount) 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++ synced++
} }
@@ -399,15 +389,8 @@ func (s *Service) SyncPricelists() (int, error) {
slog.Info("deleted stale local pricelists", "deleted", removed) slog.Info("deleted stale local pricelists", "deleted", removed)
} }
// Update component prices from latest estimate pricelist only. // Backfill lot_category for used pricelists (older local caches may miss the column values).
if latestEstimateLocalID > 0 { s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
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)
}
}
// Update last sync time // Update last sync time
s.localDB.SetLastSyncTime(time.Now()) s.localDB.SetLastSyncTime(time.Now())
@@ -417,6 +400,83 @@ func (s *Service) SyncPricelists() (int, error) {
return synced, nil return synced, nil
} }
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
if s.localDB == nil || pricelistRepo == nil {
return
}
activeSet := make(map[uint]struct{}, len(activeServerPricelistIDs))
for _, id := range activeServerPricelistIDs {
activeSet[id] = struct{}{}
}
type row struct {
ID uint `gorm:"column:id"`
}
var usedRows []row
if err := s.localDB.DB().Raw(`
SELECT DISTINCT pricelist_id AS id
FROM local_configurations
WHERE is_active = 1 AND pricelist_id IS NOT NULL
UNION
SELECT DISTINCT warehouse_pricelist_id AS id
FROM local_configurations
WHERE is_active = 1 AND warehouse_pricelist_id IS NOT NULL
UNION
SELECT DISTINCT competitor_pricelist_id AS id
FROM local_configurations
WHERE is_active = 1 AND competitor_pricelist_id IS NOT NULL
`).Scan(&usedRows).Error; err != nil {
slog.Warn("pricelist category backfill: failed to list used pricelists", "error", err)
return
}
for _, r := range usedRows {
serverID := r.ID
if serverID == 0 {
continue
}
if _, ok := activeSet[serverID]; !ok {
// Not present on server (or not active) - cannot backfill from remote.
continue
}
localPL, err := s.localDB.GetLocalPricelistByServerID(serverID)
if err != nil || localPL == nil {
continue
}
if s.localDB.CountLocalPricelistItems(localPL.ID) == 0 {
continue
}
missing, err := s.localDB.CountLocalPricelistItemsWithEmptyCategory(localPL.ID)
if err != nil {
slog.Warn("pricelist category backfill: failed to check local items", "server_id", serverID, "error", err)
continue
}
if missing == 0 {
continue
}
serverItems, _, err := pricelistRepo.GetItems(serverID, 0, 10000, "")
if err != nil {
slog.Warn("pricelist category backfill: failed to load server items", "server_id", serverID, "error", err)
continue
}
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i := range serverItems {
localItems[i] = *localdb.PricelistItemToLocal(&serverItems[i], localPL.ID)
}
if err := s.localDB.ReplaceLocalPricelistItems(localPL.ID, localItems); err != nil {
slog.Warn("pricelist category backfill: failed to replace local items", "server_id", serverID, "error", err)
continue
}
slog.Info("pricelist category backfill: refreshed local items", "server_id", serverID, "items", len(localItems))
}
}
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user. // RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
// Only users with write rights are expected to be able to update this table. // Only users with write rights are expected to be able to update this table.
func (s *Service) RecordSyncHeartbeat() { func (s *Service) RecordSyncHeartbeat() {
@@ -553,24 +613,34 @@ func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, e
} }
func ensureUserSyncStatusTable(db *gorm.DB) error { func ensureUserSyncStatusTable(db *gorm.DB) error {
if err := db.Exec(` // Check if table exists instead of trying to create (avoids permission issues)
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status ( if !tableExists(db, "qt_pricelist_sync_status") {
username VARCHAR(100) NOT NULL, if err := db.Exec(`
last_sync_at DATETIME NOT NULL, CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
updated_at DATETIME NOT NULL, username VARCHAR(100) NOT NULL,
app_version VARCHAR(64) NULL, last_sync_at DATETIME NOT NULL,
PRIMARY KEY (username), updated_at DATETIME NOT NULL,
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at) app_version VARCHAR(64) NULL,
) PRIMARY KEY (username),
`).Error; err != nil { INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
return err )
`).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. // Backward compatibility for environments where table was created without app_version.
return db.Exec(` // Only try to add column if table exists.
ALTER TABLE qt_pricelist_sync_status if tableExists(db, "qt_pricelist_sync_status") {
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL if err := db.Exec(`
`).Error 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 // SyncPricelistItems synchronizes items for a specific pricelist
@@ -606,15 +676,7 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
// Convert and save locally // Convert and save locally
localItems := make([]localdb.LocalPricelistItem, len(serverItems)) localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i, item := range serverItems { for i, item := range serverItems {
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers)) localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
partnumbers = append(partnumbers, item.Partnumbers...)
localItems[i] = localdb.LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
}
} }
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil { if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
@@ -787,6 +849,12 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
projectRepo := repository.NewProjectRepository(mariaDB) projectRepo := repository.NewProjectRepository(mariaDB)
project := payload.Snapshot project := payload.Snapshot
project.UUID = payload.ProjectUUID project.UUID = payload.ProjectUUID
if strings.TrimSpace(project.Code) == "" {
project.Code = strings.TrimSpace(derefString(project.Name))
if project.Code == "" {
project.Code = project.UUID
}
}
if err := projectRepo.UpsertByUUID(&project); err != nil { if err := projectRepo.UpsertByUUID(&project); err != nil {
return fmt.Errorf("upsert project on server: %w", err) return fmt.Errorf("upsert project on server: %w", err)
@@ -807,6 +875,17 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
return nil return nil
} }
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func ptrString(value string) *string {
return &value
}
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) { func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
var payload ProjectChangePayload var payload ProjectChangePayload
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" { if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
@@ -1077,7 +1156,8 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
systemProject = &models.Project{ systemProject = &models.Project{
UUID: uuid.NewString(), UUID: uuid.NewString(),
OwnerUsername: "", OwnerUsername: "",
Name: "Без проекта", Code: "Без проекта",
Name: ptrString("Без проекта"),
IsActive: true, IsActive: true,
IsSystem: true, IsSystem: true,
} }
@@ -1241,6 +1321,21 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
} }
} }
if currentVersionNo == 0 {
if err := s.repairMissingConfigurationVersion(localCfg); err != nil {
return models.Configuration{}, "", 0, fmt.Errorf("repair missing configuration version: %w", err)
}
var latest localdb.LocalConfigurationVersion
err = s.localDB.DB().
Where("configuration_uuid = ?", configurationUUID).
Order("version_no DESC").
First(&latest).Error
if err == nil {
currentVersionNo = latest.VersionNo
currentVersionID = latest.ID
}
}
if currentVersionNo == 0 { if currentVersionNo == 0 {
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID) return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
} }
@@ -1248,6 +1343,64 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
return cfg, currentVersionID, currentVersionNo, nil return cfg, currentVersionID, currentVersionNo, nil
} }
func (s *Service) repairMissingConfigurationVersion(localCfg *localdb.LocalConfiguration) error {
if localCfg == nil {
return fmt.Errorf("local configuration is nil")
}
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
var cfg localdb.LocalConfiguration
if err := tx.Where("uuid = ?", localCfg.UUID).First(&cfg).Error; err != nil {
return fmt.Errorf("load local configuration: %w", err)
}
// If versions exist, just make sure current_version_id is set.
var latest localdb.LocalConfigurationVersion
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
Order("version_no DESC").
First(&latest).Error; err == nil {
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", cfg.UUID).
Update("current_version_id", latest.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
}
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("load latest version: %w", err)
}
snapshot, err := localdb.BuildConfigurationSnapshot(&cfg)
if err != nil {
return fmt.Errorf("build configuration snapshot: %w", err)
}
note := "Auto-repaired missing local version"
version := localdb.LocalConfigurationVersion{
ID: uuid.NewString(),
ConfigurationUUID: cfg.UUID,
VersionNo: 1,
Data: snapshot,
ChangeNote: &note,
AppVersion: appmeta.Version(),
CreatedAt: time.Now(),
}
if err := tx.Create(&version).Error; err != nil {
return fmt.Errorf("create initial version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", cfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
slog.Warn("repaired missing local configuration version", "uuid", cfg.UUID, "version_no", version.VersionNo)
return nil
})
}
// NOTE: prepared for future conflict resolution: // NOTE: prepared for future conflict resolution:
// when server starts storing version metadata, we can compare payload.CurrentVersionNo // when server starts storing version metadata, we can compare payload.CurrentVersionNo
// against remote version and branch into custom strategies. For now use last-write-wins. // against remote version and branch into custom strategies. For now use last-write-wins.

View File

@@ -0,0 +1,107 @@
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 TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
if err := serverDB.AutoMigrate(
&models.Pricelist{},
&models.PricelistItem{},
&models.Lot{},
&models.LotPartnumber{},
&models.StockLog{},
); err != nil {
t.Fatalf("migrate server tables: %v", err)
}
serverPL := models.Pricelist{
Source: "estimate",
Version: "2026-02-11-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",
LotCategory: "CPU",
Price: 10,
PriceMethod: "",
MetaPrices: "",
ManualPrice: nil,
AvailableQty: nil,
}).Error; err != nil {
t.Fatalf("create server pricelist item: %v", err)
}
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: serverPL.ID,
Source: serverPL.Source,
Version: serverPL.Version,
Name: serverPL.Notification,
CreatedAt: serverPL.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}); err != nil {
t.Fatalf("seed local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(serverPL.ID)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{
PricelistID: localPL.ID,
LotName: "CPU_A",
LotCategory: "",
Price: 10,
},
}); err != nil {
t.Fatalf("seed local pricelist items: %v", err)
}
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
UUID: "cfg-1",
OriginalUsername: "tester",
Name: "cfg",
Items: localdb.LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 10}},
IsActive: true,
PricelistID: &serverPL.ID,
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)
}
items, err := local.GetLocalPricelistItems(localPL.ID)
if err != nil {
t.Fatalf("load local items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 local item, got %d", len(items))
}
if items[0].LotCategory != "CPU" {
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
}
}

View File

@@ -23,7 +23,7 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
projectService := services.NewProjectService(local) projectService := services.NewProjectService(local)
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false }) configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project A"}) project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"})
if err != nil { if err != nil {
t.Fatalf("create project: %v", err) t.Fatalf("create project: %v", err)
} }
@@ -74,11 +74,11 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false }) configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
pushService := syncsvc.NewServiceWithDB(serverDB, local) pushService := syncsvc.NewServiceWithDB(serverDB, local)
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project v1"}) project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"})
if err != nil { if err != nil {
t.Fatalf("create project: %v", err) t.Fatalf("create project: %v", err)
} }
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil { if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil {
t.Fatalf("update project: %v", err) t.Fatalf("update project: %v", err)
} }
@@ -100,8 +100,8 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil { if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
t.Fatalf("project not pushed to server: %v", err) t.Fatalf("project not pushed to server: %v", err)
} }
if serverProject.Name != "Project v2" { if serverProject.Name == nil || *serverProject.Name != "Project v2" {
t.Fatalf("expected latest project name, got %q", serverProject.Name) t.Fatalf("expected latest project name, got %v", serverProject.Name)
} }
var serverCfg models.Configuration var serverCfg models.Configuration
@@ -324,6 +324,8 @@ CREATE TABLE qt_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE, uuid TEXT NOT NULL UNIQUE,
owner_username TEXT NOT NULL, owner_username TEXT NOT NULL,
code TEXT NOT NULL,
variant TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL, name TEXT NOT NULL,
tracker_url TEXT NULL, tracker_url TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
@@ -333,6 +335,9 @@ CREATE TABLE qt_projects (
);`).Error; err != nil { );`).Error; err != nil {
t.Fatalf("create qt_projects: %v", err) t.Fatalf("create qt_projects: %v", err)
} }
if err := db.Exec(`CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);`).Error; err != nil {
t.Fatalf("create qt_projects index: %v", err)
}
if err := db.Exec(` if err := db.Exec(`
CREATE TABLE qt_configurations ( CREATE TABLE qt_configurations (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -348,6 +353,9 @@ CREATE TABLE qt_configurations (
notes TEXT NULL, notes TEXT NULL,
is_template INTEGER NOT NULL DEFAULT 0, is_template INTEGER NOT NULL DEFAULT 0,
server_count INTEGER NOT NULL DEFAULT 1, server_count INTEGER NOT NULL DEFAULT 1,
server_model TEXT NULL,
support_code TEXT NULL,
article TEXT NULL,
pricelist_id INTEGER NULL, pricelist_id INTEGER NULL,
warehouse_pricelist_id INTEGER NULL, warehouse_pricelist_id INTEGER NULL,
competitor_pricelist_id INTEGER NULL, competitor_pricelist_id INTEGER NULL,
@@ -361,6 +369,10 @@ CREATE TABLE qt_configurations (
return db return db
} }
func ptrString(value string) *string {
return &value
}
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) { func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
t.Helper() t.Helper()
if currentVersionID == nil || *currentVersionID == "" { if currentVersionID == nil || *currentVersionID == "" {

413
man/backup.md Normal file
View File

@@ -0,0 +1,413 @@
# AI Implementation Guide: Go Scheduled Backup Rotation (ZIP)
This document is written **for an AI** to replicate the same backup approach in another Go project. It contains the exact requirements, design notes, and full module listings you can copy.
## Requirements (Behavioral)
- Run backups on a daily schedule at a configured local time (default `00:00`).
- At startup, if there is no backup for the current period, create it immediately.
- Backup content must include:
- Local SQLite DB file (e.g., `qfs.db`).
- SQLite sidecars (`-wal`, `-shm`) if present.
- Runtime config file (e.g., `config.yaml`) if present.
- Backups must be ZIP archives named:
- `qfs-backp-YYYY-MM-DD.zip`
- Retention policy:
- 7 daily, 4 weekly, 12 monthly, 10 yearly archives.
- Keep backups in period-specific directories:
- `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`.
- Prevent duplicate backups for the same period via a marker file.
- Log success with the archive path, and log errors on failure.
## Configuration & Env
- Config key: `backup.time` with format `HH:MM` in local time. Default: `00:00`.
- Env overrides:
- `QFS_BACKUP_DIR` — backup root directory.
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`).
## Integration Steps (Minimal)
1. Add `BackupConfig` to your config struct.
2. Add a scheduler goroutine that:
- On startup: runs backup immediately if needed.
- Then sleeps until next configured time and runs daily.
3. Add the backup module (below).
4. Wire logs for success/failure.
---
# Full Go Listings
## 1) Backup Module (Drop-in)
Create: `internal/appstate/backup.go`
```go
package appstate
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type backupPeriod struct {
name string
retention int
key func(time.Time) string
date func(time.Time) string
}
var backupPeriods = []backupPeriod{
{
name: "daily",
retention: 7,
key: func(t time.Time) string {
return t.Format("2006-01-02")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "weekly",
retention: 4,
key: func(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", y, w)
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "monthly",
retention: 12,
key: func(t time.Time) string {
return t.Format("2006-01")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
{
name: "yearly",
retention: 10,
key: func(t time.Time) string {
return t.Format("2006")
},
date: func(t time.Time) string {
return t.Format("2006-01-02")
},
},
}
const (
envBackupDisable = "QFS_BACKUP_DISABLE"
envBackupDir = "QFS_BACKUP_DIR"
)
var backupNow = time.Now
// EnsureRotatingLocalBackup creates or refreshes daily/weekly/monthly/yearly backups
// for the local database and config. It keeps a limited number per period.
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
if isBackupDisabled() {
return nil, nil
}
if dbPath == "" {
return nil, nil
}
if _, err := os.Stat(dbPath); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("stat db: %w", err)
}
root := resolveBackupRoot(dbPath)
now := backupNow()
created := make([]string, 0)
for _, period := range backupPeriods {
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
if err != nil {
return created, err
}
if len(newFiles) > 0 {
created = append(created, newFiles...)
}
}
return created, nil
}
func resolveBackupRoot(dbPath string) string {
if fromEnv := strings.TrimSpace(os.Getenv(envBackupDir)); fromEnv != "" {
return filepath.Clean(fromEnv)
}
return filepath.Join(filepath.Dir(dbPath), "backups")
}
func isBackupDisabled() bool {
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
return val == "1" || val == "true" || val == "yes"
}
func ensurePeriodBackup(root string, period backupPeriod, now time.Time, dbPath, configPath string) ([]string, error) {
key := period.key(now)
periodDir := filepath.Join(root, period.name)
if err := os.MkdirAll(periodDir, 0755); err != nil {
return nil, fmt.Errorf("create %s backup dir: %w", period.name, err)
}
if hasBackupForKey(periodDir, key) {
return nil, nil
}
archiveName := fmt.Sprintf("qfs-backp-%s.zip", period.date(now))
archivePath := filepath.Join(periodDir, archiveName)
if err := createBackupArchive(archivePath, dbPath, configPath); err != nil {
return nil, fmt.Errorf("create %s backup archive: %w", period.name, err)
}
if err := writePeriodMarker(periodDir, key); err != nil {
return []string{archivePath}, err
}
if err := pruneOldBackups(periodDir, period.retention); err != nil {
return []string{archivePath}, err
}
return []string{archivePath}, nil
}
func hasBackupForKey(periodDir, key string) bool {
marker := periodMarker{Key: ""}
data, err := os.ReadFile(periodMarkerPath(periodDir))
if err != nil {
return false
}
if err := json.Unmarshal(data, &marker); err != nil {
return false
}
return marker.Key == key
}
type periodMarker struct {
Key string `json:"key"`
}
func periodMarkerPath(periodDir string) string {
return filepath.Join(periodDir, ".period.json")
}
func writePeriodMarker(periodDir, key string) error {
data, err := json.MarshalIndent(periodMarker{Key: key}, "", " ")
if err != nil {
return err
}
return os.WriteFile(periodMarkerPath(periodDir), data, 0644)
}
func pruneOldBackups(periodDir string, keep int) error {
entries, err := os.ReadDir(periodDir)
if err != nil {
return fmt.Errorf("read backups dir: %w", err)
}
files := make([]os.DirEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.HasSuffix(entry.Name(), ".zip") {
files = append(files, entry)
}
}
if len(files) <= keep {
return nil
}
sort.Slice(files, func(i, j int) bool {
infoI, errI := files[i].Info()
infoJ, errJ := files[j].Info()
if errI != nil || errJ != nil {
return files[i].Name() < files[j].Name()
}
return infoI.ModTime().Before(infoJ.ModTime())
})
for i := 0; i < len(files)-keep; i++ {
path := filepath.Join(periodDir, files[i].Name())
if err := os.Remove(path); err != nil {
return fmt.Errorf("remove old backup %s: %w", path, err)
}
}
return nil
}
func createBackupArchive(destPath, dbPath, configPath string) error {
file, err := os.Create(destPath)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
if err := addZipFile(zipWriter, dbPath); err != nil {
_ = zipWriter.Close()
return err
}
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
if strings.TrimSpace(configPath) != "" {
_ = addZipOptionalFile(zipWriter, configPath)
}
if err := zipWriter.Close(); err != nil {
return err
}
return file.Sync()
}
func addZipOptionalFile(writer *zip.Writer, path string) error {
if _, err := os.Stat(path); err != nil {
return nil
}
return addZipFile(writer, path)
}
func addZipFile(writer *zip.Writer, path string) error {
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = filepath.Base(path)
header.Method = zip.Deflate
out, err := writer.CreateHeader(header)
if err != nil {
return err
}
_, err = io.Copy(out, in)
return err
}
```
---
## 2) Scheduler Hook (Main)
Add this to your `main.go` (or equivalent). This schedules daily backups and logs success.
```go
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
if cfg == nil {
return
}
hour, minute, err := parseBackupTime(cfg.Backup.Time)
if err != nil {
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
hour = 0
minute = 0
}
// Startup check: if no backup exists for current periods, create now.
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
slog.Error("local backup failed", "error", backupErr)
} else if len(created) > 0 {
for _, path := range created {
slog.Info("local backup completed", "archive", path)
}
}
for {
next := nextBackupTime(time.Now(), hour, minute)
timer := time.NewTimer(time.Until(next))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
start := time.Now()
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
duration := time.Since(start)
if backupErr != nil {
slog.Error("local backup failed", "error", backupErr, "duration", duration)
} else {
for _, path := range created {
slog.Info("local backup completed", "archive", path, "duration", duration)
}
}
}
}
}
func parseBackupTime(value string) (int, int, error) {
if strings.TrimSpace(value) == "" {
return 0, 0, fmt.Errorf("empty backup time")
}
parsed, err := time.Parse("15:04", value)
if err != nil {
return 0, 0, err
}
return parsed.Hour(), parsed.Minute(), nil
}
func nextBackupTime(now time.Time, hour, minute int) time.Time {
location := now.Location()
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location)
if !now.Before(target) {
target = target.Add(24 * time.Hour)
}
return target
}
```
---
## 3) Config Struct (Minimal)
Add to config:
```go
type BackupConfig struct {
Time string `yaml:"time"`
}
```
Default:
```go
if c.Backup.Time == "" {
c.Backup.Time = "00:00"
}
```
---
## Notes for Replication
- Keep `backup.time` in local time. Do **not** parse with timezone offsets unless required.
- The `.period.json` marker is what prevents duplicate backups within the same period.
- The archive file name only contains the date. Uniqueness is ensured by per-period directories and the period marker.
- If you change naming or retention, update both the file naming and prune logic together.

38
memory.md Normal file
View File

@@ -0,0 +1,38 @@
# Changes summary (2026-02-11)
Implemented strict `lot_category` flow using `pricelist_items.lot_category` only (no parsing from `lot_name`), plus local caching and backfill:
1. Local DB schema + migrations
- Added `lot_category` column to `local_pricelist_items` via `LocalPricelistItem` model.
- Added local migration `2026_02_11_local_pricelist_item_category` to add the column if missing and create indexes:
- `idx_local_pricelist_items_pricelist_lot (pricelist_id, lot_name)`
- `idx_local_pricelist_items_lot_category (lot_category)`
2. Server model/repository
- Added `LotCategory` field to `models.PricelistItem`.
- `PricelistRepository.GetItems` now sets `Category` from `LotCategory` (no parsing from `lot_name`).
3. Sync + local DB helpers
- `SyncPricelistItems` now saves `lot_category` into local cache via `PricelistItemToLocal`.
- Added `LocalDB.CountLocalPricelistItemsWithEmptyCategory` and `LocalDB.ReplaceLocalPricelistItems`.
- Added `LocalDB.GetLocalLotCategoriesByServerPricelistID` for strict category lookup.
- Added `SyncPricelists` backfill step: for used active pricelists with empty categories, force refresh items from server.
4. API handler
- `GET /api/pricelists/:id/items` returns `category` from `local_pricelist_items.lot_category` (no parsing from `lot_name`).
5. Article category foundation
- New package `internal/article`:
- `ResolveLotCategoriesStrict` pulls categories from local pricelist items and errors on missing category.
- `GroupForLotCategory` maps only allowed codes (CPU/MEM/GPU/M2/SSD/HDD/EDSFF/HHHL/NIC/HCA/DPU/PSU/PS) to article groups; excludes `SFP`.
- Error type `MissingCategoryForLotError` with base `ErrMissingCategoryForLot`.
6. Tests
- Added unit tests for converters and article category resolver.
- Added handler test to ensure `/api/pricelists/:id/items` returns `lot_category`.
- Added sync test for category backfill on used pricelist items.
- `go test ./...` passed.
Additional fixes (2026-02-11):
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).

View File

@@ -0,0 +1,2 @@
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count;

View File

@@ -0,0 +1,2 @@
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count;

View File

@@ -0,0 +1,2 @@
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model;

View File

@@ -0,0 +1,38 @@
-- Add project code and enforce uniqueness
ALTER TABLE qt_projects
ADD COLUMN code VARCHAR(100) NULL AFTER owner_username;
-- Copy code from current project name (truncate to fit)
UPDATE qt_projects
SET code = LEFT(TRIM(COALESCE(name, '')), 100);
-- Fallback for any remaining blanks
UPDATE qt_projects
SET code = uuid
WHERE code IS NULL OR TRIM(code) = '';
-- Drop unique index if it already exists to allow de-duplication updates
DROP INDEX IF EXISTS idx_qt_projects_code ON qt_projects;
-- De-duplicate codes: OPS-1948-2, OPS-1948-3... (MariaDB without CTE)
UPDATE qt_projects p
JOIN (
SELECT p1.id,
p1.code AS base_code,
(
SELECT COUNT(*)
FROM qt_projects p2
WHERE p2.code = p1.code AND p2.id <= p1.id
) AS rn
FROM qt_projects p1
) r ON r.id = p.id
SET p.code = CASE
WHEN r.rn = 1 THEN r.base_code
ELSE CONCAT(LEFT(r.base_code, 90), '-', r.rn)
END;
ALTER TABLE qt_projects
MODIFY COLUMN code VARCHAR(100) NOT NULL;
CREATE UNIQUE INDEX idx_qt_projects_code ON qt_projects(code);

View File

@@ -0,0 +1,28 @@
-- Add project variant and reset codes from project names
ALTER TABLE qt_projects
ADD COLUMN variant VARCHAR(100) NOT NULL DEFAULT '' AFTER code;
-- Drop legacy unique index on code to allow duplicate codes
DROP INDEX IF EXISTS idx_qt_projects_code ON qt_projects;
DROP INDEX IF EXISTS idx_qt_projects_code_variant ON qt_projects;
-- Reset code from name and clear variant
UPDATE qt_projects
SET code = LEFT(TRIM(COALESCE(name, '')), 100),
variant = '';
-- De-duplicate by assigning variant numbers: -2, -3...
UPDATE qt_projects p
JOIN (
SELECT p1.id,
p1.code,
(SELECT COUNT(*)
FROM qt_projects p2
WHERE p2.code = p1.code AND p2.id <= p1.id) AS rn
FROM qt_projects p1
) r ON r.id = p.id
SET p.code = r.code,
p.variant = CASE WHEN r.rn = 1 THEN '' ELSE CONCAT('-', r.rn) END;
CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);

View File

@@ -0,0 +1,4 @@
-- Allow NULL project names
ALTER TABLE qt_projects
MODIFY COLUMN name VARCHAR(200) NULL;

315
pricelists_window.md Normal file
View File

@@ -0,0 +1,315 @@
# Промпт для ИИ: Перенос паттерна Прайслист
Используй этот документ как промпт для ИИ при переносе реализации прайслиста в другой проект.
---
## Задача
Я имею рабочую реализацию окна "Прайслист" в проекте QuoteForge. Нужно перенести эту реализацию в проект [ДОП_ПРОЕКТ_НАЗВАНИЕ], сохраняя структуру, логику и UI/UX.
## Что перенести
### Frontend - Лист прайслистов (`/pricelists`)
**Файл источник:** QuoteForge/web/templates/pricelists.html
**Компоненты:**
1. **Таблица** - список прайслистов с колонками:
- Версия (монофонт)
- Тип (estimate/warehouse/competitor)
- Дата создания
- Автор (обычно "sync")
- Позиций (количество товаров)
- Исп. (использований)
- Статус (зеленый "Активен" / серый "Неактивен")
- Действия (Просмотр, Удалить если не используется)
2. **Пагинация** - навигация по страницам с активной страницей выделена
3. **Модальное окно** - "Создать прайслист" (если есть прав на запись)
**Что копировать:**
- HTML структуру таблицы из lines 10-30
- JavaScript функции:
- `loadPricelists(page)` - загрузка списка
- `renderPricelists(items)` - рендер таблицы
- `renderPagination(total, page, perPage)` - пагинация
- `checkPricelistWritePermission()` - проверка прав
- Модальные функции: `openCreateModal()`, `closeCreateModal()`, `createPricelist()`
- CSS классы Tailwind (скопируются как есть)
**Где использовать в дочернем проекте:**
- URL: `/pricelists` (или адаптировать под ваши маршруты)
- API: `GET /api/pricelists?page=1&per_page=20`
---
### Frontend - Детали прайслиста (`/pricelists/:id`)
**Файл источник:** QuoteForge/web/templates/pricelist_detail.html
**Компоненты:**
1. **Хлебная крошка** - кнопка назад на список
2. **Инфо-панель** - сводка по прайслисту:
- Версия (монофонт)
- Дата создания
- Автор
- Позиций (количество)
- Использований (в скольких конфигах)
- Статус (зеленый/серый)
- Истекает (дата или "-")
3. **Таблица товаров** - с поиском и пагинацией:
- Артикул (монофонт, lot_name)
- Категория (извлекается первая часть до "_")
- Описание (обрезается до 60 символов с "...")
- [УСЛОВНО] Доступно (qty) - только для warehouse источника
- [УСЛОВНО] Partnumbers - только для warehouse источника
- Цена, $ (с 2 знаками после запятой)
- Настройки (аббревиатуры: РУЧН, Сред, Взвеш.мед, периоды (1н, 1м, 3м, 1г), коэффициент, МЕТА)
4. **Поиск** - дебаунс 300мс, поиск по lot_name
5. **Динамические колонки** - qty и partnumbers скрываются/показываются в зависимости от source (warehouse или нет)
**Что копировать:**
- HTML структуру из lines 4-78
- JavaScript функции:
- `loadPricelistInfo()` - загрузка деталей прайслиста
- `loadItems(page)` - загрузка товаров
- `renderItems(items)` - рендер таблицы товаров
- `renderItemsPagination(total, page, perPage)` - пагинация товаров
- `isWarehouseSource()` - проверка источника
- `toggleWarehouseColumns()` - показать/скрыть conditional колонки
- `formatQty(qty)` - форматирование количества
- `formatPriceSettings(item)` - форматирование строки настроек
- `escapeHtml(text)` - экранирование HTML
- Debounce для поиска (lines 300-306)
- CSS классы Tailwind
- Логику conditional колонок (lines 152-164)
**Где использовать в дочернем проекте:**
- URL: `/pricelists/:id`
- API:
- `GET /api/pricelists/:id`
- `GET /api/pricelists/:id/items?page=1&per_page=50&search=...`
---
### Backend - Handler
**Файл источник:** QuoteForge/internal/handlers/pricelist.go
**Методы для реализации:**
1. **List** (lines 23-89)
- Параметры: `page`, `per_page`, `source` (фильтр), `active_only`
- Логика:
- Получить все прайслисты
- Отфильтровать по source (case-insensitive)
- Отсортировать по CreatedAt DESC (свежее сверху)
- Пагинировать
- Для каждого: посчитать товары (CountLocalPricelistItems), использования (IsUsed)
- Вернуть JSON с полями: id, source, version, created_by, item_count, usage_count, is_active, created_at, synced_from
2. **Get** (lines 92-116)
- Параметр: `id` (uint из URL)
- Логика:
- Получить прайслист по ID
- Вернуть его детали (id, source, version, item_count, is_active, created_at)
- 404 если не найден
3. **GetItems** (lines 119-181)
- Параметры: `id` (URL), `page`, `per_page`, `search` (query)
- Логика:
- Получить прайслист по ID
- Получить товары этого прайслиста
- Фильтровать по lot_name LIKE search (если передан)
- Посчитать total
- Пагинировать
- Для каждого товара: извлечь категорию из lot_name (первая часть до "_")
- Вернуть JSON: source, items (id, lot_name, price, category, available_qty, partnumbers), total, page, per_page
4. **GetLotNames** (lines 183-211)
- Параметр: `id` (URL)
- Логика:
- Получить все lot_names из этого прайслиста
- Отсортировать alphabetically
- Вернуть JSON: lot_names (array of strings), total
5. **GetLatest** (lines 214-233)
- Параметр: `source` (query, default "estimate")
- Логика:
- Нормализовать source (case-insensitive)
- Получить самый свежий прайслист по этому source
- Вернуть его детали
- 404 если не найден
**Регистрация маршрутов:**
```go
pricelists := api.Group("/pricelists")
{
pricelists.GET("", handler.List)
pricelists.GET("/latest", handler.GetLatest)
pricelists.GET("/:id", handler.Get)
pricelists.GET("/:id/items", handler.GetItems)
pricelists.GET("/:id/lots", handler.GetLotNames)
}
```
---
## Адаптация для другого проекта
### Что нужно изменить
1. **Источник данных**
- QuoteForge использует local DB (LocalPricelist, LocalPricelistItem)
- В вашем проекте: замените на ваши структуры/таблицы
- Сущность "прайслист" может называться по-другому
2. **API маршруты**
- `/api/pricelists` → ваш путь
- `:id` - может быть UUID вместо int, адаптировать parsing
3. **Имена полей**
- Если у вас нет поля `version` - используйте ID или дату
- Если нет `source` - опустить фильтр
- Если нет `IsUsed` - считать как всегда 0
4. **Структуры данных**
- Pricelist должна иметь: id, name/version, created_at, source, item_count
- PricelistItem должна иметь: id, lot_name, price, available_qty, partnumbers
5. **Условные колонки**
- Логика: если source == "warehouse", показать qty и partnumbers
- Адаптировать под ваши источники/типы
### Что копировать как есть
- **HTML структура** - таблицы, модали, классы Tailwind
- **JavaScript логика** - все функции загрузки, рендера, пагинации
- **CSS классы** - Tailwind работает везде одинаково
- **Форматирование функций** - formatPrice, formatQty, formatDate
---
## Пошаговая инструкция для ИИ
1. **Прочитай оба файла:**
- QuoteForge/web/templates/pricelists.html (список)
- QuoteForge/web/templates/pricelist_detail.html (детали)
- QuoteForge/internal/handlers/pricelist.go (backend)
2. **Определи структуры данных в дочернем проекте:**
- Какая таблица хранит "прайслисты"?
- Какие у неё поля?
- Как связаны товары?
3. **Адаптируй Backend:**
- Скопируй методы Handler
- Замени DB вызовы на вызовы вашего хранилища
- Замени имена полей в JSON ответах если нужно
- Убедись, что API возвращает нужный формат
4. **Адаптируй Frontend - Список:**
- Скопируй HTML таблицу
- Скопируй функции load/render/pagination
- Замени маршруты `/pricelists` → ваши
- Замени API endpoint → ваш
- Протестируй список загружается
5. **Адаптируй Frontend - Детали:**
- Скопируй HTML для деталей
- Скопируй функции loadInfo/loadItems/render
- Замени маршруты и endpoints
- Особое внимание на conditional колонки (toggleWarehouseColumns)
- Протестируй поиск работает
6. **Протестируй:**
- Список загружается
- Пагинация работает
- Детали открываются
- Поиск работает
- Conditional колонки показываются/скрываются правильно
- Форматирование цен и дат работает
---
## Пример адаптации
### Backend (было):
```go
func (h *PricelistHandler) List(c *gin.Context) {
localPLs, err := h.localDB.GetLocalPricelists()
// ...
}
```
### Backend (стало):
```go
func (h *CatalogHandler) List(c *gin.Context) {
catalogs, err := h.service.GetAllCatalogs(page, perPage)
// ...
}
```
### Frontend (было):
```javascript
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
```
### Frontend (стало):
```javascript
const resp = await fetch(`/api/catalogs?page=${page}&per_page=20`);
```
---
## Качество результата
Когда закончишь:
- ✅ Список и детали выглядят идентично QuoteForge
-Все функции работают (load, render, pagination, search, conditional columns)
- ✅ Обработка ошибок (404, empty list, network errors)
- ✅ Таблицы с Tailwind классами оформлены одинаково
- ✅ Форматирование чисел/дат совпадает
---
## Вопросы для ИИ
Перед тем как давать этот промпт, ответь на эти вопросы:
1. **Какие у тебя структуры данных для "прайслиста"?**
- Пример: какие поля, как называется таблица
2. **Какие API endpoints уже есть?**
- Или нужно создать с нуля?
3. **Есть ли уже разница в источниках (estimate/warehouse)?**
- Или все одного типа?
4. **Нужна ли возможность создавать прайслисты?**
- Или только просмотр?
---
## Чеклист для проверки
После переноса проверь:
- [ ] Backend: List возвращает правильный JSON
- [ ] Backend: Get возвращает детали
- [ ] Backend: GetItems возвращает товары с поиском
- [ ] Frontend: Список загружается на `/pricelists`
- [ ] Frontend: Клик на прайслист открывает `/pricelists/:id`
- [ ] Frontend: Таблица на детальной странице рендеритсяся
- [ ] Frontend: Поиск работает с дебаунсом
- [ ] Frontend: Пагинация работает
- [ ] Frontend: Conditional колонки показываются/скрываются
- [ ] Frontend: Форматирование цен работает (2 знака)
- [ ] Frontend: Форматирование дат работает (ru-RU)
- [ ] UI: Выглядит идентично QuoteForge

BIN
qfs

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.

59
releases/memory/v1.2.2.md Normal file
View File

@@ -0,0 +1,59 @@
# Release v1.2.2 (2026-02-09)
## Summary
Fixed CSV export filename inconsistency where project names weren't being resolved correctly. Standardized export format across both manual exports and project configuration exports to use `YYYY-MM-DD (project_name) config_name BOM.csv`.
## Commits
- `8f596ce` fix: standardize CSV export filename format to use project name
## Changes
### CSV Export Filename Standardization
**Problem:**
- ExportCSV and ExportConfigCSV had inconsistent filename formats
- Project names sometimes fell back to config names when not explicitly provided
- Export timestamps didn't reflect actual price update time
**Solution:**
- Unified format: `YYYY-MM-DD (project_name) config_name BOM.csv`
- Both export paths now use PriceUpdatedAt if available, otherwise CreatedAt
- Project name resolved from ProjectUUID via ProjectService for both paths
- Frontend passes project_uuid context when exporting
**Technical Details:**
Backend:
- Added `ProjectUUID` field to `ExportRequest` struct in handlers/export.go
- Updated ExportCSV to look up project name from ProjectUUID using ProjectService
- Ensured ExportConfigCSV gets project name from config's ProjectUUID
- Both use CreatedAt (for ExportCSV) or PriceUpdatedAt/CreatedAt (for ExportConfigCSV)
Frontend:
- Added `projectUUID` and `projectName` state variables in index.html
- Load and store projectUUID when configuration is loaded
- Pass `project_uuid` in JSON body for both export requests
## Files Modified
- `internal/handlers/export.go` - Project name resolution and ExportRequest update
- `internal/handlers/export_test.go` - Updated mock initialization with projectService param
- `cmd/qfs/main.go` - Pass projectService to ExportHandler constructor
- `web/templates/index.html` - Add projectUUID tracking and export payload updates
## Testing Notes
✅ All existing tests updated and passing
✅ Code builds without errors
✅ Export filename now includes correct project name
✅ Works for both form-based and project-based exports
## Breaking Changes
None - API response format unchanged, only filename generation updated.
## Known Issues
None identified.

95
releases/memory/v1.2.3.md Normal file
View File

@@ -0,0 +1,95 @@
# Release v1.2.3 (2026-02-10)
## Summary
Unified synchronization functionality with event-driven UI updates. Resolved user confusion about duplicate sync buttons by implementing a single sync source with automatic page refreshes.
## Changes
### Main Feature: Sync Event System
- **Added `sync-completed` event** in base.html's `syncAction()` function
- Dispatched after successful `/api/sync/all` or `/api/sync/push`
- Includes endpoint and response data in event detail
- Enables pages to react automatically to sync completion
### Configs Page (`configs.html`)
- **Removed "Импорт с сервера" button** - duplicate functionality no longer needed
- **Updated layout** - changed from 2-column grid to single button layout
- **Removed `importConfigsFromServer()` function** - functionality now handled by navbar sync
- **Added sync-completed event listener**:
- Automatically reloads configurations list after sync
- Resets pagination to first page
- New configurations appear immediately without manual refresh
### Projects Page (`projects.html`)
- **Wrapped initialization in DOMContentLoaded**:
- Moved `loadProjects()` and all event listeners inside handler
- Ensures DOM is fully loaded before accessing elements
- **Added sync-completed event listener**:
- Automatically reloads projects list after sync
- New projects appear immediately without manual refresh
### Pricelists Page (`pricelists.html`)
- **Added sync-completed event listener** to existing DOMContentLoaded:
- Automatically reloads pricelists when sync completes
- Maintains existing permissions and modal functionality
## Benefits
### User Experience
- ✅ Single "Синхронизация" button in navbar - no confusion about sync sources
- ✅ Automatic list updates after sync - no need for manual F5 refresh
- ✅ Consistent behavior across all pages (configs, projects, pricelists)
- ✅ Better feedback: toast notification + automatic UI refresh
### Architecture
- ✅ Event-driven loose coupling between navbar and pages
- ✅ Easy to extend to other pages (just add event listener)
- ✅ No backend changes needed
- ✅ Production-ready
## Breaking Changes
- **`/api/configs/import` endpoint** still works but UI button removed
- Users should use navbar "Синхронизация" button instead
- Backend API remains unchanged for backward compatibility
## Files Modified
1. `web/templates/base.html` - Added sync-completed event dispatch
2. `web/templates/configs.html` - Event listener + removed duplicate UI
3. `web/templates/projects.html` - DOMContentLoaded wrapper + event listener
4. `web/templates/pricelists.html` - Event listener for auto-refresh
**Stats:** 4 files changed, 59 insertions(+), 65 deletions(-)
## Commits
- `99fd80b` - feat: unify sync functionality with event-driven UI updates
## Testing Checklist
- [x] Configs page: New configurations appear after navbar sync
- [x] Projects page: New projects appear after navbar sync
- [x] Pricelists page: Pricelists refresh after navbar sync
- [x] Both `/api/sync/all` and `/api/sync/push` trigger updates
- [x] Toast notifications still show correctly
- [x] Sync status indicator updates
- [x] Error handling (423, network errors) still works
- [x] Mode switching (Active/Archive) works correctly
- [x] Backward compatibility maintained
## Known Issues
None - implementation is production-ready
## Migration Notes
No migration needed. Changes are frontend-only and backward compatible:
- Old `/api/configs/import` endpoint still functional
- No database schema changes
- No configuration changes needed

68
releases/memory/v1.3.0.md Normal file
View File

@@ -0,0 +1,68 @@
# Release v1.3.0 (2026-02-11)
## Summary
Introduced article generation with pricelist categories, added local configuration storage, and expanded sync/export capabilities. Simplified article generator compression and loosened project update constraints.
## Changes
### Main Features: Articles + Pricelist Categories
- **Article generation pipeline**
- New generator and tests under `internal/article/`
- Category support with test coverage
- **Pricelist category integration**
- Handler and repository updates
- Sync backfill test for category propagation
### Local Configuration Storage
- **Local DB support**
- New localdb models, converters, snapshots, and migrations
- Local configuration service for cached configurations
### Export & UI
- **Export handler updates** for article data output
- **Configs and index templates** adjusted for new article-related fields
### Behavior Changes
- **Cross-user project updates allowed**
- Removed restriction in project service
- **Article compression refinement**
- Generator logic simplified to reduce complexity
## Breaking Changes
None identified. Existing APIs remain intact.
## Files Modified
1. `internal/article/*` - Article generator + categories + tests
2. `internal/localdb/*` - Local DB models, migrations, snapshots
3. `internal/handlers/export.go` - Export updates
4. `internal/handlers/pricelist.go` - Category handling
5. `internal/services/sync/service.go` - Category backfill logic
6. `web/templates/configs.html` - Article field updates
7. `web/templates/index.html` - Article field updates
**Stats:** 33 files changed, 2059 insertions(+), 329 deletions(-)
## Commits
- `5edffe8` - Add article generation and pricelist categories
- `e355903` - Allow cross-user project updates
- `e58fd35` - Refine article compression and simplify generator
## Testing Checklist
- [ ] Tests not run (not requested)
## Migration Notes
- New migrations:
- `022_add_article_to_configurations.sql`
- `023_add_server_model_to_configurations.sql`
- `024_add_support_code_to_configurations.sql`
- Ensure migrations are applied before running v1.3.0

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*

View File

@@ -285,6 +285,14 @@
showToast(successMessage, 'success'); showToast(successMessage, 'success');
// Update last sync time - removed since dropdown is gone // Update last sync time - removed since dropdown is gone
// loadLastSyncTime(); // loadLastSyncTime();
// Dispatch custom event for pages to react to sync completion
window.dispatchEvent(new CustomEvent('sync-completed', {
detail: {
endpoint: endpoint,
data: data
}
}));
} else if (resp.status === 423) { } else if (resp.status === 423) {
const reason = data.reason_text || data.error || 'Синхронизация заблокирована.'; const reason = data.reason_text || data.error || 'Синхронизация заблокирована.';
showToast(reason, 'error'); showToast(reason, 'error');

View File

@@ -4,13 +4,10 @@
<div class="space-y-4"> <div class="space-y-4">
<h1 class="text-2xl font-bold">Мои конфигурации</h1> <h1 class="text-2xl font-bold">Мои конфигурации</h1>
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3"> <div id="action-buttons" class="mt-4">
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"> <button onclick="openCreateModal()" class="w-full sm:w-auto py-3 px-6 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Создать новую конфигурацию + Создать новую конфигурацию
</button> </button>
<button id="import-configs-btn" onclick="importConfigsFromServer()" class="py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium">
Импорт с сервера
</button>
</div> </div>
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden"> <div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
@@ -57,15 +54,15 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Номер Opportunity</label> <label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
<input type="text" id="opportunity-number" placeholder="Например: OPP-2024-001" <input type="text" id="opportunity-number" placeholder="Например: Сервер для проекта X"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label> <label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input id="create-project-input" <input id="create-project-input"
list="create-project-options" list="create-project-options"
placeholder="Начните вводить название проекта" placeholder="Например: OPS-123 (Lenovo)"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<datalist id="create-project-options"></datalist> <datalist id="create-project-options"></datalist>
<div class="mt-2 flex justify-between items-center gap-3"> <div class="mt-2 flex justify-between items-center gap-3">
@@ -150,7 +147,7 @@
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label> <label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
<input id="move-project-input" <input id="move-project-input"
list="move-project-options" list="move-project-options"
placeholder="Начните вводить название проекта" placeholder="Например: OPS-123 (Lenovo)"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<datalist id="move-project-options"></datalist> <datalist id="move-project-options"></datalist>
<div class="mt-2 flex justify-between items-center gap-3"> <div class="mt-2 flex justify-between items-center gap-3">
@@ -177,7 +174,17 @@
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50"> <div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"> <div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2> <h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p> <p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
<div class="mb-4">
<label for="create-project-on-move-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
<input id="create-project-on-move-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="mb-4">
<label for="create-project-on-move-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
<input id="create-project-on-move-variant" type="text" placeholder="Например: Lenovo"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button> <button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button> <button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
@@ -194,10 +201,12 @@ let configStatusMode = 'active';
let configsSearch = ''; let configsSearch = '';
let projectsCache = []; let projectsCache = [];
let projectNameByUUID = {}; let projectNameByUUID = {};
let projectCodeByUUID = {};
let projectVariantByUUID = {};
let pendingMoveConfigUUID = ''; let pendingMoveConfigUUID = '';
let pendingMoveProjectName = ''; let pendingMoveProjectCode = '';
let pendingCreateConfigName = ''; let pendingCreateConfigName = '';
let pendingCreateProjectName = ''; let pendingCreateProjectCode = '';
function renderConfigs(configs) { function renderConfigs(configs) {
const emptyText = configStatusMode === 'archived' const emptyText = configStatusMode === 'archived'
@@ -252,10 +261,23 @@ function renderConfigs(configs) {
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>'; html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>';
} }
} }
const article = c.article ? escapeHtml(c.article) : '';
const serverModel = c.server_model ? escapeHtml(c.server_model) : '';
const subtitle = article || serverModel;
if (configStatusMode === 'archived') { if (configStatusMode === 'archived') {
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>'; html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">';
html += '<div>' + escapeHtml(c.name) + '</div>';
if (subtitle) {
html += '<div class="text-xs text-gray-500">' + subtitle + '</div>';
}
html += '</td>';
} else { } else {
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>'; html += '<td class="px-4 py-3 text-sm font-medium">';
html += '<a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a>';
if (subtitle) {
html += '<div class="text-xs text-gray-500">' + subtitle + '</div>';
}
html += '</td>';
} }
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>'; html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>'; html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
@@ -297,6 +319,30 @@ function renderConfigs(configs) {
document.getElementById('configs-list').innerHTML = html; document.getElementById('configs-list').innerHTML = html;
} }
function projectDisplayKey(project) {
const code = (project.code || '').trim();
const variant = (project.variant || '').trim();
if (!code) return '';
return variant ? (code + ' (' + variant + ')') : code;
}
function findProjectByInput(input) {
const trimmed = (input || '').trim().toLowerCase();
if (!trimmed) return null;
const directMatch = projectsCache.find(p => projectDisplayKey(p).toLowerCase() === trimmed);
if (directMatch) return directMatch;
const codeMatches = projectsCache.filter(p => (p.code || '').toLowerCase() === trimmed);
if (codeMatches.length === 1) {
return codeMatches[0];
}
if (codeMatches.length > 1) {
alert('У проекта несколько вариантов. Укажите вариант в формате "CODE (variant)".');
}
return null;
}
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
@@ -434,17 +480,21 @@ async function createConfig() {
return; return;
} }
const projectName = document.getElementById('create-project-input').value.trim(); const projectCode = document.getElementById('create-project-input').value.trim();
let projectUUID = ''; let projectUUID = '';
if (projectName) { if (projectCode) {
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase()); const matchedProject = findProjectByInput(projectCode);
if (existingProject) { if (matchedProject) {
projectUUID = existingProject.uuid; if (!matchedProject.is_active) {
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
return;
}
projectUUID = matchedProject.uuid;
} else { } else {
pendingCreateConfigName = name; pendingCreateConfigName = name;
pendingCreateProjectName = projectName; pendingCreateProjectCode = projectCode;
openCreateProjectOnCreateModal(projectName); openCreateProjectOnCreateModal(projectCode);
return; return;
} }
} }
@@ -492,12 +542,14 @@ function openMoveProjectModal(uuid, configName, currentProjectUUID) {
projectsCache.forEach(project => { projectsCache.forEach(project => {
if (!project.is_active) return; if (!project.is_active) return;
const option = document.createElement('option'); const option = document.createElement('option');
option.value = project.name; option.value = projectDisplayKey(project);
option.label = project.name || '';
options.appendChild(option); options.appendChild(option);
}); });
if (currentProjectUUID && projectNameByUUID[currentProjectUUID]) { if (currentProjectUUID && projectCodeByUUID[currentProjectUUID]) {
input.value = projectNameByUUID[currentProjectUUID]; const variant = projectVariantByUUID[currentProjectUUID] || '';
input.value = variant ? (projectCodeByUUID[currentProjectUUID] + ' (' + variant + ')') : projectCodeByUUID[currentProjectUUID];
} else { } else {
input.value = ''; input.value = '';
} }
@@ -513,19 +565,23 @@ function closeMoveProjectModal() {
async function confirmMoveProject() { async function confirmMoveProject() {
const uuid = document.getElementById('move-project-uuid').value; const uuid = document.getElementById('move-project-uuid').value;
const projectName = document.getElementById('move-project-input').value.trim(); const projectCode = document.getElementById('move-project-input').value.trim();
if (!uuid) return; if (!uuid) return;
let projectUUID = ''; let projectUUID = '';
if (projectName) { if (projectCode) {
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase()); const matchedProject = findProjectByInput(projectCode);
if (existingProject) { if (matchedProject) {
projectUUID = existingProject.uuid; if (!matchedProject.is_active) {
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
return;
}
projectUUID = matchedProject.uuid;
} else { } else {
pendingMoveConfigUUID = uuid; pendingMoveConfigUUID = uuid;
pendingMoveProjectName = projectName; pendingMoveProjectCode = projectCode;
openCreateProjectOnMoveModal(projectName); openCreateProjectOnMoveModal(projectCode);
return; return;
} }
} }
@@ -542,7 +598,9 @@ function clearCreateProjectInput() {
} }
function openCreateProjectOnMoveModal(projectName) { function openCreateProjectOnMoveModal(projectName) {
document.getElementById('create-project-on-move-name').textContent = projectName; document.getElementById('create-project-on-move-code').textContent = projectName;
document.getElementById('create-project-on-move-name').value = projectName;
document.getElementById('create-project-on-move-variant').value = '';
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?'; document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать'; document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
document.getElementById('create-project-on-move-modal').classList.remove('hidden'); document.getElementById('create-project-on-move-modal').classList.remove('hidden');
@@ -550,7 +608,9 @@ function openCreateProjectOnMoveModal(projectName) {
} }
function openCreateProjectOnCreateModal(projectName) { function openCreateProjectOnCreateModal(projectName) {
document.getElementById('create-project-on-move-name').textContent = projectName; document.getElementById('create-project-on-move-code').textContent = projectName;
document.getElementById('create-project-on-move-name').value = projectName;
document.getElementById('create-project-on-move-variant').value = '';
document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?'; document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?';
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать'; document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать';
document.getElementById('create-project-on-move-modal').classList.remove('hidden'); document.getElementById('create-project-on-move-modal').classList.remove('hidden');
@@ -561,22 +621,32 @@ function closeCreateProjectOnMoveModal() {
document.getElementById('create-project-on-move-modal').classList.add('hidden'); document.getElementById('create-project-on-move-modal').classList.add('hidden');
document.getElementById('create-project-on-move-modal').classList.remove('flex'); document.getElementById('create-project-on-move-modal').classList.remove('flex');
pendingMoveConfigUUID = ''; pendingMoveConfigUUID = '';
pendingMoveProjectName = ''; pendingMoveProjectCode = '';
pendingCreateConfigName = ''; pendingCreateConfigName = '';
pendingCreateProjectName = ''; pendingCreateProjectCode = '';
document.getElementById('create-project-on-move-name').value = '';
document.getElementById('create-project-on-move-variant').value = '';
} }
async function confirmCreateProjectOnMove() { async function confirmCreateProjectOnMove() {
if (pendingCreateConfigName && pendingCreateProjectName) { const projectNameInput = document.getElementById('create-project-on-move-name');
const projectVariantInput = document.getElementById('create-project-on-move-variant');
const projectName = (projectNameInput.value || '').trim();
const projectVariant = (projectVariantInput.value || '').trim();
if (pendingCreateConfigName && pendingCreateProjectCode) {
const configName = pendingCreateConfigName; const configName = pendingCreateConfigName;
const projectName = pendingCreateProjectName; const projectCode = pendingCreateProjectCode;
try { try {
const createResp = await fetch('/api/projects', { const createResp = await fetch('/api/projects', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: projectName }) body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
}); });
if (!createResp.ok) { if (!createResp.ok) {
if (createResp.status === 409) {
alert('Проект с таким кодом и вариантом уже существует');
return;
}
const err = await createResp.json(); const err = await createResp.json();
alert('Не удалось создать проект: ' + (err.error || 'ошибка')); alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
return; return;
@@ -584,14 +654,14 @@ async function confirmCreateProjectOnMove() {
const newProject = await createResp.json(); const newProject = await createResp.json();
pendingCreateConfigName = ''; pendingCreateConfigName = '';
pendingCreateProjectName = ''; pendingCreateProjectCode = '';
await loadProjectsForConfigUI(); await loadProjectsForConfigUI();
const created = await createConfigWithProject(configName, newProject.uuid); const created = await createConfigWithProject(configName, newProject.uuid);
if (created) { if (created) {
closeCreateProjectOnMoveModal(); closeCreateProjectOnMoveModal();
} else { } else {
closeCreateProjectOnMoveModal(); closeCreateProjectOnMoveModal();
document.getElementById('create-project-input').value = projectName; document.getElementById('create-project-input').value = projectCode;
} }
} catch (e) { } catch (e) {
alert('Ошибка создания проекта'); alert('Ошибка создания проекта');
@@ -600,8 +670,8 @@ async function confirmCreateProjectOnMove() {
} }
const configUUID = pendingMoveConfigUUID; const configUUID = pendingMoveConfigUUID;
const projectName = pendingMoveProjectName; const projectCode = pendingMoveProjectCode;
if (!configUUID || !projectName) { if (!configUUID || !projectCode) {
closeCreateProjectOnMoveModal(); closeCreateProjectOnMoveModal();
return; return;
} }
@@ -610,9 +680,13 @@ async function confirmCreateProjectOnMove() {
const createResp = await fetch('/api/projects', { const createResp = await fetch('/api/projects', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: projectName }) body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
}); });
if (!createResp.ok) { if (!createResp.ok) {
if (createResp.status === 409) {
alert('Проект с таким кодом и вариантом уже существует');
return;
}
const err = await createResp.json(); const err = await createResp.json();
alert('Не удалось создать проект: ' + (err.error || 'ошибка')); alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
return; return;
@@ -620,9 +694,9 @@ async function confirmCreateProjectOnMove() {
const newProject = await createResp.json(); const newProject = await createResp.json();
pendingMoveConfigUUID = ''; pendingMoveConfigUUID = '';
pendingMoveProjectName = ''; pendingMoveProjectCode = '';
await loadProjectsForConfigUI(); await loadProjectsForConfigUI();
document.getElementById('move-project-input').value = projectName; document.getElementById('move-project-input').value = projectCode;
const moved = await moveConfigToProject(configUUID, newProject.uuid); const moved = await moveConfigToProject(configUUID, newProject.uuid);
if (moved) { if (moved) {
closeCreateProjectOnMoveModal(); closeCreateProjectOnMoveModal();
@@ -785,44 +859,19 @@ async function loadConfigs() {
} }
} }
async function importConfigsFromServer() {
const button = document.getElementById('import-configs-btn');
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Импорт...';
try {
const resp = await fetch('/api/configs/import', { method: 'POST' });
const data = await resp.json();
if (!resp.ok) {
alert('Ошибка импорта: ' + (data.error || 'неизвестная ошибка'));
return;
}
alert(
'Импорт завершен:\n' +
'- Новых: ' + (data.imported || 0) + '\n' +
'- Обновлено: ' + (data.updated || 0) + '\n' +
'- Пропущено (локальные изменения): ' + (data.skipped || 0)
);
currentPage = 1;
await loadConfigs();
} catch (e) {
alert('Ошибка импорта с сервера');
} finally {
button.disabled = false;
button.textContent = originalText;
}
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
applyStatusModeUI(); applyStatusModeUI();
loadProjectsForConfigUI().then(loadConfigs); loadProjectsForConfigUI().then(loadConfigs);
// Load latest pricelist version for badge // Load latest pricelist version for badge
loadLatestPricelistVersion(); loadLatestPricelistVersion();
// Listen for sync completion events from navbar
window.addEventListener('sync-completed', function(e) {
// Reset pagination and reload configurations list
currentPage = 1;
loadConfigs();
});
}); });
document.getElementById('configs-search').addEventListener('input', function(e) { document.getElementById('configs-search').addEventListener('input', function(e) {
@@ -834,14 +883,25 @@ document.getElementById('configs-search').addEventListener('input', function(e)
async function loadProjectsForConfigUI() { async function loadProjectsForConfigUI() {
projectsCache = []; projectsCache = [];
projectNameByUUID = {}; projectNameByUUID = {};
projectCodeByUUID = {};
projectVariantByUUID = {};
try { 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; if (!resp.ok) return;
const data = await resp.json(); 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
projectNameByUUID[project.uuid] = project.name; projectsCache = allProjects;
allProjects.forEach(project => {
const variant = (project.variant || '').trim();
const baseName = project.name || '';
projectNameByUUID[project.uuid] = variant ? (baseName + ' (' + variant + ')') : baseName;
projectCodeByUUID[project.uuid] = project.code || '';
projectVariantByUUID[project.uuid] = project.variant || '';
}); });
const createOptions = document.getElementById('create-project-options'); const createOptions = document.getElementById('create-project-options');
@@ -850,7 +910,8 @@ async function loadProjectsForConfigUI() {
projectsCache.forEach(project => { projectsCache.forEach(project => {
if (!project.is_active) return; if (!project.is_active) return;
const option = document.createElement('option'); const option = document.createElement('option');
option.value = project.name; option.value = projectDisplayKey(project);
option.label = project.name || '';
createOptions.appendChild(option); createOptions.appendChild(option);
}); });
} }

View File

@@ -5,14 +5,25 @@
<!-- Header with config name and back button --> <!-- Header with config name and back button -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<a href="/configs" class="text-gray-500 hover:text-gray-700"> <a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
</svg> </svg>
</a> </a>
<h1 class="text-2xl font-bold"> <div class="text-2xl font-bold flex items-center gap-2" id="config-breadcrumbs">
<span id="config-name">Конфигуратор</span> <a id="breadcrumb-project-code-link" href="/projects" class="text-blue-700 hover:underline">
</h1> <span id="breadcrumb-project-code"></span>
</a>
<span class="text-gray-400">-</span>
<a id="breadcrumb-project-variant-link" href="/projects" class="text-blue-700 hover:underline">
<span id="breadcrumb-project-variant">main</span>
</a>
<span class="text-gray-400">-</span>
<span id="breadcrumb-config-name">Конфигуратор</span>
<span class="text-gray-400">-</span>
<span id="breadcrumb-config-version">v1</span>
</div>
</div> </div>
<div id="save-buttons" class="hidden flex items-center space-x-2"> <div id="save-buttons" class="hidden flex items-center space-x-2">
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"> <button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
@@ -98,6 +109,7 @@
</svg> </svg>
</button> </button>
<div id="cart-summary-content" class="p-4"> <div id="cart-summary-content" class="p-4">
<div id="article-display" class="text-sm text-gray-700 mb-3 font-mono"></div>
<div id="cart-items" class="space-y-2 mb-4"></div> <div id="cart-items" class="space-y-2 mb-4"></div>
<div class="border-t pt-3 flex justify-between items-center"> <div class="border-t pt-3 flex justify-between items-center">
<div class="text-lg font-bold"> <div class="text-lg font-bold">
@@ -326,12 +338,79 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
// State // State
let configUUID = '{{.ConfigUUID}}'; let configUUID = '{{.ConfigUUID}}';
let configName = ''; let configName = '';
let projectUUID = '';
let projectName = '';
let projectCode = '';
let projectVariant = '';
let projectIndexLoaded = false;
let projectByUUID = {};
let projectMainByCode = {};
async function loadProjectIndex() {
if (projectIndexLoaded) return;
try {
const resp = await fetch('/api/projects/all');
if (!resp.ok) return;
const data = await resp.json();
const allProjects = Array.isArray(data) ? data : (data.projects || []);
projectByUUID = {};
projectMainByCode = {};
allProjects.forEach(p => {
projectByUUID[p.uuid] = p;
const code = (p.code || '').trim();
const variant = (p.variant || '').trim();
if (code && (variant === '' || variant === 'main')) {
if (!projectMainByCode[code]) {
projectMainByCode[code] = p.uuid;
}
}
});
projectIndexLoaded = true;
} catch (e) {
// ignore
}
}
function updateConfigBreadcrumbs() {
const codeEl = document.getElementById('breadcrumb-project-code');
const variantEl = document.getElementById('breadcrumb-project-variant');
const configEl = document.getElementById('breadcrumb-config-name');
const versionEl = document.getElementById('breadcrumb-config-version');
const projectCodeLinkEl = document.getElementById('breadcrumb-project-code-link');
const projectVariantLinkEl = document.getElementById('breadcrumb-project-variant-link');
let code = 'Без проекта';
let variant = 'main';
if (projectUUID && projectByUUID[projectUUID]) {
code = (projectByUUID[projectUUID].code || '').trim() || 'Без проекта';
const rawVariant = (projectByUUID[projectUUID].variant || '').trim();
variant = rawVariant === '' ? 'main' : rawVariant;
if (projectCodeLinkEl) {
const mainUUID = projectMainByCode[code];
projectCodeLinkEl.href = mainUUID ? ('/projects/' + mainUUID) : ('/projects/' + projectUUID);
}
if (projectVariantLinkEl) {
projectVariantLinkEl.href = '/projects/' + projectUUID;
}
} else {
if (projectCodeLinkEl) projectCodeLinkEl.href = '/projects';
if (projectVariantLinkEl) projectVariantLinkEl.href = '/projects';
}
codeEl.textContent = code;
variantEl.textContent = variant;
configEl.textContent = configName || 'Конфигурация';
versionEl.textContent = 'v1';
}
let currentTab = 'base'; let currentTab = 'base';
let allComponents = []; let allComponents = [];
let cart = []; let cart = [];
let categoryOrderMap = {}; // Category code -> display_order mapping let categoryOrderMap = {}; // Category code -> display_order mapping
let autoSaveTimeout = null; // Timeout for debounced autosave let autoSaveTimeout = null; // Timeout for debounced autosave
let serverCount = 1; // Server count for the configuration let serverCount = 1; // Server count for the configuration
let serverModelForQuote = '';
let supportCode = '';
let currentArticle = '';
let articlePreviewTimeout = null;
let selectedPricelistIds = { let selectedPricelistIds = {
estimate: null, estimate: null,
warehouse: null, warehouse: null,
@@ -351,6 +430,8 @@ let priceLevelsRefreshTimer = null;
let warehouseStockLotsByPricelist = new Map(); let warehouseStockLotsByPricelist = new Map();
let warehouseStockLoadSeq = 0; let warehouseStockLoadSeq = 0;
let warehouseStockLoadsByPricelist = new Map(); 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 // Autocomplete state
let autocompleteInput = null; let autocompleteInput = null;
@@ -607,7 +688,9 @@ document.addEventListener('DOMContentLoaded', async function() {
const config = await resp.json(); const config = await resp.json();
configName = config.name; configName = config.name;
document.getElementById('config-name').textContent = config.name; projectUUID = config.project_uuid || '';
await loadProjectIndex();
updateConfigBreadcrumbs();
document.getElementById('save-buttons').classList.remove('hidden'); document.getElementById('save-buttons').classList.remove('hidden');
// Set server count from config // Set server count from config
@@ -629,6 +712,9 @@ document.addEventListener('DOMContentLoaded', async function() {
category: item.category || getCategoryFromLotName(item.lot_name) category: item.category || getCategoryFromLotName(item.lot_name)
})); }));
} }
serverModelForQuote = config.server_model || '';
supportCode = config.support_code || '';
currentArticle = config.article || '';
// Restore custom price if saved // Restore custom price if saved
if (config.custom_price) { if (config.custom_price) {
@@ -943,7 +1029,32 @@ function renderTab() {
} }
function renderSingleSelectTab(categories) { function renderSingleSelectTab(categories) {
let html = ` let html = '';
if (currentTab === 'base') {
html += `
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label>
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
</div>
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<input type="text"
id="server-model-input"
value="${escapeHtml(serverModelForQuote)}"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="updateServerModelForQuote(this.value)">
<select id="support-code-select"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updateSupportCode(this.value)">
<option value="">—</option>
<option value="1yW" ${supportCode === '1yW' ? 'selected' : ''}>1yW</option>
<option value="1yB" ${supportCode === '1yB' ? 'selected' : ''}>1yB</option>
<option value="1yS" ${supportCode === '1yS' ? 'selected' : ''}>1yS</option>
<option value="1yP" ${supportCode === '1yP' ? 'selected' : ''}>1yP</option>
</select>
</div>
`;
}
html += `
<table class="w-full"> <table class="w-full">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
@@ -1201,12 +1312,54 @@ function renderMultiSelectTabWithSections(sections) {
document.getElementById('tab-content').innerHTML = html; 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) // Autocomplete for single select (Base tab)
function showAutocomplete(category, input) { async function showAutocomplete(category, input) {
autocompleteInput = input; autocompleteInput = input;
autocompleteCategory = category; autocompleteCategory = category;
autocompleteMode = 'single'; autocompleteMode = 'single';
autocompleteIndex = -1; autocompleteIndex = -1;
const components = getComponentsForCategory(category);
await ensurePricesLoaded(components);
filterAutocomplete(category, input.value); filterAutocomplete(category, input.value);
} }
@@ -1215,7 +1368,7 @@ function filterAutocomplete(category, search) {
const searchLower = search.toLowerCase(); const searchLower = search.toLowerCase();
autocompleteFiltered = components.filter(c => { autocompleteFiltered = components.filter(c => {
if (!c.current_price) return false; if (!hasComponentPrice(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false; if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase(); const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower); return text.includes(searchLower);
@@ -1298,12 +1451,13 @@ function selectAutocompleteItem(index) {
const qtyInput = document.getElementById('qty-' + autocompleteCategory); const qtyInput = document.getElementById('qty-' + autocompleteCategory);
const qty = parseInt(qtyInput?.value) || 1; const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({ cart.push({
lot_name: comp.lot_name, lot_name: comp.lot_name,
quantity: qty, quantity: qty,
unit_price: comp.current_price, unit_price: price,
estimate_price: comp.current_price, estimate_price: price,
warehouse_price: null, warehouse_price: null,
competitor_price: null, competitor_price: null,
delta_wh_estimate_abs: null, delta_wh_estimate_abs: null,
@@ -1333,11 +1487,13 @@ function hideAutocomplete() {
} }
// Autocomplete for multi select tabs // Autocomplete for multi select tabs
function showAutocompleteMulti(input) { async function showAutocompleteMulti(input) {
autocompleteInput = input; autocompleteInput = input;
autocompleteCategory = null; autocompleteCategory = null;
autocompleteMode = 'multi'; autocompleteMode = 'multi';
autocompleteIndex = -1; autocompleteIndex = -1;
const components = getComponentsForTab(currentTab);
await ensurePricesLoaded(components);
filterAutocompleteMulti(input.value); filterAutocompleteMulti(input.value);
} }
@@ -1349,7 +1505,7 @@ function filterAutocompleteMulti(search) {
const addedLots = new Set(cart.map(i => i.lot_name)); const addedLots = new Set(cart.map(i => i.lot_name));
autocompleteFiltered = components.filter(c => { 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 (addedLots.has(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false; if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase(); const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
@@ -1390,12 +1546,13 @@ function selectAutocompleteItemMulti(index) {
const qtyInput = document.getElementById('new-qty'); const qtyInput = document.getElementById('new-qty');
const qty = parseInt(qtyInput?.value) || 1; const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({ cart.push({
lot_name: comp.lot_name, lot_name: comp.lot_name,
quantity: qty, quantity: qty,
unit_price: comp.current_price, unit_price: price,
estimate_price: comp.current_price, estimate_price: price,
warehouse_price: null, warehouse_price: null,
competitor_price: null, competitor_price: null,
delta_wh_estimate_abs: null, delta_wh_estimate_abs: null,
@@ -1417,11 +1574,16 @@ function selectAutocompleteItemMulti(index) {
} }
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections) // Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
function showAutocompleteSection(sectionId, input) { async function showAutocompleteSection(sectionId, input) {
autocompleteInput = input; autocompleteInput = input;
autocompleteCategory = sectionId; // Store section ID autocompleteCategory = sectionId; // Store section ID
autocompleteMode = 'section'; autocompleteMode = 'section';
autocompleteIndex = -1; autocompleteIndex = -1;
// Load prices for tab components
const components = getComponentsForTab(currentTab);
await ensurePricesLoaded(components);
filterAutocompleteSection(sectionId, input.value, input); filterAutocompleteSection(sectionId, input.value, input);
} }
@@ -1448,7 +1610,7 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
const addedLots = new Set(cart.map(i => i.lot_name)); const addedLots = new Set(cart.map(i => i.lot_name));
autocompleteFiltered = sectionComponents.filter(c => { 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 (addedLots.has(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false; if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase(); const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
@@ -1489,12 +1651,13 @@ function selectAutocompleteItemSection(index, sectionId) {
const qtyInput = document.getElementById('new-qty-' + sectionId); const qtyInput = document.getElementById('new-qty-' + sectionId);
const qty = parseInt(qtyInput?.value) || 1; const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({ cart.push({
lot_name: comp.lot_name, lot_name: comp.lot_name,
quantity: qty, quantity: qty,
unit_price: comp.current_price, unit_price: price,
estimate_price: comp.current_price, estimate_price: price,
warehouse_price: null, warehouse_price: null,
competitor_price: null, competitor_price: null,
delta_wh_estimate_abs: null, delta_wh_estimate_abs: null,
@@ -1579,6 +1742,8 @@ function updateCartUI() {
calculateCustomPrice(); calculateCustomPrice();
renderSalePriceTable(); renderSalePriceTable();
scheduleArticlePreview();
if (cart.length === 0) { if (cart.length === 0) {
document.getElementById('cart-items').innerHTML = document.getElementById('cart-items').innerHTML =
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>'; '<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
@@ -1654,6 +1819,69 @@ function escapeHtml(text) {
return div.innerHTML; return div.innerHTML;
} }
function updateServerModelForQuote(value) {
serverModelForQuote = value || '';
scheduleArticlePreview();
}
function updateSupportCode(value) {
supportCode = value || '';
scheduleArticlePreview();
}
function scheduleArticlePreview() {
if (articlePreviewTimeout) {
clearTimeout(articlePreviewTimeout);
}
articlePreviewTimeout = setTimeout(() => {
previewArticle();
}, 250);
}
async function previewArticle() {
const el = document.getElementById('article-display');
if (!el) return;
const model = serverModelForQuote.trim();
if (!model || !selectedPricelistIds.estimate || cart.length === 0) {
currentArticle = '';
el.textContent = 'Артикул: —';
return;
}
try {
const resp = await fetch('/api/configs/preview-article', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
server_model: serverModelForQuote,
support_code: supportCode,
pricelist_id: selectedPricelistIds.estimate,
items: cart.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity,
unit_price: item.unit_price || 0
}))
})
});
if (!resp.ok) {
currentArticle = '';
el.textContent = 'Артикул: —';
return;
}
const data = await resp.json();
currentArticle = data.article || '';
el.textContent = currentArticle ? ('Артикул: ' + currentArticle) : 'Артикул: —';
} catch(e) {
currentArticle = '';
el.textContent = 'Артикул: —';
}
}
function getCurrentArticle() {
return currentArticle || '';
}
function triggerAutoSave() { function triggerAutoSave() {
// Debounce autosave - wait 1 second after last change // Debounce autosave - wait 1 second after last change
if (autoSaveTimeout) { if (autoSaveTimeout) {
@@ -1694,6 +1922,9 @@ async function saveConfig(showNotification = true) {
custom_price: customPrice, custom_price: customPrice,
notes: '', notes: '',
server_count: serverCountValue, server_count: serverCountValue,
server_model: serverModelForQuote,
support_code: supportCode,
article: getCurrentArticle(),
pricelist_id: selectedPricelistIds.estimate, pricelist_id: selectedPricelistIds.estimate,
only_in_stock: onlyInStock only_in_stock: onlyInStock
}) })
@@ -1716,6 +1947,14 @@ 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() { async function exportCSV() {
if (cart.length === 0) return; if (cart.length === 0) return;
@@ -1730,17 +1969,19 @@ async function exportCSV() {
...item, ...item,
unit_price: getDisplayPrice(item), unit_price: getDisplayPrice(item),
})); }));
const article = getCurrentArticle();
const resp = await fetch('/api/export/csv', { const resp = await fetch('/api/export/csv', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: exportItems, name: configName}) body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID, article: article})
}); });
const blob = await resp.blob(); const blob = await resp.blob();
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = (configName || 'config') + '.csv'; const articleForName = article || 'BOM';
a.download = getFilenameFromResponse(resp) || ((configName || 'config') + ' ' + articleForName + '.csv');
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} catch(e) { } catch(e) {
@@ -1986,14 +2227,14 @@ async function exportCSVWithCustomPrice() {
const resp = await fetch('/api/export/csv', { const resp = await fetch('/api/export/csv', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: adjustedCart, name: configName}) body: JSON.stringify({items: adjustedCart, name: configName, project_uuid: projectUUID})
}); });
const blob = await resp.blob(); const blob = await resp.blob();
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = (configName || 'config') + '.csv'; a.download = getFilenameFromResponse(resp) || (configName || 'config') + '.csv';
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} catch(e) { } catch(e) {

View File

@@ -3,9 +3,10 @@
{{define "content"}} {{define "content"}}
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<a href="/pricelists" class="text-gray-500 hover:text-gray-700"> <a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
</svg> </svg>
</a> </a>
<h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1> <h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>

View File

@@ -235,6 +235,12 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
checkPricelistWritePermission(); checkPricelistWritePermission();
loadPricelists(1); loadPricelists(1);
// Listen for sync completion events from navbar
window.addEventListener('sync-completed', function(e) {
// Reload pricelists on sync completion
loadPricelists(1);
});
}); });
</script> </script>
{{end}} {{end}}

View File

@@ -4,22 +4,45 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Назад к проектам"> <a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
</svg> </svg>
</a> </a>
<h1 class="text-2xl font-bold" id="project-title">Проект</h1> <div class="text-2xl font-bold flex items-center gap-2">
<a id="project-code-link" href="/projects" class="text-blue-700 hover:underline">
<span id="project-code"></span>
</a>
<span class="text-gray-400">-</span>
<div class="relative">
<button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-base font-medium px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200">
<span id="project-variant-label">main</span>
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="project-variant-menu" class="absolute left-0 mt-2 min-w-[10rem] rounded-lg border border-gray-200 bg-white shadow-lg hidden z-10">
<div id="project-variant-list" class="py-1"></div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3"> <div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-4 gap-3">
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"> <button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
+ Новый вариант
</button>
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Создать новую квоту + Создать новую квоту
</button> </button>
<button onclick="openImportModal()" class="py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium"> <button onclick="openImportModal()" class="py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
Импорт квоты Импорт квоты
</button> </button>
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
Параметры
</button>
</div> </div>
<div class="mt-2"> <div class="mt-2">
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline"> <a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
@@ -58,6 +81,33 @@
</div> </div>
</div> </div>
<div id="new-variant-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
<h2 class="text-xl font-semibold mb-4">Новый вариант</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<div id="new-variant-code" class="px-3 py-2 bg-gray-50 border rounded text-sm text-gray-700"></div>
</div>
<div>
<label for="new-variant-name" class="block text-sm font-medium text-gray-700 mb-1">Название (необязательно)</label>
<input id="new-variant-name" type="text" placeholder="Например: Lenovo"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="new-variant-value" class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
<input id="new-variant-value" type="text" placeholder="Например: Lenovo"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<div class="text-xs text-gray-500 mt-1">Оставьте пустым для main нельзя — нужно уникальное значение.</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button onclick="closeNewVariantModal()" class="px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200">Отмена</button>
<button onclick="createNewVariant()" class="px-4 py-2 text-white bg-purple-600 rounded hover:bg-purple-700">Создать</button>
</div>
</div>
</div>
<div id="rename-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50"> <div id="rename-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"> <div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Переименовать квоту</h2> <h2 class="text-xl font-semibold mb-4">Переименовать квоту</h2>
@@ -113,11 +163,46 @@
</div> </div>
</div> </div>
<div id="project-settings-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Параметры проекта</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input type="text" id="project-settings-code"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
<input type="text" id="project-settings-variant"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
<input type="text" id="project-settings-name"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Ссылка для "открыть в трекере"</label>
<input type="text" id="project-settings-tracker-url" placeholder="https://tracker.example.com/PROJ-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<div class="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы скрыть ссылку.</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeProjectSettingsModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="saveProjectSettings()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
</div>
</div>
</div>
<script> <script>
const projectUUID = '{{.ProjectUUID}}'; const projectUUID = '{{.ProjectUUID}}';
let configStatusMode = 'active'; let configStatusMode = 'active';
let project = null; let project = null;
let allConfigs = []; let allConfigs = [];
let projectVariants = [];
let variantMenuInitialized = false;
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');
@@ -131,6 +216,91 @@ function resolveProjectTrackerURL(projectData) {
return explicitURL; return explicitURL;
} }
function formatProjectTitle(projectData) {
if (!projectData) return 'Проект';
const code = (projectData.code || '').trim();
const name = (projectData.name || '').trim();
const variant = (projectData.variant || '').trim();
if (!code) return name || 'Проект';
if (variant) {
return code + ': (' + variant + ') ' + (name || '');
}
return code + ': ' + (name || '');
}
function normalizeVariantLabel(variant) {
const trimmed = (variant || '').trim();
return trimmed === '' ? 'main' : trimmed;
}
async function loadVariantsForCode(code) {
if (!code) return;
try {
const resp = await fetch('/api/projects/all');
if (!resp.ok) return;
const data = await resp.json();
const allProjects = Array.isArray(data) ? data : (data.projects || []);
projectVariants = allProjects
.filter(p => (p.code || '').trim() === code)
.map(p => ({uuid: p.uuid, variant: (p.variant || '').trim()}));
projectVariants.sort((a, b) => normalizeVariantLabel(a.variant).localeCompare(normalizeVariantLabel(b.variant)));
} catch (e) {
// ignore
}
}
function renderVariantSelect() {
const list = document.getElementById('project-variant-list');
const menu = document.getElementById('project-variant-menu');
const button = document.getElementById('project-variant-button');
const label = document.getElementById('project-variant-label');
const codeLink = document.getElementById('project-code-link');
if (!list || !menu || !button || !label) return;
list.innerHTML = '';
const variants = projectVariants.length ? projectVariants : [{uuid: projectUUID, variant: (project && project.variant) || ''}];
let mainUUID = '';
variants.forEach(item => {
const variantLabel = normalizeVariantLabel(item.variant);
if (variantLabel === 'main' && !mainUUID) {
mainUUID = item.uuid;
}
const option = document.createElement('button');
option.type = 'button';
option.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-50';
if (item.uuid === projectUUID) {
option.className += ' font-semibold text-gray-900';
label.textContent = variantLabel;
}
option.textContent = variantLabel;
option.onclick = function() {
menu.classList.add('hidden');
if (item.uuid && item.uuid !== projectUUID) {
window.location.href = '/projects/' + item.uuid;
}
};
list.appendChild(option);
});
if (codeLink) {
const targetMain = mainUUID || projectUUID;
codeLink.href = '/projects/' + targetMain;
}
if (!variantMenuInitialized) {
button.onclick = function(e) {
e.stopPropagation();
menu.classList.toggle('hidden');
};
document.addEventListener('click', function() {
menu.classList.add('hidden');
});
menu.addEventListener('click', function(e) {
e.stopPropagation();
});
variantMenuInitialized = true;
}
}
function setConfigStatusMode(mode) { function setConfigStatusMode(mode) {
if (mode !== 'active' && mode !== 'archived') return; if (mode !== 'active' && mode !== 'archived') return;
configStatusMode = mode; configStatusMode = mode;
@@ -228,7 +398,9 @@ async function loadProject() {
return false; return false;
} }
project = await resp.json(); project = await resp.json();
document.getElementById('project-title').textContent = project.name; document.getElementById('project-code').textContent = project.code || '—';
await loadVariantsForCode(project.code || '');
renderVariantSelect();
const trackerLink = document.getElementById('tracker-link'); const trackerLink = document.getElementById('tracker-link');
if (trackerLink) { if (trackerLink) {
if (project && project.is_system) { if (project && project.is_system) {
@@ -271,6 +443,56 @@ function openCreateModal() {
document.getElementById('create-name').focus(); document.getElementById('create-name').focus();
} }
function openNewVariantModal() {
if (!project) return;
document.getElementById('new-variant-code').textContent = (project.code || '').trim() || '—';
document.getElementById('new-variant-name').value = project.name || '';
document.getElementById('new-variant-value').value = '';
document.getElementById('new-variant-modal').classList.remove('hidden');
document.getElementById('new-variant-modal').classList.add('flex');
document.getElementById('new-variant-value').focus();
}
function closeNewVariantModal() {
document.getElementById('new-variant-modal').classList.add('hidden');
document.getElementById('new-variant-modal').classList.remove('flex');
}
async function createNewVariant() {
if (!project) return;
const code = (project.code || '').trim();
const variant = (document.getElementById('new-variant-value').value || '').trim();
const nameRaw = (document.getElementById('new-variant-name').value || '').trim();
if (!code || !variant) {
showToast('Укажите вариант', 'error');
return;
}
const payload = {
code: code,
variant: variant,
name: nameRaw ? nameRaw : null
};
const resp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
showToast(data.error || 'Ошибка создания варианта', 'error');
return;
}
const created = await resp.json().catch(() => null);
closeNewVariantModal();
showToast('Вариант создан', 'success');
if (created && created.uuid) {
window.location.href = '/projects/' + created.uuid;
return;
}
loadProject();
loadConfigs();
}
function closeCreateModal() { function closeCreateModal() {
document.getElementById('create-modal').classList.add('hidden'); document.getElementById('create-modal').classList.add('hidden');
document.getElementById('create-modal').classList.remove('flex'); document.getElementById('create-modal').classList.remove('flex');
@@ -397,6 +619,65 @@ function closeImportModal() {
document.getElementById('import-modal').classList.remove('flex'); document.getElementById('import-modal').classList.remove('flex');
} }
function openProjectSettingsModal() {
if (!project) return;
if (project.is_system) {
alert('Системный проект нельзя редактировать');
return;
}
document.getElementById('project-settings-code').value = project.code || '';
document.getElementById('project-settings-variant').value = project.variant || '';
document.getElementById('project-settings-name').value = project.name || '';
document.getElementById('project-settings-tracker-url').value = (project.tracker_url || '').trim();
document.getElementById('project-settings-modal').classList.remove('hidden');
document.getElementById('project-settings-modal').classList.add('flex');
}
function closeProjectSettingsModal() {
document.getElementById('project-settings-modal').classList.add('hidden');
document.getElementById('project-settings-modal').classList.remove('flex');
}
async function saveProjectSettings() {
if (!project) return;
const code = document.getElementById('project-settings-code').value.trim();
const variant = document.getElementById('project-settings-variant').value.trim();
const name = document.getElementById('project-settings-name').value.trim();
const trackerURL = document.getElementById('project-settings-tracker-url').value.trim();
if (!code) {
alert('Введите код проекта');
return;
}
const resp = await fetch('/api/projects/' + projectUUID, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code: code, variant: variant, name: name, tracker_url: trackerURL})
});
if (!resp.ok) {
if (resp.status === 409) {
alert('Проект с таким кодом и вариантом уже существует');
return;
}
alert('Не удалось сохранить параметры проекта');
return;
}
project = await resp.json();
document.getElementById('project-code').textContent = project.code || '—';
await loadVariantsForCode(project.code || '');
renderVariantSelect();
const trackerLink = document.getElementById('tracker-link');
if (trackerLink) {
const trackerURLResolved = resolveProjectTrackerURL(project);
if (trackerURLResolved) {
trackerLink.href = trackerURLResolved;
trackerLink.classList.remove('hidden');
} else {
trackerLink.classList.add('hidden');
}
}
closeProjectSettingsModal();
}
async function loadImportOptions() { async function loadImportOptions() {
const resp = await fetch('/api/configs?page=1&per_page=500&status=active'); const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
if (!resp.ok) return; if (!resp.ok) return;
@@ -478,14 +759,17 @@ function wildcardMatch(value, pattern) {
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); }); document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
document.getElementById('rename-modal').addEventListener('click', function(e) { if (e.target === this) closeRenameModal(); }); document.getElementById('rename-modal').addEventListener('click', function(e) { if (e.target === this) closeRenameModal(); });
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
document.getElementById('clone-modal').addEventListener('click', function(e) { if (e.target === this) closeCloneModal(); }); document.getElementById('clone-modal').addEventListener('click', function(e) { if (e.target === this) closeCloneModal(); });
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); }); document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeCreateModal(); closeCreateModal();
closeRenameModal(); closeRenameModal();
closeCloneModal(); closeCloneModal();
closeImportModal(); closeImportModal();
closeProjectSettingsModal();
} }
}); });

View File

@@ -20,7 +20,7 @@
</div> </div>
<div class="max-w-md"> <div class="max-w-md">
<input id="projects-search" type="text" placeholder="Поиск проекта по названию" <input id="projects-search" type="text" placeholder="Поиск проекта по названию или коду"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div> </div>
@@ -31,11 +31,21 @@
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"> <div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Новый проект</h2> <h2 class="text-xl font-semibold mb-4">Новый проект</h2>
<div class="space-y-4"> <div class="space-y-4">
<div>
<label for="create-project-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
<input id="create-project-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div> <div>
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label> <label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input id="create-project-code" type="text" placeholder="Например: OPS-123" <input id="create-project-code" type="text" placeholder="Например: OPS-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div> </div>
<div>
<label for="create-project-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
<input id="create-project-variant" type="text" placeholder="Например: Lenovo"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div> <div>
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label> <label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
<input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123" <input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123"
@@ -59,6 +69,8 @@ let sortField = 'created_at';
let sortDir = 'desc'; let sortDir = 'desc';
let createProjectTrackerManuallyEdited = false; let createProjectTrackerManuallyEdited = false;
let createProjectLastAutoTrackerURL = ''; let createProjectLastAutoTrackerURL = '';
let variantsByCode = {};
let variantsLoaded = false;
const trackerBaseURL = 'https://tracker.yandex.ru/'; const trackerBaseURL = 'https://tracker.yandex.ru/';
@@ -85,6 +97,55 @@ function formatDateTime(value) {
}); });
} }
function normalizeVariant(variant) {
const trimmed = (variant || '').trim();
return trimmed === '' ? 'main' : trimmed;
}
function renderVariantChips(code, fallbackVariant, fallbackUUID) {
const variants = variantsByCode[code || ''] || [];
if (!variants.length) {
const single = normalizeVariant(fallbackVariant);
const href = fallbackUUID ? ('/projects/' + fallbackUUID) : '/projects';
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(single) + '</a>';
}
return variants.map(v => {
const href = v.uuid ? ('/projects/' + v.uuid) : '/projects';
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(v.label) + '</a>';
}).join(' ');
}
async function loadVariantsIndex() {
if (variantsLoaded) return;
try {
const resp = await fetch('/api/projects/all');
if (!resp.ok) return;
const data = await resp.json();
const allProjects = Array.isArray(data) ? data : (data.projects || []);
variantsByCode = {};
allProjects.forEach(p => {
const code = (p.code || '').trim();
const variant = normalizeVariant(p.variant);
if (!variantsByCode[code]) {
variantsByCode[code] = [];
}
if (!variantsByCode[code].some(v => v.label === variant)) {
variantsByCode[code].push({label: variant, uuid: p.uuid});
}
});
Object.keys(variantsByCode).forEach(code => {
variantsByCode[code].sort((a, b) => {
if (a.label === 'main') return -1;
if (b.label === 'main') return 1;
return a.label.localeCompare(b.label);
});
});
variantsLoaded = true;
} catch (e) {
// ignore
}
}
function toggleSort(field) { function toggleSort(field) {
if (sortField === field) { if (sortField === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc'; sortDir = sortDir === 'asc' ? 'desc' : 'asc';
@@ -132,10 +193,33 @@ async function loadProjects() {
} }
const data = await resp.json(); const data = await resp.json();
rows = data.projects || []; rows = data.projects || [];
if (Array.isArray(rows) && rows.length) {
const byCode = {};
rows.forEach(p => {
const codeKey = (p.code || '').trim();
if (!codeKey) {
const fallbackKey = p.uuid || Math.random().toString(36);
byCode[fallbackKey] = p;
return;
}
const variant = (p.variant || '').trim();
if (!byCode[codeKey]) {
byCode[codeKey] = p;
return;
}
const current = byCode[codeKey];
const currentVariant = (current.variant || '').trim();
if (currentVariant !== '' && variant === '') {
byCode[codeKey] = p;
}
});
rows = Object.values(byCode);
}
total = data.total || 0; total = data.total || 0;
totalPages = data.total_pages || 0; totalPages = data.total_pages || 0;
page = data.page || currentPage; page = data.page || currentPage;
currentPage = page; currentPage = page;
await loadVariantsIndex();
} catch (e) { } catch (e) {
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>'; root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
return; return;
@@ -144,27 +228,22 @@ async function loadProjects() {
let html = '<div class="overflow-x-auto"><table class="w-full">'; let html = '<div class="overflow-x-auto"><table class="w-full">';
html += '<thead class="bg-gray-50">'; html += '<thead class="bg-gray-50">';
html += '<tr>'; html += '<tr>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Код</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">'; html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название проекта'; html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название';
if (sortField === 'name') { if (sortField === 'name') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>'; html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
} }
html += '</button></th>'; html += '</button></th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>'; html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Создан @ автор</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">'; html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Изменен @ кто</th>';
html += '<button type="button" onclick="toggleSort(\'created_at\')" class="inline-flex items-center gap-1 hover:text-gray-700">Создан'; html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Варианты</th>';
if (sortField === 'created_at') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Кол-во квот</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>'; html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr>'; html += '</tr>';
html += '<tr>'; html += '<tr>';
html += '<th class="px-4 py-2"></th>'; html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
html += '<th class="px-4 py-2"></th>'; html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
html += '<th class="px-4 py-2"></th>'; html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>'; html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>'; html += '<th class="px-4 py-2"></th>';
@@ -175,21 +254,28 @@ async function loadProjects() {
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>'; html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
} }
rows.forEach(p => { rows.forEach(p => {
html += '<tr class="hover:bg-gray-50">'; html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>'; const displayName = p.name || '';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>'; const createdBy = p.owner_username || '—';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(formatDateTime(p.created_at)) + '</td>'; const updatedBy = '';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>'; const createdLabel = formatDateTime(p.created_at) + ' @ ' + createdBy;
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>'; const updatedLabel = formatDateTime(p.updated_at) + ' @ ' + updatedBy;
const variantChips = renderVariantChips(p.code, p.variant, p.uuid);
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.code || '—') + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(displayName) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(createdLabel) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(updatedLabel) + '</td>';
html += '<td class="px-4 py-3 text-sm">' + variantChips + '</td>';
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">'; html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
if (p.is_active) { if (p.is_active) {
html += '<button onclick="copyProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-green-700 hover:text-green-900" title="Копировать">'; const safeName = escapeHtml(displayName).replace(/'/g, "\\'");
html += '<button onclick="copyProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-green-700 hover:text-green-900" title="Копировать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>'; html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>';
html += '</button>'; html += '</button>';
html += '<button onclick="renameProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-blue-700 hover:text-blue-900" title="Переименовать">'; html += '<button onclick="renameProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-blue-700 hover:text-blue-900" title="Переименовать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>'; html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>';
html += '</button>'; html += '</button>';
@@ -251,15 +337,19 @@ function buildTrackerURLFromProjectCode(projectCode) {
} }
function openCreateProjectModal() { function openCreateProjectModal() {
const nameInput = document.getElementById('create-project-name');
const codeInput = document.getElementById('create-project-code'); const codeInput = document.getElementById('create-project-code');
const variantInput = document.getElementById('create-project-variant');
const trackerInput = document.getElementById('create-project-tracker-url'); const trackerInput = document.getElementById('create-project-tracker-url');
nameInput.value = '';
codeInput.value = ''; codeInput.value = '';
variantInput.value = '';
trackerInput.value = ''; trackerInput.value = '';
createProjectTrackerManuallyEdited = false; createProjectTrackerManuallyEdited = false;
createProjectLastAutoTrackerURL = ''; createProjectLastAutoTrackerURL = '';
document.getElementById('create-project-modal').classList.remove('hidden'); document.getElementById('create-project-modal').classList.remove('hidden');
document.getElementById('create-project-modal').classList.add('flex'); document.getElementById('create-project-modal').classList.add('flex');
codeInput.focus(); nameInput.focus();
} }
function closeCreateProjectModal() { function closeCreateProjectModal() {
@@ -278,10 +368,14 @@ function updateCreateProjectTrackerURL() {
} }
async function createProject() { async function createProject() {
const nameInput = document.getElementById('create-project-name');
const codeInput = document.getElementById('create-project-code'); const codeInput = document.getElementById('create-project-code');
const variantInput = document.getElementById('create-project-variant');
const trackerInput = document.getElementById('create-project-tracker-url'); const trackerInput = document.getElementById('create-project-tracker-url');
const name = (codeInput.value || '').trim(); const name = (nameInput.value || '').trim();
if (!name) { const code = (codeInput.value || '').trim();
const variant = (variantInput.value || '').trim();
if (!code) {
alert('Введите код проекта'); alert('Введите код проекта');
return; return;
} }
@@ -290,10 +384,16 @@ async function createProject() {
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
name: name, name: name,
code: code,
variant: variant,
tracker_url: (trackerInput.value || '').trim() tracker_url: (trackerInput.value || '').trim()
}) })
}); });
if (!resp.ok) { if (!resp.ok) {
if (resp.status === 409) {
alert('Проект с таким кодом и вариантом уже существует');
return;
}
alert('Не удалось создать проект'); alert('Не удалось создать проект');
return; return;
} }
@@ -310,6 +410,10 @@ async function renameProject(projectUUID, currentName) {
body: JSON.stringify({name: name.trim()}) body: JSON.stringify({name: name.trim()})
}); });
if (!resp.ok) { if (!resp.ok) {
if (resp.status === 409) {
alert('Проект с таким названием уже существует');
return;
}
alert('Не удалось переименовать проект'); alert('Не удалось переименовать проект');
return; return;
} }
@@ -353,13 +457,20 @@ async function addConfigToProject(projectUUID) {
async function copyProject(projectUUID, projectName) { async function copyProject(projectUUID, projectName) {
const newName = prompt('Название копии проекта', projectName + ' (копия)'); const newName = prompt('Название копии проекта', projectName + ' (копия)');
if (!newName || !newName.trim()) return; if (!newName || !newName.trim()) return;
const newCode = prompt('Код проекта', '');
if (!newCode || !newCode.trim()) return;
const newVariant = prompt('Вариант (необязательно)', '');
const createResp = await fetch('/api/projects', { const createResp = await fetch('/api/projects', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: newName.trim()}) body: JSON.stringify({name: newName.trim(), code: newCode.trim(), variant: (newVariant || '').trim()})
}); });
if (!createResp.ok) { if (!createResp.ok) {
if (createResp.status === 409) {
alert('Проект с таким кодом и вариантом уже существует');
return;
}
alert('Не удалось создать копию проекта'); alert('Не удалось создать копию проекта');
return; return;
} }
@@ -385,40 +496,55 @@ async function copyProject(projectUUID, projectName) {
loadProjects(); loadProjects();
} }
loadProjects(); document.addEventListener('DOMContentLoaded', function() {
document.getElementById('projects-search').addEventListener('input', function(e) {
projectsSearch = (e.target.value || '').trim();
currentPage = 1;
loadProjects(); loadProjects();
});
document.getElementById('create-project-code').addEventListener('input', function() { document.getElementById('projects-search').addEventListener('input', function(e) {
updateCreateProjectTrackerURL(); projectsSearch = (e.target.value || '').trim();
}); currentPage = 1;
loadProjects();
});
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) { document.getElementById('create-project-code').addEventListener('input', function() {
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL; updateCreateProjectTrackerURL();
}); });
document.getElementById('create-project-code').addEventListener('keydown', function(e) { document.getElementById('create-project-name').addEventListener('keydown', function(e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
createProject(); createProject();
} }
}); });
document.getElementById('create-project-tracker-url').addEventListener('keydown', function(e) { document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
if (e.key === 'Enter') { createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
e.preventDefault(); });
createProject();
}
});
document.getElementById('create-project-modal').addEventListener('click', function(e) { document.getElementById('create-project-code').addEventListener('keydown', function(e) {
if (e.target === this) { if (e.key === 'Enter') {
closeCreateProjectModal(); e.preventDefault();
} createProject();
}
});
document.getElementById('create-project-tracker-url').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
createProject();
}
});
document.getElementById('create-project-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeCreateProjectModal();
}
});
// Listen for sync completion events from navbar
window.addEventListener('sync-completed', function(e) {
// Reset pagination and reload projects list
loadProjects();
});
}); });
</script> </script>
{{end}} {{end}}