Compare commits

..

163 Commits

Author SHA1 Message Date
Mikhail Chusavitin
06397a6bd1 Local-first runtime cleanup and recovery hardening 2026-03-07 23:18:07 +03:00
Mikhail Chusavitin
4e977737ee Document legacy BOM tables 2026-03-07 21:13:08 +03:00
Mikhail Chusavitin
7c3752f110 Add vendor workspace import and pricing export workflow 2026-03-07 21:03:40 +03:00
Mikhail Chusavitin
08ecfd0826 Merge branch 'feature/vendor-spec-import' 2026-03-06 10:54:05 +03:00
Mikhail Chusavitin
42458455f7 Fix article generator producing 1xINTEL in GPU segment
MB_ lots (e.g. MB_INTEL_..._GPU8) are incorrectly categorized as GPU
in the pricelist. Two fixes:
- Skip MB_ lots in buildGPUSegment regardless of pricelist category
- Add INTEL to vendor token skip list in parseGPUModel (was missing,
  unlike AMD/NV/NVIDIA which were already skipped)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:53:31 +03:00
Mikhail Chusavitin
8663a87d28 Fix article generator producing 1xINTEL in GPU segment
MB_ lots (e.g. MB_INTEL_..._GPU8) are incorrectly categorized as GPU
in the pricelist. Two fixes:
- Skip MB_ lots in buildGPUSegment regardless of pricelist category
- Add INTEL to vendor token skip list in parseGPUModel (was missing,
  unlike AMD/NV/NVIDIA which were already skipped)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:52:22 +03:00
2f0957ae4e Fix price levels returning empty in offline mode
CalculatePriceLevels now falls back to localDB when pricelistRepo is nil
(offline mode) to resolve the latest pricelist ID per source. Previously
all price lookups were skipped, resulting in empty prices on the pricing tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 12:47:32 +03:00
65db9b37ea Update bible submodule to latest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 12:37:18 +03:00
ed0ef04d10 Merge feature/vendor-spec-import into main (v1.4) 2026-03-04 12:35:40 +03:00
2e0faf4aec Rename vendor price to project price, expand pricing CSV export
- Rename "Цена вендора" to "Цена проектная" in pricing tab table header and comments
- Expand pricing CSV export to include: Lot, P/N вендора, Описание, Кол-во, Цена проектная

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 12:27:34 +03:00
4b0879779a Update bible submodule to latest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:27:45 +03:00
2b175a3d1e Update bible paths kit/ → rules/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:57:50 +03:00
5732c75b85 Update bible submodule to latest 2026-03-01 16:41:42 +03:00
eb7c3739ce Add shared bible submodule, rename local bible to bible-local
- Add bible.git as submodule at bible/
- Rename bible/ → bible-local/ (project-specific architecture)
- Update CLAUDE.md to reference both bible/ and bible-local/
- Add AGENTS.md for Codex with same structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:41:14 +03:00
Mikhail Chusavitin
6e0335af7c Fix pricing tab warehouse totals and guard custom price DOM access 2026-02-27 16:53:34 +03:00
Mikhail Chusavitin
a42a80beb8 fix(bom): preserve local vendor spec on config import 2026-02-27 10:11:20 +03:00
Mikhail Chusavitin
586114c79c refactor(bom): enforce canonical lot_mappings persistence 2026-02-27 09:47:46 +03:00
Mikhail Chusavitin
e9230c0e58 feat(bom): canonical lot mappings and updated vendor spec docs 2026-02-25 19:07:27 +03:00
Mikhail Chusavitin
aa65fc8156 Fix project line numbering and reorder bootstrap 2026-02-25 17:19:26 +03:00
Mikhail Chusavitin
b22e961656 feat(projects): compact table layout for dates and names 2026-02-25 17:19:26 +03:00
Mikhail Chusavitin
af83818564 fix(pricelists): tolerate restricted DB grants and use embedded assets only 2026-02-25 17:19:26 +03:00
Mikhail Chusavitin
8a138327a3 fix(sync): backfill missing items for existing local pricelists 2026-02-25 17:18:57 +03:00
Mikhail Chusavitin
d1f65f6684 feat(projects): compact table layout for dates and names 2026-02-24 15:42:04 +03:00
Mikhail Chusavitin
7b371add10 Merge branch 'stable'
# Conflicts:
#	bible/03-database.md
2026-02-24 15:13:41 +03:00
Mikhail Chusavitin
8d7fab39b4 fix(pricelists): tolerate restricted DB grants and use embedded assets only 2026-02-24 15:09:12 +03:00
Mikhail Chusavitin
1906a74759 fix(sync): backfill missing items for existing local pricelists 2026-02-24 14:54:38 +03:00
d0400b18a3 feat(vendor-spec): BOM import, LOT autocomplete, pricing, partnumber_seen push
- BOM paste: auto-detect columns by content (price, qty, PN, description);
  handles $5,114.00 and European comma-decimal formats
- LOT input: HTML5 datalist rebuilt on each renderBOMTable from allComponents;
  oninput updates data only (no re-render), onchange validates+resolves
- BOM persistence: PUT handler explicitly marshals VendorSpec to JSON string
  (GORM Update does not reliably call driver.Valuer for custom types)
- BOM autosave after every resolveBOM() call
- Pricing tab: async renderPricingTab() calls /api/quote/price-levels for all
  resolved LOTs directly — Estimate prices shown even before cart apply
- Unresolved PNs pushed to qt_vendor_partnumber_seen via POST
  /api/sync/partnumber-seen (fire-and-forget from JS)
- sync.PushPartnumberSeen(): upsert with ON DUPLICATE KEY UPDATE last_seen_at
- partnumber_books: pull ALL books (not only is_active=1); re-pull items when
  header exists but item count is 0; fallback for missing description column
- partnumber_books UI: collapsible snapshot section (collapsed by default),
  pagination (10/page), sync button always visible in header
- vendorSpec handlers: use GetConfigurationByUUID + IsActive check (removed
  original_username from WHERE — GetUsername returns "" without JWT)
- bible/09-vendor-spec.md: updated with all architectural decisions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 22:21:13 +03:00
d3f1a838eb feat: add Партномера nav item and summary page
- Top nav: link to /partnumber-books
- Page: summary cards (active version, unique LOTs, total PN, primary PN)
  + searchable items table for active book
  + collapsible history of all snapshots

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:19:40 +03:00
c6086ac03a ui: simplify BOM paste to fixed positional column order
Format: PN | qty | [description] | [price]. Remove heuristic
column-type detection. Update hint text accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:16:57 +03:00
a127ebea82 ui: add clear BOM button with server-side reset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:15:13 +03:00
347599e06b ui: add format hint to BOM vendor paste area
Show supported column formats and auto-detection rules so users
know what to copy from Excel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:13:49 +03:00
4a44d48366 docs(bible): fix and clarify SQLite migration mechanism in 03-database.md
Previous description was wrong: migrations/*.sql are MariaDB-only.
Document the actual 3-level SQLite migration flow:
1. GORM AutoMigrate (primary, runs on every start)
2. runLocalMigrations Go functions (data backfill, index creation)
3. Centralized remote migrations via qt_client_local_migrations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:09:45 +03:00
23882637b5 fix: use AutoMigrate for new SQLite tables instead of hardcoded migrations
LocalPartnumberBook and LocalPartnumberBookItem added to AutoMigrate list
in localdb.go — consistent with all other local tables. Removed incorrectly
added addPartnumberBooks/addVendorSpecColumn functions from migrations.go
(vendor_spec column is handled by AutoMigrate via the LocalConfiguration model field).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:07:44 +03:00
5e56f386cc feat: implement vendor spec BOM import and PN→LOT resolution (Phase 1)
- Migration 029: local_partnumber_books, local_partnumber_book_items,
  vendor_spec TEXT column on local_configurations
- Models: LocalPartnumberBook, LocalPartnumberBookItem, VendorSpec,
  VendorSpecItem with JSON Valuer/Scanner
- Repository: PartnumberBookRepository (GetActiveBook, FindLotByPartnumber,
  SaveBook/Items, ListBooks, CountBookItems)
- Service: VendorSpecResolver 3-step resolution (book → manual suggestion
  → unresolved) + AggregateLOTs with is_primary_pn qty logic
- Sync: PullPartnumberBooks append-only pull from qt_partnumber_books
- Handlers: VendorSpecHandler (GET/PUT/resolve/apply), PartnumberBooksHandler
- Routes: /api/configs/:uuid/vendor-spec*, /api/partnumber-books,
  /api/sync/partnumber-books, /partnumber-books page
- UI: 3 top-level tabs [Estimate][BOM вендора][Ценообразование]; Excel paste,
  PN resolution, inline LOT autocomplete, pricing table
- Bible: 03-database.md updated, 09-vendor-spec.md added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 10:22:22 +03:00
e5b6902c9e Implement persistent Line ordering for project specs and update bible 2026-02-21 07:09:38 +03:00
Mikhail Chusavitin
3c46cd7bf0 Fix auto pricelist resolution and latest-price selection; update Bible 2026-02-20 19:15:24 +03:00
Mikhail Chusavitin
7f8491d197 docs(bible): require updates on user-requested commits 2026-02-20 15:39:00 +03:00
Mikhail Chusavitin
3fd7a2231a Add persistent startup console warning 2026-02-20 14:37:21 +03:00
Mikhail Chusavitin
c295b60dd8 docs: introduce bible/ as single source of architectural truth
- Add bible/ with 7 hierarchical English-only files covering overview,
  architecture, database schemas, API endpoints, config/env, backup, and dev guides
- Consolidate all docs from README.md, CLAUDE.md, man/backup.md into bible/
- Simplify CLAUDE.md to a single rule: read and respect the bible
- Simplify README.md to a brief intro with links to bible/
- Remove man/backup.md and pricelists_window.md (content migrated or obsolete)
- Fix API docs: add missing endpoints (preview-article, sync/repair),
  correct DELETE /api/projects/:uuid semantics (variant soft-delete only)
- Add Soft Deletes section to architecture doc (is_active pattern)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 14:15:52 +03:00
cc9b846c31 docs: remove local absolute paths from v1.3.2 notes 2026-02-19 18:47:29 +03:00
87cb12906d docs: add release notes for v1.3.2 2026-02-19 18:43:03 +03:00
075fc709dd Harden local config updates and error logging 2026-02-19 18:41:45 +03:00
cbaeafa9c8 Deduplicate configuration revisions and update revisions UI 2026-02-19 14:09:00 +03:00
71f73e2f1d chore: save current changes 2026-02-18 07:02:17 +03:00
2e973b6d78 Add configuration revisions system and project variant deletion
Features:
- Configuration versioning: immutable snapshots in local_configuration_versions
- Revisions UI: /configs/:uuid/revisions page to view version history
- Clone from version: ability to clone configuration from specific revision
- Project variant deletion: DELETE /api/projects/:uuid endpoint
- Updated CLAUDE.md with new architecture details and endpoints

Architecture updates:
- local_configuration_versions table for immutable snapshots
- Version tracking on each configuration save
- Rollback capability to previous versions
- Variant deletion with main variant protection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:30:33 +03:00
8508ee2921 Fix sync errors for duplicate projects and add modal scrolling
Root cause: Projects with duplicate (code, variant) pairs fail to sync
due to unique constraint on server. Example: multiple "OPS-1934" projects
with variant="Dell" where one already exists on server.

Fixes:
1. Sync service now detects duplicate (code, variant) on server and links
   local project to existing server project instead of failing
2. Local repair checks for duplicate (code, variant) pairs and deduplicates
   by appending UUID suffix to variant
3. Modal now scrollable with fixed header/footer (max-h-90vh)

This allows users to sync projects that were created offline with
conflicting codes/variants without losing data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:25:22 +03:00
b153afbf51 Add smart self-healing for sync errors
Implements automatic repair mechanism for pending changes with sync errors:
- Projects: validates and fixes empty name/code fields
- Configurations: ensures project references exist or assigns system project
- Clears errors and resets attempts to give changes another sync chance

Backend:
- LocalDB.RepairPendingChanges() with smart validation logic
- POST /api/sync/repair endpoint
- Detailed repair results with remaining errors

Frontend:
- Auto-repair section in sync modal shown when errors exist
- "ИСПРАВИТЬ" button with clear explanation of actions
- Real-time feedback with result messages

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 23:30:01 +03:00
Mikhail Chusavitin
5f2969a85a Refine stock import UX with suggestions, ignore rules, and inline mapping controls 2026-02-06 19:58:42 +03:00
Mikhail Chusavitin
eb8ac34d83 Fix stock mappings JSON fields and enable row selection for editing 2026-02-06 19:39:39 +03:00
Mikhail Chusavitin
104a26d907 Add stock pricelist admin flow with mapping placeholders and warehouse details 2026-02-06 19:37:12 +03:00
Mikhail Chusavitin
b965c6bb95 WIP: save current pricing and pricelist changes 2026-02-06 19:07:22 +03:00
Mikhail Chusavitin
29035ddc5a configs: save pending template changes 2026-02-06 16:43:04 +03:00
Mikhail Chusavitin
2f0ac2f6d2 projects: add tracker_url and project create modal 2026-02-06 16:42:32 +03:00
Mikhail Chusavitin
8a8ea10dc2 Add tracker link on project detail page 2026-02-06 16:31:34 +03:00
Mikhail Chusavitin
51e2d1fc83 Fix local pricelist uniqueness and preserve config project on update 2026-02-06 16:00:23 +03:00
Mikhail Chusavitin
3d5ab63970 Make full sync push pending and pull projects/configurations 2026-02-06 15:25:07 +03:00
Mikhail Chusavitin
c02a7eac73 Prepare v1.0.3 release notes 2026-02-06 14:04:06 +03:00
Mikhail Chusavitin
651427e0dd Add projects table controls and sync status tab with app version 2026-02-06 14:02:21 +03:00
Mikhail Chusavitin
f665e9b08c sync: recover missing server config during update push 2026-02-06 13:41:01 +03:00
Mikhail Chusavitin
994eec53e7 Fix MySQL DSN escaping for setup passwords and clarify DB user setup 2026-02-06 13:27:57 +03:00
Mikhail Chusavitin
2f3c20fea6 update stale files list 2026-02-06 13:03:59 +03:00
Mikhail Chusavitin
80ec7bc6b8 Apply remaining pricelist and local-first updates 2026-02-06 13:01:40 +03:00
Mikhail Chusavitin
8e5c4f5a7c Use admin price-refresh logic for pricelist recalculation 2026-02-06 13:00:27 +03:00
Mikhail Chusavitin
1744e6a3b8 fix: skip startup sql migrations when not needed or no permissions 2026-02-06 11:56:55 +03:00
Mikhail Chusavitin
726dccb07c feat: add projects flow and consolidate default project handling 2026-02-06 11:39:12 +03:00
Mikhail Chusavitin
38d7332a38 Update pricelist repository, service, and tests 2026-02-06 10:14:24 +03:00
Mikhail Chusavitin
c0beed021c Enforce pricelist write checks and auto-restart on DB settings change 2026-02-05 15:44:54 +03:00
Mikhail Chusavitin
08b95c293c Purge orphan sync queue entries before push 2026-02-05 15:17:06 +03:00
Mikhail Chusavitin
c418d6cfc3 Handle stale configuration sync events when local row is missing 2026-02-05 15:11:43 +03:00
Mikhail Chusavitin
548a256d04 Drop qt_users dependency for configs and track app version 2026-02-05 15:07:23 +03:00
Mikhail Chusavitin
77c00de97a Добавил шаблон для создания пользователя в БД 2026-02-05 10:55:02 +03:00
Mikhail Chusavitin
0c190efda4 Fix sync owner mapping before pushing configurations 2026-02-05 10:43:34 +03:00
Mikhail Chusavitin
41c0a47f54 Implement local DB migrations and archived configuration lifecycle 2026-02-04 18:52:56 +03:00
Mikhail Chusavitin
f4f92dea66 Store configuration owner by MariaDB username 2026-02-04 12:20:41 +03:00
Mikhail Chusavitin
f42b850734 Recover DB connection automatically after network returns 2026-02-04 11:43:31 +03:00
Mikhail Chusavitin
d094d39427 Add server-to-local configuration import in web UI 2026-02-04 11:31:23 +03:00
Mikhail Chusavitin
4509e93864 Store config in user state and clean old release notes 2026-02-04 11:21:48 +03:00
Mikhail Chusavitin
e2800b06f9 Log binary version and executable path on startup 2026-02-04 10:21:18 +03:00
Mikhail Chusavitin
7c606af2bb Fix missing config handling and auto-restart after setup 2026-02-04 10:19:35 +03:00
Mikhail Chusavitin
fabd30650d Store local DB in user state dir as qfs.db 2026-02-04 10:03:17 +03:00
Mikhail Chusavitin
40ade651b0 Ignore local Go cache directory 2026-02-04 09:55:36 +03:00
Mikhail Chusavitin
1b87c53609 Fix offline usage tracking and active pricelist sync 2026-02-04 09:54:13 +03:00
a3dc264efd Merge feature/phase2-sqlite-sync into main 2026-02-03 22:04:17 +03:00
20056f3593 Embed assets and fix offline/sync/pricing issues 2026-02-03 21:58:02 +03:00
Mikhail Chusavitin
8a37542929 docs: add release notes for v0.2.7 2026-02-03 11:39:23 +03:00
Mikhail Chusavitin
0eb6730a55 fix: Windows compatibility and localhost binding
**Windows compatibility:**
- Added filepath.Join for all template and static paths
- Fixes "path not found" errors on Windows

**Localhost binding:**
- Changed default host from 0.0.0.0 to 127.0.0.1
- Browser always opens on 127.0.0.1 (localhost)
- Setup mode now listens on 127.0.0.1:8080
- Updated config.example.yaml with comment about 0.0.0.0

This ensures the app works correctly on Windows and opens
browser on the correct localhost address.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 11:38:28 +03:00
Mikhail Chusavitin
e2d056e7cb feat: add Windows support to build system
- Add make build-windows for Windows AMD64
- Update make build-all to include Windows
- Update release script to package Windows binary as .zip
- Add Windows installation instructions to docs
- Windows binary: qfs-windows-amd64.exe (~17MB)

All platforms now supported:
- Linux AMD64 (.tar.gz)
- macOS Intel/ARM (.tar.gz)
- Windows AMD64 (.zip)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 11:04:04 +03:00
Mikhail Chusavitin
1bce8086d6 feat: add release build script for multi-platform binaries
- Add scripts/release.sh for automated release builds
- Creates tar.gz packages for Linux and macOS
- Generates SHA256 checksums
- Add 'make release' target
- Add releases/ to .gitignore

Usage:
  make release  # Build and package for all platforms

Output: releases/v0.2.5/*.tar.gz + SHA256SUMS.txt

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:58:41 +03:00
Mikhail Chusavitin
0bdd163728 feat: add version flag and Makefile for release builds
- Add -version flag to show build version
- Add Makefile with build targets:
  - make build-release: optimized build with version
  - make build-all: cross-compile for Linux/macOS
  - make run/test/clean: dev commands
- Update documentation with build commands
- Version is embedded via ldflags during build

Usage:
  make build-release  # Build with version
  ./bin/qfs -version  # Show version

Version format: v0.2.5-1-gfa0f5e3 (tag-commits-hash)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:57:22 +03:00
Mikhail Chusavitin
fa0f5e321d refactor: rename binary from quoteforge to qfs
- Rename cmd/server to cmd/qfs for shorter binary name
- Update all documentation references (README, CLAUDE.md, etc.)
- Update build commands to output bin/qfs
- Binary name now matches directory name

Usage:
  go run ./cmd/qfs              # Development
  go build -o bin/qfs ./cmd/qfs # Production

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:55:14 +03:00
Mikhail Chusavitin
502832ac9a Merge feature/phase2-sqlite-sync into main
This merge brings Phase 2.5 (Full Offline Mode) with the following improvements:

- Local-first architecture: all operations work through SQLite
- Background sync worker for automatic synchronization
- Sync queue (pending_changes table) for reliable data push
- LocalConfigurationService for offline-capable CRUD operations
- Pre-create pricelist check before configuration creation
- RefreshPrices works in offline mode using local_components
- UI improvements: sync status indicator, pricelist badge, unified admin tabs
- Fixed online mode: automatic MariaDB connection on startup
- Fixed nil pointer dereference in PricingHandler alert methods
- Improved setup flow with restart requirement notification

Phase 2.5 is now complete. Ready for production.
2026-02-03 10:51:48 +03:00
Mikhail Chusavitin
8d84484412 fix: fix online mode after offline-first architecture changes
- Fix nil pointer dereference in PricingHandler alert methods
- Add automatic MariaDB connection on startup if settings exist
- Update setupRouter to accept mariaDB as parameter
- Fix offline mode checks: use h.db instead of h.alertService
- Update setup handler to show restart required message
- Add warning status support in setup.html UI

This ensures that after saving connection settings, the application
works correctly in online mode after restart. All repositories are
properly initialized with MariaDB connection on startup.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:50:07 +03:00
2510d9e36e feat: show local pricelists in offline mode
**Problem:**
Pricelist page showed empty list in offline mode even though
local pricelists existed in SQLite cache.

**Solution:**
Modified PricelistHandler.List() to fallback to local pricelists:

1. Check if server list is empty (offline)
2. Load from localDB.GetLocalPricelists()
3. Convert LocalPricelist to summary format
4. Add "synced_from": "local" field
5. Add "offline": true flag

**Response format:**
```json
{
  "offline": true,
  "total": 4,
  "pricelists": [
    {
      "version": "2026-02-02-002",
      "created_by": "sync",
      "synced_from": "local",
      "is_active": true
    }
  ]
}
```

**Impact:**
-  Local pricelists visible in offline mode
-  UI can show cached pricelist versions
-  Users can browse pricelists without connection
-  Clear indication of local/remote source

Part of Phase 2.5: Full Offline Mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:19:43 +03:00
d7285fc730 fix: prevent PricingHandler panics in offline mode
**Problem:**
Opening /admin/pricing page caused nil pointer panic when offline
because PricingHandler methods accessed nil repositories.

**Solution:**
Added offline checks to all PricingHandler public methods:

1. **GetStats** - returns empty stats with offline flag
2. **ListComponents** - returns empty list with message
3. **GetComponentPricing** - returns 503 with offline error
4. **UpdatePrice** - blocks mutations with offline error
5. **RecalculateAll** - blocks recalculation with offline error
6. **PreviewPrice** - blocks preview with offline error

**Response format:**
```json
{
  "offline": true,
  "message": "Управление ценами доступно только в онлайн режиме",
  "components": [],
  "total": 0
}
```

**Impact:**
-  No panics when viewing admin pricing offline
-  Clear offline status indication
-  Graceful degradation for all operations
-  UI can detect offline and show appropriate message

Fixes Phase 2.5 admin panel offline issue.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:17:58 +03:00
e33a3f2c88 fix: enable component search and pricing in offline mode
**Problem:**
Configurator was broken in offline mode - no component search
and no price calculation because /api/components returned empty list.

**Solution:**
Added local component fallback to ComponentHandler:

1. **ComponentHandler with localDB** (component.go)
   - Added localDB parameter to NewComponentHandler
   - List() now fallbacks to local_components when offline
   - Converts LocalComponent to ComponentView format
   - Preserves prices from local cache

2. **Updated initialization** (main.go)
   - Pass localDB to NewComponentHandler

**Impact:**
-  Component search works offline
-  Prices load from local_components table
-  Configuration creation fully functional offline
-  Price calculation works with cached prices

**Testing:**
- Verified /api/components returns local components
- Verified current_price field populated from cache
- Search, filtering, and pagination work correctly

Fixes critical Phase 2.5 offline mode issue.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:15:03 +03:00
4735e2b9bb feat: always show admin menu with online checks for operations
**Changes:**

1. **Admin menu always visible** (base.html)
   - Removed 'hidden' class from "Администратор цен" link
   - Menu no longer depends on write permission check
   - Users can access pricing/pricelists pages in offline mode

2. **Online status checks for mutations** (admin_pricing.html)
   - Added checkOnlineStatus() helper function
   - createPricelist() checks online before creating
   - deletePricelist() checks online before deleting
   - Clear user feedback when operations blocked offline

**User Impact:**
- Admin menu accessible in both online and offline modes
- View-only access to pricelists when offline
- Clear error messages when attempting mutations offline
- Better offline-first UX

Part of Phase 2.5: Full Offline Mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:12:18 +03:00
cdf5cef2cf perf: eliminate connection timeouts in offline mode
Fixed application freezing in offline mode by preventing unnecessary
reconnection attempts:

**Changes:**

1. **DSN timeouts** (localdb.go)
   - Added timeout=3s, readTimeout=3s, writeTimeout=3s to MySQL DSN
   - Reduces connection timeout from 75s to 3s when MariaDB unreachable

2. **Fast /api/db-status** (main.go)
   - Check connection status before attempting GetDB()
   - Avoid reconnection attempts on every status request
   - Returns cached offline status instantly

3. **Optimized sync service** (sync/service.go)
   - GetStatus() checks connection status before GetDB()
   - NeedSync() skips server check if already offline
   - Prevents repeated 3s timeouts on every sync info request

4. **Local pricelist fallback** (pricelist.go)
   - GetLatest() returns local pricelists when server offline
   - UI can now display pricelist version in offline mode

5. **Better UI error messages** (configs.html)
   - 404 shows "Не загружен" instead of "Ошибка загрузки"
   - Network errors show "Не доступен" in gray
   - Distinguishes between missing data and real errors

**Performance:**
- Before: 75s timeout on every offline request
- After: <5ms response time in offline mode
- Cached error state prevents repeated connection attempts

**User Impact:**
- UI no longer freezes when loading pages offline
- Instant page loads and API responses
- Pricelist version displays correctly in offline mode
- Clear visual feedback for offline state

Fixes Phase 2.5 offline mode performance issues.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:10:53 +03:00
7f030e7db7 refactor: migrate sync service and handlers to use ConnectionManager
Updated sync-related code to use ConnectionManager instead of direct
database references:

- SyncService now creates repositories on-demand when connection available
- SyncHandler uses ConnectionManager for lazy DB access
- Added ComponentFilter and ListComponents to localdb for offline queries
- All sync operations check connection status before attempting MariaDB access

This completes the transition to offline-first architecture where all
database access goes through ConnectionManager.

Part of Phase 2.5: Full Offline Mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 23:29:36 +03:00
3d222b7f14 feat: add ConnectionManager for lazy database connections
Introduced ConnectionManager to support offline-first architecture:

- New internal/db/connection.go with thread-safe connection management
- Lazy connection establishment (5s timeout, 10s cooldown)
- Automatic ping caching (30s interval) to avoid excessive checks
- Updated middleware/offline.go to use ConnectionManager.IsOnline()
- Updated sync/worker.go to use ConnectionManager instead of direct DB

This enables the application to start without MariaDB and gracefully
handle offline/online transitions.

Part of Phase 2.5: Full Offline Mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 23:29:04 +03:00
c024b96de7 fix: enable instant startup and offline mode for server
Fixed two critical issues preventing offline-first operation:

1. **Instant startup** - Removed blocking GetDB() call during server
   initialization. Server now starts in <10ms instead of 1+ minute.
   - Changed setupRouter() to use lazy DB connection via ConnectionManager
   - mariaDB connection is now nil on startup, established only when needed
   - Fixes timeout issues when MariaDB is unreachable

2. **Offline mode nil pointer panics** - Added graceful degradation
   when database is offline:
   - ComponentService.GetCategories() returns DefaultCategories if repo is nil
   - ComponentService.List/GetByLotName checks for nil repo
   - PricelistService methods return empty/error responses in offline mode
   - All methods properly handle nil repositories

**Before**: Server startup took 1min+ and crashed with nil pointer panic
when trying to load /configurator page offline.

**After**: Server starts instantly and serves pages in offline mode using
DefaultCategories and SQLite data.

Related to Phase 2.5: Full Offline Mode (local-first architecture)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 23:28:14 +03:00
2c75a7ccb8 feat: improve admin pricing modal quote count display to show period and total counts 2026-02-02 21:34:51 +03:00
Mikhail Chusavitin
f25477a25e add todo 2026-02-02 19:44:45 +03:00
Mikhail Chusavitin
0bde12a39d fix: display only real sync errors in error count and list
- Added CountErroredChanges() method to count only pending changes with LastError
- Previously, error count included all pending changes, not just failed ones
- Added /api/sync/info endpoint with proper error count and error list
- Added sync info modal to display sync status, error count, and error details
- Made sync status indicators clickable to open the modal
- Fixed disconnect between "Error count: 4" and "No errors" in the list

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 17:19:52 +03:00
Mikhail Chusavitin
e0404186ad fix: remove duplicate showToast declaration causing JavaScript error
Root cause: admin_pricing.html declared 'const showToast' while base.html
already defined 'function showToast', causing SyntaxError that prevented
all JavaScript from executing on the admin pricing page.

Changes:
- Removed duplicate showToast declaration from admin_pricing.html (lines 206-210)
- Removed debug logging added in previous commit
- Kept immediate function calls in base.html to ensure early initialization

This fixes the issue where username and "Администратор цен" link
disappeared when navigating to /admin/pricing.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 14:54:13 +03:00
Mikhail Chusavitin
eda0e7cb47 debug: add logging to diagnose admin pricing page issue
- Added immediate calls to checkDbStatus() and checkWritePermission() in base.html
- Calls happen right after function definitions, before DOMContentLoaded
- Added console.log statements to track function execution and API responses
- Removed duplicate calls from admin_pricing.html to avoid conflicts
- This will help diagnose why username and admin link disappear on admin pricing page

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 14:51:38 +03:00
Mikhail Chusavitin
693c1d05d7 fix: ensure write permission check on admin pricing page load\n\n- Added explicit checkWritePermission() call when admin pricing page loads\n- Ensures 'Администратор цен' link and username are properly displayed\n- Fixes issue where these elements disappeared when navigating to admin pricing 2026-02-02 14:30:28 +03:00
Mikhail Chusavitin
7fb9dd0267 fix: cache database username to avoid redundant API calls\n\n- Added cachedDbUsername variable to store username after first API call\n- Modified loadPricelistsDbUsername to check cache before making API request\n- Reduces unnecessary API calls when opening pricelists modal multiple times\n- Improves performance and reduces server load 2026-02-02 14:19:23 +03:00
Mikhail Chusavitin
61646bea46 fix: hide pagination when pricelists loading fails\n\n- Added pagination hiding when pricelists load error occurs\n- Prevents display of empty pagination controls when there's an error\n- Maintains consistent UI behavior 2026-02-02 14:15:23 +03:00
Mikhail Chusavitin
9495f929aa fix: add double-submit protection for pricelist creation\n\n- Added isCreatingPricelist flag to prevent duplicate submissions\n- Disable submit button during creation process\n- Show loading text during submission\n- Re-enable button and restore text in finally block\n- Prevents accidental creation of duplicate pricelists 2026-02-02 14:03:39 +03:00
Mikhail Chusavitin
b80bde7dac fix: add showToast fallback for robustness\n\n- Added fallback showToast function to prevent undefined errors\n- If showToast is not available from base.html, use simple alert fallback\n- Maintains same functionality while improving robustness\n- Addresses potential undefined showToast issue in pricelists functions 2026-02-02 13:50:32 +03:00
Mikhail Chusavitin
e307a2765d fix: rename global canWrite variable to avoid naming conflicts\n\n- Renamed global 'canWrite' variable to 'pricelistsCanWrite' to avoid potential conflicts\n- Updated all references to the renamed variable in pricelists functions\n- Maintains same functionality while improving code quality 2026-02-02 13:00:05 +03:00
Mikhail Chusavitin
6f1feb942a fix: handle URL tab parameter in admin pricing page
- Parse URLSearchParams to detect ?tab=pricelists on page load
- Load tab from URL or default to 'alerts'
- Fixes redirect from /pricelists to /admin/pricing?tab=pricelists

This resolves the critical UX issue where users redirected from
/pricelists would see the 'alerts' tab instead of 'pricelists'.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 12:56:14 +03:00
Mikhail Chusavitin
236e37376e fix: properly hide main tab content when pricelists tab is active\n\n- Fixed tab switching logic to properly hide main tab-content when pricelists tab is selected\n- Ensures no 'Загрузка...' text appears in pricelists tab\n- Maintains proper tab visibility for all other tabs 2026-02-02 12:45:33 +03:00
Mikhail Chusavitin
ded6e09b5e feat: move pricelists to admin pricing tab\n\n- Removed separate 'Прайслисты' link from navigation\n- Added 4th tab 'Прайслисты' to admin_pricing.html\n- Moved pricelists table, create modal, and CRUD functionality to admin pricing\n- Updated /pricelists route to redirect to /admin/pricing?tab=pricelists\n\nFixes task 2: Прайслисты → вкладка в "Администратор цен" 2026-02-02 12:42:05 +03:00
Mikhail Chusavitin
96bbe0a510 fix: use originalHTML to restore button state after sync
- Pass originalHTML through syncAction function chain
- Simplify finally block by restoring original button innerHTML
- Remove hardcoded button HTML values (5 lines reduction)
- Improve maintainability: button text changes won't break code
- Preserve any custom classes, attributes, or nested elements

This fixes the issue where originalHTML was declared but never used.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 12:32:44 +03:00
Mikhail Chusavitin
b672cbf27d feat: implement comprehensive sync UI improvements and bug fixes
- Fix critical race condition in sync dropdown actions
  - Add loading states and spinners for sync operations
  - Implement proper event delegation to prevent memory leaks
  - Add accessibility attributes (aria-label, aria-haspopup, aria-expanded)
  - Add keyboard navigation (Escape to close dropdown)
  - Reduce code duplication in sync functions (70% reduction)
  - Improve error handling for pricelist badge
  - Fix z-index issues in dropdown menu
  - Maintain full backward compatibility

  Addresses all issues identified in the TODO list and bug reports
2026-02-02 12:17:17 +03:00
Mikhail Chusavitin
e206531364 feat: implement sync icon + pricelist badge UI improvements
- Replace text 'Online/Offline' with SVG icons in sync status
- Change sync button to circular arrow icon
- Add dropdown menu with push changes, full sync, and last sync status
- Add pricelist version badge to configuration page
- Load pricelist version via /api/pricelists/latest on DOMContentLoaded

This completes task 1 of Phase 2.5 (UI Improvements) as specified in CLAUDE.md
2026-02-02 11:18:24 +03:00
Mikhail Chusavitin
9bd2acd4f7 Add offline RefreshPrices, fix sync bugs, implement auto-restart
- Implement RefreshPrices for local-first mode
  - Update prices from local_components.current_price cache
  - Graceful degradation when component not found
  - Add PriceUpdatedAt timestamp to LocalConfiguration model
  - Support both authenticated and no-auth price refresh

- Fix sync duplicate entry bug
  - pushConfigurationUpdate now ensures server_id exists before update
  - Fetch from LocalConfiguration.ServerID or search on server if missing
  - Update local config with server_id after finding

- Add application auto-restart after settings save
  - Implement restartProcess() using syscall.Exec
  - Setup handler signals restart via channel
  - Setup page polls /health endpoint and redirects when ready
  - Add "Back" button on setup page when settings exist

- Fix setup handler password handling
  - Use PasswordEncrypted field consistently
  - Support empty password by using saved value

- Improve sync status handling
  - Add fallback for is_offline check in SyncStatusPartial
  - Enhance background sync logging with prefixes

- Update CLAUDE.md documentation
  - Mark Phase 2.5 tasks as complete
  - Add UI Improvements section with future tasks
  - Update SQLite tables documentation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 11:03:41 +03:00
ec3c16f3fc Add UI sync status indicator with pending badge
- Create htmx-powered partial template for sync status display
- Show Online/Offline indicator with color coding (green/red)
- Display pending changes count badge when there are unsynced items
- Add Sync button to push pending changes (appears only when needed)
- Auto-refresh every 30 seconds via htmx polling
- Replace JavaScript-based sync indicator with server-rendered partial
- Integrate SyncStatusPartial handler with template rendering

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 06:38:23 +03:00
1f739a3ab2 Update CLAUDE.md TODO list and add local-first documentation
- Consolidate UI TODO items into single sync status partial task
- Move conflict resolution to Phase 4
- Add LOCAL_FIRST_INTEGRATION.md with architecture guide
- Add unified repository interface for future use

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 22:20:23 +03:00
be77256d4e Add background sync worker and complete local-first architecture
Implements automatic background synchronization every 5 minutes:
- Worker pushes pending changes to server (PushPendingChanges)
- Worker pulls new pricelists (SyncPricelistsIfNeeded)
- Graceful shutdown with context cancellation
- Automatic online/offline detection via DB ping

New files:
- internal/services/sync/worker.go - Background sync worker
- internal/services/local_configuration.go - Local-first CRUD
- internal/localdb/converters.go - MariaDB ↔ SQLite converters

Extended sync infrastructure:
- Pending changes queue (pending_changes table)
- Push/pull sync endpoints (/api/sync/push, /pending)
- ConfigurationGetter interface for handler compatibility
- LocalConfigurationService replaces ConfigurationService

All configuration operations now run through SQLite with automatic
background sync to MariaDB when online. Phase 2.5 nearly complete.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 22:17:00 +03:00
143d217397 Add Phase 2: Local SQLite database with sync functionality
Implements complete offline-first architecture with SQLite caching and MariaDB synchronization.

Key features:
- Local SQLite database for offline operation (data/quoteforge.db)
- Connection settings with encrypted credentials
- Component and pricelist caching with auto-sync
- Sync API endpoints (/api/sync/status, /components, /pricelists, /all)
- Real-time sync status indicator in UI with auto-refresh
- Offline mode detection middleware
- Migration tool for database initialization
- Setup wizard for initial configuration

New components:
- internal/localdb: SQLite repository layer (components, pricelists, sync)
- internal/services/sync: Synchronization service
- internal/handlers/sync: Sync API handlers
- internal/handlers/setup: Setup wizard handlers
- internal/middleware/offline: Offline detection
- cmd/migrate: Database migration tool

UI improvements:
- Setup page for database configuration
- Sync status indicator with online/offline detection
- Warning icons for pending synchronization
- Auto-refresh every 30 seconds

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 11:00:32 +03:00
8b8d2f18f9 Update CLAUDE.md with new architecture, remove Docker
- Add development phases (pricelists, projects, local SQLite, price versioning)
- Add new table schemas (qt_pricelists, qt_projects, qt_specifications)
- Add local SQLite database structure for offline work
- Remove Docker files (distributing as binary only)
- Disable RBAC for initial phases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:57:23 +03:00
8c1c8ccace Add Go binaries to .gitignore
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 10:31:43 +03:00
f31ae69233 Add price refresh functionality to configurator
- Add price_updated_at field to qt_configurations table to track when prices were last updated
- Add RefreshPrices() method in configuration service to update all component prices with current values from database
- Add POST /api/configs/:uuid/refresh-prices API endpoint for price updates
- Add "Refresh Prices" button in configurator UI next to Save button
- Display last price update timestamp in human-readable format (e.g., "5 min ago", "2 hours ago")
- Create migration 004_add_price_updated_at.sql for database schema update
- Update CLAUDE.md documentation with new API endpoint and schema changes
- Add MIGRATION_PRICE_REFRESH.md with detailed migration instructions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 10:31:00 +03:00
3132ab2fa2 Add cron job functionality and Docker integration 2026-01-31 00:31:43 +03:00
73acc5410f Update documentation to reflect actual implementation 2026-01-31 00:06:46 +03:00
68d0e9a540 delete dangling files 2026-01-30 23:51:24 +03:00
8309a5dc0e Add hide component feature, usage indicators, and Docker support
- Add is_hidden field to hide components from configurator
- Add colored dot indicator showing component usage status:
  - Green: available in configurator
  - Cyan: used as source for meta-articles
  - Gray: hidden from configurator
- Optimize price recalculation with caching and skip unchanged
- Show current lot name during price recalculation
- Add Dockerfile (Alpine-based multi-stage build)
- Add docker-compose.yml and .dockerignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:49:11 +03:00
Mikhail Chusavitin
48921c699d Add meta component pricing functionality and admin UI enhancements 2026-01-30 20:49:59 +03:00
153 changed files with 4945 additions and 9835 deletions

12
.gitignore vendored
View File

@@ -1,10 +1,5 @@
# QuoteForge # QuoteForge
config.yaml config.yaml
# Data exports and imports with real supplier/pricing data
*_import.sql
*_export.csv
test_export.csv
.env .env
.env.* .env.*
*.pem *.pem
@@ -80,12 +75,7 @@ Network Trash Folder
Temporary Items Temporary Items
.apdisk .apdisk
# Release artifacts (binaries, archives, checksums), but keep markdown notes tracked # Release artifacts (binaries, archives, checksums), but DO track releases/memory/ for changelog
releases/* releases/*
!releases/README.md
!releases/memory/ !releases/memory/
!releases/memory/** !releases/memory/**
!releases/**/
releases/**/*
!releases/README.md
!releases/*/RELEASE_NOTES.md

View File

@@ -1,53 +1,66 @@
# QuoteForge # QuoteForge
Local-first desktop web app for server configuration, quotation, and project work. **Корпоративный конфигуратор серверов и расчёт КП**
Runtime model: Offline-first архитектура: пользовательские операции через локальную SQLite, MariaDB только для синхронизации.
- user work is stored in local SQLite;
- MariaDB is used only for setup checks and background sync;
- HTTP server binds to loopback only.
## What the app does ![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat&logo=go)
![License](https://img.shields.io/badge/License-Proprietary-red)
![Status](https://img.shields.io/badge/Status-In%20Development-yellow)
- configuration editor with price refresh from synced pricelists; ---
- projects with variants and ordered configurations;
- vendor BOM import and PN -> LOT resolution;
- revision history with rollback;
- rotating local backups.
## Run ## Документация
```bash Полная архитектурная документация хранится в **[bible/](bible/README.md)**:
go run ./cmd/qfs
``` | Файл | Тема |
|------|------|
Useful commands: | [bible/01-overview.md](bible/01-overview.md) | Продукт, возможности, технологии, структура репо |
| [bible/02-architecture.md](bible/02-architecture.md) | Local-first, sync, ценообразование, версионность |
| [bible/03-database.md](bible/03-database.md) | SQLite и MariaDB схемы, права, миграции |
| [bible/04-api.md](bible/04-api.md) | Все API endpoints и web-маршруты |
| [bible/05-config.md](bible/05-config.md) | Конфигурация, env vars, установка |
| [bible/06-backup.md](bible/06-backup.md) | Резервное копирование |
| [bible/07-dev.md](bible/07-dev.md) | Команды разработки, стиль кода, guardrails |
---
## Быстрый старт
```bash ```bash
# Применить миграции
go run ./cmd/qfs -migrate go run ./cmd/qfs -migrate
go test ./...
go vet ./... # Запустить
go run ./cmd/qfs
# или
make run
```
Приложение: http://localhost:8080 → откроется `/setup` для настройки подключения к MariaDB.
```bash
# Сборка
make build-release make build-release
# Проверка
go build ./cmd/qfs && go vet ./...
``` ```
On first run the app creates a minimal `config.yaml`, starts on `http://127.0.0.1:8080`, and opens `/setup` if DB credentials were not saved yet. ---
## Documentation ## Releases & Changelog
- Shared engineering rules: [bible/README.md](bible/README.md) Changelog между версиями: `releases/memory/v{major}.{minor}.{patch}.md`
- Project architecture: [bible-local/README.md](bible-local/README.md)
- Release notes: `releases/<version>/RELEASE_NOTES.md`
`bible-local/` is the source of truth for QuoteForge-specific architecture. If code changes behavior, update the matching file there in the same commit. ---
## Repository map ## Поддержка
```text - Email: mike@mchus.pro
cmd/ entry points and migration tools - Internal: @mchus
internal/ application code
web/ templates and static assets ## Лицензия
bible/ shared engineering rules
bible-local/ project architecture and contracts Собственность компании, только для внутреннего использования. См. [LICENSE](LICENSE).
releases/ packaged release artifacts and release notes
config.example.yaml runtime config reference
```

33
acc_lot_log_import.sql Normal file
View File

@@ -0,0 +1,33 @@
-- Generated from /Users/mchusavitin/Downloads/acc.csv
-- Unambiguous rows only. Rows from headers without a date were skipped.
INSERT INTO lot_log (`lot`, `supplier`, `date`, `price`, `quality`, `comments`) VALUES
('ACC_RMK_L_Type', '', '2024-04-01', 19, NULL, 'header supplier missing in source (45383)'),
('ACC_RMK_SLIDE', '', '2024-04-01', 31, NULL, 'header supplier missing in source (45383)'),
('NVLINK_2S_Bridge', '', '2023-01-01', 431, NULL, 'header supplier missing in source (44927)'),
('NVLINK_2S_Bridge', 'Jevy Yang', '2025-01-15', 139, NULL, NULL),
('NVLINK_2S_Bridge', 'Wendy', '2025-01-15', 143, NULL, NULL),
('NVLINK_2S_Bridge', 'HONCH (Darian)', '2025-05-06', 155, NULL, NULL),
('NVLINK_2S_Bridge', 'HONCH (Sunny)', '2025-06-17', 155, NULL, NULL),
('NVLINK_2S_Bridge', 'Wendy', '2025-07-02', 145, NULL, NULL),
('NVLINK_2S_Bridge', 'Honch (Sunny)', '2025-07-10', 155, NULL, NULL),
('NVLINK_2S_Bridge', 'Honch (Yan)', '2025-08-07', 155, NULL, NULL),
('NVLINK_2S_Bridge', 'Jevy', '2025-09-09', 155, NULL, NULL),
('NVLINK_2S_Bridge', 'Honch (Darian)', '2025-11-17', 102, NULL, NULL),
('NVLINK_2W_Bridge(H200)', '', '2023-01-01', 405, NULL, 'header supplier missing in source (44927)'),
('NVLINK_2W_Bridge(H200)', 'network logic / Stephen', '2025-02-10', 305, NULL, NULL),
('NVLINK_2W_Bridge(H200)', 'JEVY', '2025-02-18', 411, NULL, NULL),
('NVLINK_4W_Bridge(H200)', '', '2023-01-01', 820, NULL, 'header supplier missing in source (44927)'),
('NVLINK_4W_Bridge(H200)', 'network logic / Stephen', '2025-02-10', 610, NULL, NULL),
('NVLINK_4W_Bridge(H200)', 'JEVY', '2025-02-18', 754, NULL, NULL),
('25G_SFP28_MMA2P00-AS', 'HONCH (Doris)', '2025-02-19', 65, NULL, NULL),
('ACC_SuperCap', '', '2024-04-01', 59, NULL, 'header supplier missing in source (45383)'),
('ACC_SuperCap', 'Chiphome', '2025-02-28', 48, NULL, NULL);
-- Skipped source values due to missing date in header:
-- lot=ACC_RMK_L_Type; header=FOB; price=19; reason=header has supplier but no date
-- lot=ACC_RMK_SLIDE; header=FOB; price=31; reason=header has supplier but no date
-- lot=NVLINK_2S_Bridge; header=FOB; price=155; reason=header has supplier but no date
-- lot=NVLINK_2W_Bridge(H200); header=FOB; price=405; reason=header has supplier but no date
-- lot=NVLINK_4W_Bridge(H200); header=FOB; price=754; reason=header has supplier but no date
-- lot=25G_SFP28_MMA2P00-AS; header=FOB; price=65; reason=header has supplier but no date
-- lot=ACC_SuperCap; header=FOB; price=48; reason=header has supplier but no date

2
bible

Submodule bible updated: 52444350c1...5a69e0bba8

View File

@@ -1,70 +1,130 @@
# 01 - Overview # 01 — Product Overview
## Product ## What is QuoteForge
QuoteForge is a local-first tool for server configuration, quotation, and project tracking. A corporate server configuration and quotation tool.
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only by synchronization and dedicated setup/migration tooling.
Core user flows: ---
- create and edit configurations locally;
- calculate prices from synced pricelists;
- group configurations into projects and variants;
- import vendor workspaces and map vendor PNs to internal LOTs;
- review revision history and roll back safely.
## Runtime model ## Features
QuoteForge is a single-user thick client. ### For Users
- Mobile-first interface — works comfortably on phones and tablets
- Server configurator — step-by-step component selection
- Automatic price calculation — based on pricelists from local cache
- CSV export — ready-to-use specifications for clients
- Configuration history — versioned snapshots with rollback support
- Full offline operation — continue working without network, sync later
- Guarded synchronization — sync is blocked by preflight check if local schema is not ready
Rules: ### Local Client Security Model
- runtime HTTP binds to loopback only;
- browser requests are treated as part of the same local user session;
- MariaDB is not a live dependency for normal CRUD;
- if non-loopback deployment is ever introduced, auth/RBAC must be added first.
## Product scope QuoteForge is currently a **single-user thick client** bound to `localhost`.
In scope: - The local HTTP/UI layer is not treated as a multi-user security boundary.
- configurator and quote calculation; - RBAC is not part of the active product contract for the local client.
- projects, variants, and configuration ordering; - The authoritative authentication boundary is the remote sync server and its DB credentials captured during setup.
- local revision history; - If the app is ever exposed beyond `localhost`, auth/RBAC must be reintroduced as an enforced perimeter before release.
- read-only pricelist browsing from SQLite cache;
- background sync with MariaDB;
- rotating local backups.
Out of scope and intentionally removed: ### Price Freshness Indicators
- admin pricing UI/API;
- alerts and notification workflows;
- stock import tooling;
- cron jobs and importer utilities.
## Tech stack | Color | Status | Condition |
|-------|--------|-----------|
| Green | Fresh | < 30 days, ≥ 3 sources |
| Yellow | Normal | 3060 days |
| Orange | Aging | 6090 days |
| Red | Stale | > 90 days or no data |
---
## Tech Stack
| Layer | Stack | | Layer | Stack |
| --- | --- | |-------|-------|
| Backend | Go, Gin, GORM | | Backend | Go 1.22+, Gin, GORM |
| Frontend | HTML templates, htmx, Tailwind CSS | | Frontend | HTML, Tailwind CSS, htmx |
| Local storage | SQLite | | Local DB | SQLite (`qfs.db`) |
| Sync transport | MariaDB | | Server DB | MariaDB 11+ (sync transport only for app runtime) |
| Export | CSV and XLSX generation | | Export | encoding/csv, excelize (XLSX) |
## Repository map ---
## Product Scope
**In scope:**
- Component configurator and quotation calculation
- Projects and configurations
- Read-only pricelist viewing from local cache
- Sync (pull components/pricelists, push local changes)
**Out of scope (removed intentionally — do not restore):**
- Admin pricing UI/API
- Stock import
- Alerts
- Cron/importer utilities
---
## Repository Structure
```text
cmd/
qfs/ main HTTP runtime
migrate/ server migration tool
migrate_ops_projects/ OPS project migration helper
internal/
appstate/ backup and runtime state
config/ runtime config parsing
handlers/ HTTP handlers
localdb/ SQLite models and migrations
repository/ repositories
services/ business logic and sync
web/
templates/ HTML templates
static/ static assets
bible/ shared engineering rules
bible-local/ project-specific architecture
releases/ release artifacts and notes
``` ```
quoteforge/
├── cmd/
│ ├── qfs/main.go # HTTP server entry point
│ ├── migrate/ # Migration tool
│ └── migrate_ops_projects/ # OPS project migrator
├── internal/
│ ├── appmeta/ # App version metadata
│ ├── appstate/ # State management, backup
│ ├── article/ # Article generation
│ ├── config/ # Config parsing
│ ├── db/ # DB initialization
│ ├── handlers/ # HTTP handlers
│ ├── localdb/ # SQLite layer
│ ├── middleware/ # Auth, CORS, etc.
│ ├── models/ # GORM models
│ ├── repository/ # Repository layer
│ └── services/ # Business logic
├── web/
│ ├── templates/ # HTML templates + partials
│ └── static/ # CSS, JS, assets
├── migrations/ # SQL migration files (30+)
├── bible/ # Architectural documentation (this section)
├── releases/memory/ # Per-version changelogs
├── config.example.yaml # Config template (the only one in repo)
└── go.mod
```
---
## Integration with Existing DB
QuoteForge integrates with the existing `RFQ_LOG` database.
Hard boundary:
- normal runtime HTTP handlers, UI flows, pricing, export, BOM resolution, and project/config CRUD must use SQLite only;
- MariaDB access is allowed only inside `internal/services/sync/*` and dedicated setup/migration tools under `cmd/`;
- any new direct MariaDB query in non-sync runtime code is an architectural violation.
**Read-only:**
- `lot` — component catalog
- `qt_lot_metadata` — extended component data
- `qt_categories` — categories
- `qt_pricelists`, `qt_pricelist_items` — pricelists
- `stock_log` — stock quantities consumed during sync enrichment
- `qt_partnumber_books`, `qt_partnumber_book_items` — partnumber book snapshots consumed during sync pull
**Read + Write:**
- `qt_configurations` — configurations
- `qt_projects` — projects
**Sync service tables:**
- `qt_client_schema_state` — applied migrations state and operational client status per device (`username + hostname`)
Fields written by QuoteForge:
`app_version`, `last_sync_at`, `last_sync_status`,
`pending_changes_count`, `pending_errors_count`, `configurations_count`, `projects_count`,
`estimate_pricelist_version`, `warehouse_pricelist_version`, `competitor_pricelist_version`,
`last_sync_error_code`, `last_sync_error_text`, `last_checked_at`, `updated_at`
- `qt_pricelist_sync_status` — pricelist sync status

View File

@@ -1,149 +1,251 @@
# 02 - Architecture # 02 Architecture
## Local-first rule ## Local-First Principle
SQLite is the runtime source of truth. **SQLite** is the single source of truth for the user.
MariaDB is sync transport plus setup and migration tooling. **MariaDB** is a sync server only — it never blocks local operations.
```text
browser -> Gin handlers -> SQLite
-> pending_changes
background sync <------> MariaDB
```
Rules:
- user CRUD must continue when MariaDB is offline;
- runtime handlers and pages must read and write SQLite only;
- MariaDB access in runtime code is allowed only inside sync and setup flows;
- no live MariaDB fallback for reads that already exist in local cache.
## Sync contract
Bidirectional:
- projects;
- configurations;
- `vendor_spec`;
- pending change metadata.
Pull-only:
- components;
- pricelists and pricelist items;
- partnumber books and partnumber book items.
Readiness guard:
- every sync push/pull runs a preflight check;
- blocked sync returns `423 Locked` with a machine-readable reason;
- local work continues even when sync is blocked.
- sync metadata updates must preserve project `updated_at`; sync time belongs in `synced_at`, not in the user-facing last-modified timestamp.
- pricelist pull must persist a new local snapshot atomically: header and items appear together, and `last_pricelist_sync` advances only after item download succeeds.
- UI sync status must distinguish "last sync failed" from "up to date"; if the app can prove newer server pricelist data exists, the indicator must say local cache is incomplete.
## Pricing contract
Prices come only from `local_pricelist_items`.
Rules:
- `local_components` is metadata-only;
- quote calculation must not read prices from components;
- latest pricelist selection ignores snapshots without items;
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
## Pricing tab layout
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
Column order (both tables):
``` ```
PN вендора | Описание | LOT | Кол-во | Estimate | Склад | Конкуренты | Ручная цена User
SQLite (qfs.db) ← all CRUD operations go here
│ background sync (every 5 min)
MariaDB (RFQ_LOG) ← pull/push only
``` ```
Per-LOT row expansion rules: **Rules:**
- each `lot_mappings` entry in a BOM row becomes its own table row with its own quantity and prices; - All CRUD operations go through SQLite only
- `baseLot` (resolved LOT without an explicit mapping) is treated as the first sub-row with `quantity_per_pn` from `_getRowLotQtyPerPN`; - If MariaDB is unavailable → local work continues without restrictions
- when one vendor PN expands into N LOT sub-rows, PN вендора and Описание cells use `rowspan="N"` and appear only on the first sub-row; - Changes are queued in `pending_changes` and pushed on next sync
- a visual top border (`border-t border-gray-200`) separates each vendor PN group.
Vendor price attachment: ## MariaDB Boundary
- `vendorOrig` and `vendorOrigUnit` (BOM unit/total price) are attached to the first LOT sub-row only;
- subsequent sub-rows carry empty `data-vendor-orig` so `setPricingCustomPriceFromVendor` counts each vendor PN exactly once.
Controls terminology: MariaDB is not part of the runtime read/write path for user features.
- custom price input is labeled **Ручная цена** (not "Своя цена");
- the button that fills custom price from BOM totals is labeled **BOM Цена** (not "Проставить цены BOM").
CSV export reads PN вендора, Описание, and LOT from `data-vendor-pn`, `data-desc`, `data-lot` row attributes to bypass the rowspan cell offset problem. Hard rules:
## Configuration versioning - HTTP handlers, web pages, quote calculation, export, vendor BOM resolution, pricelist browsing, project browsing, and configuration CRUD must read/write SQLite only.
- MariaDB access from the app runtime is allowed only inside the sync subsystem (`internal/services/sync/*`) for explicit pull/push work.
- Dedicated tooling under `cmd/migrate` and `cmd/migrate_ops_projects` may access MariaDB for operator-run schema/data migration tasks.
- Setup may test/store connection settings, but after setup the application must treat MariaDB as sync transport only.
- Any new repository/service/handler that issues MariaDB queries outside sync is a regression and must be rejected in review.
- Local SQLite migrations are code-defined only (`AutoMigrate` + `runLocalMigrations`); there is no server-driven client migration registry.
- Read-only local sync caches are disposable. If a local cache table cannot be migrated safely at startup, the client may quarantine/reset that cache and continue booting.
Configuration revisions are append-only snapshots stored in `local_configuration_versions`. Forbidden patterns:
Rules: - calling `connMgr.GetDB()` from non-sync runtime business code;
- the editable working configuration is always the implicit head named `main`; UI must not switch the user to a numbered revision after save; - constructing MariaDB-backed repositories in handlers for normal user requests;
- create a new revision when spec, BOM, or pricing content changes; - using MariaDB as online fallback for reads when local SQLite already contains the synced dataset;
- revision history is retrospective: the revisions page shows past snapshots, not the current `main` state; - adding UI/API features that depend on live MariaDB availability.
- rollback creates a new head revision from an old snapshot;
- rename, reorder, project move, and similar operational edits do not create a new revision snapshot;
- revision deduplication includes `items`, `server_count`, `total_price`, `custom_price`, `vendor_spec`, pricelist selectors, `disable_price_refresh`, and `only_in_stock`;
- BOM updates must use version-aware save flow, not a direct SQL field update;
- current revision pointer must be recoverable if legacy or damaged rows are found locally.
## Sync UX ## Local Client Boundary
UI-facing sync status must never block on live MariaDB calls. The running app is a localhost-only thick client.
Rules: - Browser/UI requests on the local machine are treated as part of the same trusted user session.
- navbar sync indicator and sync info modal read only local cached state from SQLite/app settings; - Local routes are not modeled as a hardened multi-user API perimeter.
- background/manual sync may talk to MariaDB, but polling endpoints must stay fast even on slow or broken connections; - Authorization to the central server happens through the saved MariaDB connection configured during setup.
- any MariaDB timeout/invalid-connection during sync must invalidate the cached remote handle immediately so UI stops treating the connection as healthy. - Any future deployment that binds beyond `127.0.0.1` must add enforced auth/RBAC before exposure.
## Naming collisions ---
UI-driven rename and copy flows use one suffix convention for conflicts. ## Synchronization
Rules: ### Data Flow Diagram
- configuration and variant names must auto-resolve collisions with `_копия`, then `_копия2`, `_копия3`, and so on;
- copy checkboxes and copy modals must prefill `_копия`, not ` (копия)`;
- the literal variant name `main` is reserved and must not be allowed for non-main variants.
## Configuration types ```
[ SERVER / MariaDB ]
┌───────────────────────────┐
│ qt_projects │
│ qt_configurations │
│ qt_pricelists │
│ qt_pricelist_items │
│ qt_pricelist_sync_status │
└─────────────┬─────────────┘
pull (projects/configs/pricelists)
┌────────────────────┴────────────────────┐
│ │
[ CLIENT A / SQLite ] [ CLIENT B / SQLite ]
local_projects local_projects
local_configurations local_configurations
local_pricelists local_pricelists
local_pricelist_items local_pricelist_items
pending_changes pending_changes
│ │
└────── push (projects/configs only) ─────┘
[ SERVER / MariaDB ]
```
Configurations have a `config_type` field: `"server"` (default) or `"storage"`. ### Sync Direction by Entity
Rules: | Entity | Direction |
- `config_type` defaults to `"server"` for all existing and new configurations unless explicitly set; |--------|-----------|
- the configurator page is shared for both types; the SW tab is always visible regardless of type; | Configurations | Client ↔ Server ↔ Other Clients |
- storage configurations use the same vendor_spec + PN→LOT + pricing flow as server configurations; | Projects | Client ↔ Server ↔ Other Clients |
- storage component categories map to existing tabs: `ENC`/`DKC`/`CTL` → Base, `HIC` → PCI (HIC-карты СХД; `HBA`/`NIC` — серверные, не смешивать), `SSD`/`HDD` → Storage (используют существующие серверные LOT), `ACC` → Accessories (используют существующие серверные LOT), `SW` → SW. | Pricelists | Server → Clients only (no push) |
- `DKC` = контроллерная полка (модель СХД + тип дисков + кол-во слотов + кол-во контроллеров); `CTL` = контроллер (кэш + встроенные порты); `ENC` = дисковая полка без контроллера. | Components | Server → Clients only |
- the available config types and their localized names flow from `qt_settings.config_types` on the server; | Partnumber books | Server → Clients only |
QF falls back to hardcoded "server/Сервер" and "storage/СХД" when the setting is absent.
## Server-driven configurator settings (`qt_settings`) Local pricelists not present on the server and not referenced by active configurations are deleted automatically on sync.
QF reads four settings from `qt_settings` (MariaDB) and caches them in `local_qt_settings` (SQLite). ### Soft Deletes (Archive Pattern)
They are synced during every component sync. See `bible-local/server-contract-qt-settings.md` for the
full contract and JSON schemas.
| Setting key | Effect in QF | Configurations and projects are **never hard-deleted**. Deletion is archive via `is_active = false`.
|-------------|-------------|
| `config_types` | New-config modal buttons; category allowlist per config type |
| `tab_config` | Configurator tab structure, sections, singleSelect |
| `always_visible_tabs` | Which tabs are shown even when empty |
| `required_categories` | Per-config-type badge on tabs with unfilled required categories |
Rules: - `DELETE /api/configs/:uuid` → sets `is_active = false` (archived); can be restored via `reactivate`
- sync runs after `SyncComponents`; failure is non-fatal (Warn log only); - `DELETE /api/projects/:uuid` → archives a project **variant** only (`variant` field must be non-empty); main projects cannot be deleted via this endpoint
- `local_qt_settings` is a read-only cache — never written by user actions;
- absent or unparseable settings: QF uses hardcoded fallbacks for that key only;
- `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere;
- `qt_categories.name` and `qt_categories.name_ru` are not used by QF runtime; do not depend on them.
## Vendor BOM contract ## Sync Readiness Guard
Vendor BOM is stored in `vendor_spec` on the configuration row. Before every push/pull, a preflight check runs:
1. Is the server (MariaDB) reachable?
2. Is the local client schema initialized and writable?
Rules: **If the check fails:**
- PN to LOT resolution uses the active local partnumber book; - Local CRUD continues without restriction
- canonical persisted mapping is `lot_mappings[]`; - Sync API returns `423 Locked` with `reason_code` and `reason_text`
- QuoteForge does not use legacy BOM tables such as `qt_bom`, `qt_lot_bundles`, or `qt_lot_bundle_items`. - UI shows a red indicator with the block reason
---
## Pricing
### Principle
**Prices come only from `local_pricelist_items`.**
Components (`local_components`) are metadata-only — they contain no pricing information.
Stock enrichment for pricelist rows is persisted into `local_pricelist_items` during sync; UI/runtime must not resolve it live from MariaDB.
### Lookup Pattern
```go
// Look up a price for a line item
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
if found && price > 0 {
// use price
}
// Inside lookupPriceByPricelistID:
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
```
### Multi-Level Pricelists
A configuration can reference up to three pricelists simultaneously:
| Field | Purpose |
|-------|---------|
| `pricelist_id` | Primary (estimate) |
| `warehouse_pricelist_id` | Warehouse pricing |
| `competitor_pricelist_id` | Competitor pricing |
Pricelist sources: `estimate` | `warehouse` | `competitor`
### "Auto" Pricelist Selection
Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`):
- **Explicit mode:** concrete `pricelist_id` is set by user in settings.
- **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist.
`auto` must stay `auto` after price-level refresh and after manual "refresh prices":
- resolved IDs are runtime-only and must not overwrite user's mode;
- switching to explicit selection must clear runtime auto resolution for that source.
### Latest Pricelist Resolution Rules
For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with:
1. only pricelists that have at least one item (`EXISTS ...pricelist_items`);
2. deterministic sort: `created_at DESC, id DESC`.
This prevents selecting empty/incomplete snapshots and removes nondeterministic ties.
---
## Configuration Versioning
### Principle
Append-only for **spec+price** changes: immutable snapshots are stored in `local_configuration_versions`.
```
local_configurations
└── current_version_id ──► local_configuration_versions (v3) ← active
local_configuration_versions (v2)
local_configuration_versions (v1)
```
- `version_no = max + 1` when configuration **spec+price** changes
- Old versions are never modified or deleted in normal flow
- Rollback does **not** rewind history — it creates a **new** version from the snapshot
- Operational updates (`line_no` reorder, server count, project move, rename)
are synced via `pending_changes` but do **not** create a new revision snapshot
### Rollback
```bash
POST /api/configs/:uuid/rollback
{
"target_version": 3,
"note": "optional comment"
}
```
Result:
- A new version `vN` is created with `data` from the target version
- `change_note = "rollback to v{target_version}"` (+ note if provided)
- `current_version_id` is switched to the new version
- Configuration moves to `sync_status = pending`
### Sync Status Flow
```
local → pending → synced
```
---
## Project Specification Ordering (`Line`)
- Each project configuration has persistent `line_no` (`10,20,30...`) in both SQLite and MariaDB.
- Project list ordering is deterministic:
`line_no ASC`, then `created_at DESC`, then `id DESC`.
- Drag-and-drop reorder in project UI updates `line_no` for active project configurations.
- Reorder writes are queued as configuration `update` events in `pending_changes`
without creating new configuration versions.
- Backward compatibility: if remote MariaDB schema does not yet include `line_no`,
sync falls back to create/update without `line_no` instead of failing.
---
## Sync Payload for Versioning
Events in `pending_changes` for configurations contain:
| Field | Description |
|-------|-------------|
| `configuration_uuid` | Identifier |
| `operation` | `create` / `update` / `rollback` |
| `current_version_id` | Active version ID |
| `current_version_no` | Version number |
| `snapshot` | Current configuration state |
| `idempotency_key` | For idempotent push |
| `conflict_policy` | `last_write_wins` |
---
## Background Processes
| Process | Interval | What it does |
|---------|----------|--------------|
| Sync worker | 5 min | push pending + pull all |
| Backup scheduler | configurable (`backup.time`) | creates ZIP archives |

View File

@@ -1,420 +1,267 @@
# 03 - Database # 03 Database
## SQLite ## SQLite (local, client-side)
SQLite is the local runtime database. File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
Main tables: ### Tables
#### Components and Reference Data
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_components` | Component metadata (NO prices) | `lot_name` (PK), `lot_description`, `category`, `model` |
| `connection_settings` | MariaDB connection settings | key-value store |
| `app_settings` | Application settings | `key` (PK), `value`, `updated_at` |
Read-only cache contract:
- `local_components`, `local_pricelists`, `local_pricelist_items`, `local_partnumber_books`, and `local_partnumber_book_items` are synchronized caches, not user-authored data.
- Startup must prefer application availability over preserving a broken cache schema.
- If one of these tables cannot be migrated safely, the client may quarantine or drop it and recreate it empty; the next sync repopulates it.
#### Pricelists
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` |
| `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` |
#### Partnumber Books (PN → LOT mapping, pull-only from PriceForge)
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_partnumber_books` | Version snapshots of PN→LOT mappings | `id`, `server_id` (unique), `version`, `created_at`, `is_active` |
| `local_partnumber_book_items` | Canonical PN catalog rows | `id`, `partnumber`, `lots_json`, `description` |
Active book: `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
#### Configurations and Projects
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `vendor_spec` (JSON: PN/qty/description + canonical `lot_mappings[]`), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
#### Sync
| Table | Purpose | | Table | Purpose |
| --- | --- | |-------|---------|
| `local_components` | synced component metadata | | `pending_changes` | Queue of changes to push to MariaDB |
| `local_pricelists` | local pricelist headers | | `local_schema_migrations` | Applied migrations (idempotency guard) |
| `local_pricelist_items` | local pricelist rows, the only runtime price source |
| `local_projects` | user projects |
| `local_configurations` | user configurations |
| `local_configuration_versions` | immutable revision snapshots |
| `local_partnumber_books` | partnumber book headers |
| `local_partnumber_book_items` | PN -> LOT catalog payload |
| `pending_changes` | sync queue |
| `connection_settings` | encrypted MariaDB connection settings |
| `app_settings` | local app state |
| `local_schema_migrations` | applied local migration markers |
| `local_qt_settings` | server-pushed configurator settings cache (from `qt_settings`) |
Rules: ---
- cache tables may be rebuilt if local migration recovery requires it;
- user-authored tables must not be dropped as a recovery shortcut;
- `local_pricelist_items` is the only valid runtime source of prices;
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows.
## MariaDB ### Key SQLite Indexes
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
### QuoteForge tables (qt_*)
Runtime read:
- `qt_categories` — pricelist categories (note: `name`/`name_ru` columns being removed; QF does not use them)
- `qt_lot_metadata` — component metadata, price settings
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
- `qt_pricelist_items` — pricelist rows
- `qt_partnumber_books` — partnumber book headers
- `qt_partnumber_book_items` — PN→LOT catalog payload
- `qt_settings` — server-pushed configurator settings; schema managed by server-side agent (see `bible-local/server-contract-qt-settings.md`)
Runtime read/write:
- `qt_projects` — projects
- `qt_configurations` — configurations
- `qt_client_schema_state` — per-client sync status and version tracking
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
Insert-only tracking:
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
Server-side only (not queried by client runtime):
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
- `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime)
- `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`)
- `qt_scheduler_runs` — server background job tracking (no Go code references it in this repo)
### Competitor subsystem (server-side only, not used by QuoteForge Go code)
- `qt_competitors` — competitor registry
- `partnumber_log_competitors` — competitor price log (FK → qt_competitors)
These tables exist in the schema and are maintained by another tool or workflow.
QuoteForge references competitor pricelists only via `qt_pricelists` (source='competitor').
### Legacy RFQ tables (pre-QuoteForge, no Go code references)
- `lot` — original component registry (data preserved; superseded by `qt_lot_metadata`)
- `lot_log` — original supplier price log
- `supplier` — supplier registry (FK target for lot_log and machine_log)
- `machine` — device model registry
- `machine_log` — device price/quote log
- `parts_log` — supplier partnumber log used by server-side import/pricing workflows, not by QuoteForge runtime
These tables are retained for historical data. QuoteForge does not read or write them at runtime.
Rules:
- QuoteForge runtime must not depend on any legacy RFQ tables;
- QuoteForge sync reads prices and categories from `qt_pricelists` / `qt_pricelist_items` only;
- QuoteForge does not enrich local pricelist rows from `parts_log` or any other raw supplier log table;
- normal UI requests must not query MariaDB tables directly;
- `qt_client_local_migrations` exists in the 2026-04-15 schema dump, but runtime sync does not depend on it.
## MariaDB Table Structures
Full column reference as of 2026-03-21 (`RFQ_LOG` final schema).
### qt_categories
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| code | varchar(20) UNIQUE NOT NULL | |
| name | varchar(100) NOT NULL | being removed; QF does not use at runtime |
| name_ru | varchar(100) | being removed; QF does not use at runtime |
| display_order | bigint DEFAULT 0 | |
| is_required | tinyint(1) DEFAULT 0 | |
### qt_settings
Managed by the server-side agent. QF has SELECT-only access.
See `bible-local/server-contract-qt-settings.md` for full schema and value formats.
| Column | Type | Notes |
|--------|------|-------|
| name | varchar(100) PK | setting key |
| value | TEXT NOT NULL | JSON-encoded value |
### local_qt_settings (SQLite)
Read-only cache of `qt_settings`. Synced during component sync.
| Column | Type | Notes |
|--------|------|-------|
| name | text PK | setting key |
| value | text | JSON value as-is from server |
### qt_client_schema_state
PK: (username, hostname)
| Column | Type | Notes |
|--------|------|-------|
| username | varchar(100) | |
| hostname | varchar(255) DEFAULT '' | |
| last_applied_migration_id | varchar(128) | |
| app_version | varchar(64) | |
| last_sync_at | datetime | |
| last_sync_status | varchar(32) | |
| pending_changes_count | int DEFAULT 0 | |
| pending_errors_count | int DEFAULT 0 | |
| configurations_count | int DEFAULT 0 | |
| projects_count | int DEFAULT 0 | |
| estimate_pricelist_version | varchar(128) | |
| warehouse_pricelist_version | varchar(128) | |
| competitor_pricelist_version | varchar(128) | |
| last_sync_error_code | varchar(128) | |
| last_sync_error_text | text | |
| last_checked_at | datetime NOT NULL | |
| updated_at | datetime NOT NULL | |
### qt_component_usage_stats
PK: lot_name
| Column | Type | Notes |
|--------|------|-------|
| lot_name | varchar(255) | |
| quotes_total | bigint DEFAULT 0 | |
| quotes_last30d | bigint DEFAULT 0 | |
| quotes_last7d | bigint DEFAULT 0 | |
| total_quantity | bigint DEFAULT 0 | |
| total_revenue | decimal(14,2) DEFAULT 0 | |
| trend_direction | enum('up','stable','down') DEFAULT 'stable' | |
| trend_percent | decimal(5,2) DEFAULT 0 | |
| last_used_at | datetime(3) | |
### qt_competitors
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| name | varchar(255) NOT NULL | |
| code | varchar(100) UNIQUE NOT NULL | |
| delivery_basis | varchar(50) DEFAULT 'DDP' | |
| currency | varchar(10) DEFAULT 'USD' | |
| column_mapping | longtext JSON | |
| is_active | tinyint(1) DEFAULT 1 | |
| created_at | timestamp | |
| updated_at | timestamp ON UPDATE | |
| price_uplift | decimal(8,4) DEFAULT 1.3 | effective_price = price / price_uplift |
### qt_configurations
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| uuid | varchar(36) UNIQUE NOT NULL | |
| user_id | bigint UNSIGNED | |
| owner_username | varchar(100) NOT NULL | |
| app_version | varchar(64) | |
| project_uuid | char(36) | FK → qt_projects.uuid ON DELETE SET NULL |
| name | varchar(200) NOT NULL | |
| items | longtext JSON NOT NULL | component list |
| total_price | decimal(12,2) | |
| notes | text | |
| is_template | tinyint(1) DEFAULT 0 | |
| created_at | datetime(3) | |
| custom_price | decimal(12,2) | |
| server_count | bigint DEFAULT 1 | |
| server_model | varchar(100) | |
| support_code | varchar(20) | |
| article | varchar(80) | |
| pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
| warehouse_pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
| competitor_pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
| disable_price_refresh | tinyint(1) DEFAULT 0 | |
| only_in_stock | tinyint(1) DEFAULT 0 | |
| line_no | int | position within project |
| price_updated_at | timestamp | |
| vendor_spec | longtext JSON | |
### qt_lot_metadata
PK: lot_name
| Column | Type | Notes |
|--------|------|-------|
| lot_name | varchar(255) | |
| category_id | bigint UNSIGNED | FK → qt_categories.id |
| vendor | varchar(50) | |
| model | varchar(100) | |
| specs | longtext JSON | |
| current_price | decimal(12,2) | cached computed price |
| price_method | enum('manual','median','average','weighted_median') DEFAULT 'median' | |
| price_period_days | bigint DEFAULT 90 | |
| price_updated_at | datetime(3) | |
| request_count | bigint DEFAULT 0 | |
| last_request_date | date | |
| popularity_score | decimal(10,4) DEFAULT 0 | |
| price_coefficient | decimal(5,2) DEFAULT 0 | markup % |
| manual_price | decimal(12,2) | |
| meta_prices | varchar(1000) | raw price samples JSON |
| meta_method | varchar(20) | method used for last compute |
| meta_period_days | bigint DEFAULT 90 | |
| is_hidden | tinyint(1) DEFAULT 0 | |
### qt_partnumber_books
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| version | varchar(30) UNIQUE NOT NULL | |
| created_at | timestamp | |
| created_by | varchar(100) | |
| is_active | tinyint(1) DEFAULT 0 | only one active at a time |
| partnumbers_json | longtext DEFAULT '[]' | flat list of partnumbers |
### qt_partnumber_book_items
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| partnumber | varchar(255) UNIQUE NOT NULL | |
| lots_json | longtext NOT NULL | JSON array of lot_names |
| description | varchar(10000) | |
### qt_pricelists
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| source | varchar(20) DEFAULT 'estimate' | 'estimate' / 'warehouse' / 'competitor' |
| version | varchar(20) NOT NULL | UNIQUE with source |
| created_at | datetime(3) | |
| created_by | varchar(100) | |
| is_active | tinyint(1) DEFAULT 1 | |
| usage_count | bigint DEFAULT 0 | |
| expires_at | datetime(3) | |
| notification | varchar(500) | shown to clients on sync |
### qt_pricelist_items
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| pricelist_id | bigint UNSIGNED NOT NULL | FK → qt_pricelists.id |
| lot_name | varchar(255) NOT NULL | INDEX with pricelist_id |
| lot_category | varchar(50) | |
| price | decimal(12,2) NOT NULL | |
| price_method | varchar(20) | |
| price_period_days | bigint DEFAULT 90 | |
| price_coefficient | decimal(5,2) DEFAULT 0 | |
| manual_price | decimal(12,2) | |
| meta_prices | varchar(1000) | |
### qt_pricelist_sync_status
PK: username
| Column | Type | Notes |
|--------|------|-------|
| username | varchar(100) | |
| last_sync_at | datetime NOT NULL | |
| updated_at | datetime NOT NULL | |
| app_version | varchar(64) | |
### qt_pricing_alerts
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| lot_name | varchar(255) NOT NULL | |
| alert_type | enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price') | |
| severity | enum('low','medium','high','critical') DEFAULT 'medium' | |
| message | text NOT NULL | |
| details | longtext JSON | |
| status | enum('new','acknowledged','resolved','ignored') DEFAULT 'new' | |
| created_at | datetime(3) | |
### qt_projects
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| uuid | char(36) UNIQUE NOT NULL | |
| owner_username | varchar(100) NOT NULL | |
| code | varchar(100) NOT NULL | UNIQUE with variant |
| variant | varchar(100) DEFAULT '' | UNIQUE with code |
| name | varchar(200) | |
| tracker_url | varchar(500) | |
| is_active | tinyint(1) DEFAULT 1 | |
| is_system | tinyint(1) DEFAULT 0 | |
| created_at | timestamp | |
| updated_at | timestamp ON UPDATE | |
### qt_schema_migrations
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| filename | varchar(255) UNIQUE NOT NULL | |
| applied_at | datetime(3) | |
### qt_scheduler_runs
PK: job_name
| Column | Type | Notes |
|--------|------|-------|
| job_name | varchar(100) | |
| last_started_at | datetime | |
| last_finished_at | datetime | |
| last_status | varchar(20) DEFAULT 'idle' | |
| last_error | text | |
| updated_at | timestamp ON UPDATE | |
### qt_vendor_partnumber_seen
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| source_type | varchar(32) NOT NULL | |
| vendor | varchar(255) DEFAULT '' | |
| partnumber | varchar(255) UNIQUE NOT NULL | |
| description | varchar(10000) | |
| last_seen_at | datetime(3) NOT NULL | |
| is_ignored | tinyint(1) DEFAULT 0 | |
| is_pattern | tinyint(1) DEFAULT 0 | |
| ignored_at | datetime(3) | |
| ignored_by | varchar(100) | |
| created_at | datetime(3) | |
| updated_at | datetime(3) | |
| lot_suggestion | longtext (JSON) | nullable; set when user manually maps PN → LOT in vendor-spec UI; same format as `qt_partnumber_book_items.lots_json`; see [11-lot-suggestions.md](11-lot-suggestions.md) |
### stock_ignore_rules
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| target | varchar(20) NOT NULL | UNIQUE with match_type+pattern |
| match_type | varchar(20) NOT NULL | |
| pattern | varchar(500) NOT NULL | |
| created_at | timestamp | |
### stock_log
| Column | Type | Notes |
|--------|------|-------|
| stock_log_id | bigint UNSIGNED PK AUTO_INCREMENT | |
| partnumber | varchar(255) NOT NULL | INDEX with date |
| supplier | varchar(255) | |
| date | date NOT NULL | |
| price | decimal(12,2) NOT NULL | |
| quality | varchar(255) | |
| comments | text | |
| vendor | varchar(255) | INDEX |
| qty | decimal(14,3) | |
### partnumber_log_competitors
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| competitor_id | bigint UNSIGNED NOT NULL | FK → qt_competitors.id |
| partnumber | varchar(255) NOT NULL | |
| description | varchar(500) | |
| vendor | varchar(255) | |
| price | decimal(12,2) NOT NULL | |
| price_loccur | decimal(12,2) | local currency price |
| currency | varchar(10) | |
| qty | decimal(12,4) DEFAULT 1 | |
| date | date NOT NULL | |
| created_at | timestamp | |
### Legacy tables (lot / lot_log / machine / machine_log / supplier)
Retained for historical data only. Not queried by QuoteForge.
**lot**: lot_name (PK, char 255), lot_category, lot_description
**lot_log**: lot_log_id AUTO_INCREMENT, lot (FK→lot), supplier (FK→supplier), date, price double, quality, comments
**supplier**: supplier_name (PK, char 255), supplier_comment
**machine**: machine_name (PK, char 255), machine_description
**machine_log**: machine_log_id AUTO_INCREMENT, date, supplier (FK→supplier), country, opty, type, machine (FK→machine), customer_requirement, variant, price_gpl, price_estimate, qty, quality, carepack, lead_time_weeks, prepayment_percent, price_got, Comment
## MariaDB User Permissions
The application user needs read-only access to reference tables and read/write access to runtime tables.
```sql ```sql
-- Read-only: reference and pricing data -- Pricelists
GRANT SELECT ON RFQ_LOG.qt_categories TO 'qfs_user'@'%'; INDEX local_pricelist_items(pricelist_id)
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'qfs_user'@'%'; UNIQUE INDEX local_pricelists(server_id)
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'qfs_user'@'%'; INDEX local_pricelists(source, created_at) -- used for "latest by source" queries
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'qfs_user'@'%'; -- latest-by-source runtime query also applies deterministic tie-break by id DESC
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
-- Read/write: runtime sync and user data -- Configurations
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_projects TO 'qfs_user'@'%'; INDEX local_configurations(pricelist_id)
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'qfs_user'@'%'; INDEX local_configurations(warehouse_pricelist_id)
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'qfs_user'@'%'; INDEX local_configurations(competitor_pricelist_id)
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%'; INDEX local_configurations(project_uuid, line_no) -- project ordering (Line column)
UNIQUE INDEX local_configurations(uuid)
```
---
### `items` JSON Structure in Configurations
```json
{
"items": [
{
"lot_name": "CPU_AMD_9654",
"quantity": 2,
"unit_price": 123456.78,
"section": "Processors"
}
]
}
```
Prices are stored inside the `items` JSON field and refreshed from the pricelist on configuration refresh.
---
## MariaDB (server-side, sync-only)
Database: `RFQ_LOG`
### Tables and Permissions
| Table | Purpose | Permissions |
|-------|---------|-------------|
| `lot` | Component catalog | SELECT |
| `qt_lot_metadata` | Extended component data | SELECT |
| `qt_categories` | Component categories | SELECT |
| `qt_pricelists` | Pricelists | SELECT |
| `qt_pricelist_items` | Pricelist line items | SELECT |
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment during sync only) | SELECT |
| `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE |
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
| `qt_client_schema_state` | Applied migrations state + client operational status per `username + hostname` | SELECT, INSERT, UPDATE |
| `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE |
| `qt_partnumber_books` | Partnumber book headers with snapshot membership in `partnumbers_json` (written by PriceForge) | SELECT |
| `qt_partnumber_book_items` | Canonical PN catalog with `lots_json` composition (written by PriceForge) | SELECT |
| `qt_vendor_partnumber_seen` | Vendor PN tracking for unresolved/ignored BOM rows (`is_ignored`) | INSERT only for new `partnumber`; existing rows must not be modified |
Legacy server tables not used by QuoteForge runtime anymore:
- `qt_bom`
- `qt_lot_bundles`
- `qt_lot_bundle_items`
QuoteForge canonical BOM storage is:
- `qt_configurations.vendor_spec`
- row-level PN -> multiple LOT decomposition in `vendor_spec[].lot_mappings[]`
Partnumber book server read contract:
1. Read active or target book from `qt_partnumber_books`.
2. Parse `partnumbers_json`.
3. Load payloads from `qt_partnumber_book_items WHERE partnumber IN (...)`.
Pricelist stock enrichment contract:
1. Sync pulls base pricelist rows from `qt_pricelist_items`.
2. Sync reads latest stock quantities from `stock_log`.
3. Sync resolves `partnumber -> lot` through the local mirror of `qt_partnumber_book_items` (`local_partnumber_book_items.lots_json`).
4. Sync stores enriched `available_qty` and `partnumbers` into `local_pricelist_items`.
Runtime rule:
- pricelist UI and quote logic read only `local_pricelist_items`;
- runtime code must not query `stock_log`, `qt_pricelist_items`, or `qt_partnumber_book_items` directly outside sync.
`qt_partnumber_book_items` no longer contains `book_id` or `lot_name`.
It stores one row per `partnumber` with:
- `partnumber`
- `lots_json` as `[{"lot_name":"CPU_X","qty":2}, ...]`
- `description`
`qt_client_schema_state` current contract:
- identity key: `username + hostname`
- client/runtime state:
`app_version`, `last_checked_at`, `updated_at`
- operational state:
`last_sync_at`, `last_sync_status`
- queue health:
`pending_changes_count`, `pending_errors_count`
- local dataset size:
`configurations_count`, `projects_count`
- price context:
`estimate_pricelist_version`, `warehouse_pricelist_version`, `competitor_pricelist_version`
- last known sync problem:
`last_sync_error_code`, `last_sync_error_text`
`last_sync_error_*` source priority:
1. blocked readiness state from `local_sync_guard_state`
2. latest non-empty `pending_changes.last_error`
3. `NULL` when no known sync problem exists
### Grant Permissions to Existing User
```sql
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>'@'%';
GRANT SELECT ON RFQ_LOG.stock_log TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO '<DB_USER>'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
``` ```
Rules: ### Create a New User
- `qt_client_schema_state` requires INSERT + UPDATE for sync status tracking (uses `ON DUPLICATE KEY UPDATE`);
- `qt_vendor_partnumber_seen` requires INSERT + UPDATE (vendor PN discovery during sync); ```sql
- no DELETE is needed on sync/tracking tables — rows are never removed by the client; CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
- `lot` SELECT is required for the connection validation probe in `/setup`;
- the setup page shows `can_write: true` only when `qt_client_schema_state` INSERT succeeds. GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.stock_log 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, 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'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'quote_user'@'%';
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'quote_user'@'%';
FLUSH PRIVILEGES;
SHOW GRANTS FOR 'quote_user'@'%';
```
**Note:** If pricelists sync but stock enrichment is empty, verify `SELECT` on `qt_pricelist_items`, `qt_partnumber_books`, `qt_partnumber_book_items`, and `stock_log`.
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
---
## Migrations ## Migrations
SQLite: ### SQLite Migrations (local) — два уровня, выполняются при каждом старте
- schema creation and additive changes go through GORM `AutoMigrate`;
- data fixes, index repair, and one-off rewrites go through `runLocalMigrations`;
- local migration state is tracked in `local_schema_migrations`.
MariaDB: **1. GORM AutoMigrate** (`internal/localdb/localdb.go`) — первый и основной уровень.
- SQL files live in `migrations/`; Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**.
- they are applied by `go run ./cmd/qfs -migrate`.
Local SQLite partnumber book cache contract:
- `local_partnumber_books.partnumbers_json` stores PN membership for a pulled book.
- `local_partnumber_book_items` is a deduplicated local catalog by `partnumber`.
- `local_partnumber_book_items.lots_json` mirrors the server `lots_json` payload.
- SQLite migration `2026_03_07_local_partnumber_book_catalog` rebuilds old `book_id + lot_name` rows into the new local cache shape.
→ Для добавления новой таблицы или колонки достаточно добавить модель/поле и включить модель в AutoMigrate.
**2. `runLocalMigrations`** (`internal/localdb/migrations.go`) — второй уровень, для операций которые AutoMigrate не умеет: backfill данных, пересоздание таблиц, создание индексов.
Каждая функция выполняется один раз — идемпотентность через запись `id` в `local_schema_migrations`.
QuoteForge does not use centralized server-driven SQLite migrations.
All local SQLite schema/data migrations live in the client codebase.
### MariaDB Migrations (server-side)
- Stored in `migrations/` (SQL files)
- Applied via `-migrate` flag
- `min_app_version` — minimum app version required for the migration
---
## DB Debugging
```bash
# Inspect schema
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_components"
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_configurations"
# Check pricelist item count
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM local_pricelist_items"
# Check pending sync queue
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM pending_changes"
```

View File

@@ -1,125 +1,170 @@
# 04 - API # 04 API and Web Routes
## Public web routes ## API Endpoints
| Route | Purpose | ### Setup
| --- | --- |
| `/` | configurator |
| `/configs` | configuration list |
| `/configs/:uuid/revisions` | revision history page |
| `/projects` | project list |
| `/projects/:uuid` | project detail |
| `/pricelists` | pricelist list |
| `/pricelists/:id` | pricelist detail |
| `/partnumber-books` | partnumber book page |
| `/setup` | DB setup page |
## Setup and health | Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/setup` | Initial setup page |
| POST | `/setup` | Save connection settings |
| POST | `/setup/test` | Test MariaDB connection |
| GET | `/setup/status` | Setup status |
| Method | Path | Purpose | ### Components
| --- | --- | --- |
| `GET` | `/health` | process health |
| `GET` | `/setup` | setup page |
| `POST` | `/setup` | save tested DB settings |
| `POST` | `/setup/test` | test DB connection |
| `GET` | `/setup/status` | setup status |
| `GET` | `/api/db-status` | current DB/sync status |
| `GET` | `/api/current-user` | local user identity |
| `GET` | `/api/ping` | lightweight API ping |
`POST /api/restart` exists only in `debug` mode. | Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/components` | List components (metadata only) |
| GET | `/api/components/:lot_name` | Component by lot_name |
| GET | `/api/categories` | List categories |
## Reference data ### Quote
| Method | Path | Purpose | | Method | Endpoint | Purpose |
| --- | --- | --- | |--------|----------|---------|
| `GET` | `/api/components` | list component metadata | | POST | `/api/quote/validate` | Validate line items |
| `GET` | `/api/components/:lot_name` | one component | | POST | `/api/quote/calculate` | Calculate quote (prices from pricelist) |
| `GET` | `/api/categories` | list categories | | POST | `/api/quote/price-levels` | Prices by level (estimate/warehouse/competitor) |
| `GET` | `/api/pricelists` | list local pricelists |
| `GET` | `/api/pricelists/latest` | latest pricelist by source |
| `GET` | `/api/pricelists/:id` | pricelist header |
| `GET` | `/api/pricelists/:id/items` | pricelist rows |
| `GET` | `/api/pricelists/:id/lots` | lot names in a pricelist |
| `GET` | `/api/partnumber-books` | local partnumber books |
| `GET` | `/api/partnumber-books/:id` | book items by `server_id` |
## Quote and export ### Pricelists (read-only)
| Method | Path | Purpose | | Method | Endpoint | Purpose |
| --- | --- | --- | |--------|----------|---------|
| `POST` | `/api/quote/validate` | validate config items | | GET | `/api/pricelists` | List pricelists (`source`, `active_only`, pagination) |
| `POST` | `/api/quote/calculate` | calculate quote totals | | GET | `/api/pricelists/latest` | Latest pricelist by source |
| `POST` | `/api/quote/price-levels` | resolve estimate/warehouse/competitor prices | | GET | `/api/pricelists/:id` | Pricelist by ID |
| `POST` | `/api/export/csv` | export a single configuration | | GET | `/api/pricelists/:id/items` | Pricelist line items |
| `GET` | `/api/configs/:uuid/export` | export a stored configuration | | GET | `/api/pricelists/:id/lots` | Lot names in pricelist |
| `GET` | `/api/projects/:uuid/export` | legacy project BOM export |
| `POST` | `/api/projects/:uuid/export` | pricing-tab project export |
## Configurations `GET /api/pricelists?active_only=true` returns only pricelists that have synced items (`item_count > 0`).
| Method | Path | Purpose | ### Configurations
| --- | --- | --- |
| `GET` | `/api/configs` | list configurations |
| `POST` | `/api/configs/import` | import configurations from server |
| `POST` | `/api/configs` | create configuration |
| `POST` | `/api/configs/preview-article` | preview article |
| `GET` | `/api/configs/:uuid` | get configuration |
| `PUT` | `/api/configs/:uuid` | update configuration |
| `DELETE` | `/api/configs/:uuid` | archive configuration |
| `POST` | `/api/configs/:uuid/reactivate` | reactivate configuration |
| `PATCH` | `/api/configs/:uuid/rename` | rename configuration |
| `POST` | `/api/configs/:uuid/clone` | clone configuration |
| `POST` | `/api/configs/:uuid/refresh-prices` | refresh prices |
| `PATCH` | `/api/configs/:uuid/project` | move configuration to project |
| `GET` | `/api/configs/:uuid/versions` | list revisions |
| `GET` | `/api/configs/:uuid/versions/:version` | get one revision |
| `POST` | `/api/configs/:uuid/rollback` | rollback by creating a new head revision |
| `PATCH` | `/api/configs/:uuid/server-count` | update server count |
| `GET` | `/api/configs/:uuid/vendor-spec` | read vendor BOM |
| `PUT` | `/api/configs/:uuid/vendor-spec` | replace vendor BOM |
| `POST` | `/api/configs/:uuid/vendor-spec/resolve` | resolve PN -> LOT |
| `POST` | `/api/configs/:uuid/vendor-spec/apply` | apply BOM to cart |
## Projects | Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/configs` | List configurations |
| POST | `/api/configs` | Create configuration |
| GET | `/api/configs/:uuid` | Get configuration |
| PUT | `/api/configs/:uuid` | Update configuration |
| DELETE | `/api/configs/:uuid` | Archive configuration |
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
| POST | `/api/configs/:uuid/clone` | Clone configuration |
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
| POST | `/api/configs/:uuid/rename` | Rename configuration |
| POST | `/api/configs/preview-article` | Preview generated article for a configuration |
| POST | `/api/configs/:uuid/rollback` | Roll back to a version |
| GET | `/api/configs/:uuid/versions` | List versions |
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
| Method | Path | Purpose | `line` field in configuration payloads is backed by persistent `line_no` in DB.
| --- | --- | --- |
| `GET` | `/api/projects` | paginated project list |
| `GET` | `/api/projects/all` | lightweight list for dropdowns |
| `POST` | `/api/projects` | create project |
| `GET` | `/api/projects/:uuid` | get project |
| `PUT` | `/api/projects/:uuid` | update project |
| `POST` | `/api/projects/:uuid/archive` | archive project |
| `POST` | `/api/projects/:uuid/reactivate` | reactivate project |
| `DELETE` | `/api/projects/:uuid` | delete project variant only |
| `GET` | `/api/projects/:uuid/configs` | list project configurations |
| `PATCH` | `/api/projects/:uuid/configs/reorder` | persist line order |
| `POST` | `/api/projects/:uuid/configs` | create configuration inside project |
| `POST` | `/api/projects/:uuid/configs/:config_uuid/clone` | clone config into project |
| `POST` | `/api/projects/:uuid/vendor-import` | import CFXML workspace into project |
Vendor import contract: ### Projects
- multipart field name is `file`;
- file limit is `1 GiB`;
- oversized payloads are rejected before XML parsing.
## Sync | Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/projects` | List projects |
| POST | `/api/projects` | Create project |
| GET | `/api/projects/:uuid` | Get project |
| PUT | `/api/projects/:uuid` | Update project |
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
| GET | `/api/projects/:uuid/configs` | Project configurations |
| PATCH | `/api/projects/:uuid/configs/reorder` | Reorder active project configurations (`ordered_uuids`) and persist `line_no` |
| POST | `/api/projects/:uuid/vendor-import` | Import a vendor `CFXML` workspace into the existing project |
| Method | Path | Purpose | `GET /api/projects/:uuid/configs` ordering:
| --- | --- | --- | `line ASC`, then `created_at DESC`, then `id DESC`.
| `GET` | `/api/sync/status` | sync status |
| `GET` | `/api/sync/readiness` | sync readiness |
| `GET` | `/api/sync/info` | sync modal data |
| `GET` | `/api/sync/users-status` | remote user status |
| `GET` | `/api/sync/pending/count` | pending queue count |
| `GET` | `/api/sync/pending` | pending queue rows |
| `POST` | `/api/sync/components` | pull components |
| `POST` | `/api/sync/pricelists` | pull pricelists |
| `POST` | `/api/sync/partnumber-books` | pull partnumber books |
| `POST` | `/api/sync/partnumber-seen` | report unresolved vendor PN |
| `POST` | `/api/sync/all` | push and pull full sync |
| `POST` | `/api/sync/push` | push pending changes |
| `POST` | `/api/sync/repair` | repair broken pending rows |
When readiness is blocked, sync write endpoints return `423 Locked`. `POST /api/projects/:uuid/vendor-import` accepts `multipart/form-data` with one required file field:
- `file` — vendor configurator export in `CFXML` format
### Sync
| Method | Endpoint | Purpose | Flow |
|--------|----------|---------|------|
| GET | `/api/sync/status` | Overall sync status | read-only |
| GET | `/api/sync/readiness` | Preflight status (ready/blocked/unknown) | read-only |
| GET | `/api/sync/info` | Data for sync modal | read-only |
| GET | `/api/sync/users-status` | Users status | read-only |
| GET | `/api/sync/pending` | List pending changes | read-only |
| GET | `/api/sync/pending/count` | Count of pending changes | read-only |
| POST | `/api/sync/push` | Push pending → MariaDB | SQLite → MariaDB |
| POST | `/api/sync/components` | Pull components | MariaDB → SQLite |
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
| POST | `/api/sync/partnumber-seen` | Report unresolved/ignored vendor PNs for server-side tracking | QuoteForge → MariaDB |
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
### Vendor Spec (BOM)
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored vendor BOM |
| PUT | `/api/configs/:uuid/vendor-spec` | Replace vendor BOM (full update) |
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (read-only) |
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
Notes:
- `GET` / `PUT /api/configs/:uuid/vendor-spec` exchange normalized BOM rows (`vendor_spec`), not raw pasted Excel layout.
- BOM row contract stores canonical LOT mapping list as seen in BOM UI:
- `lot_mappings[]`
- each mapping contains `lot_name` + `quantity_per_pn`
- `POST /api/configs/:uuid/vendor-spec/apply` rebuilds cart items from explicit BOM mappings:
- all LOTs from `lot_mappings[]`
### Partnumber Books (read-only)
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/partnumber-books` | List local book snapshots |
| GET | `/api/partnumber-books/:id` | Items for a book by `server_id` (`page`, `per_page`, `search`) |
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
See [09-vendor-spec.md](09-vendor-spec.md) for schema and pull logic.
See [09-vendor-spec.md](09-vendor-spec.md) for `vendor_spec` JSON schema and BOM UI mapping contract.
### Export
| Method | Endpoint | Purpose |
|--------|----------|---------|
| POST | `/api/export/csv` | Export configuration to CSV |
| GET | `/api/projects/:uuid/export` | Legacy project CSV export in block BOM format |
| POST | `/api/projects/:uuid/export` | Project CSV export in pricing-tab format with selectable columns (`include_lot`, `include_bom`, `include_estimate`, `include_stock`, `include_competitor`) |
**Export filename format:** `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
(uses `project.Code`, not `project.Name`)
---
## Web Routes
| Route | Page |
|-------|------|
| `/configs` | Configuration list |
| `/configurator` | Configurator |
| `/configs/:uuid/revisions` | Configuration revision history |
| `/projects` | Project list |
| `/projects/:uuid` | Project details |
| `/pricelists` | Pricelist list |
| `/pricelists/:id` | Pricelist details |
| `/partnumber-books` | Partnumber books (active book summary + snapshot history) |
| `/setup` | Connection settings |
---
## Rollback API (details)
```bash
POST /api/configs/:uuid/rollback
Content-Type: application/json
{
"target_version": 3,
"note": "optional comment"
}
```
Response: updated configuration with the new version.

View File

@@ -1,74 +1,141 @@
# 05 - Config # 05 Configuration and Environment
## Runtime files ## File Paths
| Artifact | Default location | ### SQLite database (`qfs.db`)
| --- | --- |
| `qfs.db` | OS-specific user state directory |
| `config.yaml` | same state directory as `qfs.db` |
| `local_encryption.key` | same state directory as `qfs.db` |
| `backups/` | next to `qfs.db` unless overridden |
The runtime state directory can be overridden with `QFS_STATE_DIR`. | OS | Default path |
Direct paths can be overridden with `QFS_DB_PATH` and `QFS_CONFIG_PATH`. |----|-------------|
| macOS | `~/Library/Application Support/QuoteForge/qfs.db` |
| Linux | `$XDG_STATE_HOME/quoteforge/qfs.db` or `~/.local/state/quoteforge/qfs.db` |
| Windows | `%LOCALAPPDATA%\QuoteForge\qfs.db` |
## Runtime config shape Override: `-localdb <path>` or `QFS_DB_PATH`.
Runtime keeps `config.yaml` intentionally small: ### config.yaml
Searched in the same user-state directory as `qfs.db` by default.
If the file does not exist, it is created automatically.
If the format is outdated, it is automatically migrated to the runtime format (`server` + `logging` sections only).
Override: `-config <path>` or `QFS_CONFIG_PATH`.
**Important:** `config.yaml` is a runtime user file — it is **not stored in the repository**.
`config.example.yaml` is the only config template in the repo.
### Local encryption key
Saved MariaDB credentials in SQLite are encrypted with:
1. `QUOTEFORGE_ENCRYPTION_KEY` if explicitly provided, otherwise
2. an application-managed random key file stored at `<state dir>/local_encryption.key`.
Rules:
- The key file is created automatically with mode `0600`.
- The key file is not committed and is not included in normal backups.
- Restoring `qfs.db` on another machine requires re-entering DB credentials unless the key file is migrated separately.
---
## config.yaml Structure
```yaml ```yaml
server: server:
host: "127.0.0.1" host: "0.0.0.0"
port: 8080 port: 8080
mode: "release" mode: "release" # release | debug
read_timeout: 30s
write_timeout: 30s
backup:
time: "00:00"
logging: logging:
level: "info" level: "info" # debug | info | warn | error
format: "json" format: "json" # json | text
output: "stdout" output: "stdout" # stdout | stderr | /path/to/file
backup:
time: "00:00" # HH:MM in local time
``` ```
Rules: ---
- QuoteForge creates this file automatically if it does not exist;
- startup rewrites legacy config files into this minimal runtime shape;
- startup normalizes any `server.host` value to `127.0.0.1` before saving the runtime config;
- `server.host` must stay on loopback.
Saved MariaDB credentials do not live in `config.yaml`. ## Environment Variables
They are stored in SQLite and encrypted with `local_encryption.key` unless `QUOTEFORGE_ENCRYPTION_KEY` overrides the key material.
## Environment variables | Variable | Description | Default |
|----------|-------------|---------|
| `QFS_DB_PATH` | Full path to SQLite DB | OS-specific user state dir |
| `QFS_STATE_DIR` | State directory (if `QFS_DB_PATH` is not set) | OS-specific user state dir |
| `QFS_CONFIG_PATH` | Full path to `config.yaml` | OS-specific user state dir |
| `QFS_BACKUP_DIR` | Root directory for rotating backups | `<db dir>/backups` |
| `QFS_BACKUP_DISABLE` | Disable automatic backups | — |
| `QUOTEFORGE_ENCRYPTION_KEY` | Explicit override for local credential encryption key | app-managed key file |
| `QF_DB_HOST` | MariaDB host | localhost |
| `QF_DB_PORT` | MariaDB port | 3306 |
| `QF_DB_NAME` | Database name | RFQ_LOG |
| `QF_DB_USER` | DB user | — |
| `QF_DB_PASSWORD` | DB password | — |
| `QF_SERVER_PORT` | HTTP server port | 8080 |
| Variable | Purpose | `QFS_BACKUP_DISABLE` accepts: `1`, `true`, `yes`.
| --- | --- |
| `QFS_STATE_DIR` | override runtime state directory |
| `QFS_DB_PATH` | explicit SQLite path |
| `QFS_CONFIG_PATH` | explicit config path |
| `QFS_BACKUP_DIR` | explicit backup root |
| `QFS_BACKUP_DISABLE` | disable rotating backups |
| `QUOTEFORGE_ENCRYPTION_KEY` | override encryption key |
| `QF_SERVER_PORT` | override HTTP port |
`QFS_BACKUP_DISABLE` accepts `1`, `true`, or `yes`. ---
## CLI flags ## CLI Flags
| Flag | Purpose | | Flag | Description |
| --- | --- | |------|-------------|
| `-config <path>` | config file path | | `-config <path>` | Path to config.yaml |
| `-localdb <path>` | SQLite path | | `-localdb <path>` | Path to SQLite DB |
| `-reset-localdb` | destructive local DB reset | | `-reset-localdb` | Reset local DB (destructive!) |
| `-migrate` | apply server migrations and exit | | `-migrate` | Apply pending migrations and exit |
| `-version` | print app version and exit | | `-version` | Print version and exit |
## First run ---
1. runtime ensures `config.yaml` exists; ## Installation and First Run
2. runtime opens the local SQLite database;
3. if no stored MariaDB credentials exist, `/setup` is served; ### Requirements
4. after setup, runtime works locally and sync uses saved DB settings in the background. - Go 1.22 or higher
- MariaDB 11.x (or MySQL 8.x)
- ~50 MB disk space
### Steps
```bash
# 1. Clone the repository
git clone <repo-url>
cd quoteforge
# 2. Apply migrations
go run ./cmd/qfs -migrate
# 3. Start
go run ./cmd/qfs
# or
make run
```
Application is available at: http://localhost:8080
On first run, `/setup` opens for configuring the MariaDB connection.
### OPS Project Migrator
Migrates quotes whose names start with `OPS-xxxx` (where `x` is a digit) into a project named `OPS-xxxx`.
```bash
# Preview first (always)
go run ./cmd/migrate_ops_projects
# Apply
go run ./cmd/migrate_ops_projects -apply
# Apply without interactive confirmation
go run ./cmd/migrate_ops_projects -apply -yes
```
---
## Docker
```bash
docker build -t quoteforge .
docker-compose up -d
```

View File

@@ -1,55 +1,227 @@
# 06 - Backup # 06 Backup
## Scope ## Overview
QuoteForge creates rotating local ZIP backups of: Automatic rotating ZIP backup system for local data.
- a consistent SQLite snapshot saved as `qfs.db`;
- `config.yaml` when present.
The backup intentionally does not include `local_encryption.key`. **What is included in each archive:**
- SQLite DB (`qfs.db`)
- SQLite sidecars (`qfs.db-wal`, `qfs.db-shm`) if present
- `config.yaml` if present
## Location and naming **Archive name format:** `qfs-backp-YYYY-MM-DD.zip`
Default root:
- `<db dir>/backups`
Subdirectories:
- `daily/`
- `weekly/`
- `monthly/`
- `yearly/`
Archive name:
- `qfs-backp-YYYY-MM-DD.zip`
## Retention
**Retention policy:**
| Period | Keep | | Period | Keep |
| --- | --- | |--------|------|
| Daily | 7 | | Daily | 7 archives |
| Weekly | 4 | | Weekly | 4 archives |
| Monthly | 12 | | Monthly | 12 archives |
| Yearly | 10 | | Yearly | 10 archives |
**Directories:** `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`
---
## Configuration
```yaml
backup:
time: "00:00" # Trigger time in local time (HH:MM format)
```
**Environment variables:**
- `QFS_BACKUP_DIR` — backup root directory (default: `<db dir>/backups`)
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`)
**Safety rules:**
- Backup root must resolve outside any git worktree.
- If `qfs.db` is placed inside a repository checkout, default backups are rejected until `QFS_BACKUP_DIR` points outside the repo.
- Backup archives intentionally do **not** include `local_encryption.key`; restored installations on another machine must re-enter DB credentials.
---
## Behavior ## Behavior
- on startup, QuoteForge creates a backup if the current period has none yet; - **At startup:** if no backup exists for the current period, one is created immediately
- a daily scheduler creates the next backup at `backup.time`; - **Daily:** at the configured time, a new backup is created
- duplicate snapshots inside the same period are prevented by a period marker file; - **Deduplication:** prevented via a `.period.json` marker file in each period directory
- old archives are pruned automatically. - **Rotation:** excess old archives are deleted automatically
## Safety rules ---
- backup root must be outside the git worktree; ## Implementation
- backup creation is blocked if the resolved backup root sits inside the repository;
- SQLite snapshot must be created from a consistent database copy, not by copying live WAL files directly;
- restore to another machine requires re-entering DB credentials unless the encryption key is migrated separately.
## Restore Module: `internal/appstate/backup.go`
1. stop QuoteForge; Main function:
2. unpack the chosen archive outside the repository; ```go
3. replace `qfs.db`; func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error)
4. replace `config.yaml` if needed; ```
5. restart the app;
6. re-enter MariaDB credentials if the original encryption key is unavailable. Scheduler (in `main.go`):
```go
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string)
```
### Config struct
```go
type BackupConfig struct {
Time string `yaml:"time"`
}
// Default: "00:00"
```
---
## Implementation Notes
- `backup.time` is in **local time** without timezone offset parsing
- `.period.json` is the marker that prevents duplicate backups within the same period
- Archive filenames contain only the date; uniqueness is ensured by per-period directories + the period marker
- When changing naming or retention: update both the filename logic and the prune logic together
- Git worktree detection is path-based (`.git` ancestor check) and blocks backup creation inside the repo tree
---
## Full Listing: `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") },
},
}
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
if isBackupDisabled() || dbPath == "" {
return nil, nil
}
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, nil
}
root := resolveBackupRoot(dbPath)
now := time.Now()
created := make([]string, 0)
for _, period := range backupPeriods {
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
if err != nil {
return created, err
}
created = append(created, newFiles...)
}
return created, nil
}
```
---
## Full Listing: Scheduler Hook (`main.go`)
```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, minute = 0, 0
}
// Startup check: create backup immediately if none exists for current periods
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
slog.Error("local backup failed", "error", backupErr)
} else {
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 {
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
if !now.Before(target) {
target = target.Add(24 * time.Hour)
}
return target
}
```

View File

@@ -1,35 +1,141 @@
# 07 - Development # 07 Development
## Common commands ## Commands
```bash ```bash
# Run (dev)
go run ./cmd/qfs go run ./cmd/qfs
go run ./cmd/qfs -migrate make run
go run ./cmd/migrate_project_updated_at
# Build
make build-release # Optimized build with version info
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
# Cross-platform build
make build-all # Linux, macOS, Windows
make build-windows # Windows only
# Verification
go build ./cmd/qfs # Must compile without errors
go vet ./... # Linter
# Tests
go test ./... go test ./...
go vet ./... make test
make build-release
make install-hooks # Utilities
make install-hooks # Git hooks (block committing secrets)
make clean # Clean bin/
make help # All available commands
``` ```
---
## Code Style
- **Formatting:** `gofmt` (mandatory)
- **Logging:** `slog` only (structured logging to the binary's stdout/stderr). No `console.log` or any other logging in browser-side JS — the browser console is never used for diagnostics.
- **Errors:** explicit wrapping with context (`fmt.Errorf("context: %w", err)`)
- **Style:** no unnecessary abstractions; minimum code for the task
---
## Guardrails ## Guardrails
- run `gofmt` before commit; ### What Must Never Be Restored
- use `slog` for server logging;
- keep runtime business logic SQLite-only;
- limit MariaDB access to sync, setup, and migration tooling;
- keep `config.yaml` out of git and use `config.example.yaml` only as a template;
- update `bible-local/` in the same commit as architecture changes.
## Removed features that must not return The following components were **intentionally removed** and must not be brought back:
- cron jobs
- importer utility
- admin pricing UI/API
- alerts
- stock import
- admin pricing UI/API; ### Configuration Files
- alerts and notification workflows;
- stock import tooling;
- cron jobs;
- standalone importer utility.
## Release notes - `config.yaml` — runtime user file, **not stored in the repository**
- `config.example.yaml` — the only config template in the repo
Release history belongs under `releases/<version>/RELEASE_NOTES.md`. ### Sync and Local-First
Do not keep temporary change summaries in the repository root.
- Any sync changes must preserve local-first behavior
- Local CRUD must not be blocked when MariaDB is unavailable
- Runtime business code must not query MariaDB directly; all normal reads/writes go through SQLite snapshots
- Direct MariaDB access is allowed only in `internal/services/sync/*` and dedicated setup/migration tools under `cmd/`
- `connMgr.GetDB()` in handlers/services outside sync is a code review failure unless the code is strictly setup or operator tooling
- Local SQLite migrations must be implemented in code; do not add a server-side registry of client SQLite SQL patches
- Read-only local cache tables may be reset during startup recovery if migration fails; do not apply that strategy to user-authored tables like configurations, projects, pending changes, or connection settings
### Formats and UI
- **CSV export:** filename must use **project code** (`project.Code`), not project name
Format: `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
- **Breadcrumbs UI:** names longer than 16 characters must be truncated with an ellipsis
### Architecture Documentation
- **Every architectural decision must be recorded in `bible/`**
- The corresponding Bible file must be updated **in the same commit** as the code change
- On every user-requested commit, review and update the Bible in that same commit
---
## Common Tasks
### Add a Field to Configuration
1. Add the field to `LocalConfiguration` struct (`internal/models/`)
2. Add GORM tags for the DB column
3. Write a SQL migration (`migrations/`)
4. Update `ConfigurationToLocal` / `LocalToConfiguration` converters
5. Update API handlers and services
### Add a Field to Component
1. Add the field to `LocalComponent` struct (`internal/models/`)
2. Update the SQL query in `SyncComponents()`
3. Update the `componentRow` struct to match
4. Update converter functions
### Add a Pricelist Price Lookup
```go
// Modern pattern
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
if found && price > 0 {
// use price
}
```
---
## Known Gotchas
1. **`CurrentPrice` removed from components** — any code using it will fail to compile
2. **`HasPrice` filter removed** — `component.go ListComponents` no longer supports this filter
3. **Quote calculation:** always SQLite-only; do not add a live MariaDB fallback
4. **Items JSON:** prices are stored in the `items` field of the configuration, not fetched from components
5. **Migrations are additive:** already-applied migrations are skipped (checked by `id` in `local_schema_migrations`)
6. **`SyncedAt` removed:** last component sync time is now in `app_settings` (key=`last_component_sync`)
---
## Debugging Price Issues
**Problem: quote returns no prices**
1. Check that `pricelist_id` is set on the configuration
2. Check that pricelist items exist: `SELECT COUNT(*) FROM local_pricelist_items`
3. Check `lookupPriceByPricelistID()` in `quote.go`
4. Verify the correct source is used (estimate/warehouse/competitor)
**Problem: component sync not working**
1. Components sync as metadata only — no prices
2. Prices come via a separate pricelist sync
3. Check `SyncComponents()` and the MariaDB query
**Problem: configuration refresh does not update prices**
1. Refresh uses the latest estimate pricelist by default
2. Latest resolution ignores pricelists without items (`EXISTS local_pricelist_items`)
3. Old prices in `config.items` are preserved if a line item is not found in the pricelist
4. To force a pricelist update: set `configuration.pricelist_id`
5. In configurator, `Авто` must remain auto-mode (runtime resolved ID must not be persisted as explicit selection)

View File

@@ -1,160 +1,568 @@
# 09 - Vendor BOM # 09 Vendor Spec (BOM Import)
## Storage contract ## Overview
Vendor BOM is stored in `local_configurations.vendor_spec` and synced with `qt_configurations.vendor_spec`. The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a configuration. It maps vendor part numbers (PN) to internal LOT names using an active partnumber book (snapshot pulled from PriceForge), then aggregates quantities to populate the Estimate (cart).
Each row uses this canonical shape: ---
## Architecture
### Storage
| Data | Storage | Sync direction |
|------|---------|---------------|
| `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT, JSON-encoded) | Two-way via `pending_changes` |
| Partnumber book snapshots | `local_partnumber_books` + `local_partnumber_book_items` | Pull-only from PriceForge |
`vendor_spec` is a JSON array of `VendorSpecItem` objects stored inside the configuration row.
It syncs to MariaDB `qt_configurations.vendor_spec` via the existing pending_changes mechanism.
Legacy storage note:
- QuoteForge does not use `qt_bom`
- QuoteForge does not use `qt_lot_bundles`
- QuoteForge does not use `qt_lot_bundle_items`
The only canonical persisted BOM contract for QuoteForge is `qt_configurations.vendor_spec`.
### `vendor_spec` JSON Schema
```json
[
{
"sort_order": 10,
"vendor_partnumber": "ABC-123",
"quantity": 2,
"description": "...",
"unit_price": 4500.00,
"total_price": 9000.00,
"lot_mappings": [
{ "lot_name": "LOT_A", "quantity_per_pn": 1 },
{ "lot_name": "LOT_B", "quantity_per_pn": 2 }
]
}
]
```
`lot_mappings[]` is the canonical persisted LOT mapping list for a BOM row.
Each mapping entry stores:
- `lot_name`
- `quantity_per_pn` (how many units of this LOT are included in **one vendor PN**)
### PN → LOT Mapping Contract (single LOT, multiplier, bundle)
QuoteForge expects the server to return/store BOM rows (`vendor_spec`) using a single canonical mapping list:
- `lot_mappings[]` contains **all** LOT mappings for the PN row (single LOT and bundle cases alike)
- the list stores exactly what the user sees in BOM (LOT + "LOT в 1 PN")
- the DB contract does **not** split mappings into "base LOT" vs "bundle LOTs"
#### Final quantity contribution to Estimate
For one BOM row with vendor PN quantity `pn_qty`:
- each mapping contribution:
- `lot_qty = pn_qty * lot_mappings[i].quantity_per_pn`
#### Example: one PN maps to multiple LOTs
```json ```json
{ {
"sort_order": 10, "vendor_partnumber": "SYS-821GE-TNHR",
"vendor_partnumber": "ABC-123", "quantity": 3,
"quantity": 2,
"description": "row description",
"unit_price": 4500.0,
"total_price": 9000.0,
"lot_mappings": [ "lot_mappings": [
{ "lot_name": "LOT_A", "quantity_per_pn": 1 } { "lot_name": "CHASSIS_X13_8GPU", "quantity_per_pn": 1 },
{ "lot_name": "PS_3000W_Titanium", "quantity_per_pn": 2 },
{ "lot_name": "RAILKIT_X13", "quantity_per_pn": 1 }
] ]
} }
``` ```
Rules: This row contributes to Estimate:
- `lot_mappings[]` is the only persisted PN -> LOT mapping contract;
- QuoteForge does not use legacy BOM tables;
- apply flow rebuilds cart rows from `lot_mappings[]`.
## Partnumber books - `CHASSIS_X13_8GPU``3 * 1 = 3`
- `PS_3000W_Titanium``3 * 2 = 6`
- `RAILKIT_X13``3 * 1 = 3`
Partnumber books are pull-only snapshots from PriceForge. ---
Local tables: ## Partnumber Books (Snapshots)
- `local_partnumber_books`
- `local_partnumber_book_items`
Server tables: Partnumber books are immutable versioned snapshots of the global PN→LOT mapping table, analogous to pricelists. PriceForge creates new snapshots; QuoteForge only pulls and reads them.
- `qt_partnumber_books`
- `qt_partnumber_book_items`
Resolution flow: ### SQLite (local mirror)
1. load the active local book;
2. find `vendor_partnumber`;
3. copy `lots_json` into `lot_mappings[]`;
4. keep unresolved rows editable in the UI.
## CFXML import ```sql
CREATE TABLE local_partnumber_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER UNIQUE NOT NULL, -- id from qt_partnumber_books
version TEXT NOT NULL, -- format YYYY-MM-DD-NNN
created_at DATETIME NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1
);
`POST /api/projects/:uuid/vendor-import` imports one vendor workspace into an existing project. CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL,
lots_json TEXT NOT NULL,
description TEXT
);
CREATE UNIQUE INDEX idx_local_book_pn ON local_partnumber_book_items(partnumber);
```
**Active book query:** `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
**Schema creation:** GORM AutoMigrate (not `runLocalMigrations`).
### MariaDB (managed exclusively by PriceForge)
```sql
CREATE TABLE qt_partnumber_books (
id INT AUTO_INCREMENT PRIMARY KEY,
version VARCHAR(50) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active TINYINT(1) NOT NULL DEFAULT 1,
partnumbers_json LONGTEXT NOT NULL
);
CREATE TABLE qt_partnumber_book_items (
id INT AUTO_INCREMENT PRIMARY KEY,
partnumber VARCHAR(255) NOT NULL,
lots_json LONGTEXT NOT NULL,
description VARCHAR(10000) NULL,
UNIQUE KEY uq_qt_partnumber_book_items_partnumber (partnumber)
);
ALTER TABLE qt_configurations ADD COLUMN vendor_spec JSON NULL;
```
QuoteForge has `SELECT` permission only on `qt_partnumber_books` and `qt_partnumber_book_items`. All writes are managed by PriceForge.
**Grant (add to existing user setup):**
```sql
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
```
---
## Resolution Algorithm (3-step)
For each `vendor_partnumber` in the BOM, QuoteForge builds/updates UI-visible LOT mappings:
1. **Active book lookup** — read active `local_partnumber_books`, verify PN membership in `partnumbers_json`, then query `local_partnumber_book_items WHERE partnumber = ?`.
2. **Populate BOM UI** — if a match exists, BOM row gets `lot_mappings[]` from `lots_json` (user can still edit it).
3. **Unresolved** — red row + inline LOT input with strict autocomplete.
Persistence note: the application stores the final user-visible mappings in `lot_mappings[]` (not separate "resolved/manual" persisted fields).
---
## CFXML Workspace Import Contract
QuoteForge may import a vendor configurator workspace in `CFXML` format as an existing project update path.
This import path must convert one external workspace into one QuoteForge project containing multiple configurations.
### Import Unit Boundaries
- One `CFXML` workspace file = one QuoteForge project import session.
- One top-level configuration group inside the workspace = one QuoteForge configuration.
- Software rows are **not** imported as standalone configurations.
- All software rows must be attached to the configuration group they belong to.
### Configuration Grouping
Top-level `ProductLineItem` rows are grouped by:
- `ProprietaryGroupIdentifier`
This field is the canonical boundary of one imported configuration.
Rules: Rules:
- accepted file field is `file`;
- maximum file size is `1 GiB`;
- one `ProprietaryGroupIdentifier` becomes one QuoteForge configuration;
- software rows stay inside their hardware group and never become standalone configurations;
- primary group row is selected structurally, without vendor-specific SKU hardcoding;
- imported configuration order follows workspace order.
Imported configuration fields: 1. Read all top-level `ProductLineItem` rows in document order.
- `name` from primary row `ProductName` 2. Group them by `ProprietaryGroupIdentifier`.
- `server_count` from primary row `Quantity` 3. Preserve document order of groups by the first encountered `ProductLineNumber`.
- `server_model` from primary row `ProductDescription` 4. Import each group as exactly one QuoteForge configuration.
- `article` or `support_code` from `ProprietaryProductIdentifier`
Imported BOM rows become `vendor_spec` rows and are resolved through the active local partnumber book when possible. `ConfigurationGroupLineNumberReference` is not sufficient for grouping imported configurations because
multiple independent configuration groups may share the same value in one workspace.
## Inspur BOM import ### Primary Row Selection (no SKU hardcode)
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts Inspur text BOM exports. The importer must not hardcode vendor, model, or server SKU values.
Format: one component per line, `<partnumber>*<quantity>`. A leading `|` character is optional and stripped. Trailing whitespace around `*` is normalised. Within each `ProprietaryGroupIdentifier` group, the importer selects one primary top-level row using
structural rules only:
Example: 1. Prefer rows with `ProductTypeCode = Hardware`.
``` 2. If multiple rows match, prefer the row with the largest number of `ProductSubLineItem` children.
|CPU_AMD_9535-EPYC2.4_64C_256M_300W*1 3. If there is still a tie, prefer the first row by `ProductLineNumber`.
|PowerSupply_1300W_Titanium_220VACor240VDC_GaN*2
The primary row provides configuration-level metadata such as:
- configuration name
- server count
- server model / description
- article / support code candidate
### Software Inclusion Rule
All top-level rows belonging to the same `ProprietaryGroupIdentifier` must be imported into the same
QuoteForge configuration, including:
- `Hardware`
- `Software`
- instruction / service rows represented as software-like items
Effects:
- a workspace never creates a separate configuration made only of software;
- `software1`, `software2`, license rows, and instruction rows stay inside the related configuration;
- the user sees one complete configuration instead of fragmented partial imports.
### Mapping to QuoteForge Project / Configuration
For one imported configuration group:
- QuoteForge configuration `name` <- primary row `ProductName`
- QuoteForge configuration `server_count` <- primary row `Quantity`
- QuoteForge configuration `server_model` <- primary row `ProductDescription`
- QuoteForge configuration `article` or `support_code` <- primary row `ProprietaryProductIdentifier`
- QuoteForge configuration `line` <- stable order by group appearance in the workspace
Project-level fields such as QuoteForge `code`, `name`, and `variant` are not reliably defined by `CFXML`
itself and should come from the existing target project context or explicit user input.
### Mapping to `vendor_spec`
The importer must build one combined `vendor_spec` array per configuration group.
Source rows:
- all `ProductSubLineItem` rows from the primary top-level row;
- all `ProductSubLineItem` rows from every non-primary top-level row in the same group;
- if a top-level row has no `ProductSubLineItem`, the top-level row itself may be converted into one
`vendor_spec` row so that software-only content is not lost.
Each imported row maps into one `VendorSpecItem`:
- `sort_order` <- stable sequence within the group
- `vendor_partnumber` <- `ProprietaryProductIdentifier`
- `quantity` <- `Quantity`
- `description` <- `ProductDescription`
- `unit_price` <- `UnitListPrice.FinancialAmount.MonetaryAmount` when present
- `total_price` <- `quantity * unit_price` when unit price is present
- `lot_mappings` <- resolved immediately from the active partnumber book using `lots_json`
The importer stores vendor-native rows in `vendor_spec`, then immediately runs the same logical flow as BOM
Resolve + Apply:
- resolve vendor PN rows through the active partnumber book
- persist canonical `lot_mappings[]`
- build normalized configuration `items` from `row.quantity * quantity_per_pn`
- fill `items.unit_price` from the latest local `estimate` pricelist
- recalculate configuration `total_price`
### Import Pipeline
Recommended parser pipeline:
1. Parse XML into top-level `ProductLineItem` rows.
2. Group rows by `ProprietaryGroupIdentifier`.
3. Select one primary row per group using structural rules.
4. Build one QuoteForge configuration DTO per group.
5. Merge all hardware/software rows of the group into one `vendor_spec`.
6. Resolve imported PN rows into canonical `lot_mappings[]` using the active partnumber book.
7. Build configuration `items` from resolved `lot_mappings[]`.
8. Price those `items` from the latest local `estimate` pricelist.
9. Save or update the QuoteForge configuration inside the existing project.
### Recommended Internal DTO
```go
type ImportedProject struct {
SourceFormat string
SourceFilePath string
SourceDocID string
Code string
Name string
Variant string
Configurations []ImportedConfiguration
}
type ImportedConfiguration struct {
GroupID string
Name string
Line int
ServerCount int
ServerModel string
Article string
SupportCode string
CurrencyCode string
TopLevelRows []ImportedTopLevelRow
VendorSpec []ImportedVendorRow
}
type ImportedTopLevelRow struct {
ProductLineNumber string
ItemNo string
GroupID string
ProductType string
ProductCode string
ProductName string
Description string
Quantity int
UnitPrice *float64
IsPrimary bool
SubRows []ImportedVendorRow
}
type ImportedVendorRow struct {
SortOrder int
SourceLineNumber string
SourceParentLine string
SourceProductType string
VendorPartnumber string
Description string
Quantity int
UnitPrice *float64
TotalPrice *float64
ProductCharacter string
ProductCharPath string
}
``` ```
Rules: ### Current Product Assumption
- the entire file becomes a single configuration (`server_count = 1`);
- configuration `name` is derived from the uploaded filename (without extension);
- lines that do not contain `*<digits>` are skipped;
- no price data is present in the format; `unit_price` and `total_price` are left nil.
## Text BOM import For QuoteForge product behavior, the correct user-facing interpretation is:
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a human-readable Russian text BOM. - one external project/workspace contains several configurations;
- each configuration contains both hardware and software rows that belong to it;
- the importer must preserve that grouping exactly.
Format: an optional header line ending with `, в составе:` followed by one component per line as ---
`<description> - <quantity> шт.`. The separator may be a hyphen, en-dash, or em-dash; the space before
`шт` is optional; a trailing `.` after `шт` is optional. Quantities are anchored to the end of the line, ## Qty Aggregation Logic
so hyphens, commas, and digits inside the description (e.g. `8-GPU-2304GB`, `RAID0,1,10`) are preserved.
After resolution, qty per LOT is computed from the BOM row quantity multiplied by the matched `lots_json.qty`:
Example:
``` ```
Вычислительный GPU сервер G5500V7, в составе: qty(lot) = SUM(quantity_of_pn_row * quantity_of_lot_inside_lots_json)
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
NVIDIA twin port transceiver, 800Gbps, OSFP - 8шт.
``` ```
Rules: Examples (book: PN_X → `[{LOT_A, qty:2}, {LOT_B, qty:1}]`):
- the entire file becomes a single configuration (`server_count = 1`); - BOM: PN_X ×3 → `LOT_A ×6`, `LOT_B ×3`
- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the - BOM: PN_X ×1 and PN_X ×2 → `LOT_A ×6`, `LOT_B ×3`
last whitespace-separated token before the comma (so both `Сервер X3` and `Вычислительный GPU сервер X3`
resolve to `X3`);
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
- each line is trimmed, so leading/trailing whitespace never enters `vendor_partnumber`;
- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and
`description`, so rows resolve through the active partnumber book when matched and otherwise stay
unresolved and editable in the UI;
- lines that do not match `<description> - <quantity> шт.` are skipped;
- no price data is present in the format; `unit_price` and `total_price` are left nil.
## Nx BOM import (quantity-first) ---
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a quantity-first BOM ## UI: Three Top-Level Tabs
where each item line begins with `<qty>x <description>`.
Format: an optional header line ending with `, в составе:` followed by one component per line as The configurator (`/configurator`) has three tabs:
`<qty>x <description>`. The `x` separator is case-insensitive; parentheses, commas, and hyphens
inside the description are preserved as-is. 1. **Estimate** — existing cart/component configurator (unchanged).
2. **BOM** — paste/import vendor BOM, manual column mapping, LOT matching, bundle decomposition (`1 PN -> multiple LOTs`), "Пересчитать эстимейт", "Очистить".
3. **Ценообразование** — pricing summary table + custom price input.
BOM data is shared between tabs 2 and 3.
### BOM Import UI (raw table, manual column mapping)
After paste (`Ctrl+V`) QuoteForge renders an editable raw table (not auto-detected parsing).
- The pasted rows are shown **as-is** (including header rows, if present).
- The user selects a type for each column manually:
- `P/N`
- `Кол-во`
- `Цена`
- `Описание`
- `Не использовать`
- Required mapping:
- exactly one `P/N`
- exactly one `Кол-во`
- Optional mapping:
- `Цена` (0..1)
- `Описание` (0..1)
- Rows can be:
- ignored (UI-only, excluded from `vendor_spec`)
- deleted
- Raw cells are editable inline after paste.
Notes:
- There is **no auto column detection**.
- There is **no auto header-row skip**.
- Raw import layout itself is not stored on server; only normalized `vendor_spec` is stored.
### LOT matching in BOM table
The BOM table adds service columns on the right:
- `LOT`
- `LOT в 1 PN`
- actions (`+`, ignore, delete)
`LOT` behavior:
- The first LOT row shown in the BOM UI is the primary LOT mapping for the PN row.
- Additional LOT rows are added via the `+` action.
- inline LOT input is strict:
- autocomplete source = full local components list (`/api/components?per_page=5000`)
- free text that does not match an existing LOT is rejected
`LOT в 1 PN` behavior:
- quantity multiplier for each visible LOT row in BOM (`quantity_per_pn` in persisted `lot_mappings[]`)
- default = `1`
- editable inline
### Bundle mode (`1 PN -> multiple LOTs`)
The `+` action in BOM rows adds an extra LOT mapping row for the same vendor PN row.
- All visible LOT rows (first + added rows) are persisted uniformly in `lot_mappings[]`
- Each mapping row has:
- LOT
- qty (`LOT in 1 PN` = `quantity_per_pn`)
### BOM restore on config open
On config open, QuoteForge loads `vendor_spec` from server and reconstructs the editable BOM table in normalized form:
- columns restored as: `Qty | P/N | Description | Price`
- column mapping restored as:
- `qty`, `pn`, `description`, `price`
- LOT / `LOT в 1 PN` rows are restored from `vendor_spec.lot_mappings[]`
This restores the BOM editing state, but not the original raw Excel layout (extra columns, ignored rows, original headers).
### Pricing Tab: column order
Example:
``` ```
Сервер G893-SD1-AAX3, в составе: LOT | PN вендора | Описание | Кол-во | Estimate | Цена вендора | Склад | Конк.
1x 8U 2CPU 8GPU Server System (32x DDR5 DIMM Slots,8x 2.5" Hot-Swap Drive Bays, 4+4 3000W, 2x 10Gb/s RJ45, 2x IPMI RJ45)
2x Intel Xeon 8570 (56 cores, 2.1GHz, 300MB, 350W)
32x 64GB DDR5 ECC RDIMM
1x GPU Nvidia HGX H200 141GB 8GPU
3x 1.92TB NVMe PCIe SFF RI
5x 7.68TB NVMe PCIe SFF RI
8x 1-port 400G NDR OSFP CX7
2x 2-port 100GbE QSFP56 CX6
1x 2-port 10GbE RJ45
``` ```
Rules: **If BOM is empty** — the pricing tab still renders, using cart items as rows (PN вендора = "—", Цена вендора = "—").
- the entire file becomes a single configuration (`server_count = 1`);
- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the
last whitespace-separated token before the comma;
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and
`description`, so rows resolve through the active partnumber book when matched and otherwise stay
unresolved and editable in the UI;
- lines that do not match `<qty>x <description>` are skipped;
- no price data is present in the format; `unit_price` and `total_price` are left nil;
- detection runs before Text BOM in the format switch (Inspur → Nx → Text).
## Pasted BOM text parsing **Description source priority:** BOM row description → LOT description from `local_components`.
`POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM ### Pricing Tab: BOM + Estimate merge behavior
(Inspur and Russian text BOM) into rows. Request body: `{"text": "..."}`. Response:
`{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`.
This shares the exact detectors and parsers used by the file-import path When BOM exists, the pricing tab renders:
(`ParsePastedBOMText``IsInspurBOM`/`parseInspurBOM`, `IsNxBOM`/`parseNxBOM`, `IsTextBOM`/`parseTextBOM`),
so paste and upload behave identically — there is no second parser in the frontend. The configurator's BOM - BOM-based rows (including rows resolved via manual LOT and bundle mappings)
paste box calls this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real - plus **Estimate-only LOTs** (rows currently in cart but not covered by BOM mappings)
spreadsheet table) falls back to the manual column-mapping grid.
Estimate-only rows are shown as separate rows with:
- `PN вендора = "—"`
- vendor price = `—`
- description from local components
### Pricing Tab: "Своя цена" input
- Manual entry → proportionally redistributes custom price into "Цена вендора" cells (proportional to each row's Estimate share). Last row absorbs rounding remainder.
- "Проставить цены BOM" button → restores per-row original BOM prices directly (no proportional redistribution). Sets "Своя цена" to their sum.
- Both paths show "Скидка от Estimate: X%" info.
- "Экспорт CSV" button → downloads `pricing_<uuid>.csv` with UTF-8 BOM, same column order as table, plus Итого row.
---
## API Endpoints
| Method | URL | Description |
|--------|-----|-------------|
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored BOM |
| PUT | `/api/configs/:uuid/vendor-spec` | Replace BOM (full update) |
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (no cart mutation) |
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
| POST | `/api/projects/:uuid/vendor-import` | Import `CFXML` workspace into an existing project and create grouped configurations |
| GET | `/api/partnumber-books` | List local book snapshots |
| GET | `/api/partnumber-books/:id` | Items for a book by server_id |
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
| POST | `/api/sync/partnumber-seen` | Push unresolved PNs to `qt_vendor_partnumber_seen` on MariaDB |
## Unresolved PN Tracking (`qt_vendor_partnumber_seen`)
After each `resolveBOM()` call, QuoteForge pushes PN rows to `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored):
- unresolved BOM rows (`ignored = false`)
- raw BOM rows explicitly marked as ignored in UI (`ignored = true`) — these rows are **not** saved to `vendor_spec`, but are reported for server-side tracking
The handler calls `sync.PushPartnumberSeen()` which inserts into `qt_vendor_partnumber_seen`.
If a row with the same `partnumber` already exists, QuoteForge must leave it untouched:
- do not update `last_seen_at`
- do not update `is_ignored`
- do not update `description`
Canonical insert behavior:
```sql
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, last_seen_at)
VALUES ('manual', '', ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
partnumber = partnumber
```
Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge uses this table for populating the partnumber book.
Partnumber book sync contract:
- PriceForge writes membership snapshots to `qt_partnumber_books.partnumbers_json`.
- PriceForge writes canonical PN payloads to `qt_partnumber_book_items`.
- QuoteForge syncs book headers first, then pulls PN payloads with:
`SELECT partnumber, lots_json, description FROM qt_partnumber_book_items WHERE partnumber IN (...)`
## BOM Persistence
- `vendor_spec` is saved to server via `PUT /api/configs/:uuid/vendor-spec`.
- `GET` / `PUT` `vendor_spec` must preserve row-level mapping fields used by the UI:
- `lot_mappings[]`
- each item: `lot_name`, `quantity_per_pn`
- `description` is persisted in each BOM row and is used by the Pricing tab when available.
- Ignored raw rows are **not** persisted into `vendor_spec`.
- The PUT handler explicitly marshals `VendorSpec` to JSON string before passing to GORM `Update` (GORM does not reliably call `driver.Valuer` for custom types in `Update(column, value)`).
- BOM is autosaved (debounced) after BOM-changing actions, including:
- `resolveBOM()`
- LOT row qty (`LOT в 1 PN`) changes
- LOT row add/remove (`+` / delete in bundle context)
- "Сохранить BOM" button triggers explicit save.
## Pricing Tab: Estimate Price Source
`renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with LOTs collected from:
- `lot_mappings[]` from BOM rows
- current Estimate/cart LOTs not covered by BOM mappings (to show estimate-only rows)
This ensures Estimate prices appear for:
- manually matched LOTs in the BOM tab
- bundle LOTs
- LOTs already present in Estimate but not mapped from BOM
### Apply to Estimate (`Пересчитать эстимейт`)
When applying BOM to Estimate, QuoteForge builds cart rows from explicit UI mappings stored in `lot_mappings[]`.
For a BOM row with PN qty = `Q`:
- each mapped LOT contributes `Q * quantity_per_pn`
Rows without any valid LOT mapping are skipped.
## Web Route
| Route | Page |
|-------|------|
| `/partnumber-books` | Partnumber books — active book summary (unique LOTs, total PN, primary PN count), searchable items table, collapsible snapshot history |

View File

@@ -1,563 +0,0 @@
# 10 - Agent API Guide: Pricing Servers from a TZ
This guide is written for an AI agent that needs to price a server configuration
(техническое задание, ТЗ) using the QuoteForge HTTP API.
## Runtime assumptions
- QuoteForge runs locally, binds to `127.0.0.1:8080` by default.
- No authentication is required — the app is single-user, loopback-only.
- All responses are JSON. All request bodies are JSON unless stated otherwise.
- The port can be overridden with the `QF_SERVER_PORT` environment variable.
Base URL for all examples: `http://127.0.0.1:8080`
---
## Configuration composition rules
These rules are mandatory and must be respected before saving any configuration.
### 1. Every configuration must belong to a project
Configurations cannot be created in isolation. The correct sequence is:
1. Create a project (`POST /api/projects`) and save the returned `uuid`.
2. Create the configuration inside that project by passing `project_uuid` in the
config body, or by using `POST /api/projects/:uuid/configs`.
If the project for a given TZ already exists, retrieve its `uuid` first:
```
GET /api/projects?page=1&per_page=100
```
then pass the matching `uuid` in `project_uuid`.
### 2. Every server configuration must contain all four required component groups
A configuration is not valid for pricing unless items from all four of the
following category groups are present:
| Category code | Meaning | Notes |
|---------------|------------------|---------------------------------------------------|
| `MB` | Motherboard | exactly one MB per configuration |
| `CPU` | Processor | one or more CPUs |
| `MEM` | Memory / RAM | one or more memory modules |
| `PS` / `PSU` | Power supply | `PSU` is the current code; `PS` is legacy — both are accepted |
Before saving, verify the assembled BOM with `POST /api/quote/validate`:
the response `errors` array will contain `"Component not found: …"` entries
for unknown lot names, and `warnings` will list lots without a price.
Reject the configuration and report back to the user if any of the four
required categories is missing.
### 3. Category codes to use when searching
Use `category=<code>` in `GET /api/components` to narrow results:
```
GET /api/components?category=MB&search=X13&has_price=true
GET /api/components?category=CPU&search=Xeon+Gold&has_price=true
GET /api/components?category=MEM&search=32GB+DDR5&has_price=true
GET /api/components?category=PSU&search=800W&has_price=true
```
Retrieve the full list of active categories at any time:
```
GET /api/categories
```
---
## Typical workflow for pricing a server
```
1. Check the app is up GET /api/ping
2. Find or create a project GET /api/projects → POST /api/projects
3. Find the latest pricelist GET /api/pricelists/latest?source=estimate
4. Look up lot names for MB GET /api/components?category=MB&search=…
5. Look up lot names for CPU GET /api/components?category=CPU&search=…
6. Look up lot names for MEM GET /api/components?category=MEM&search=…
7. Look up lot names for PSU GET /api/components?category=PSU&search=…
8. (Repeat for other components) GET /api/components?category=…&search=…
9. Validate and calculate the quote POST /api/quote/validate
10. (Optional) Compare price tiers POST /api/quote/price-levels
11. Save configuration in the project POST /api/projects/:uuid/configs
```
---
## Step 1 — Verify the app is running
```
GET /api/ping
```
Response `200 OK`:
```json
{"status": "ok"}
```
---
## Step 2 — Find or create a project
Each TZ maps to one project. Use the TZ identifier as the `code` field.
### Find an existing project
```
GET /api/projects?page=1&per_page=100
```
Response `200 OK`:
```json
{
"projects": [
{
"uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"code": "TZ-123",
"variant": "",
"name": "Проект по ТЗ №123",
"tracker_url": "",
"is_active": true,
"created_at": "2026-06-01T00:00:00Z"
}
],
"total": 1,
"page": 1,
"per_page": 100
}
```
### Create a new project
```
POST /api/projects
Content-Type: application/json
```
Request body:
```json
{
"code": "TZ-123",
"name": "Проект по ТЗ №123",
"tracker_url": ""
}
```
Fields:
| field | type | required | description |
|---------------|--------|----------|--------------------------------------------------------------------|
| `code` | string | yes | short identifier, unique per variant; use the TZ number or ticket |
| `variant` | string | no | variant label within the same `code`; default is empty string |
| `name` | string | no | human-readable title |
| `tracker_url` | string | no | link to a ticket or issue tracker |
Response `201 Created`:
```json
{
"uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"code": "TZ-123",
"variant": "",
"name": "Проект по ТЗ №123",
"is_active": true,
"created_at": "2026-06-11T10:00:00Z"
}
```
Save the `uuid` — it is required to create configurations inside this project.
---
## Step 3 — Find the latest pricelist
QuoteForge maintains three pricing tiers. The `source` values are:
| source | meaning |
|--------------|-----------------------------|
| `estimate` | list / catalogue price |
| `warehouse` | stock price (purchase cost) |
| `competitor` | competitor reference price |
```
GET /api/pricelists/latest?source=estimate
```
Response `200 OK`:
```json
{
"id": 42,
"source": "estimate",
"version": "2026-05-28",
"item_count": 12500,
"is_active": true,
"created_at": "2026-05-28T06:00:00Z"
}
```
The `id` field is a numeric pricelist identifier. Pass it as `pricelist_id`
when calculating a quote to pin pricing to a specific pricelist.
To list all available pricelists:
```
GET /api/pricelists?source=estimate&active_only=true
```
---
## Steps 48 — Look up component lot names
Each component is identified by a `lot_name` (internal SKU). The TZ typically
contains model names or descriptions; use the search endpoint to resolve them.
```
GET /api/components?search=Xeon+Gold+6342&category=CPU&has_price=true&page=1&per_page=20
```
Query parameters:
| parameter | default | description |
|------------------|---------|---------------------------------------------------|
| `search` | — | free-text search in lot name and description |
| `category` | — | filter by category code (`MB`, `CPU`, `MEM`, `PSU`, …) |
| `has_price` | false | return only components that have a price |
| `include_hidden` | false | include hidden/retired components |
| `page` | 1 | page number |
| `per_page` | 20 | page size |
Response `200 OK`:
```json
{
"components": [
{
"lot_name": "CPU-XEON-6342",
"description": "Intel Xeon Gold 6342, 24C/48T, 2.8 GHz, LGA4189",
"category": "CPU",
"category_name": "CPU",
"model": "Xeon Gold 6342"
}
],
"total": 1,
"page": 1,
"per_page": 20
}
```
To look up a single component by exact lot name:
```
GET /api/components/CPU-XEON-6342
```
To list all known categories:
```
GET /api/categories
```
---
## Step 9 — Validate and calculate the quote
Before saving, validate the assembled BOM. This catches unknown lot names and
missing prices, and also confirms that all required categories are covered.
```
POST /api/quote/validate
Content-Type: application/json
```
Request body:
```json
{
"items": [
{"lot_name": "MB-X13DAI-N", "quantity": 1},
{"lot_name": "CPU-XEON-6342", "quantity": 2},
{"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8},
{"lot_name": "SSD-480GB-SATA", "quantity": 2},
{"lot_name": "PSU-800W-TITANIUM", "quantity": 2}
],
"pricelist_id": 42
}
```
Response `200 OK`:
```json
{
"valid": true,
"items": [
{
"lot_name": "MB-X13DAI-N",
"quantity": 1,
"unit_price": 95000.00,
"total_price": 95000.00,
"description": "Supermicro X13DAi-N dual-socket server board",
"category": "MB",
"has_price": true
},
{
"lot_name": "CPU-XEON-6342",
"quantity": 2,
"unit_price": 87500.00,
"total_price": 175000.00,
"description": "Intel Xeon Gold 6342, 24C/48T, 2.8 GHz",
"category": "CPU",
"has_price": true
},
{
"lot_name": "RAM-32GB-DDR4-3200",
"quantity": 8,
"unit_price": 12000.00,
"total_price": 96000.00,
"description": "32 GB DDR4-3200 ECC RDIMM",
"category": "MEM",
"has_price": true
},
{
"lot_name": "PSU-800W-TITANIUM",
"quantity": 2,
"unit_price": 18500.00,
"total_price": 37000.00,
"description": "800W 80+ Titanium redundant PSU",
"category": "PSU",
"has_price": true
}
],
"errors": [],
"warnings": [],
"total": 403000.00
}
```
**Agent check after validation:**
1. `valid` must be `true` — all lot names resolved.
2. `errors` must be empty — no unknown components.
3. The returned `items` array must contain at least one entry from each required
category: `MB`, `CPU`, `MEM`, and `PS` or `PSU`.
4. Items with `has_price: false` are allowed but should be flagged to the user.
If any check fails, do not save the configuration. Report the issue and ask the
user to clarify or replace the problematic component.
For simple price totals without validation metadata use `POST /api/quote/calculate`
— identical request body, response contains only `items` and `total`.
---
## Step 10 (optional) — Compare price tiers
To see estimate, warehouse, and competitor prices side-by-side for a BOM:
```
POST /api/quote/price-levels
Content-Type: application/json
```
Request body:
```json
{
"items": [
{"lot_name": "CPU-XEON-6342", "quantity": 2},
{"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8}
],
"pricelist_ids": {
"estimate": 42,
"warehouse": 31,
"competitor": 15
},
"no_cache": false
}
```
`pricelist_ids` is optional. When omitted the latest pricelist for each source
is used automatically.
Response `200 OK`:
```json
{
"items": [
{
"lot_name": "CPU-XEON-6342",
"quantity": 2,
"estimate_price": 87500.00,
"warehouse_price": 71000.00,
"competitor_price": 85000.00,
"delta_wh_estimate_abs": -16500.00,
"delta_wh_estimate_pct": -18.86,
"delta_comp_estimate_abs": -2500.00,
"delta_comp_estimate_pct": -2.86,
"delta_comp_wh_abs": 14000.00,
"delta_comp_wh_pct": 19.72,
"price_missing": []
}
],
"resolved_pricelist_ids": {
"estimate": 42,
"warehouse": 31,
"competitor": 15
}
}
```
`price_missing` lists the source names for which no price was found for that lot.
Delta fields are `null` when either operand price is missing.
---
## Step 11 — Save a configuration inside the project
Use the project-scoped endpoint so the configuration is immediately linked to
the correct project without a separate move operation.
```
POST /api/projects/:project_uuid/configs
Content-Type: application/json
```
The request body is identical to `POST /api/configs` — the `project_uuid` field
in the body is ignored when using the project-scoped route; the URL parameter
takes precedence.
Request body:
```json
{
"name": "Сервер по ТЗ №123 — вариант А",
"items": [
{"lot_name": "MB-X13DAI-N", "quantity": 1, "unit_price": 95000.00},
{"lot_name": "CPU-XEON-6342", "quantity": 2, "unit_price": 87500.00},
{"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8, "unit_price": 12000.00},
{"lot_name": "SSD-480GB-SATA", "quantity": 2, "unit_price": 8500.00},
{"lot_name": "PSU-800W-TITANIUM", "quantity": 2, "unit_price": 18500.00}
],
"server_model": "2U",
"support_code": "NBD",
"server_count": 1,
"pricelist_id": 42,
"warehouse_pricelist_id": 31,
"competitor_pricelist_id": 15,
"config_type": "server",
"notes": "Автоматически создано агентом на основании ТЗ №123"
}
```
Key fields:
| field | type | required | description |
|--------------------------|--------|----------|-----------------------------------------------------|
| `name` | string | yes | human-readable name |
| `items` | array | yes | `{lot_name, quantity, unit_price}` from validate |
| `server_model` | string | no | chassis/form-factor code; used for article generation |
| `support_code` | string | no | support tier code; used for article generation |
| `server_count` | int | no | number of identical servers; total is multiplied |
| `pricelist_id` | uint | no | estimate pricelist to attach |
| `warehouse_pricelist_id` | uint | no | warehouse pricelist to attach |
| `competitor_pricelist_id`| uint | no | competitor pricelist to attach |
| `config_type` | string | no | `"server"` (default) or `"storage"` |
| `notes` | string | no | free text |
| `custom_price` | float | no | override total price |
| `disable_price_refresh` | bool | no | prevent automatic price refresh on open |
| `only_in_stock` | bool | no | filter to in-stock components only |
Response `201 Created`:
```json
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "Сервер по ТЗ №123 — вариант А",
"items": [...],
"total_price": 403000.00,
"server_count": 1,
"config_type": "server",
"article": "2U-6342x2-32GBx8-NBD",
"project_uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"created_at": "2026-06-11T10:00:00Z"
}
```
The `uuid` can be used for all subsequent operations on this configuration.
---
## Working with saved configurations
```
GET /api/configs/:uuid — retrieve a saved configuration
PUT /api/configs/:uuid — full update (same body as create)
POST /api/configs/:uuid/refresh-prices — re-price from latest pricelist
POST /api/configs/:uuid/clone — duplicate: body {"name": "clone name"}
GET /api/configs/:uuid/versions — revision history
GET /api/configs?page=1&per_page=20 — list all configurations
```
---
## Error responses
All error responses follow the same shape:
```json
{"error": "human-readable message"}
```
Common status codes:
| code | meaning |
|------|-------------------------------------------------------|
| 400 | invalid request body or validation failure |
| 404 | entity (component, pricelist, config) not found |
| 423 | sync readiness is blocked; retry after sync completes |
| 500 | internal server error |
---
## Minimal end-to-end example
```bash
BASE=http://127.0.0.1:8080
# 1. Verify the app is up
curl -s $BASE/api/ping
# 2. Create a project for this TZ
PROJECT_UUID=$(curl -s -X POST $BASE/api/projects \
-H "Content-Type: application/json" \
-d '{"code": "TZ-123", "name": "Проект по ТЗ №123"}' | jq -r .uuid)
# 3. Get latest estimate pricelist
PRICELIST_ID=$(curl -s "$BASE/api/pricelists/latest?source=estimate" | jq .id)
# 4. Find lot names for required categories
curl -s "$BASE/api/components?category=MB&search=X13&has_price=true" | jq '.components[].lot_name'
curl -s "$BASE/api/components?category=CPU&search=Xeon&has_price=true" | jq '.components[].lot_name'
curl -s "$BASE/api/components?category=MEM&search=32GB&has_price=true" | jq '.components[].lot_name'
curl -s "$BASE/api/components?category=PSU&search=800W&has_price=true" | jq '.components[].lot_name'
# 5. Validate the BOM (must contain MB, CPU, MEM, PSU/PS)
curl -s -X POST $BASE/api/quote/validate \
-H "Content-Type: application/json" \
-d "{
\"pricelist_id\": $PRICELIST_ID,
\"items\": [
{\"lot_name\": \"MB-X13DAI-N\", \"quantity\": 1},
{\"lot_name\": \"CPU-XEON-6342\", \"quantity\": 2},
{\"lot_name\": \"RAM-32GB-DDR4-3200\", \"quantity\": 8},
{\"lot_name\": \"PSU-800W-TITANIUM\", \"quantity\": 2}
]
}" | jq '{valid, errors, warnings, total}'
# 6. Save the configuration inside the project
curl -s -X POST "$BASE/api/projects/$PROJECT_UUID/configs" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Сервер по ТЗ №123\",
\"pricelist_id\": $PRICELIST_ID,
\"server_model\": \"2U\",
\"server_count\": 1,
\"config_type\": \"server\",
\"items\": [
{\"lot_name\": \"MB-X13DAI-N\", \"quantity\": 1, \"unit_price\": 95000},
{\"lot_name\": \"CPU-XEON-6342\", \"quantity\": 2, \"unit_price\": 87500},
{\"lot_name\": \"RAM-32GB-DDR4-3200\", \"quantity\": 8, \"unit_price\": 12000},
{\"lot_name\": \"PSU-800W-TITANIUM\", \"quantity\": 2, \"unit_price\": 18500}
]
}" | jq '{uuid, total_price, article}'
```

View File

@@ -1,161 +0,0 @@
# 11 - Lot Suggestions (qt_vendor_partnumber_seen)
## Purpose
`qt_vendor_partnumber_seen` records vendor partnumbers encountered during import
that have no mapping in the active partnumber book. When a user manually maps
such a partnumber to one or more LOT names in the QuoteForge UI, those mappings
are written back to the server as **lot suggestions** — hints for the team that
maintains `qt_partnumber_book_items`.
## Schema Extension
Add one nullable column to `qt_vendor_partnumber_seen`:
```sql
ALTER TABLE `qt_vendor_partnumber_seen`
ADD COLUMN `lot_suggestion` longtext DEFAULT NULL
COMMENT 'JSON array [{lot_name, qty}] — user-entered LOT mappings from the UI';
```
### Updated table contract (relevant columns only)
| Column | Type | Notes |
|--------|------|-------|
| `partnumber` | varchar(255) UNIQUE NOT NULL | natural key |
| `lot_suggestion` | longtext (JSON) | nullable; set when user maps the PN manually |
`lot_suggestion` contains the same JSON shape as `qt_partnumber_book_items.lots_json`:
```json
[
{ "lot_name": "LOT_A", "qty": 1 },
{ "lot_name": "LOT_B", "qty": 2 }
]
```
Rules:
- `null` or absent means no suggestion has been entered yet;
- an empty array `[]` is not a valid value — use `null` instead;
- a single PN may map to multiple lots (`lot_name` entries), each with its own `qty`;
- the array is ordered — the order reflects the order of `lot_mappings[]` in the
vendor spec row at the time of last user save;
- `qty` must be a positive integer (≥ 1).
## Write Contract (QuoteForge → MariaDB)
QuoteForge writes `lot_suggestion` when all of the following are true:
1. The user saves a vendor BOM via `PUT /api/configs/:uuid/vendor-spec`.
2. At least one `vendor_spec` row has a non-empty `lot_mappings[]` array (manually
entered or confirmed by the user — not auto-resolved from a partnumber book).
3. The MariaDB connection is available at the time of save.
For each such row:
```sql
INSERT INTO qt_vendor_partnumber_seen
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
VALUES
('manual', '', ?, ?, 0, NOW(3), ?)
ON DUPLICATE KEY UPDATE
lot_suggestion = VALUES(lot_suggestion),
last_seen_at = IF(lot_suggestion IS NULL, last_seen_at, NOW(3))
```
- `lot_suggestion` value = JSON-marshalled `lot_mappings[]` from the vendor spec item,
reusing the same `{lot_name, qty}` shape.
- If the PN row already exists and `lot_suggestion` is already set, it is **overwritten**
with the latest user input (the user is assumed to have corrected it).
- If the user **clears** all lot_mappings for a PN (sets to empty), no update is sent —
the existing `lot_suggestion` on the server is left untouched.
- Rows where `lot_mappings[]` is empty or nil are skipped entirely (no insert, no update).
- Writes are best-effort: a MariaDB error for one row is logged and skipped; remaining
rows continue. A write failure does not fail the vendor-spec save.
## Read Contract (Partnumber-Book Creation Tool → MariaDB)
The tool that maintains `qt_partnumber_book_items` reads `qt_vendor_partnumber_seen`
to discover new partnumbers and their suggested mappings.
### Discovery query
```sql
SELECT
s.id,
s.partnumber,
s.description,
s.vendor,
s.lot_suggestion,
s.last_seen_at,
b.lots_json AS book_lots_json
FROM qt_vendor_partnumber_seen s
LEFT JOIN qt_partnumber_book_items b ON b.partnumber = s.partnumber
WHERE s.is_ignored = 0
AND s.lot_suggestion IS NOT NULL
ORDER BY s.last_seen_at DESC;
```
### Interpretation rules
| Condition | Meaning | Suggested action |
|-----------|---------|-----------------|
| `book_lots_json IS NULL` AND `lot_suggestion IS NOT NULL` | No book entry yet; user suggested mapping | Create new `qt_partnumber_book_items` row with `lots_json = lot_suggestion` |
| `book_lots_json IS NOT NULL` AND `lot_suggestion IS NOT NULL` AND they differ | User corrected or extended the existing mapping | Review diff and decide whether to update `qt_partnumber_book_items` |
| `book_lots_json IS NOT NULL` AND `lot_suggestion IS NOT NULL` AND they match | Suggestion already applied | No action needed |
### Suggestion format
`lot_suggestion` is valid JSON (or `null`). Parse it as an array of objects:
```json
[
{ "lot_name": "LOT_A", "qty": 1 },
{ "lot_name": "LOT_B", "qty": 2 }
]
```
Map directly to `qt_partnumber_book_items.lots_json` — the formats are identical.
### Multiple lots per PN
One PN may have multiple suggestion entries (e.g., a bundle). The array carries
all of them. The book-creation tool must preserve the full array when writing
`lots_json`, not just the first element.
### Qty semantics
`qty` in a lot suggestion means "how many of this LOT per one occurrence of the
vendor PN". This matches `qt_partnumber_book_items.lots_json` exactly. Example:
a server platform that comes with 4 PSUs would produce
`[{"lot_name": "PS_1300W_Titanium", "qty": 4}]`.
## Permissions
The existing `qfs_user` grant covers this column — no new permission is required:
```sql
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
```
The book-creation tool connects with its own credentials and needs at minimum:
```sql
GRANT SELECT ON RFQ_LOG.qt_vendor_partnumber_seen TO '<book_tool_user>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_partnumber_book_items TO '<book_tool_user>'@'%';
```
## Migration
Migration is applied outside this repo (server-side DDL):
```sql
ALTER TABLE `qt_vendor_partnumber_seen`
ADD COLUMN IF NOT EXISTS `lot_suggestion` longtext DEFAULT NULL
COMMENT 'JSON [{lot_name, qty}] — user LOT suggestions from QuoteForge UI';
```
QuoteForge handles a missing column gracefully: if the migration has not run yet,
the write with `lot_suggestion` fails with "Unknown column" (MariaDB 1054), a warning
is logged, and the row is re-inserted without the column. The app never crashes on
migration lag.

View File

@@ -1,32 +1,55 @@
# QuoteForge Bible # QuoteForge Bible — Architectural Documentation
Project-specific architecture and operational contracts. The single source of truth for architecture, schemas, and patterns.
## Files ---
| File | Scope | ## Table of Contents
| --- | --- |
| [01-overview.md](01-overview.md) | Product scope, runtime model, repository map |
| [02-architecture.md](02-architecture.md) | Local-first rules, sync, pricing, versioning |
| [03-database.md](03-database.md) | SQLite and MariaDB data model, permissions, migrations |
| [04-api.md](04-api.md) | HTTP routes and API contract |
| [05-config.md](05-config.md) | Runtime config, paths, env vars, startup behavior |
| [06-backup.md](06-backup.md) | Backup contract and restore workflow |
| [07-dev.md](07-dev.md) | Development commands and guardrails |
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
| [10-agent-api-guide.md](10-agent-api-guide.md) | End-to-end API guide for agents pricing servers from a TZ |
| [11-lot-suggestions.md](11-lot-suggestions.md) | lot_suggestion column in qt_vendor_partnumber_seen — write/read contract for manual UI mappings |
## Rules | File | Topic |
|------|-------|
| [01-overview.md](01-overview.md) | Product: purpose, features, tech stack, repository structure |
| [02-architecture.md](02-architecture.md) | Architecture: local-first, sync, pricing, versioning |
| [03-database.md](03-database.md) | DB schemas: SQLite + MariaDB, permissions, indexes |
| [04-api.md](04-api.md) | API endpoints and web routes |
| [05-config.md](05-config.md) | Configuration, environment variables, paths, installation |
| [06-backup.md](06-backup.md) | Backup: implementation, rotation policy |
| [07-dev.md](07-dev.md) | Development: commands, code style, guardrails |
- `bible-local/` is the source of truth for QuoteForge-specific behavior. ---
- Keep these files in English.
- Update the matching file in the same commit as any architectural change.
- Remove stale documentation instead of preserving history in place.
## Quick reference ## Bible Rules
- Local DB path: see [05-config.md](05-config.md) > **Every architectural decision must be recorded in the Bible.**
- Runtime bind: loopback only >
- Local backups: see [06-backup.md](06-backup.md) > Any change to DB schema, data access patterns, sync behavior, API contracts,
- Release notes: `releases/<version>/RELEASE_NOTES.md` > configuration format, or any other system-level aspect — the corresponding `bible/` file
> **must be updated in the same commit** as the code.
>
> On every user-requested commit, the Bible must be reviewed and updated in that commit.
>
> The Bible is the single source of truth for architecture. Outdated documentation is worse than none.
> **Documentation language: English.**
>
> All files in `bible/` are written and updated **in English only**.
> Mixing languages is not allowed.
---
## Quick Reference
**Where is user data stored?**
SQLite → `~/Library/Application Support/QuoteForge/qfs.db` (macOS). MariaDB is sync-only.
**How to look up a price for a line item?**
`local_pricelist_items` → by `pricelist_id` from config + `lot_name`. Prices are **never** taken from `local_components`.
**Pre-commit check?**
`go build ./cmd/qfs && go vet ./...`
**What must never be restored?**
cron jobs, admin pricing, alerts, stock import, importer utility — all removed intentionally.
**Where is the release changelog?**
`releases/memory/v{major}.{minor}.{patch}.md`

View File

@@ -1,31 +0,0 @@
# Architectural Decision Log
One file per decision, named `YYYY-MM-DD-short-topic.md`.
Write a new entry when:
- Choosing between non-obvious implementation approaches.
- Intentionally rejecting a feature or pattern.
- A bug causes a rule change.
- Freezing or deprecating something.
Format:
```markdown
# Decision: <short title>
**Date:** YYYY-MM-DD
**Status:** active | superseded by YYYY-MM-DD-topic.md
## Context
Situation making this decision necessary.
## Decision
What was decided, stated clearly.
## Consequences
What this means going forward; what is forbidden or required.
```
When a decision is superseded: add "superseded by" to the old file and create the new one.
Do NOT delete old entries.
Record the decision in the SAME COMMIT as the implementation code.

View File

@@ -1,86 +0,0 @@
# Runtime Flows
Critical mutation paths, deduplication logic, and cross-entity side effects.
Update this file in the same commit as any change to the flows below.
---
## 1. Configuration save (create/update)
1. Handler receives JSON body; validates via `ShouldBindJSON`.
2. `LocalConfigurationService.Create` or `Update` is called.
3. Service computes `total_price` from `req.Items.Total()` (sum of `unit_price * quantity` per item).
4. A new revision snapshot is created via `createWithVersion`; revision number increments.
5. `quoteService.RecordUsage` is called best-effort (warn on failure, do not abort save).
6. Configuration row written to SQLite (`local_configurations`); version row appended to `local_configuration_versions`.
7. Pending change queued in `pending_changes` for later sync push.
**DO NOT** read prices from `local_components` during save - prices must already be on items.
**DO NOT** skip version creation on rename/reorder/project-move - those operations call different paths that must NOT call `createWithVersion`.
---
## 2. Refresh prices (POST /api/configs/:uuid/refresh-prices)
1. Handler calls `LocalConfigurationService.RefreshPricesNoAuth(uuid, pricelistServerID)`.
2. If online, `SyncPricelistsIfNeeded` runs best-effort (warn on failure, do not block).
3. Resolves target pricelist in order:
a. Explicitly requested pricelist (`pricelistServerID` param).
b. Pricelist stored in configuration row (`localCfg.PricelistID`).
c. Latest local pricelist as fallback.
4. For each item in the config, looks up price from `local_pricelist_items` via `GetLocalPricesForLots` (batch, single query).
5. Items with matching prices are updated; items with no price keep their existing `unit_price`.
6. Updated configuration saved as a new version (same flow as §1 from step 4 onward).
**DO NOT** read prices from `qt_pricelist_items` (MariaDB) directly - prices come from SQLite cache only.
---
## 3. Pricelist sync (POST /api/sync/pricelists)
1. Readiness guard checked; returns 423 if guard blocks sync.
2. `SyncService.SyncPricelists` pulls from `qt_pricelists` and `qt_pricelist_items` (MariaDB).
3. For each pricelist: header upserted first, then items replaced atomically via `ReplaceLocalPricelistItems`.
4. After all pricelists: `RecalculateAllLocalPricelistUsage` marks which pricelists are referenced by active configurations.
5. Sync result (status, error, timestamp) written to `app_settings` via `SetPricelistSyncResult`.
**DO NOT** write pricelist header without items in the same transaction - must be atomic.
**DO NOT** query MariaDB from runtime handlers outside sync/setup flows.
---
## 4. Vendor spec apply (POST /api/configs/:uuid/vendor-spec/apply)
1. Incoming `items[]` (lot_name, quantity, unit_price) replace the configuration's `items` entirely.
2. New item list saved through `LocalConfigurationService.UpdateItemsNoAuth`.
3. A new revision is created reflecting the BOM-derived item state.
**DO NOT** apply vendor spec without going through the service layer - handler must not write items directly to DB.
---
## 5. Configuration versioning invariants
- `local_configuration_versions` is append-only; rows are never updated or deleted.
- Version deduplication: if new snapshot hash equals current head, no new version is created.
- Rollback = create new HEAD revision from old snapshot data (does not restore version pointer to old row).
- UI must always show "main" (implicit head) as the active state; never point to a numbered revision after save.
- Operations that do NOT create a new version: rename, reorder within project, project move, pricelist selector change only.
---
## 6. Pending changes queue
- Every local write (create/update/delete) appends a row to `pending_changes`.
- `POST /api/sync/push` drains the queue by writing to MariaDB.
- If a push fails, `increment_attempts` and `last_error` are updated; row stays in queue.
- `RepairPendingChanges` reconciles orphaned changes (configuration/project deleted locally).
---
## 7. Error handling boundary rules
- Handlers: log 500 responses with `slog.Error`; surface error message via `RespondError`.
- Services: wrap errors with `fmt.Errorf("context: %w", err)`; do NOT log inside service.
- Repositories: return raw errors; no logging.
- Best-effort operations (usage stats, background sync): log `slog.Warn` and continue.

View File

@@ -1,165 +0,0 @@
# Server contract: qt_settings
## Purpose
`qt_settings` is a general-purpose key→JSON-value table that the price management
application uses to push configuration into QuoteForge clients. QF reads it during
component sync and caches the result in `local_qt_settings` (SQLite).
## Required MariaDB changes (implemented by server-side agent)
```sql
CREATE TABLE IF NOT EXISTS qt_settings (
name VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NOT NULL -- JSON-encoded value
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
GRANT SELECT ON RFQ_LOG.qt_settings TO 'qfs_user'@'%';
```
## Settings consumed by QuoteForge
All values are JSON. Missing or unparseable entries are silently skipped; QF
falls back to hardcoded defaults for each missing key.
---
### `config_types`
Defines the available device configuration types, their localized names, and the
category codes that are allowed for each type. QF uses this for:
- the new-config modal (button list + labels);
- the configurator's category filter per `config_type`.
**Value format:** JSON array of objects.
```json
[
{
"code": "server",
"name_ru": "Сервер",
"display_order": 10,
"categories": [
"MB","CPU","MEM","RAID",
"SSD","HDD","M2","EDSFF","HHHL",
"GPU","NIC","HCA","DPU","HBA",
"PSU","PS","ACC","RISERS","CARD","BB"
]
},
{
"code": "storage",
"name_ru": "СХД",
"display_order": 20,
"categories": [
"DKC","CPU","MEM","PS",
"SSD","HDD","M2","EDSFF","HHHL",
"NIC","HBA","HCA","ACC","CARD"
]
}
]
```
Fields:
| Field | Type | Description |
|-------|------|-------------|
| `code` | string | Identifier stored on `qt_configurations.config_type`. Must be stable. |
| `name_ru` | string | Display name in Russian for the QF UI. |
| `display_order` | int | Sort order for the modal button list. |
| `categories` | string[] | Allowlist of LOT category codes visible in this config type. A category absent from ALL entries is visible in all types. |
---
### `tab_config`
Defines the configurator tab layout: which tabs exist, which categories each tab
contains, optional sub-sections within a tab, and whether the tab uses
single-select mode.
**Value format:** JSON array of tab objects (ordered — defines tab bar order).
```json
[
{
"key": "base",
"label": "Base",
"single_select": true,
"categories": ["MB","CPU","MEM","ENC","DKC","CTL"],
"sections": null
},
{
"key": "storage",
"label": "Storage",
"single_select": false,
"categories": ["RAID","M2","SSD","HDD","EDSFF","HHHL"],
"sections": [
{ "title": "RAID Контроллеры", "categories": ["RAID"] },
{ "title": "Диски", "categories": ["M2","SSD","HDD","EDSFF","HHHL"] }
]
},
{
"key": "pci",
"label": "PCI",
"single_select": false,
"categories": ["GPU","DPU","NIC","HCA","HBA","HIC"],
"sections": [
{ "title": "GPU / DPU", "categories": ["GPU","DPU"] },
{ "title": "NIC / HCA", "categories": ["NIC","HCA"] },
{ "title": "HBA", "categories": ["HBA"] },
{ "title": "HIC", "categories": ["HIC"] }
]
},
{ "key": "power", "label": "Power", "single_select": false, "categories": ["PS","PSU"] },
{ "key": "accessories", "label": "Accessories", "single_select": false, "categories": ["ACC","CARD"] },
{ "key": "sw", "label": "SW", "single_select": false, "categories": ["SW"] }
]
```
The QF frontend always appends an "other" tab for any categories not listed here.
---
### `always_visible_tabs`
Tab keys that are always shown in the configurator regardless of whether they
contain any items. Other tabs are hidden when empty.
**Value format:** JSON string array.
```json
["base", "storage", "pci"]
```
---
### `required_categories`
Category codes that must have at least one LOT selected for a configuration to
be considered complete. Keyed by `config_type` code. QF uses this to show a
badge on the tab label when required categories are missing.
**Value format:** JSON object mapping config_type code → string array.
```json
{
"server": ["CPU", "MEM", "BB"],
"storage": ["DKC", "CPU", "MEM"]
}
```
---
## Backward compatibility
- If `qt_settings` does not exist (old server): QF logs `Warn` during sync and
leaves `local_qt_settings` empty. The frontend falls back to hardcoded defaults
for all four settings. No crash, no data loss.
- If a specific key is absent from `qt_settings`: QF falls back to the hardcoded
default for that key only.
- Old QF clients that do not know about `local_qt_settings` continue to use their
hardcoded JS constants unchanged.
## Note on `qt_categories`
`qt_categories.name` and `qt_categories.name_ru` are being removed.
QF runtime does not depend on them — `GetCategories` derives `Name` from the
category code string stored in `local_components`.

View File

@@ -2,8 +2,8 @@ package main
import ( import (
"flag" "flag"
"fmt"
"log" "log"
"log/slog"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appstate" "git.mchus.pro/mchus/quoteforge/internal/appstate"
@@ -153,7 +153,7 @@ func main() {
log.Printf(" Skipped: %d", skipped) log.Printf(" Skipped: %d", skipped)
log.Printf(" Errors: %d", errors) log.Printf(" Errors: %d", errors)
slog.Info("Done! You can now run the server with: go run ./cmd/qfs") fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs")
} }
func derefUint(v *uint) uint { func derefUint(v *uint) uint {

View File

@@ -5,7 +5,6 @@ import (
"flag" "flag"
"fmt" "fmt"
"log" "log"
"log/slog"
"os" "os"
"regexp" "regexp"
"sort" "sort"
@@ -80,12 +79,12 @@ func main() {
printPlan(actions) printPlan(actions)
if len(actions) == 0 { if len(actions) == 0 {
slog.Info("Nothing to migrate.") fmt.Println("Nothing to migrate.")
return return
} }
if !*apply { if !*apply {
slog.Info("Preview complete. Re-run with -apply to execute.") fmt.Println("\nPreview complete. Re-run with -apply to execute.")
return return
} }
@@ -95,7 +94,7 @@ func main() {
log.Fatalf("confirmation failed: %v", confirmErr) log.Fatalf("confirmation failed: %v", confirmErr)
} }
if !ok { if !ok {
slog.Info("Aborted.") fmt.Println("Aborted.")
return return
} }
} }
@@ -104,7 +103,7 @@ func main() {
log.Fatalf("migration failed: %v", err) log.Fatalf("migration failed: %v", err)
} }
slog.Info("Migration completed successfully.") fmt.Println("Migration completed successfully.")
} }
func ensureProjectsTable(db *gorm.DB) error { func ensureProjectsTable(db *gorm.DB) error {
@@ -213,8 +212,10 @@ func printPlan(actions []migrationAction) {
} }
} }
slog.Info("Plan summary", "actions", len(actions), "create", createCount, "reactivate", reactivateCount) fmt.Printf("Planned actions: %d\n", len(actions))
slog.Info("Details:") fmt.Printf("Projects to create: %d\n", createCount)
fmt.Printf("Projects to reactivate: %d\n", reactivateCount)
fmt.Println("\nDetails:")
for _, a := range actions { for _, a := range actions {
extra := "" extra := ""

View File

@@ -1,173 +0,0 @@
package main
import (
"flag"
"log"
"log/slog"
"sort"
"time"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type projectTimestampRow struct {
UUID string
UpdatedAt time.Time
}
type updatePlanRow struct {
UUID string
Code string
Variant string
LocalUpdatedAt time.Time
ServerUpdatedAt time.Time
}
func main() {
defaultLocalDBPath, err := appstate.ResolveDBPath("")
if err != nil {
log.Fatalf("failed to resolve default local SQLite path: %v", err)
}
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
apply := flag.Bool("apply", false, "apply updates to local SQLite (default is preview only)")
flag.Parse()
local, err := localdb.New(*localDBPath)
if err != nil {
log.Fatalf("failed to initialize local database: %v", err)
}
defer local.Close()
if !local.HasSettings() {
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
}
dsn, err := local.GetDSN()
if err != nil {
log.Fatalf("failed to build DSN from SQLite settings: %v", err)
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("failed to connect to MariaDB: %v", err)
}
serverRows, err := loadServerProjects(db)
if err != nil {
log.Fatalf("failed to load server projects: %v", err)
}
localProjects, err := local.GetAllProjects(true)
if err != nil {
log.Fatalf("failed to load local projects: %v", err)
}
plan := buildUpdatePlan(localProjects, serverRows)
printPlan(plan, *apply)
if !*apply || len(plan) == 0 {
return
}
updated := 0
for i := range plan {
project, err := local.GetProjectByUUID(plan[i].UUID)
if err != nil {
log.Printf("skip %s: load local project: %v", plan[i].UUID, err)
continue
}
project.UpdatedAt = plan[i].ServerUpdatedAt
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
log.Printf("skip %s: save local project: %v", plan[i].UUID, err)
continue
}
updated++
}
log.Printf("updated %d local project timestamps", updated)
}
func loadServerProjects(db *gorm.DB) (map[string]time.Time, error) {
var rows []projectTimestampRow
if err := db.Model(&models.Project{}).
Select("uuid, updated_at").
Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]time.Time, len(rows))
for _, row := range rows {
if row.UUID == "" {
continue
}
out[row.UUID] = row.UpdatedAt
}
return out, nil
}
func buildUpdatePlan(localProjects []localdb.LocalProject, serverRows map[string]time.Time) []updatePlanRow {
plan := make([]updatePlanRow, 0)
for i := range localProjects {
project := localProjects[i]
serverUpdatedAt, ok := serverRows[project.UUID]
if !ok {
continue
}
if project.UpdatedAt.Equal(serverUpdatedAt) {
continue
}
plan = append(plan, updatePlanRow{
UUID: project.UUID,
Code: project.Code,
Variant: project.Variant,
LocalUpdatedAt: project.UpdatedAt,
ServerUpdatedAt: serverUpdatedAt,
})
}
sort.Slice(plan, func(i, j int) bool {
if plan[i].Code != plan[j].Code {
return plan[i].Code < plan[j].Code
}
return plan[i].Variant < plan[j].Variant
})
return plan
}
func printPlan(plan []updatePlanRow, apply bool) {
mode := "preview"
if apply {
mode = "apply"
}
log.Printf("project updated_at resync mode=%s changes=%d", mode, len(plan))
if len(plan) == 0 {
log.Printf("no local project timestamps need resync")
return
}
for _, row := range plan {
variant := row.Variant
if variant == "" {
variant = "main"
}
log.Printf("%s [%s] local=%s server=%s", row.Code, variant, formatStamp(row.LocalUpdatedAt), formatStamp(row.ServerUpdatedAt))
}
if !apply {
slog.Info("Re-run with -apply to write server updated_at into local SQLite.")
}
}
func formatStamp(value time.Time) string {
if value.IsZero() {
return "zero"
}
return value.Format(time.RFC3339)
}

View File

@@ -39,10 +39,6 @@ logging:
t.Fatalf("load legacy config: %v", err) t.Fatalf("load legacy config: %v", err)
} }
setConfigDefaults(cfg) setConfigDefaults(cfg)
cfg.Server.Host, _, err = normalizeLoopbackServerHost(cfg.Server.Host)
if err != nil {
t.Fatalf("normalize server host: %v", err)
}
if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil { if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil {
t.Fatalf("migrate config: %v", err) t.Fatalf("migrate config: %v", err)
} }
@@ -64,43 +60,7 @@ logging:
if !strings.Contains(text, "port: 9191") { if !strings.Contains(text, "port: 9191") {
t.Fatalf("migrated config did not preserve server port:\n%s", text) t.Fatalf("migrated config did not preserve server port:\n%s", text)
} }
if !strings.Contains(text, "host: 127.0.0.1") {
t.Fatalf("migrated config did not normalize server host:\n%s", text)
}
if !strings.Contains(text, "level: debug") { if !strings.Contains(text, "level: debug") {
t.Fatalf("migrated config did not preserve logging level:\n%s", text) t.Fatalf("migrated config did not preserve logging level:\n%s", text)
} }
} }
func TestNormalizeLoopbackServerHost(t *testing.T) {
t.Parallel()
cases := []struct {
host string
want string
wantChanged bool
wantErr bool
}{
{host: "127.0.0.1", want: "127.0.0.1", wantChanged: false, wantErr: false},
{host: "localhost", want: "127.0.0.1", wantChanged: true, wantErr: false},
{host: "::1", want: "127.0.0.1", wantChanged: true, wantErr: false},
{host: "0.0.0.0", want: "127.0.0.1", wantChanged: true, wantErr: false},
{host: "192.168.1.10", want: "127.0.0.1", wantChanged: true, wantErr: false},
}
for _, tc := range cases {
got, changed, err := normalizeLoopbackServerHost(tc.host)
if tc.wantErr && err == nil {
t.Fatalf("expected error for host %q", tc.host)
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error for host %q: %v", tc.host, err)
}
if got != tc.want {
t.Fatalf("unexpected normalized host for %q: got %q want %q", tc.host, got, tc.want)
}
if changed != tc.wantChanged {
t.Fatalf("unexpected changed flag for %q: got %t want %t", tc.host, changed, tc.wantChanged)
}
}
}

View File

@@ -10,7 +10,6 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
"math" "math"
"net"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
@@ -44,16 +43,11 @@ import (
// Version is set via ldflags during build // Version is set via ldflags during build
var Version = "dev" var Version = "dev"
var errVendorImportTooLarge = errors.New("vendor workspace file exceeds 1 GiB limit")
const backgroundSyncInterval = 5 * time.Minute const backgroundSyncInterval = 5 * time.Minute
const onDemandPullCooldown = 30 * time.Second const onDemandPullCooldown = 30 * time.Second
const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать" const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать"
var vendorImportMaxBytes int64 = 1 << 30
const vendorImportMultipartOverheadBytes int64 = 8 << 20
func main() { func main() {
showStartupConsoleWarning() showStartupConsoleWarning()
@@ -148,15 +142,6 @@ func main() {
} }
} }
setConfigDefaults(cfg) setConfigDefaults(cfg)
normalizedHost, changed, err := normalizeLoopbackServerHost(cfg.Server.Host)
if err != nil {
slog.Error("invalid server host", "host", cfg.Server.Host, "error", err)
os.Exit(1)
}
if changed {
slog.Warn("corrected server host to loopback", "from", cfg.Server.Host, "to", normalizedHost)
}
cfg.Server.Host = normalizedHost
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil { if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err) slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
os.Exit(1) os.Exit(1)
@@ -289,7 +274,8 @@ func main() {
} }
func showStartupConsoleWarning() { func showStartupConsoleWarning() {
slog.Warn(startupConsoleWarning) // Visible in console output.
fmt.Println(startupConsoleWarning)
// Keep the warning always visible in the console window title when supported. // Keep the warning always visible in the console window title when supported.
fmt.Printf("\033]0;%s\007", startupConsoleWarning) fmt.Printf("\033]0;%s\007", startupConsoleWarning)
} }
@@ -331,51 +317,31 @@ func setConfigDefaults(cfg *config.Config) {
cfg.Server.ReadTimeout = 30 * time.Second cfg.Server.ReadTimeout = 30 * time.Second
} }
if cfg.Server.WriteTimeout == 0 { if cfg.Server.WriteTimeout == 0 {
// Sync operations (pricelist download over slow VPN) can take several minutes. cfg.Server.WriteTimeout = 30 * time.Second
// Loopback-only binding means there is no risk of holding connections from external clients. }
cfg.Server.WriteTimeout = 10 * time.Minute if cfg.Pricing.DefaultMethod == "" {
cfg.Pricing.DefaultMethod = "weighted_median"
}
if cfg.Pricing.DefaultPeriodDays == 0 {
cfg.Pricing.DefaultPeriodDays = 90
}
if cfg.Pricing.FreshnessGreenDays == 0 {
cfg.Pricing.FreshnessGreenDays = 30
}
if cfg.Pricing.FreshnessYellowDays == 0 {
cfg.Pricing.FreshnessYellowDays = 60
}
if cfg.Pricing.FreshnessRedDays == 0 {
cfg.Pricing.FreshnessRedDays = 90
}
if cfg.Pricing.MinQuotesForMedian == 0 {
cfg.Pricing.MinQuotesForMedian = 3
} }
if cfg.Backup.Time == "" { if cfg.Backup.Time == "" {
cfg.Backup.Time = "00:00" cfg.Backup.Time = "00:00"
} }
} }
func normalizeLoopbackServerHost(host string) (string, bool, error) {
trimmed := strings.TrimSpace(host)
if trimmed == "" {
return "", false, fmt.Errorf("server.host must not be empty")
}
const loopbackHost = "127.0.0.1"
if trimmed == loopbackHost {
return loopbackHost, false, nil
}
if strings.EqualFold(trimmed, "localhost") {
return loopbackHost, true, nil
}
ip := net.ParseIP(strings.Trim(trimmed, "[]"))
if ip != nil {
if ip.IsLoopback() || ip.IsUnspecified() {
return loopbackHost, trimmed != loopbackHost, nil
}
return loopbackHost, true, nil
}
return loopbackHost, true, nil
}
func vendorImportBodyLimit() int64 {
return vendorImportMaxBytes + vendorImportMultipartOverheadBytes
}
func isVendorImportTooLarge(fileSize int64, err error) bool {
if fileSize > vendorImportMaxBytes {
return true
}
var maxBytesErr *http.MaxBytesError
return errors.As(err, &maxBytesErr)
}
func ensureDefaultConfigFile(configPath string) error { func ensureDefaultConfigFile(configPath string) error {
if strings.TrimSpace(configPath) == "" { if strings.TrimSpace(configPath) == "" {
return fmt.Errorf("config path is empty") return fmt.Errorf("config path is empty")
@@ -395,7 +361,7 @@ func ensureDefaultConfigFile(configPath string) error {
port: 8080 port: 8080
mode: "release" mode: "release"
read_timeout: 30s read_timeout: 30s
write_timeout: 10m write_timeout: 30s
backup: backup:
time: "00:00" time: "00:00"
@@ -677,9 +643,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
var projectService *services.ProjectService var projectService *services.ProjectService
syncService = sync.NewService(connMgr, local) syncService = sync.NewService(connMgr, local)
componentService := services.NewComponentService(nil, nil) componentService := services.NewComponentService(nil, nil, nil)
quoteService := services.NewQuoteService(nil, nil, local, nil) quoteService := services.NewQuoteService(nil, nil, nil, local, nil)
exportService := services.NewExportService(cfg.Export, local) exportService := services.NewExportService(cfg.Export, nil, local)
// isOnline function for local-first architecture // isOnline function for local-first architecture
isOnline := func() bool { isOnline := func() bool {
@@ -779,16 +745,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
quoteHandler := handlers.NewQuoteHandler(quoteService) quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername) exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
pricelistHandler := handlers.NewPricelistHandler(local) pricelistHandler := handlers.NewPricelistHandler(local)
vendorSpecHandler := handlers.NewVendorSpecHandler(local, syncService) vendorSpecHandler := handlers.NewVendorSpecHandler(local)
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local) partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
respondError := handlers.RespondError
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err) return nil, nil, fmt.Errorf("creating sync handler: %w", err)
} }
supportBundleHandler := handlers.NewSupportBundleHandler(local, connMgr, syncService, cfg.Logging.FilePath)
// Setup handler (for reconfiguration) // Setup handler (for reconfiguration)
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig) setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig)
if err != nil { if err != nil {
@@ -803,7 +766,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Router // Router
router := gin.New() router := gin.New()
router.MaxMultipartMemory = vendorImportBodyLimit()
router.Use(gin.Recovery()) router.Use(gin.Recovery())
router.Use(requestLogger()) router.Use(requestLogger())
router.Use(middleware.CORS()) router.Use(middleware.CORS())
@@ -824,17 +786,17 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}) })
}) })
// Restart endpoint is intentionally debug-only. // Restart endpoint (for development purposes)
if cfg.Server.Mode == "debug" { router.POST("/api/restart", func(c *gin.Context) {
router.POST("/api/restart", func(c *gin.Context) { // This will cause the server to restart by exiting
slog.Info("Restart requested via API") // The restartProcess function will be called to restart the process
go func() { slog.Info("Restart requested via API")
time.Sleep(100 * time.Millisecond) go func() {
restartProcess() time.Sleep(100 * time.Millisecond)
}() restartProcess()
c.JSON(http.StatusOK, gin.H{"message": "restarting..."}) }()
}) c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
} })
// DB status endpoint // DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) { router.GET("/api/db-status", func(c *gin.Context) {
@@ -894,17 +856,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/partnumber-books", webHandler.PartnumberBooks) router.GET("/partnumber-books", webHandler.PartnumberBooks)
// Short project URL: /:code → redirect to /projects/:uuid
router.GET("/:code", func(c *gin.Context) {
code := c.Param("code")
project, err := projectService.GetByCode(code)
if err != nil {
c.Redirect(http.StatusFound, "/projects")
return
}
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
})
// htmx partials // htmx partials
partials := router.Group("/partials") partials := router.Group("/partials")
{ {
@@ -919,8 +870,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusOK, gin.H{"message": "pong"}) c.JSON(http.StatusOK, gin.H{"message": "pong"})
}) })
api.GET("/support-bundle", supportBundleHandler.DownloadBundle)
// Components (public read) // Components (public read)
components := api.Group("/components") components := api.Group("/components")
{ {
@@ -930,7 +879,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Categories (public) // Categories (public)
api.GET("/categories", componentHandler.GetCategories) api.GET("/categories", componentHandler.GetCategories)
api.GET("/configurator-settings", componentHandler.GetConfiguratorSettings)
// Quote (public) // Quote (public)
quote := api.Group("/quote") quote := api.Group("/quote")
@@ -963,9 +911,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pnBooks.GET("/:id", partnumberBooksHandler.GetItems) pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
} }
// Stateless BOM text parsing shared by paste and file-import paths.
api.POST("/vendor-spec/parse-text", vendorSpecHandler.ParseText)
// Configurations (public - RBAC disabled) // Configurations (public - RBAC disabled)
configs := api.Group("/configs") configs := api.Group("/configs")
{ {
@@ -983,7 +928,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search) cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search)
if err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -1004,7 +949,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"})
return return
} }
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
@@ -1013,13 +958,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.POST("", func(c *gin.Context) { configs.POST("", func(c *gin.Context) {
var req services.CreateConfigRequest var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
config, err := configService.Create(dbUsername, &req) config, err := configService.Create(dbUsername, &req)
if err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -1029,12 +974,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.POST("/preview-article", func(c *gin.Context) { configs.POST("/preview-article", func(c *gin.Context) {
var req services.ArticlePreviewRequest var req services.ArticlePreviewRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
result, err := configService.BuildArticlePreview(&req) result, err := configService.BuildArticlePreview(&req)
if err != nil { if err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -1057,7 +1002,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req services.CreateConfigRequest var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -1065,13 +1010,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrConfigNotFound): case errors.Is(err, services.ErrConfigNotFound):
respondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
respondError(c, http.StatusForbidden, "access denied", err) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default: default:
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1082,7 +1027,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.DELETE("/:uuid", func(c *gin.Context) { configs.DELETE("/:uuid", func(c *gin.Context) {
uuid := c.Param("uuid") uuid := c.Param("uuid")
if err := configService.DeleteNoAuth(uuid); err != nil { if err := configService.DeleteNoAuth(uuid); err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "archived"}) c.JSON(http.StatusOK, gin.H{"message": "archived"})
@@ -1092,7 +1037,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := configService.ReactivateNoAuth(uuid) config, err := configService.ReactivateNoAuth(uuid)
if err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -1107,13 +1052,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
Name string `json:"name"` Name string `json:"name"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
config, err := configService.RenameNoAuth(uuid, req.Name) config, err := configService.RenameNoAuth(uuid, req.Name)
if err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -1127,7 +1072,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
FromVersion int `json:"from_version"` FromVersion int `json:"from_version"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -1137,7 +1082,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
return return
} }
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -1146,48 +1091,34 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) { configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req struct { config, err := configService.RefreshPricesNoAuth(uuid)
PricelistID *uint `json:"pricelist_id"`
}
// Ignore bind error — pricelist_id is optional
_ = c.ShouldBindJSON(&req)
config, err := configService.RefreshPricesNoAuth(uuid, req.PricelistID)
if err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, config) c.JSON(http.StatusOK, config)
}) })
configs.POST("/:uuid/snapshot", func(c *gin.Context) {
uuid := c.Param("uuid")
if err := configService.SnapshotCurrentState(uuid); err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
configs.PATCH("/:uuid/project", func(c *gin.Context) { configs.PATCH("/:uuid/project", func(c *gin.Context) {
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req struct { var req struct {
ProjectUUID string `json:"project_uuid"` ProjectUUID string `json:"project_uuid"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID) updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrConfigNotFound): case errors.Is(err, services.ErrConfigNotFound):
respondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
respondError(c, http.StatusForbidden, "access denied", err) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default: default:
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1216,7 +1147,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
case errors.Is(err, services.ErrInvalidVersionNumber): case errors.Is(err, services.ErrInvalidVersionNumber):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"})
default: default:
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1244,7 +1175,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
case errors.Is(err, services.ErrConfigVersionNotFound): case errors.Is(err, services.ErrConfigVersionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
default: default:
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1259,7 +1190,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
Note string `json:"note"` Note string `json:"note"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if req.TargetVersion <= 0 { if req.TargetVersion <= 0 {
@@ -1277,7 +1208,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
case errors.Is(err, services.ErrVersionConflict): case errors.Is(err, services.ErrVersionConflict):
c.JSON(http.StatusConflict, gin.H{"error": "version conflict"}) c.JSON(http.StatusConflict, gin.H{"error": "version conflict"})
default: default:
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1299,7 +1230,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}) })
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV) configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
configs.POST("/:uuid/export/pricing", exportHandler.ExportConfigPricingCSV)
// Vendor spec (BOM) endpoints // Vendor spec (BOM) endpoints
configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec) configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec)
@@ -1313,12 +1243,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
ServerCount int `json:"server_count" binding:"required,min=1"` ServerCount int `json:"server_count" binding:"required,min=1"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
config, err := configService.UpdateServerCount(uuid, req.ServerCount) config, err := configService.UpdateServerCount(uuid, req.ServerCount)
if err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, config) c.JSON(http.StatusOK, config)
@@ -1363,7 +1293,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
allProjects, err := projectService.ListByUser(dbUsername, true) allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -1497,7 +1427,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.GET("/all", func(c *gin.Context) { projects.GET("/all", func(c *gin.Context) {
allProjects, err := projectService.ListByUser(dbUsername, true) allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -1527,7 +1457,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
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 {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if strings.TrimSpace(req.Code) == "" { if strings.TrimSpace(req.Code) == "" {
@@ -1537,13 +1467,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
project, err := projectService.Create(dbUsername, &req) project, err := projectService.Create(dbUsername, &req)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrReservedMainVariant),
errors.Is(err, services.ErrProjectCodeInvalidChars):
respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectCodeExists): case errors.Is(err, services.ErrProjectCodeExists):
respondError(c, http.StatusConflict, "conflict detected", err) c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default: default:
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1555,11 +1482,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
respondError(c, http.StatusForbidden, "access denied", err) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default: default:
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1569,24 +1496,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.PUT("/:uuid", func(c *gin.Context) { projects.PUT("/:uuid", func(c *gin.Context) {
var req services.UpdateProjectRequest var req services.UpdateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return 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.ErrReservedMainVariant),
errors.Is(err, services.ErrCannotRenameMainVariant),
errors.Is(err, services.ErrProjectCodeInvalidChars):
respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectCodeExists): case errors.Is(err, services.ErrProjectCodeExists):
respondError(c, http.StatusConflict, "conflict detected", err) c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
respondError(c, http.StatusForbidden, "access denied", err) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default: default:
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1597,11 +1520,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil { if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
respondError(c, http.StatusForbidden, "access denied", err) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default: default:
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1612,11 +1535,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil { if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
respondError(c, http.StatusForbidden, "access denied", err) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default: default:
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1627,13 +1550,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil { if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
switch { switch {
case errors.Is(err, services.ErrCannotDeleteMainVariant): case errors.Is(err, services.ErrCannotDeleteMainVariant):
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
respondError(c, http.StatusForbidden, "access denied", err) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default: default:
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1653,11 +1576,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
respondError(c, http.StatusForbidden, "access denied", err) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default: default:
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1670,7 +1593,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
OrderedUUIDs []string `json:"ordered_uuids"` OrderedUUIDs []string `json:"ordered_uuids"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if len(req.OrderedUUIDs) == 0 { if len(req.OrderedUUIDs) == 0 {
@@ -1682,9 +1605,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
default: default:
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1705,7 +1628,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.POST("/:uuid/configs", func(c *gin.Context) { projects.POST("/:uuid/configs", func(c *gin.Context) {
var req services.CreateConfigRequest var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
projectUUID := c.Param("uuid") projectUUID := c.Param("uuid")
@@ -1713,45 +1636,32 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
config, err := configService.Create(dbUsername, &req) config, err := configService.Create(dbUsername, &req)
if err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusCreated, config) c.JSON(http.StatusCreated, config)
}) })
projects.POST("/:uuid/vendor-import", func(c *gin.Context) { projects.POST("/:uuid/vendor-import", func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, vendorImportBodyLimit())
fileHeader, err := c.FormFile("file") fileHeader, err := c.FormFile("file")
if err != nil { if err != nil {
if isVendorImportTooLarge(0, err) { c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return
}
respondError(c, http.StatusBadRequest, "file is required", err)
return
}
if isVendorImportTooLarge(fileHeader.Size, nil) {
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return return
} }
file, err := fileHeader.Open() file, err := fileHeader.Open()
if err != nil { if err != nil {
respondError(c, http.StatusBadRequest, "failed to open uploaded file", err) c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"})
return return
} }
defer file.Close() defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, vendorImportMaxBytes+1)) data, err := io.ReadAll(file)
if err != nil { if err != nil {
respondError(c, http.StatusBadRequest, "failed to read uploaded file", err) c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read uploaded file"})
return return
} }
if int64(len(data)) > vendorImportMaxBytes { if !services.IsCFXMLWorkspace(data) {
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return
}
if !services.IsCFXMLWorkspace(data) && !services.IsQuoteForgeCSV(data) && !services.IsInspurBOM(data) && !services.IsTextBOM(data) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"}) c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
return return
} }
@@ -1760,9 +1670,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
default: default:
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
} }
return return
} }
@@ -1778,14 +1688,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
Name string `json:"name"` Name string `json:"name"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
projectUUID := c.Param("uuid") projectUUID := c.Param("uuid")
config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID) config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID)
if err != nil { if err != nil {
respondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusCreated, config) c.JSON(http.StatusCreated, config)
@@ -1859,12 +1769,22 @@ func requestLogger() gin.HandlerFunc {
path := c.Request.URL.Path path := c.Request.URL.Path
query := c.Request.URL.RawQuery query := c.Request.URL.RawQuery
blw := &captureResponseWriter{
ResponseWriter: c.Writer,
body: bytes.NewBuffer(nil),
}
c.Writer = blw
c.Next() c.Next()
latency := time.Since(start) latency := time.Since(start)
status := c.Writer.Status() status := c.Writer.Status()
if status >= http.StatusBadRequest { if status >= http.StatusBadRequest {
responseBody := strings.TrimSpace(blw.body.String())
if len(responseBody) > 2048 {
responseBody = responseBody[:2048] + "...(truncated)"
}
errText := strings.TrimSpace(c.Errors.String()) errText := strings.TrimSpace(c.Errors.String())
slog.Error("request failed", slog.Error("request failed",
@@ -1875,6 +1795,7 @@ func requestLogger() gin.HandlerFunc {
"latency", latency, "latency", latency,
"ip", c.ClientIP(), "ip", c.ClientIP(),
"errors", errText, "errors", errText,
"response", responseBody,
) )
return return
} }
@@ -1889,3 +1810,22 @@ func requestLogger() gin.HandlerFunc {
) )
} }
} }
type captureResponseWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w *captureResponseWriter) Write(b []byte) (int, error) {
if len(b) > 0 {
_, _ = w.body.Write(b)
}
return w.ResponseWriter.Write(b)
}
func (w *captureResponseWriter) WriteString(s string) (int, error) {
if s != "" {
_, _ = w.body.WriteString(s)
}
return w.ResponseWriter.WriteString(s)
}

View File

@@ -1,48 +0,0 @@
package main
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestRequestLoggerDoesNotLogResponseBody(t *testing.T) {
gin.SetMode(gin.TestMode)
var logBuffer bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{})))
defer slog.SetDefault(previousLogger)
router := gin.New()
router.Use(requestLogger())
router.GET("/fail", func(c *gin.Context) {
_ = c.Error(errors.New("root cause"))
c.JSON(http.StatusBadRequest, gin.H{"error": "do not log this body"})
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/fail?debug=1", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
logOutput := logBuffer.String()
if !strings.Contains(logOutput, "request failed") {
t.Fatalf("expected request failure log, got %q", logOutput)
}
if strings.Contains(logOutput, "do not log this body") {
t.Fatalf("response body leaked into logs: %q", logOutput)
}
if !strings.Contains(logOutput, "root cause") {
t.Fatalf("expected error details in logs, got %q", logOutput)
}
}

View File

@@ -3,12 +3,10 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/config"
@@ -292,88 +290,6 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
} }
} }
func TestVendorImportRejectsOversizedUpload(t *testing.T) {
moveToRepoRoot(t)
prevLimit := vendorImportMaxBytes
vendorImportMaxBytes = 128
defer func() { vendorImportMaxBytes = prevLimit }()
local, connMgr, _ := newAPITestStack(t)
cfg := &config.Config{}
setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
if err != nil {
t.Fatalf("setup router: %v", err)
}
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Import Project","code":"IMP"}`)))
createProjectReq.Header.Set("Content-Type", "application/json")
createProjectRec := httptest.NewRecorder()
router.ServeHTTP(createProjectRec, createProjectReq)
if createProjectRec.Code != http.StatusCreated {
t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String())
}
var project models.Project
if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil {
t.Fatalf("unmarshal project: %v", err)
}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "huge.xml")
if err != nil {
t.Fatalf("create form file: %v", err)
}
payload := "<CFXML>" + strings.Repeat("A", int(vendorImportMaxBytes)+1) + "</CFXML>"
if _, err := part.Write([]byte(payload)); err != nil {
t.Fatalf("write multipart payload: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/vendor-import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for oversized upload, got %d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "1 GiB") {
t.Fatalf("expected size limit message, got %s", rec.Body.String())
}
}
func TestCreateConfigMalformedJSONReturnsGenericError(t *testing.T) {
moveToRepoRoot(t)
local, connMgr, _ := newAPITestStack(t)
cfg := &config.Config{}
setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
if err != nil {
t.Fatalf("setup router: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/configs", bytes.NewReader([]byte(`{"name":`)))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for malformed json, got %d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(strings.ToLower(rec.Body.String()), "unexpected eof") {
t.Fatalf("expected sanitized error body, got %s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "invalid request") {
t.Fatalf("expected generic invalid request message, got %s", rec.Body.String())
}
}
func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) { func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) {
t.Helper() t.Helper()

View File

@@ -1,18 +1,56 @@
# QuoteForge runtime config # QuoteForge Configuration
# Runtime creates a minimal config automatically on first start. # Copy this file to config.yaml and update values
# This file is only a reference template.
server: server:
host: "127.0.0.1" # Loopback only; remote HTTP binding is unsupported host: "127.0.0.1" # Use 0.0.0.0 to listen on all interfaces
port: 8080 port: 8080
mode: "release" # debug | release mode: "release" # debug | release
read_timeout: "30s" read_timeout: "30s"
write_timeout: "30s" write_timeout: "30s"
database:
host: "localhost"
port: 3306
name: "RFQ_LOG"
user: "quoteforge"
password: "CHANGE_ME"
max_open_conns: 25
max_idle_conns: 5
conn_max_lifetime: "5m"
pricing:
default_method: "weighted_median" # median | average | weighted_median
default_period_days: 90
freshness_green_days: 30
freshness_yellow_days: 60
freshness_red_days: 90
min_quotes_for_median: 3
popularity_decay_days: 180
export:
temp_dir: "/tmp/quoteforge-exports"
max_file_age: "1h"
company_name: "Your Company Name"
backup: backup:
time: "00:00" time: "00:00"
alerts:
enabled: true
check_interval: "1h"
high_demand_threshold: 5 # КП за 30 дней
trending_threshold_percent: 50 # % роста для алерта
notifications:
email_enabled: false
smtp_host: "smtp.example.com"
smtp_port: 587
smtp_user: ""
smtp_password: ""
from_address: "quoteforge@example.com"
logging: logging:
level: "info" # debug | info | warn | error level: "info" # debug | info | warn | error
format: "json" # json | text format: "json" # json | text
output: "stdout" # stdout | stderr | /path/to/file output: "stdout" # stdout | file
file_path: "/var/log/quoteforge/app.log"

View File

@@ -1,213 +0,0 @@
# Руководство по составлению каталога лотов СХД
## Что такое LOT и зачем он нужен
LOT — это внутренний идентификатор типа компонента в системе QuoteForge.
Каждый LOT представляет одну рыночную позицию и хранит **средневзвешенную рыночную цену**, рассчитанную по историческим данным от поставщиков. Это позволяет получать актуальную оценку стоимости независимо от конкретного поставщика или прайс-листа.
Партномера вендора (Part Number, Feature Code) сами по себе не имеют цены в системе — они **переводятся в LOT** через книгу партномеров. Именно через LOT происходит расценка конфигурации.
**Пример:** Feature Code `B4B9` и Part Number `4C57A14368` — это два разных обозначения одной и той же HIC-карты от Lenovo. Оба маппируются на один LOT `HIC_4pFC32`, у которого есть рыночная цена.
---
## Категории и вкладки конфигуратора
Категория LOT определяет, в какой вкладке конфигуратора он появится.
| Код категории | Название | Вкладка | Что сюда относится |
|---|---|---|---|
| `ENC` | Storage Enclosure | **Base** | Дисковая полка без контроллера |
| `DKC` | Disk/Controller Enclosure | **Base** | Контроллерная полка: модель СХД + тип дисков + кол-во слотов + кол-во контроллеров |
| `CTL` | Storage Controller | **Base** | Контроллер СХД: объём кэша + встроенные хост-порты |
| `HIC` | Host Interface Card | **PCI** | HIC-карты СХД: интерфейсы подключения (FC, iSCSI, SAS) |
| `HDD` | HDD | **Storage** | Жёсткие диски (HDD) |
| `SSD` | SSD | **Storage** | Твердотельные диски (SSD, NVMe) |
| `ACC` | Accessories | **Accessories** | Кабели подключения, кабели питания |
| `SW` | Software | **SW** | Программные лицензии |
| *(прочее)* | — | **Other** | Гарантийные опции, инсталляция |
---
## Правила именования LOT
Формат: `КАТЕГОРИЯ_МОДЕЛЬСХД_СПЕЦИФИКА`
- только латиница, цифры и знак `_`
- регистр — ВЕРХНИЙ
- без пробелов, дефисов, точек
- каждый LOT уникален — два разных компонента не могут иметь одинаковое имя
### DKC — контроллерная полка
Специфика: `ТИПДИСКА_СЛОТЫ_NCTRL`
| Пример | Расшифровка |
|---|---|
| `DKC_DE4000H_SFF_24_2CTRL` | DE4000H, 24 слота SFF (2.5"), 2 контроллера |
| `DKC_DE4000H_LFF_12_2CTRL` | DE4000H, 12 слотов LFF (3.5"), 2 контроллера |
| `DKC_DE4000H_SFF_24_1CTRL` | DE4000H, 24 слота SFF, 1 контроллер (симплекс) |
Обозначения типа диска: `SFF` — 2.5", `LFF` — 3.5", `NVMe` — U.2/U.3.
### CTL — контроллер
Специфика: `КЭШГБ_ПОРТЫТИП` (если встроенные порты есть) или `КЭШГБ_BASE` (если без портов, добавляются через HIC)
| Пример | Расшифровка |
|---|---|
| `CTL_DE4000H_32GB_BASE` | 32GB кэш, без встроенных хост-портов |
| `CTL_DE4000H_8GB_BASE` | 8GB кэш, без встроенных хост-портов |
| `CTL_MSA2060_8GB_ISCSI10G_4P` | 8GB кэш, встроенные 4× iSCSI 10GbE |
### HIC — HIC-карты (интерфейс подключения)
Специфика: `NpПРОТОКОЛ` — без привязки к модели СХД, по аналогии с серверными `HBA_2pFC16`, `HBA_4pFC32_Gen6`.
| Пример | Расшифровка |
|---|---|
| `HIC_4pFC32` | 4 порта FC 32Gb |
| `HIC_4pFC16` | 4 порта FC 16G/10GbE |
| `HIC_4p25G_iSCSI` | 4 порта 25G iSCSI |
| `HIC_4p12G_SAS` | 4 порта SAS 12Gb |
| `HIC_2p10G_BaseT` | 2 порта 10G Base-T |
### HDD / SSD / NVMe — диски
Диски **не привязываются к модели СХД** — используются существующие LOT из серверного каталога (`HDD_...`, `SSD_...`, `NVME_...`). Новые LOT для дисков СХД не создаются; партномера дисков маппируются на уже существующие серверные LOT.
### ACC — кабели
Кабели **не привязываются к модели СХД**. Формат: `ACC_CABLE_{ТИП}_{ДЛИНА}` — универсальные LOT, одинаковые для серверов и СХД.
| Пример | Расшифровка |
|---|---|
| `ACC_CABLE_CAT6_10M` | Кабель CAT6 10м |
| `ACC_CABLE_FC_OM4_3M` | Кабель FC LC-LC OM4 до 3м |
| `ACC_CABLE_PWR_C13C14_15M` | Кабель питания C13C14 1.5м |
### SW — программные лицензии
Специфика: краткое название функции.
| Пример | Расшифровка |
|---|---|
| `SW_DE4000H_ASYNC_MIRROR` | Async Mirroring |
| `SW_DE4000H_SNAPSHOT_512` | Snapshot 512 |
---
## Таблица лотов: DE4000H (пример заполнения)
### DKC — контроллерная полка
| lot_name | vendor | model | description | disk_slots | disk_type | controllers |
|---|---|---|---|---|---|---|
| `DKC_DE4000H_SFF_24_2CTRL` | Lenovo | DE4000H 2U24 | DE4000H, 24× SFF, 2 контроллера | 24 | SFF | 2 |
| `DKC_DE4000H_LFF_12_2CTRL` | Lenovo | DE4000H 2U12 | DE4000H, 12× LFF, 2 контроллера | 12 | LFF | 2 |
### CTL — контроллер
| lot_name | vendor | model | description | cache_gb | host_ports |
|---|---|---|---|---|---|
| `CTL_DE4000H_32GB_BASE` | Lenovo | DE4000 Controller 32GB Gen2 | Контроллер DE4000, 32GB кэш, без встроенных портов | 32 | — |
| `CTL_DE4000H_8GB_BASE` | Lenovo | DE4000 Controller 8GB Gen2 | Контроллер DE4000, 8GB кэш, без встроенных портов | 8 | — |
### HIC — HIC-карты
| lot_name | vendor | model | description |
|---|---|---|---|
| `HIC_2p10G_BaseT` | Lenovo | HIC 10GBASE-T 2-Ports | HIC 10GBASE-T, 2 порта |
| `HIC_4p25G_iSCSI` | Lenovo | HIC 10/25GbE iSCSI 4-ports | HIC iSCSI 10/25GbE, 4 порта |
| `HIC_4p12G_SAS` | Lenovo | HIC 12Gb SAS 4-ports | HIC SAS 12Gb, 4 порта |
| `HIC_4pFC32` | Lenovo | HIC 32Gb FC 4-ports | HIC FC 32Gb, 4 порта |
| `HIC_4pFC16` | Lenovo | HIC 16G FC/10GbE 4-ports | HIC FC 16G/10GbE, 4 порта |
### HDD / SSD / NVMe / ACC — диски и кабели
Для дисков и кабелей новые LOT не создаются. Партномера маппируются на существующие серверные LOT из каталога.
### SW — программные лицензии
| lot_name | vendor | model | description |
|---|---|---|---|
| `SW_DE4000H_ASYNC_MIRROR` | Lenovo | DE4000H Asynchronous Mirroring | Лицензия Async Mirroring |
| `SW_DE4000H_SNAPSHOT_512` | Lenovo | DE4000H Snapshot Upgrade 512 | Лицензия Snapshot 512 |
| `SW_DE4000H_SYNC_MIRROR` | Lenovo | DE4000 Synchronous Mirroring | Лицензия Sync Mirroring |
---
## Таблица партномеров: DE4000H (пример заполнения)
Каждый Feature Code и Part Number должен быть привязан к своему LOT.
Если у компонента есть оба — добавить две строки.
| partnumber | lot_name | описание |
|---|---|---|
| `BEY7` | `ENC_2U24_CHASSIS` | Lenovo ThinkSystem Storage 2U24 Chassis |
| `BQA0` | `CTL_DE4000H_32GB_BASE` | DE4000 Controller 32GB Gen2 |
| `BQ9Z` | `CTL_DE4000H_8GB_BASE` | DE4000 Controller 8GB Gen2 |
| `B4B1` | `HIC_2p10G_BaseT` | HIC 10GBASE-T 2-Ports |
| `4C57A14376` | `HIC_2p10G_BaseT` | HIC 10GBASE-T 2-Ports |
| `B4BA` | `HIC_4p25G_iSCSI` | HIC 10/25GbE iSCSI 4-ports |
| `4C57A14369` | `HIC_4p25G_iSCSI` | HIC 10/25GbE iSCSI 4-ports |
| `B4B8` | `HIC_4p12G_SAS` | HIC 12Gb SAS 4-ports |
| `4C57A14367` | `HIC_4p12G_SAS` | HIC 12Gb SAS 4-ports |
| `B4B9` | `HIC_4pFC32` | HIC 32Gb FC 4-ports |
| `4C57A14368` | `HIC_4pFC32` | HIC 32Gb FC 4-ports |
| `B4B7` | `HIC_4pFC16` | HIC 16G FC/10GbE 4-ports |
| `4C57A14366` | `HIC_4pFC16` | HIC 16G FC/10GbE 4-ports |
| `BW12` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD 2U24 |
| `4XB7A88046` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD 2U24 |
| `B4C0` | `HDD_SAS_01.8TB` | 1.8TB 10K 2.5" HDD SED FIPS |
| `4XB7A14114` | `HDD_SAS_01.8TB` | 1.8TB 10K 2.5" HDD SED FIPS |
| `BW13` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD FIPS |
| `4XB7A88048` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD FIPS |
| `BKUQ` | `SSD_SAS_0.960T` | 960GB 1DWD 2.5" SSD |
| `4XB7A74948` | `SSD_SAS_0.960T` | 960GB 1DWD 2.5" SSD |
| `BKUT` | `SSD_SAS_01.92T` | 1.92TB 1DWD 2.5" SSD |
| `4XB7A74951` | `SSD_SAS_01.92T` | 1.92TB 1DWD 2.5" SSD |
| `BKUK` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD |
| `4XB7A74955` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD |
| `B4RY` | `SSD_SAS_07.68T` | 7.68TB 1DWD 2.5" SSD |
| `4XB7A14176` | `SSD_SAS_07.68T` | 7.68TB 1DWD 2.5" SSD |
| `B4CD` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD |
| `4XB7A14110` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD |
| `BWCJ` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD FIPS |
| `4XB7A88469` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD FIPS |
| `BW2B` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD SED |
| `4XB7A88466` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD SED |
| `AVFW` | `ACC_CABLE_CAT6_1M` | CAT6 0.75-1.5m |
| `A1MT` | `ACC_CABLE_CAT6_10M` | CAT6 10m |
| `90Y3718` | `ACC_CABLE_CAT6_10M` | CAT6 10m |
| `A1MW` | `ACC_CABLE_CAT6_25M` | CAT6 25m |
| `90Y3727` | `ACC_CABLE_CAT6_25M` | CAT6 25m |
| `39Y7937` | `ACC_CABLE_PWR_C13C14_15M` | C13C14 1.5m |
| `39Y7938` | `ACC_CABLE_PWR_C13C20_28M` | C13C20 2.8m |
| `4L67A08371` | `ACC_CABLE_PWR_C13C14_43M` | C13C14 4.3m |
| `C932` | `SW_DE4000H_ASYNC_MIRROR` | DE4000H Asynchronous Mirroring |
| `00WE123` | `SW_DE4000H_ASYNC_MIRROR` | DE4000H Asynchronous Mirroring |
| `C930` | `SW_DE4000H_SNAPSHOT_512` | DE4000H Snapshot Upgrade 512 |
| `C931` | `SW_DE4000H_SYNC_MIRROR` | DE4000 Synchronous Mirroring |
---
## Шаблон для новых моделей СХД
```
DKC_МОДЕЛЬ_ТИПДИСКА_СЛОТЫ_NCTRL — контроллерная полка
CTL_МОДЕЛЬ_КЭШГБ_ПОРТЫ — контроллер
HIC_МОДЕЛЬРОТОКОЛ_СКОРОСТЬОРТЫ — HIC-карта (интерфейс подключения)
SW_МОДЕЛЬУНКЦИЯ — лицензия
```
Диски (HDD/SSD/NVMe) и кабели (ACC) — маппируются на существующие серверные LOT, новые не создаются.
Пример для HPE MSA 2060:
```
DKC_MSA2060_SFF_24_2CTRL
CTL_MSA2060_8GB_ISCSI10G_4P
HIC_MSA2060_FC32G_2P
SW_MSA2060_REMOTE_SNAP
```

View File

@@ -10,10 +10,6 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
) )
type backupPeriod struct { type backupPeriod struct {
@@ -254,12 +250,6 @@ func pruneOldBackups(periodDir string, keep int) error {
} }
func createBackupArchive(destPath, dbPath, configPath string) error { func createBackupArchive(destPath, dbPath, configPath string) error {
snapshotPath, cleanup, err := createSQLiteSnapshot(dbPath)
if err != nil {
return err
}
defer cleanup()
file, err := os.Create(destPath) file, err := os.Create(destPath)
if err != nil { if err != nil {
return err return err
@@ -267,10 +257,12 @@ func createBackupArchive(destPath, dbPath, configPath string) error {
defer file.Close() defer file.Close()
zipWriter := zip.NewWriter(file) zipWriter := zip.NewWriter(file)
if err := addZipFileAs(zipWriter, snapshotPath, filepath.Base(dbPath)); err != nil { if err := addZipFile(zipWriter, dbPath); err != nil {
_ = zipWriter.Close() _ = zipWriter.Close()
return err return err
} }
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
if strings.TrimSpace(configPath) != "" { if strings.TrimSpace(configPath) != "" {
_ = addZipOptionalFile(zipWriter, configPath) _ = addZipOptionalFile(zipWriter, configPath)
@@ -282,77 +274,6 @@ func createBackupArchive(destPath, dbPath, configPath string) error {
return file.Sync() return file.Sync()
} }
func createSQLiteSnapshot(dbPath string) (string, func(), error) {
tempFile, err := os.CreateTemp("", "qfs-backup-*.db")
if err != nil {
return "", func() {}, err
}
tempPath := tempFile.Name()
if err := tempFile.Close(); err != nil {
_ = os.Remove(tempPath)
return "", func() {}, err
}
if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) {
return "", func() {}, err
}
cleanup := func() {
_ = os.Remove(tempPath)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
cleanup()
return "", func() {}, err
}
sqlDB, err := db.DB()
if err != nil {
cleanup()
return "", func() {}, err
}
defer sqlDB.Close()
if err := db.Exec("PRAGMA busy_timeout = 5000").Error; err != nil {
cleanup()
return "", func() {}, fmt.Errorf("configure sqlite busy_timeout: %w", err)
}
literalPath := strings.ReplaceAll(tempPath, "'", "''")
if err := vacuumIntoWithRetry(db, literalPath); err != nil {
cleanup()
return "", func() {}, err
}
return tempPath, cleanup, nil
}
func vacuumIntoWithRetry(db *gorm.DB, literalPath string) error {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if err := db.Exec("VACUUM INTO '" + literalPath + "'").Error; err != nil {
lastErr = err
if !isSQLiteBusyError(err) {
return fmt.Errorf("create sqlite snapshot: %w", err)
}
time.Sleep(time.Duration(attempt+1) * 250 * time.Millisecond)
continue
}
return nil
}
return fmt.Errorf("create sqlite snapshot after retries: %w", lastErr)
}
func isSQLiteBusyError(err error) bool {
if err == nil {
return false
}
lower := strings.ToLower(err.Error())
return strings.Contains(lower, "database is locked") || strings.Contains(lower, "database is busy")
}
func addZipOptionalFile(writer *zip.Writer, path string) error { func addZipOptionalFile(writer *zip.Writer, path string) error {
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
return nil return nil
@@ -361,10 +282,6 @@ func addZipOptionalFile(writer *zip.Writer, path string) error {
} }
func addZipFile(writer *zip.Writer, path string) error { func addZipFile(writer *zip.Writer, path string) error {
return addZipFileAs(writer, path, filepath.Base(path))
}
func addZipFileAs(writer *zip.Writer, path string, archiveName string) error {
in, err := os.Open(path) in, err := os.Open(path)
if err != nil { if err != nil {
return err return err
@@ -380,7 +297,7 @@ func addZipFileAs(writer *zip.Writer, path string, archiveName string) error {
if err != nil { if err != nil {
return err return err
} }
header.Name = archiveName header.Name = filepath.Base(path)
header.Method = zip.Deflate header.Method = zip.Deflate
out, err := writer.CreateHeader(header) out, err := writer.CreateHeader(header)

View File

@@ -1,15 +1,11 @@
package appstate package appstate
import ( import (
"archive/zip"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
) )
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) { func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
@@ -17,8 +13,8 @@ func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
dbPath := filepath.Join(temp, "qfs.db") dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml") cfgPath := filepath.Join(temp, "config.yaml")
if err := writeTestSQLiteDB(dbPath); err != nil { if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatalf("write sqlite db: %v", err) t.Fatalf("write db: %v", err)
} }
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil { if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
@@ -40,7 +36,6 @@ func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
if _, err := os.Stat(dailyArchive); err != nil { if _, err := os.Stat(dailyArchive); err != nil {
t.Fatalf("daily archive missing: %v", err) t.Fatalf("daily archive missing: %v", err)
} }
assertZipContains(t, dailyArchive, "qfs.db", "config.yaml")
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) } backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath) created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
@@ -62,8 +57,8 @@ func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
dbPath := filepath.Join(temp, "qfs.db") dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml") cfgPath := filepath.Join(temp, "config.yaml")
if err := writeTestSQLiteDB(dbPath); err != nil { if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatalf("write sqlite db: %v", err) t.Fatalf("write db: %v", err)
} }
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil { if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
@@ -100,8 +95,8 @@ func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) {
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
t.Fatalf("mkdir data dir: %v", err) t.Fatalf("mkdir data dir: %v", err)
} }
if err := writeTestSQLiteDB(dbPath); err != nil { if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatalf("write sqlite db: %v", err) t.Fatalf("write db: %v", err)
} }
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil { if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write cfg: %v", err) t.Fatalf("write cfg: %v", err)
@@ -115,43 +110,3 @@ func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
} }
func writeTestSQLiteDB(path string) error {
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
defer sqlDB.Close()
return db.Exec(`
CREATE TABLE sample_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
INSERT INTO sample_items(name) VALUES ('backup');
`).Error
}
func assertZipContains(t *testing.T, archivePath string, expected ...string) {
t.Helper()
reader, err := zip.OpenReader(archivePath)
if err != nil {
t.Fatalf("open archive: %v", err)
}
defer reader.Close()
found := make(map[string]bool, len(reader.File))
for _, file := range reader.File {
found[file.Name] = true
}
for _, name := range expected {
if !found[name] {
t.Fatalf("archive %s missing %s", archivePath, name)
}
}
}

View File

@@ -22,29 +22,24 @@ type BuildResult struct {
} }
var ( var (
reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`) reMemGiB = regexp.MustCompile(`(?i)(\d+)\s*(GB|G)`)
reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`) reMemTiB = regexp.MustCompile(`(?i)(\d+)\s*(TB|T)`)
reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`) reCapacityT = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)T`)
reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`) reCapacityG = regexp.MustCompile(`(?i)(\d+(?:[.,]\d+)?)G`)
rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`) rePortSpeed = regexp.MustCompile(`(?i)(\d+)p(\d+)(GbE|G)`)
rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`) rePortFC = regexp.MustCompile(`(?i)(\d+)pFC(\d+)`)
reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`) reWatts = regexp.MustCompile(`(?i)(\d{3,5})\s*W`)
) )
type namedSeg struct {
group string // "MODEL","CPU","MEM","GPU","DISK","NET","PSU","SUPPORT"
value string
}
func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) { func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) (BuildResult, error) {
segs := make([]namedSeg, 0, 8) segments := make([]string, 0, 8)
warnings := make([]string, 0) warnings := make([]string, 0)
model := NormalizeServerModel(opts.ServerModel) model := NormalizeServerModel(opts.ServerModel)
if model == "" { if model == "" {
return BuildResult{}, fmt.Errorf("server_model required") return BuildResult{}, fmt.Errorf("server_model required")
} }
segs = append(segs, namedSeg{"MODEL", model}) segments = append(segments, model)
lotNames := make([]string, 0, len(items)) lotNames := make([]string, 0, len(items))
for _, it := range items { for _, it := range items {
@@ -60,39 +55,41 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
return BuildResult{}, err return BuildResult{}, err
} }
if cpuSeg := buildCPUSegment(items, cats); cpuSeg != "" { cpuSeg := buildCPUSegment(items, cats)
segs = append(segs, namedSeg{"CPU", cpuSeg}) if cpuSeg != "" {
segments = append(segments, cpuSeg)
} }
memSeg, memWarn := buildMemSegment(items, cats) memSeg, memWarn := buildMemSegment(items, cats)
if memWarn != "" { if memWarn != "" {
warnings = append(warnings, memWarn) warnings = append(warnings, memWarn)
} }
if memSeg != "" { if memSeg != "" {
segs = append(segs, namedSeg{"MEM", memSeg}) segments = append(segments, memSeg)
} }
if gpuSeg := buildGPUSegment(items, cats); gpuSeg != "" { gpuSeg := buildGPUSegment(items, cats)
segs = append(segs, namedSeg{"GPU", gpuSeg}) if gpuSeg != "" {
segments = append(segments, gpuSeg)
} }
diskSeg, diskWarn := buildDiskSegment(items, cats) diskSeg, diskWarn := buildDiskSegment(items, cats)
if diskWarn != "" { if diskWarn != "" {
warnings = append(warnings, diskWarn) warnings = append(warnings, diskWarn)
} }
if diskSeg != "" { if diskSeg != "" {
segs = append(segs, namedSeg{"DISK", diskSeg}) segments = append(segments, diskSeg)
} }
netSeg, netWarn := buildNetSegment(items, cats) netSeg, netWarn := buildNetSegment(items, cats)
if netWarn != "" { if netWarn != "" {
warnings = append(warnings, netWarn) warnings = append(warnings, netWarn)
} }
if netSeg != "" { if netSeg != "" {
segs = append(segs, namedSeg{"NET", netSeg}) segments = append(segments, netSeg)
} }
psuSeg, psuWarn := buildPSUSegment(items, cats) psuSeg, psuWarn := buildPSUSegment(items, cats)
if psuWarn != "" { if psuWarn != "" {
warnings = append(warnings, psuWarn) warnings = append(warnings, psuWarn)
} }
if psuSeg != "" { if psuSeg != "" {
segs = append(segs, namedSeg{"PSU", psuSeg}) segments = append(segments, psuSeg)
} }
if strings.TrimSpace(opts.SupportCode) != "" { if strings.TrimSpace(opts.SupportCode) != "" {
@@ -100,12 +97,12 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
if !isSupportCodeValid(code) { if !isSupportCodeValid(code) {
return BuildResult{}, fmt.Errorf("invalid_support_code") return BuildResult{}, fmt.Errorf("invalid_support_code")
} }
segs = append(segs, namedSeg{"SUPPORT", code}) segments = append(segments, code)
} }
article := strings.Join(namedSegsValues(segs), "-") article := strings.Join(segments, "-")
if len([]rune(article)) > 80 { if len([]rune(article)) > 80 {
article = compressArticle(segs) article = compressArticle(segments)
warnings = append(warnings, "compressed") warnings = append(warnings, "compressed")
} }
if len([]rune(article)) > 80 { if len([]rune(article)) > 80 {
@@ -115,23 +112,6 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
return BuildResult{Article: article, Warnings: warnings}, nil return BuildResult{Article: article, Warnings: warnings}, nil
} }
func namedSegsValues(segs []namedSeg) []string {
out := make([]string, len(segs))
for i, s := range segs {
out[i] = s.value
}
return out
}
func findSegGroup(segs []namedSeg, group string) int {
for i, s := range segs {
if s.group == group {
return i
}
}
return -1
}
func isSupportCodeValid(code string) bool { func isSupportCodeValid(code string) bool {
if len(code) < 3 { if len(code) < 3 {
return false return false
@@ -349,60 +329,33 @@ func parseGPUModel(lotName string) string {
} }
parts := strings.Split(upper, "_") parts := strings.Split(upper, "_")
model := "" model := ""
numSuffix := ""
mem := "" mem := ""
for i, p := range parts { for i, p := range parts {
if p == "" { if p == "" {
continue continue
} }
switch p { switch p {
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX", "SFF", "LOVELACE": case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
continue
case "ADA", "AMPERE", "HOPPER", "BLACKWELL":
if model != "" {
archAbbr := map[string]string{
"ADA": "ADA", "AMPERE": "AMP", "HOPPER": "HOP", "BLACKWELL": "BWL",
}
numSuffix += archAbbr[p]
}
continue continue
default: default:
if strings.Contains(p, "GB") { if strings.Contains(p, "GB") {
mem = p mem = p
continue continue
} }
if model == "" && i > 0 { if model == "" && (i > 0) {
model = p model = p
} else if model != "" && numSuffix == "" && isNumeric(p) {
numSuffix = p
} }
} }
} }
full := model if model != "" && mem != "" {
if numSuffix != "" { return model + "_" + mem
full = model + numSuffix
} }
if full != "" && mem != "" { if model != "" {
return full + "_" + mem return model
}
if full != "" {
return full
} }
return normalizeModelToken(lotName) return normalizeModelToken(lotName)
} }
func isNumeric(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
func parseMemGiB(lotName string) int { func parseMemGiB(lotName string) int {
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 { if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
return atoi(m[1]) * 1024 return atoi(m[1]) * 1024
@@ -504,50 +457,60 @@ func atoi(v string) int {
return n return n
} }
func compressArticle(segs []namedSeg) string { func compressArticle(segments []string) string {
if len(segs) == 0 { if len(segments) == 0 {
return "" return ""
} }
for i, s := range segs { normalized := make([]string, 0, len(segments))
segs[i].value = strings.ReplaceAll(s.value, "GbE", "G") for _, s := range segments {
normalized = append(normalized, strings.ReplaceAll(s, "GbE", "G"))
} }
article := strings.Join(namedSegsValues(segs), "-") segments = normalized
article := strings.Join(segments, "-")
if len([]rune(article)) <= 80 { if len([]rune(article)) <= 80 {
return article 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 // 1) remove PSU
if i := findSegGroup(segs, "PSU"); i >= 0 { if i, ok := index(6); ok {
segs = append(segs[:i], segs[i+1:]...) segments = append(segments[:i], segments[i+1:]...)
article = strings.Join(namedSegsValues(segs), "-") article = strings.Join(segments, "-")
if len([]rune(article)) <= 80 { if len([]rune(article)) <= 80 {
return article return article
} }
} }
// 2) compress NET/HBA/HCA // 2) compress NET/HBA/HCA
if i := findSegGroup(segs, "NET"); i >= 0 { if i, ok := index(5); ok {
segs[i].value = compressNetSegment(segs[i].value) segments[i] = compressNetSegment(segments[i])
article = strings.Join(namedSegsValues(segs), "-") article = strings.Join(segments, "-")
if len([]rune(article)) <= 80 { if len([]rune(article)) <= 80 {
return article return article
} }
} }
// 3) compress DISK // 3) compress DISK
if i := findSegGroup(segs, "DISK"); i >= 0 { if i, ok := index(4); ok {
segs[i].value = compressDiskSegment(segs[i].value) segments[i] = compressDiskSegment(segments[i])
article = strings.Join(namedSegsValues(segs), "-") article = strings.Join(segments, "-")
if len([]rune(article)) <= 80 { if len([]rune(article)) <= 80 {
return article return article
} }
} }
// 4) compress GPU to vendor only (GPU_NV) // 4) compress GPU to vendor only (GPU_NV)
if i := findSegGroup(segs, "GPU"); i >= 0 { if i, ok := index(3); ok {
segs[i].value = compressGPUSegment(segs[i].value) segments[i] = compressGPUSegment(segments[i])
} }
return strings.Join(namedSegsValues(segs), "-") return strings.Join(segments, "-")
} }
func compressNetSegment(seg string) string { func compressNetSegment(seg string) string {

View File

@@ -61,79 +61,6 @@ func TestBuild_ParsesNetAndPSU(t *testing.T) {
} }
} }
// TestBuild_CompressArticle_NoGPU_PSUNotNIC reproduces the bug where 2 PSUs produced
// "2xNIC" in the article because compressArticle used hard-coded indices that assumed
// GPU was always present.
func TestBuild_CompressArticle_NoGPU_PSUNotNIC(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-05-19-001",
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_INTEL_8358", LotCategory: "CPU", Price: 1},
{PricelistID: localPL.ID, LotName: "MEM_DDR4_64G_3200", LotCategory: "MEM", Price: 1},
{PricelistID: localPL.ID, LotName: "SSD_SATA_0.4T", LotCategory: "SSD", Price: 1},
{PricelistID: localPL.ID, LotName: "SSD_SATA_0.9T", LotCategory: "SSD", Price: 1},
{PricelistID: localPL.ID, LotName: "HDD_SATA_16T", LotCategory: "HDD", Price: 1},
{PricelistID: localPL.ID, LotName: "NIC_2p25G_MCX512A", LotCategory: "NIC", Price: 1},
{PricelistID: localPL.ID, LotName: "HBA_2pFC32_Gen6", LotCategory: "HBA", Price: 1},
{PricelistID: localPL.ID, LotName: "NIC_4p1G_I350", LotCategory: "NIC", Price: 1},
{PricelistID: localPL.ID, LotName: "PS_1500W_Platinum", LotCategory: "PS", Price: 1},
}); err != nil {
t.Fatalf("save local items: %v", err)
}
// PS_1500W → "2x1.5kW" (7 chars) brings uncompressed article to 81 chars, triggering
// compressArticle. Before the fix, compressArticle used hard-coded index 5 for NET, but
// without GPU the PSU sits at index 5, so compressNetSegment("2x1.5kW") returned "2xNIC".
items := models.ConfigItems{
{LotName: "CPU_INTEL_8358", Quantity: 2},
{LotName: "MEM_DDR4_64G_3200", Quantity: 16}, // 1024 GiB = 1T
{LotName: "SSD_SATA_0.4T", Quantity: 2},
{LotName: "SSD_SATA_0.9T", Quantity: 4},
{LotName: "HDD_SATA_16T", Quantity: 6},
{LotName: "NIC_2p25G_MCX512A", Quantity: 1},
{LotName: "HBA_2pFC32_Gen6", Quantity: 1},
{LotName: "NIC_4p1G_I350", Quantity: 1},
{LotName: "PS_1500W_Platinum", Quantity: 2},
}
result, err := Build(local, items, BuildOptions{
ServerModel: "NF5280M6",
ServerPricelist: &localPL.ServerID,
})
if err != nil {
t.Fatalf("build article: %v", err)
}
if len([]rune(result.Article)) > 80 {
t.Fatalf("article too long (%d): %s", len([]rune(result.Article)), result.Article)
}
// PSU segment must not be mis-labeled as NIC during compression
// The correct behaviour: PSU is dropped, NET stays as-is or compressed to HBA/NIC labels
// Before the fix: article ended with "-2xNIC" (PSU turned into NIC)
// After the fix: article must not contain a standalone "NIC" that came from PSU wattage
if strings.HasSuffix(result.Article, "-2xNIC") {
t.Fatalf("PSU mis-labeled as NIC in article: %s", result.Article)
}
t.Logf("article: %s (warnings: %v)", result.Article, result.Warnings)
}
func contains(s, sub string) bool { func contains(s, sub string) bool {
return strings.Contains(s, sub) return strings.Contains(s, sub)
} }

View File

@@ -7,14 +7,19 @@ import (
"strconv" "strconv"
"time" "time"
mysqlDriver "github.com/go-sql-driver/mysql"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
Export ExportConfig `yaml:"export"` Database DatabaseConfig `yaml:"database"`
Logging LoggingConfig `yaml:"logging"` Pricing PricingConfig `yaml:"pricing"`
Backup BackupConfig `yaml:"backup"` Export ExportConfig `yaml:"export"`
Alerts AlertsConfig `yaml:"alerts"`
Notifications NotificationsConfig `yaml:"notifications"`
Logging LoggingConfig `yaml:"logging"`
Backup BackupConfig `yaml:"backup"`
} }
type ServerConfig struct { type ServerConfig struct {
@@ -25,6 +30,64 @@ type ServerConfig struct {
WriteTimeout time.Duration `yaml:"write_timeout"` WriteTimeout time.Duration `yaml:"write_timeout"`
} }
type DatabaseConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Name string `yaml:"name"`
User string `yaml:"user"`
Password string `yaml:"password"`
MaxOpenConns int `yaml:"max_open_conns"`
MaxIdleConns int `yaml:"max_idle_conns"`
ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime"`
}
func (d *DatabaseConfig) DSN() string {
cfg := mysqlDriver.NewConfig()
cfg.User = d.User
cfg.Passwd = d.Password
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(d.Host, strconv.Itoa(d.Port))
cfg.DBName = d.Name
cfg.ParseTime = true
cfg.Loc = time.Local
cfg.Params = map[string]string{
"charset": "utf8mb4",
}
return cfg.FormatDSN()
}
type PricingConfig struct {
DefaultMethod string `yaml:"default_method"`
DefaultPeriodDays int `yaml:"default_period_days"`
FreshnessGreenDays int `yaml:"freshness_green_days"`
FreshnessYellowDays int `yaml:"freshness_yellow_days"`
FreshnessRedDays int `yaml:"freshness_red_days"`
MinQuotesForMedian int `yaml:"min_quotes_for_median"`
PopularityDecayDays int `yaml:"popularity_decay_days"`
}
type ExportConfig struct {
TempDir string `yaml:"temp_dir"`
MaxFileAge time.Duration `yaml:"max_file_age"`
CompanyName string `yaml:"company_name"`
}
type AlertsConfig struct {
Enabled bool `yaml:"enabled"`
CheckInterval time.Duration `yaml:"check_interval"`
HighDemandThreshold int `yaml:"high_demand_threshold"`
TrendingThresholdPercent int `yaml:"trending_threshold_percent"`
}
type NotificationsConfig struct {
EmailEnabled bool `yaml:"email_enabled"`
SMTPHost string `yaml:"smtp_host"`
SMTPPort int `yaml:"smtp_port"`
SMTPUser string `yaml:"smtp_user"`
SMTPPassword string `yaml:"smtp_password"`
FromAddress string `yaml:"from_address"`
}
type LoggingConfig struct { type LoggingConfig struct {
Level string `yaml:"level"` Level string `yaml:"level"`
Format string `yaml:"format"` Format string `yaml:"format"`
@@ -32,10 +95,6 @@ type LoggingConfig struct {
FilePath string `yaml:"file_path"` FilePath string `yaml:"file_path"`
} }
// ExportConfig is kept for constructor compatibility in export services.
// Runtime no longer persists an export section in config.yaml.
type ExportConfig struct{}
type BackupConfig struct { type BackupConfig struct {
Time string `yaml:"time"` Time string `yaml:"time"`
} }
@@ -73,6 +132,38 @@ func (c *Config) setDefaults() {
c.Server.WriteTimeout = 30 * time.Second c.Server.WriteTimeout = 30 * time.Second
} }
if c.Database.Port == 0 {
c.Database.Port = 3306
}
if c.Database.MaxOpenConns == 0 {
c.Database.MaxOpenConns = 25
}
if c.Database.MaxIdleConns == 0 {
c.Database.MaxIdleConns = 5
}
if c.Database.ConnMaxLifetime == 0 {
c.Database.ConnMaxLifetime = 5 * time.Minute
}
if c.Pricing.DefaultMethod == "" {
c.Pricing.DefaultMethod = "weighted_median"
}
if c.Pricing.DefaultPeriodDays == 0 {
c.Pricing.DefaultPeriodDays = 90
}
if c.Pricing.FreshnessGreenDays == 0 {
c.Pricing.FreshnessGreenDays = 30
}
if c.Pricing.FreshnessYellowDays == 0 {
c.Pricing.FreshnessYellowDays = 60
}
if c.Pricing.FreshnessRedDays == 0 {
c.Pricing.FreshnessRedDays = 90
}
if c.Pricing.MinQuotesForMedian == 0 {
c.Pricing.MinQuotesForMedian = 3
}
if c.Logging.Level == "" { if c.Logging.Level == "" {
c.Logging.Level = "info" c.Logging.Level = "info"
} }
@@ -89,5 +180,5 @@ func (c *Config) setDefaults() {
} }
func (c *Config) Address() string { func (c *Config) Address() string {
return net.JoinHostPort(c.Server.Host, strconv.Itoa(c.Server.Port)) return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
} }

View File

@@ -238,22 +238,6 @@ func (cm *ConnectionManager) Disconnect() {
cm.lastError = nil cm.lastError = nil
} }
// MarkOffline closes the current connection and preserves the last observed error.
func (cm *ConnectionManager) MarkOffline(err error) {
cm.mu.Lock()
defer cm.mu.Unlock()
if cm.db != nil {
sqlDB, dbErr := cm.db.DB()
if dbErr == nil {
sqlDB.Close()
}
}
cm.db = nil
cm.lastError = err
cm.lastCheck = time.Now()
}
// GetLastError returns the last connection error (thread-safe) // GetLastError returns the last connection error (thread-safe)
func (cm *ConnectionManager) GetLastError() error { func (cm *ConnectionManager) GetLastError() error {
cm.mu.RLock() cm.mu.RLock()

View File

@@ -1,60 +0,0 @@
package db
import (
"errors"
"fmt"
"time"
gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var errPermissionProbeRollback = errors.New("permission probe rollback")
// ValidateMariaDBConnection opens a one-off connection using dsn, pings, checks
// the required lot table exists, and probes write access to qt_client_schema_state.
// Returns (lot row count, canWrite, error).
func ValidateMariaDBConnection(dsn string) (int64, bool, error) {
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return 0, false, fmt.Errorf("open MariaDB connection: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return 0, false, fmt.Errorf("get database handle: %w", err)
}
defer sqlDB.Close()
if err := sqlDB.Ping(); err != nil {
return 0, false, fmt.Errorf("ping MariaDB: %w", err)
}
var lotCount int64
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
return 0, false, fmt.Errorf("check required table lot: %w", err)
}
return lotCount, testSyncWritePermission(db), nil
}
func testSyncWritePermission(db *gorm.DB) bool {
sentinel := fmt.Sprintf("quoteforge-permission-check-%d", time.Now().UnixNano())
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`
INSERT INTO qt_client_schema_state (username, hostname, last_checked_at, updated_at)
VALUES (?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at)
`, sentinel, "setup-check").Error; err != nil {
return err
}
return errPermissionProbeRollback
})
return errors.Is(err, errPermissionProbeRollback)
}

View File

@@ -49,7 +49,7 @@ func (h *ComponentHandler) List(c *gin.Context) {
offset := (page - 1) * perPage offset := (page - 1) * perPage
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage) localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -64,16 +64,11 @@ func (h *ComponentHandler) List(c *gin.Context) {
} }
} }
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
if totalPages < 1 {
totalPages = 1
}
c.JSON(http.StatusOK, &services.ComponentListResult{ c.JSON(http.StatusOK, &services.ComponentListResult{
Items: components, Components: components,
TotalCount: total, Total: total,
Page: page, Page: page,
PerPage: perPage, PerPage: perPage,
TotalPages: totalPages,
}) })
} }
@@ -95,12 +90,6 @@ func (h *ComponentHandler) Get(c *gin.Context) {
} }
func (h *ComponentHandler) GetCategories(c *gin.Context) { func (h *ComponentHandler) GetCategories(c *gin.Context) {
// Build display_order lookup from the canonical list.
orderMap := make(map[string]int, len(models.DefaultCategories))
for _, cat := range models.DefaultCategories {
orderMap[strings.ToUpper(cat.Code)] = cat.DisplayOrder
}
codes, err := h.localDB.GetLocalComponentCategories() codes, err := h.localDB.GetLocalComponentCategories()
if err == nil && len(codes) > 0 { if err == nil && len(codes) > 0 {
categories := make([]models.Category, 0, len(codes)) categories := make([]models.Category, 0, len(codes))
@@ -109,15 +98,7 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
if trimmed == "" { if trimmed == "" {
continue continue
} }
order := orderMap[strings.ToUpper(trimmed)] categories = append(categories, models.Category{Code: trimmed, Name: trimmed})
if order == 0 {
order = models.MaxKnownDisplayOrder + 1
}
categories = append(categories, models.Category{
Code: trimmed,
Name: trimmed,
DisplayOrder: order,
})
} }
c.JSON(http.StatusOK, categories) c.JSON(http.StatusOK, categories)
return return
@@ -125,102 +106,3 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
c.JSON(http.StatusOK, models.DefaultCategories) c.JSON(http.StatusOK, models.DefaultCategories)
} }
func (h *ComponentHandler) GetConfiguratorSettings(c *gin.Context) {
s, _ := h.localDB.GetConfiguratorSettings()
if s == nil {
s = &localdb.ConfiguratorSettings{}
}
if len(s.ConfigTypes) == 0 {
s.ConfigTypes = defaultConfigTypes()
}
if len(s.TabConfig) == 0 {
s.TabConfig = defaultTabConfig()
}
if len(s.AlwaysVisibleTabs) == 0 {
s.AlwaysVisibleTabs = []string{"base", "storage", "pci"}
}
if len(s.RequiredCategories) == 0 {
s.RequiredCategories = map[string][]string{"server": {"CPU", "MEM", "BB"}}
}
c.JSON(http.StatusOK, s)
}
func defaultConfigTypes() []localdb.ConfigTypeDef {
return []localdb.ConfigTypeDef{
{
Code: "server",
NameRu: "Сервер",
DisplayOrder: 10,
Categories: []string{
"MB", "CPU", "MEM", "RAID",
"SSD", "HDD", "M2", "EDSFF", "HHHL",
"GPU", "NIC", "HCA", "DPU", "HBA",
"PSU", "PS", "ACC", "RISERS", "CARD", "BB",
},
},
{
Code: "storage",
NameRu: "СХД",
DisplayOrder: 20,
Categories: []string{
"DKC", "CPU", "MEM", "PS",
"SSD", "HDD", "M2", "EDSFF", "HHHL",
"NIC", "HBA", "HCA", "ACC", "CARD",
},
},
}
}
func defaultTabConfig() []localdb.TabDef {
return []localdb.TabDef{
{
Key: "base",
Label: "Base",
SingleSelect: true,
Categories: []string{"MB", "CPU", "MEM", "ENC", "DKC", "CTL"},
},
{
Key: "storage",
Label: "Storage",
SingleSelect: false,
Categories: []string{"RAID", "M2", "SSD", "HDD", "EDSFF", "HHHL"},
Sections: []localdb.TabSection{
{Title: "RAID Контроллеры", Categories: []string{"RAID"}},
{Title: "Диски", Categories: []string{"M2", "SSD", "HDD", "EDSFF", "HHHL"}},
},
},
{
Key: "pci",
Label: "PCI",
SingleSelect: false,
Categories: []string{"GPU", "DPU", "NIC", "HCA", "HBA", "HIC"},
Sections: []localdb.TabSection{
{Title: "GPU / DPU", Categories: []string{"GPU", "DPU"}},
{Title: "NIC / HCA", Categories: []string{"NIC", "HCA"}},
{Title: "HBA", Categories: []string{"HBA"}},
{Title: "HIC", Categories: []string{"HIC"}},
},
},
{
Key: "power",
Label: "Power",
SingleSelect: false,
Categories: []string{"PS", "PSU"},
},
{
Key: "accessories",
Label: "Accessories",
SingleSelect: false,
Categories: []string{"ACC", "CARD"},
},
{
Key: "sw",
Label: "SW",
SingleSelect: false,
Categories: []string{"SW"},
},
}
}

View File

@@ -48,19 +48,17 @@ type ExportRequest struct {
} }
type ProjectExportOptionsRequest struct { type ProjectExportOptionsRequest struct {
IncludeLOT bool `json:"include_lot"` IncludeLOT bool `json:"include_lot"`
IncludeBOM bool `json:"include_bom"` IncludeBOM bool `json:"include_bom"`
IncludeEstimate bool `json:"include_estimate"` IncludeEstimate bool `json:"include_estimate"`
IncludeStock bool `json:"include_stock"` IncludeStock bool `json:"include_stock"`
IncludeCompetitor bool `json:"include_competitor"` IncludeCompetitor bool `json:"include_competitor"`
Basis string `json:"basis"` // "fob" or "ddp"
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
} }
func (h *ExportHandler) ExportCSV(c *gin.Context) { func (h *ExportHandler) ExportCSV(c *gin.Context) {
var req ExportRequest var req ExportRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -68,7 +66,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
// Validate before streaming (can return JSON error) // Validate before streaming (can return JSON error)
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 { if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no items to export"}) c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
return return
} }
@@ -150,9 +148,9 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
uuid := c.Param("uuid") uuid := c.Param("uuid")
// Get config before streaming (can return JSON error) // Get config before streaming (can return JSON error)
config, err := h.configService.GetByUUIDNoAuth(uuid) config, err := h.configService.GetByUUID(uuid, h.dbUsername)
if err != nil { if err != nil {
RespondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return return
} }
@@ -160,7 +158,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
// Validate before streaming (can return JSON error) // Validate before streaming (can return JSON error)
if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 { if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no items to export"}) c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
return return
} }
@@ -195,18 +193,18 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername) project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
if err != nil { if err != nil {
RespondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return return
} }
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active") result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if len(result.Configs) == 0 { if len(result.Configs) == 0 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"}) c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
return return
} }
@@ -223,85 +221,28 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
} }
} }
func (h *ExportHandler) ExportConfigPricingCSV(c *gin.Context) {
uuid := c.Param("uuid")
var req ProjectExportOptionsRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}
config, err := h.configService.GetByUUIDNoAuth(uuid)
if err != nil {
RespondError(c, http.StatusNotFound, "resource not found", err)
return
}
opts := services.ProjectPricingExportOptions{
IncludeLOT: req.IncludeLOT,
IncludeBOM: req.IncludeBOM,
IncludeEstimate: req.IncludeEstimate,
IncludeStock: req.IncludeStock,
IncludeCompetitor: req.IncludeCompetitor,
Basis: req.Basis,
SaleMarkup: req.SaleMarkup,
}
data, err := h.exportService.ConfigToPricingExportData(config, opts)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
basisLabel := "FOB"
if strings.EqualFold(strings.TrimSpace(req.Basis), "ddp") {
basisLabel = "DDP"
}
projectCode := config.Name
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, h.dbUsername); err == nil && project != nil {
projectCode = project.Code
}
}
filename := fmt.Sprintf("%s (%s) %s %s SPEC.csv",
time.Now().Format("2006-01-02"),
projectCode,
config.Name,
basisLabel,
)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
if err := h.exportService.ToPricingCSV(c.Writer, data, opts); err != nil {
c.Error(err)
}
}
func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) { func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
projectUUID := c.Param("uuid") projectUUID := c.Param("uuid")
var req ProjectExportOptionsRequest var req ProjectExportOptionsRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername) project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
if err != nil { if err != nil {
RespondError(c, http.StatusNotFound, "resource not found", err) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return return
} }
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active") result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if len(result.Configs) == 0 { if len(result.Configs) == 0 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"}) c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
return return
} }
@@ -311,25 +252,15 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
IncludeEstimate: req.IncludeEstimate, IncludeEstimate: req.IncludeEstimate,
IncludeStock: req.IncludeStock, IncludeStock: req.IncludeStock,
IncludeCompetitor: req.IncludeCompetitor, IncludeCompetitor: req.IncludeCompetitor,
Basis: req.Basis,
SaleMarkup: req.SaleMarkup,
} }
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts) data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
basisLabel := "FOB" filename := fmt.Sprintf("%s (%s) pricing.csv", time.Now().Format("2006-01-02"), project.Code)
if strings.EqualFold(strings.TrimSpace(req.Basis), "ddp") {
basisLabel = "DDP"
}
variantLabel := strings.TrimSpace(project.Variant)
if variantLabel == "" {
variantLabel = "main"
}
filename := fmt.Sprintf("%s (%s) %s %s.csv", time.Now().Format("2006-01-02"), project.Code, basisLabel, variantLabel)
c.Header("Content-Type", "text/csv; charset=utf-8") 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))

View File

@@ -26,15 +26,11 @@ func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*model
return m.config, m.err return m.config, m.err
} }
func (m *mockConfigService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
return m.config, m.err
}
func TestExportCSV_Success(t *testing.T) { func TestExportCSV_Success(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
// Create handler with mocks // Create handler with mocks
exportSvc := services.NewExportService(config.ExportConfig{}, nil) exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
handler := NewExportHandler( handler := NewExportHandler(
exportSvc, exportSvc,
&mockConfigService{}, &mockConfigService{},
@@ -109,7 +105,7 @@ func TestExportCSV_Success(t *testing.T) {
func TestExportCSV_InvalidRequest(t *testing.T) { func TestExportCSV_InvalidRequest(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
exportSvc := services.NewExportService(config.ExportConfig{}, nil) exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
handler := NewExportHandler( handler := NewExportHandler(
exportSvc, exportSvc,
&mockConfigService{}, &mockConfigService{},
@@ -128,8 +124,8 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
handler.ExportCSV(c) handler.ExportCSV(c)
// Should return 400 Bad Request // Should return 400 Bad Request
if w.Code != http.StatusUnprocessableEntity { if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 422, got %d", w.Code) t.Errorf("Expected status 400, got %d", w.Code)
} }
// Should return JSON error // Should return JSON error
@@ -143,7 +139,7 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
func TestExportCSV_EmptyItems(t *testing.T) { func TestExportCSV_EmptyItems(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
exportSvc := services.NewExportService(config.ExportConfig{}, nil) exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
handler := NewExportHandler( handler := NewExportHandler(
exportSvc, exportSvc,
&mockConfigService{}, &mockConfigService{},
@@ -162,8 +158,8 @@ func TestExportCSV_EmptyItems(t *testing.T) {
handler.ExportCSV(c) handler.ExportCSV(c)
// Should return 400 Bad Request (validation error from gin binding) // Should return 400 Bad Request (validation error from gin binding)
if w.Code != http.StatusUnprocessableEntity { if w.Code != http.StatusBadRequest {
t.Logf("Status code: %d (expected 422 for empty items)", w.Code) t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
} }
} }
@@ -185,7 +181,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
exportSvc := services.NewExportService(config.ExportConfig{}, nil) exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
handler := NewExportHandler( handler := NewExportHandler(
exportSvc, exportSvc,
&mockConfigService{config: mockConfig}, &mockConfigService{config: mockConfig},
@@ -232,7 +228,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
func TestExportConfigCSV_NotFound(t *testing.T) { func TestExportConfigCSV_NotFound(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
exportSvc := services.NewExportService(config.ExportConfig{}, nil) exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
handler := NewExportHandler( handler := NewExportHandler(
exportSvc, exportSvc,
&mockConfigService{err: errors.New("config not found")}, &mockConfigService{err: errors.New("config not found")},
@@ -275,7 +271,7 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
exportSvc := services.NewExportService(config.ExportConfig{}, nil) exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
handler := NewExportHandler( handler := NewExportHandler(
exportSvc, exportSvc,
&mockConfigService{config: mockConfig}, &mockConfigService{config: mockConfig},
@@ -294,8 +290,8 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
handler.ExportConfigCSV(c) handler.ExportConfigCSV(c)
// Should return 400 Bad Request // Should return 400 Bad Request
if w.Code != http.StatusUnprocessableEntity { if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 422, got %d", w.Code) t.Errorf("Expected status 400, got %d", w.Code)
} }
// Should return JSON error // Should return JSON error

View File

@@ -25,7 +25,7 @@ func (h *PartnumberBooksHandler) List(c *gin.Context) {
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB()) bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
books, err := bookRepo.ListBooks() books, err := bookRepo.ListBooks()
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -51,11 +51,8 @@ func (h *PartnumberBooksHandler) List(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"items": summaries, "books": summaries,
"total_count": len(summaries), "total": len(summaries),
"page": 1,
"per_page": len(summaries),
"total_pages": 1,
}) })
} }
@@ -65,7 +62,7 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64) id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil { if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid book ID"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"})
return return
} }
@@ -80,32 +77,28 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
perPage = 100 perPage = 100
} }
book, err := h.localDB.GetLocalPartnumberBookByServerID(uint(id)) // Find local book by server_id
if err != nil { var book localdb.LocalPartnumberBook
if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
return return
} }
items, total, err := bookRepo.GetBookItemsPage(book.ID, search, page, perPage) items, total, err := bookRepo.GetBookItemsPage(book.ID, search, page, perPage)
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
if totalPages < 1 {
totalPages = 1
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"book_id": book.ServerID, "book_id": book.ServerID,
"version": book.Version, "version": book.Version,
"is_active": book.IsActive, "is_active": book.IsActive,
"partnumbers": book.PartnumbersJSON, "partnumbers": book.PartnumbersJSON,
"items": items, "items": items,
"total_count": total, "total": total,
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
"total_pages": totalPages,
"search": search, "search": search,
"book_total": bookRepo.CountBookItems(book.ID), "book_total": bookRepo.CountBookItems(book.ID),
"lot_count": bookRepo.CountDistinctLots(book.ID), "lot_count": bookRepo.CountDistinctLots(book.ID),

View File

@@ -34,7 +34,7 @@ func (h *PricelistHandler) List(c *gin.Context) {
localPLs, err := h.localDB.GetLocalPricelists() localPLs, err := h.localDB.GetLocalPricelists()
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if source != "" { if source != "" {
@@ -106,16 +106,11 @@ func (h *PricelistHandler) List(c *gin.Context) {
}) })
} }
totalPages := (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"items": summaries, "pricelists": summaries,
"total_count": total, "total": total,
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
"total_pages": totalPages,
}) })
} }
@@ -124,7 +119,7 @@ func (h *PricelistHandler) Get(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32) id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return return
} }
@@ -151,7 +146,7 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32) id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return return
} }
@@ -170,47 +165,40 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
if perPage < 1 { if perPage < 1 {
perPage = 50 perPage = 50
} }
var items []localdb.LocalPricelistItem
items, total, err := h.localDB.GetLocalPricelistItemsPage(localPL.ID, strings.TrimSpace(search), page, perPage) dbq := h.localDB.DB().Model(&localdb.LocalPricelistItem{}).Where("pricelist_id = ?", localPL.ID)
if err != nil { if strings.TrimSpace(search) != "" {
RespondError(c, http.StatusInternalServerError, "internal server error", err) dbq = dbq.Where("lot_name LIKE ?", "%"+strings.TrimSpace(search)+"%")
}
var total int64
if err := dbq.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
offset := (page - 1) * perPage
lotNames := make([]string, len(items)) if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
for i, item := range items { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
lotNames[i] = item.LotName
}
descMap, err := h.localDB.GetLocalComponentDescriptionsByLotNames(lotNames)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
resultItems := make([]gin.H, 0, len(items)) resultItems := make([]gin.H, 0, len(items))
for _, item := range items { for _, item := range items {
resultItems = append(resultItems, gin.H{ resultItems = append(resultItems, gin.H{
"id": item.ID, "id": item.ID,
"lot_name": item.LotName, "lot_name": item.LotName,
"lot_description": descMap[item.LotName], "price": item.Price,
"price": item.Price, "category": item.LotCategory,
"category": item.LotCategory, "available_qty": item.AvailableQty,
"available_qty": item.AvailableQty, "partnumbers": []string(item.Partnumbers),
"partnumbers": []string(item.Partnumbers),
"partnumber_qtys": map[string]interface{}{},
"competitor_names": []string{},
"price_spread_pct": nil,
}) })
} }
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"source": localPL.Source, "source": localPL.Source,
"items": resultItems, "items": resultItems,
"total_count": total, "total": total,
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
"total_pages": totalPages,
}) })
} }
@@ -218,7 +206,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32) id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return return
} }
@@ -229,7 +217,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
} }
items, err := h.localDB.GetLocalPricelistItems(localPL.ID) items, err := h.localDB.GetLocalPricelistItems(localPL.ID)
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
lotNames := make([]string, 0, len(items)) lotNames := make([]string, 0, len(items))

View File

@@ -141,21 +141,21 @@ func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
} }
var resp struct { var resp struct {
Items []struct { Pricelists []struct {
ID uint `json:"id"` ID uint `json:"id"`
} `json:"items"` } `json:"pricelists"`
TotalCount int `json:"total_count"` Total int `json:"total"`
} }
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err) t.Fatalf("unmarshal response: %v", err)
} }
if resp.TotalCount != 1 { if resp.Total != 1 {
t.Fatalf("expected total=1, got %d", resp.TotalCount) t.Fatalf("expected total=1, got %d", resp.Total)
} }
if len(resp.Items) != 1 { if len(resp.Pricelists) != 1 {
t.Fatalf("expected 1 pricelist, got %d", len(resp.Items)) t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists))
} }
if resp.Items[0].ID != 10 { if resp.Pricelists[0].ID != 10 {
t.Fatalf("expected pricelist id=10, got %d", resp.Items[0].ID) t.Fatalf("expected pricelist id=10, got %d", resp.Pricelists[0].ID)
} }
} }

View File

@@ -18,13 +18,13 @@ func NewQuoteHandler(quoteService *services.QuoteService) *QuoteHandler {
func (h *QuoteHandler) Validate(c *gin.Context) { func (h *QuoteHandler) Validate(c *gin.Context) {
var req services.QuoteRequest var req services.QuoteRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
result, err := h.quoteService.ValidateAndCalculate(&req) result, err := h.quoteService.ValidateAndCalculate(&req)
if err != nil { if err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -34,13 +34,13 @@ func (h *QuoteHandler) Validate(c *gin.Context) {
func (h *QuoteHandler) Calculate(c *gin.Context) { func (h *QuoteHandler) Calculate(c *gin.Context) {
var req services.QuoteRequest var req services.QuoteRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
result, err := h.quoteService.ValidateAndCalculate(&req) result, err := h.quoteService.ValidateAndCalculate(&req)
if err != nil { if err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -53,13 +53,13 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
func (h *QuoteHandler) PriceLevels(c *gin.Context) { func (h *QuoteHandler) PriceLevels(c *gin.Context) {
var req services.PriceLevelsRequest var req services.PriceLevelsRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
result, err := h.quoteService.CalculatePriceLevels(&req) result, err := h.quoteService.CalculatePriceLevels(&req)
if err != nil { if err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }

View File

@@ -1,73 +0,0 @@
package handlers
import (
"encoding/json"
"errors"
"io"
"strings"
"github.com/gin-gonic/gin"
)
func RespondError(c *gin.Context, status int, fallback string, err error) {
if err != nil {
_ = c.Error(err)
}
c.JSON(status, gin.H{"error": clientFacingErrorMessage(status, fallback, err)})
}
func clientFacingErrorMessage(status int, fallback string, err error) string {
if err == nil {
return fallback
}
if status >= 500 {
return fallback
}
if isRequestDecodeError(err) {
return fallback
}
message := strings.TrimSpace(err.Error())
if message == "" {
return fallback
}
if looksTechnicalError(message) {
return fallback
}
return message
}
func isRequestDecodeError(err error) bool {
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
return true
}
var unmarshalTypeErr *json.UnmarshalTypeError
if errors.As(err, &unmarshalTypeErr) {
return true
}
return errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF)
}
func looksTechnicalError(message string) bool {
lower := strings.ToLower(strings.TrimSpace(message))
needles := []string{
"sql",
"gorm",
"driver",
"constraint",
"syntax error",
"unexpected eof",
"record not found",
"no such table",
"stack trace",
}
for _, needle := range needles {
if strings.Contains(lower, needle) {
return true
}
}
return false
}

View File

@@ -1,41 +0,0 @@
package handlers
import (
"encoding/json"
"testing"
)
func TestClientFacingErrorMessageKeepsDomain4xx(t *testing.T) {
t.Parallel()
got := clientFacingErrorMessage(400, "invalid request", &json.SyntaxError{Offset: 1})
if got != "invalid request" {
t.Fatalf("expected fallback for decode error, got %q", got)
}
}
func TestClientFacingErrorMessagePreservesBusinessMessage(t *testing.T) {
t.Parallel()
err := errString("main project variant cannot be deleted")
got := clientFacingErrorMessage(400, "invalid request", err)
if got != err.Error() {
t.Fatalf("expected business message, got %q", got)
}
}
func TestClientFacingErrorMessageHidesTechnical4xx(t *testing.T) {
t.Parallel()
err := errString("sql: no rows in result set")
got := clientFacingErrorMessage(404, "resource not found", err)
if got != "resource not found" {
t.Fatalf("expected fallback for technical error, got %q", got)
}
}
type errString string
func (e errString) Error() string {
return string(e)
}

View File

@@ -12,8 +12,11 @@ import (
qfassets "git.mchus.pro/mchus/quoteforge" qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"github.com/gin-gonic/gin"
mysqlDriver "github.com/go-sql-driver/mysql" mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin"
gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
) )
type SetupHandler struct { type SetupHandler struct {
@@ -61,8 +64,7 @@ func (h *SetupHandler) ShowSetup(c *gin.Context) {
tmpl := h.templates["setup.html"] tmpl := h.templates["setup.html"]
if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil { if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil {
_ = c.Error(err) c.String(http.StatusInternalServerError, "Template error: %v", err)
c.String(http.StatusInternalServerError, "Template error")
} }
} }
@@ -87,16 +89,49 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
} }
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
lotCount, canWrite, err := db.ValidateMariaDBConnection(dsn)
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil { if err != nil {
_ = c.Error(err)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"error": "Connection check failed", "error": fmt.Sprintf("Connection failed: %v", err),
}) })
return return
} }
sqlDB, err := db.DB()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Failed to get database handle: %v", err),
})
return
}
defer sqlDB.Close()
if err := sqlDB.Ping(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Ping failed: %v", err),
})
return
}
// Check for required tables
var lotCount int64
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Table 'lot' not found or inaccessible: %v", err),
})
return
}
// Check write permission
canWrite := testWritePermission(db)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"lot_count": lotCount, "lot_count": lotCount,
@@ -129,21 +164,26 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
// Test connection first // Test connection first
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
if _, _, err := db.ValidateMariaDBConnection(dsn); err != nil {
_ = c.Error(err) db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"success": false, "success": false,
"error": "Connection check failed", "error": fmt.Sprintf("Connection failed: %v", err),
}) })
return return
} }
sqlDB, _ := db.DB()
sqlDB.Close()
// Save settings // Save settings
if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil { if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil {
_ = c.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Failed to save settings", "error": fmt.Sprintf("Failed to save settings: %v", err),
}) })
return return
} }
@@ -192,6 +232,22 @@ func (h *SetupHandler) GetStatus(c *gin.Context) {
}) })
} }
func testWritePermission(db *gorm.DB) bool {
// Simple check: try to create a temporary table and drop it
testTable := fmt.Sprintf("qt_write_test_%d", time.Now().UnixNano())
// Try to create a test table
err := db.Exec(fmt.Sprintf("CREATE TABLE %s (id INT)", testTable)).Error
if err != nil {
return false
}
// Drop it immediately
db.Exec(fmt.Sprintf("DROP TABLE %s", testTable))
return true
}
func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string { func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string {
cfg := mysqlDriver.NewConfig() cfg := mysqlDriver.NewConfig()
cfg.User = user cfg.User = user
@@ -207,4 +263,3 @@ func buildMySQLDSN(host string, port int, database, user, password string, timeo
} }
return cfg.FormatDSN() return cfg.FormatDSN()
} }

View File

@@ -1,212 +0,0 @@
package handlers
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"runtime"
"strconv"
"time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
)
type SupportBundleHandler struct {
localDB *localdb.LocalDB
connMgr *db.ConnectionManager
syncService *syncsvc.Service
logFilePath string
}
func NewSupportBundleHandler(local *localdb.LocalDB, connMgr *db.ConnectionManager, svc *syncsvc.Service, logFilePath string) *SupportBundleHandler {
return &SupportBundleHandler{
localDB: local,
connMgr: connMgr,
syncService: svc,
logFilePath: logFilePath,
}
}
// DownloadBundle collects diagnostic data and streams a ZIP archive.
// GET /api/support-bundle
func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
now := time.Now().UTC()
hostname, err := os.Hostname()
if err != nil {
slog.Warn("support bundle: could not get hostname", "err", err)
}
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="qfs-bundle-%s.zip"`, now.Format("20060102-150405")))
zw := zip.NewWriter(c.Writer)
defer zw.Close()
writeJSON := func(name string, v any) {
w, err := zw.Create(name)
if err != nil {
return
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
// app_info.json
writeJSON("app_info.json", map[string]any{
"app_version": appmeta.Version(),
"go_version": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"hostname": hostname,
"db_user": h.localDB.GetDBUser(),
"collected_at": now.Format(time.RFC3339),
})
// local_db_stats.json
writeJSON("local_db_stats.json", map[string]any{
"components": h.localDB.CountLocalComponents(),
"configurations": h.localDB.CountConfigurations(),
"projects": h.localDB.CountProjects(),
"pricelists": h.localDB.CountLocalPricelists(),
"pending_changes": h.localDB.GetPendingCount(),
"db_size_bytes": h.localDB.DBFileSizeBytes(),
"last_pricelist_sync_time": h.localDB.GetLastSyncTime(),
"last_pricelist_attempt": h.localDB.GetLastPricelistSyncAttemptAt(),
"last_pricelist_status": h.localDB.GetLastPricelistSyncStatus(),
"last_pricelist_error": h.localDB.GetLastPricelistSyncError(),
"last_component_sync_attempt": h.localDB.GetLastComponentSyncAttemptAt(),
"last_component_sync_status": h.localDB.GetLastComponentSyncStatus(),
"last_component_sync_error": h.localDB.GetLastComponentSyncError(),
})
// db_connection.json — includes TCP ping to DB host
connStatus := h.connMgr.GetStatus()
dbConnDoc := map[string]any{
"is_connected": connStatus.IsConnected,
"last_error": connStatus.LastError,
}
if settings, err := h.localDB.GetSettings(); err == nil && settings.Host != "" {
addr := net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
start := time.Now()
conn, dialErr := net.DialTimeout("tcp", addr, 3*time.Second)
pingMs := time.Since(start).Milliseconds()
if dialErr == nil {
conn.Close()
dbConnDoc["tcp_ping_ms"] = pingMs
dbConnDoc["tcp_ping_addr"] = addr
} else {
dbConnDoc["tcp_ping_error"] = dialErr.Error()
dbConnDoc["tcp_ping_addr"] = addr
}
}
writeJSON("db_connection.json", dbConnDoc)
// sync_readiness.json
if h.syncService != nil {
readiness, err := h.syncService.GetReadiness()
if err != nil {
writeJSON("sync_readiness.json", map[string]any{"error": err.Error()})
} else {
writeJSON("sync_readiness.json", readiness)
}
}
// system_metrics.json
writeJSON("system_metrics.json", collectSystemMetrics())
// sync_log.json — history of sync operations
if entries, err := h.localDB.GetSyncLog(200); err == nil {
writeJSON("sync_log.json", entries)
}
// pricelists.json — downloaded pricelists grouped by source
if pricelists, err := h.localDB.GetLocalPricelists(); err == nil {
type plEntry struct {
ServerID uint `json:"server_id"`
Source string `json:"source"`
Version string `json:"version"`
Name string `json:"name,omitempty"`
CreatedAt time.Time `json:"created_at"`
SyncedAt time.Time `json:"synced_at"`
IsUsed bool `json:"is_used"`
}
bySource := map[string][]plEntry{}
for _, pl := range pricelists {
e := plEntry{
ServerID: pl.ServerID,
Source: pl.Source,
Version: pl.Version,
Name: pl.Name,
CreatedAt: pl.CreatedAt,
SyncedAt: pl.SyncedAt,
IsUsed: pl.IsUsed,
}
bySource[pl.Source] = append(bySource[pl.Source], e)
}
writeJSON("pricelists.json", bySource)
}
// schema_migrations.json
migrations, err := h.localDB.GetSchemaMigrations()
if err != nil {
slog.Warn("support bundle: could not load schema migrations", "err", err)
}
writeJSON("schema_migrations.json", migrations)
// app.log (tail 5 MiB)
if h.logFilePath != "" {
if f, err := os.Open(h.logFilePath); err == nil {
defer f.Close()
if info, err := f.Stat(); err == nil {
const maxLog = 5 << 20
offset := int64(0)
if info.Size() > maxLog {
offset = info.Size() - maxLog
}
if _, err := f.Seek(offset, io.SeekStart); err == nil {
if w, err := zw.Create("app.log"); err == nil {
if _, err := io.Copy(w, f); err != nil {
slog.Warn("support bundle: error copying log file", "err", err)
}
}
}
}
}
}
c.Status(http.StatusOK)
}
func collectSystemMetrics() map[string]any {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
m := map[string]any{
"goroutines": runtime.NumGoroutine(),
"cpu_count": runtime.NumCPU(),
"heap_alloc_bytes": ms.HeapAlloc,
"heap_sys_bytes": ms.HeapSys,
"heap_inuse_bytes": ms.HeapInuse,
"stack_inuse_bytes": ms.StackInuse,
"gc_cycles": ms.NumGC,
"next_gc_bytes": ms.NextGC,
}
if wd, err := os.Getwd(); err == nil {
if info := diskUsage(wd); info != nil {
m["disk"] = info
}
}
return m
}

View File

@@ -1,20 +0,0 @@
//go:build linux || darwin
package handlers
import "syscall"
func diskUsage(path string) map[string]any {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return nil
}
total := stat.Blocks * uint64(stat.Bsize)
free := stat.Bfree * uint64(stat.Bsize)
return map[string]any{
"total_bytes": total,
"free_bytes": free,
"used_bytes": total - free,
"path": path,
}
}

View File

@@ -1,7 +0,0 @@
//go:build windows
package handlers
func diskUsage(_ string) map[string]any {
return nil
}

View File

@@ -6,7 +6,6 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
stdsync "sync" stdsync "sync"
"time" "time"
@@ -50,20 +49,15 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
// SyncStatusResponse represents the sync status // SyncStatusResponse represents the sync status
type SyncStatusResponse struct { type SyncStatusResponse struct {
LastComponentSync *time.Time `json:"last_component_sync"` LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"` LastPricelistSync *time.Time `json:"last_pricelist_sync"`
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"` IsOnline bool `json:"is_online"`
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"` ComponentsCount int64 `json:"components_count"`
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"` PricelistsCount int64 `json:"pricelists_count"`
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"` ServerPricelists int `json:"server_pricelists"`
KnownServerChangesMiss bool `json:"known_server_changes_missing"` NeedComponentSync bool `json:"need_component_sync"`
IsOnline bool `json:"is_online"` NeedPricelistSync bool `json:"need_pricelist_sync"`
ComponentsCount int64 `json:"components_count"` Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
} }
type SyncReadinessResponse struct { type SyncReadinessResponse struct {
@@ -78,34 +72,42 @@ type SyncReadinessResponse struct {
// GetStatus returns current sync status // GetStatus returns current sync status
// GET /api/sync/status // GET /api/sync/status
func (h *SyncHandler) GetStatus(c *gin.Context) { func (h *SyncHandler) GetStatus(c *gin.Context) {
connStatus := h.connMgr.GetStatus() // Check online status by pinging MariaDB
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == "" isOnline := h.checkOnline()
// Get sync times
lastComponentSync := h.localDB.GetComponentSyncTime() lastComponentSync := h.localDB.GetComponentSyncTime()
lastPricelistSync := h.localDB.GetLastSyncTime() lastPricelistSync := h.localDB.GetLastSyncTime()
// Get counts
componentsCount := h.localDB.CountLocalComponents() componentsCount := h.localDB.CountLocalComponents()
pricelistsCount := h.localDB.CountLocalPricelists() pricelistsCount := h.localDB.CountLocalPricelists()
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus() // Get server pricelist count if online
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError() serverPricelists := 0
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed") needPricelistSync := false
if isOnline {
status, err := h.syncService.GetStatus()
if err == nil {
serverPricelists = status.ServerPricelists
needPricelistSync = status.NeedsSync
}
}
// Check if component sync is needed (older than 24 hours)
needComponentSync := h.localDB.NeedComponentSync(24) needComponentSync := h.localDB.NeedComponentSync(24)
readiness := h.getReadinessLocal() readiness := h.getReadinessCached(10 * time.Second)
c.JSON(http.StatusOK, SyncStatusResponse{ c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync, LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync, LastPricelistSync: lastPricelistSync,
LastPricelistAttemptAt: lastPricelistAttemptAt, IsOnline: isOnline,
LastPricelistSyncStatus: lastPricelistSyncStatus, ComponentsCount: componentsCount,
LastPricelistSyncError: lastPricelistSyncError, PricelistsCount: pricelistsCount,
HasIncompleteServerSync: hasFailedSync, ServerPricelists: serverPricelists,
KnownServerChangesMiss: hasFailedSync, NeedComponentSync: needComponentSync,
IsOnline: isOnline, NeedPricelistSync: needPricelistSync,
ComponentsCount: componentsCount, Readiness: readiness,
PricelistsCount: pricelistsCount,
ServerPricelists: 0,
NeedComponentSync: needComponentSync,
NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
Readiness: readiness,
}) })
} }
@@ -114,7 +116,9 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
func (h *SyncHandler) GetReadiness(c *gin.Context) { func (h *SyncHandler) GetReadiness(c *gin.Context) {
readiness, err := h.syncService.GetReadiness() readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil { if err != nil && readiness == nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return return
} }
if readiness == nil { if readiness == nil {
@@ -154,9 +158,8 @@ func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "internal server error", "error": err.Error(),
}) })
_ = c.Error(err)
_ = readiness _ = readiness
return false return false
} }
@@ -181,31 +184,20 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
if err != nil { if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{ c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false, "success": false,
"error": "database connection failed", "error": "Database connection failed: " + err.Error(),
}) })
_ = c.Error(err)
return return
} }
now := time.Now()
result, err := h.localDB.SyncComponents(mariaDB) result, err := h.localDB.SyncComponents(mariaDB)
if err != nil { if err != nil {
_ = h.localDB.SetComponentSyncResult("error", err.Error(), now)
h.localDB.AppendSyncLog("components", "error", err.Error(), 0, now, time.Since(now).Milliseconds())
slog.Error("component sync failed", "error", err) slog.Error("component sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "component sync failed", "error": err.Error(),
}) })
_ = c.Error(err)
return return
} }
_ = h.localDB.SetComponentSyncResult("ok", "", now)
h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds())
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
slog.Warn("qt_settings sync failed", "error", err)
}
c.JSON(http.StatusOK, SyncResultResponse{ c.JSON(http.StatusOK, SyncResultResponse{
Success: true, Success: true,
@@ -225,20 +217,13 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
startTime := time.Now() startTime := time.Now()
synced, err := h.syncService.SyncPricelists() synced, err := h.syncService.SyncPricelists()
if err != nil { if err != nil {
h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, startTime, time.Since(startTime).Milliseconds())
slog.Error("pricelist sync failed", "error", err) slog.Error("pricelist sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "pricelist sync failed", "error": err.Error(),
}) })
_ = c.Error(err)
return return
} }
h.localDB.AppendSyncLog("pricelists", "ok", "", synced, startTime, time.Since(startTime).Milliseconds())
if _, err := h.syncService.PullPartnumberBooks(); err != nil {
slog.Warn("partnumber books pull failed after pricelist sync", "error", err)
}
c.JSON(http.StatusOK, SyncResultResponse{ c.JSON(http.StatusOK, SyncResultResponse{
Success: true, Success: true,
@@ -246,6 +231,7 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
Synced: synced, Synced: synced,
Duration: time.Since(startTime).String(), Duration: time.Since(startTime).String(),
}) })
h.syncService.RecordSyncHeartbeat()
} }
// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite. // SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite.
@@ -261,9 +247,8 @@ func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
slog.Error("partnumber books pull failed", "error", err) slog.Error("partnumber books pull failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "partnumber books sync failed", "error": err.Error(),
}) })
_ = c.Error(err)
return return
} }
@@ -273,6 +258,7 @@ func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
Synced: pulled, Synced: pulled,
Duration: time.Since(startTime).String(), Duration: time.Since(startTime).String(),
}) })
h.syncService.RecordSyncHeartbeat()
} }
// SyncAllResponse represents result of full sync // SyncAllResponse represents result of full sync
@@ -309,9 +295,8 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("pending push failed during full sync", "error", err) slog.Error("pending push failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "pending changes push failed", "error": "Pending changes push failed: " + err.Error(),
}) })
_ = c.Error(err)
return return
} }
@@ -320,65 +305,45 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
if err != nil { if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{ c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false, "success": false,
"error": "database connection failed", "error": "Database connection failed: " + err.Error(),
}) })
_ = c.Error(err)
return return
} }
compNow := time.Now()
compResult, err := h.localDB.SyncComponents(mariaDB) compResult, err := h.localDB.SyncComponents(mariaDB)
if err != nil { if err != nil {
_ = h.localDB.SetComponentSyncResult("error", err.Error(), compNow)
h.localDB.AppendSyncLog("components", "error", err.Error(), 0, compNow, time.Since(compNow).Milliseconds())
slog.Error("component sync failed during full sync", "error", err) slog.Error("component sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "component sync failed", "error": "Component sync failed: " + err.Error(),
}) })
_ = c.Error(err)
return return
} }
_ = h.localDB.SetComponentSyncResult("ok", "", compNow)
h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds())
componentsSynced = compResult.TotalSynced componentsSynced = compResult.TotalSynced
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
slog.Warn("qt_settings sync failed", "error", err)
}
// Sync pricelists // Sync pricelists
plNow := time.Now()
pricelistsSynced, err = h.syncService.SyncPricelists() pricelistsSynced, err = h.syncService.SyncPricelists()
if err != nil { if err != nil {
h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, plNow, time.Since(plNow).Milliseconds())
slog.Error("pricelist sync failed during full sync", "error", err) slog.Error("pricelist sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "pricelist sync failed", "error": "Pricelist sync failed: " + err.Error(),
"pending_pushed": pendingPushed, "pending_pushed": pendingPushed,
"components_synced": componentsSynced, "components_synced": componentsSynced,
}) })
_ = c.Error(err)
return return
} }
h.localDB.AppendSyncLog("pricelists", "ok", "", pricelistsSynced, plNow, time.Since(plNow).Milliseconds())
if _, err := h.syncService.PullPartnumberBooks(); err != nil {
slog.Warn("partnumber books pull failed during full sync", "error", err)
}
projectsResult, err := h.syncService.ImportProjectsToLocal() projectsResult, err := h.syncService.ImportProjectsToLocal()
if err != nil { if err != nil {
slog.Error("project import failed during full sync", "error", err) slog.Error("project import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "project import failed", "error": "Project import failed: " + err.Error(),
"pending_pushed": pendingPushed, "pending_pushed": pendingPushed,
"components_synced": componentsSynced, "components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced, "pricelists_synced": pricelistsSynced,
}) })
_ = c.Error(err)
return return
} }
@@ -387,7 +352,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("configuration import failed during full sync", "error", err) slog.Error("configuration import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "configuration import failed", "error": "Configuration import failed: " + err.Error(),
"pending_pushed": pendingPushed, "pending_pushed": pendingPushed,
"components_synced": componentsSynced, "components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced, "pricelists_synced": pricelistsSynced,
@@ -395,7 +360,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
"projects_updated": projectsResult.Updated, "projects_updated": projectsResult.Updated,
"projects_skipped": projectsResult.Skipped, "projects_skipped": projectsResult.Skipped,
}) })
_ = c.Error(err)
return return
} }
@@ -413,6 +377,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
ConfigurationsSkipped: configsResult.Skipped, ConfigurationsSkipped: configsResult.Skipped,
Duration: time.Since(startTime).String(), Duration: time.Since(startTime).String(),
}) })
h.syncService.RecordSyncHeartbeat()
} }
// checkOnline checks if MariaDB is accessible // checkOnline checks if MariaDB is accessible
@@ -433,9 +398,8 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
slog.Error("push pending changes failed", "error", err) slog.Error("push pending changes failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "pending changes push failed", "error": err.Error(),
}) })
_ = c.Error(err)
return return
} }
@@ -445,6 +409,7 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
Synced: pushed, Synced: pushed,
Duration: time.Since(startTime).String(), Duration: time.Since(startTime).String(),
}) })
h.syncService.RecordSyncHeartbeat()
} }
// GetPendingCount returns the number of pending changes // GetPendingCount returns the number of pending changes
@@ -461,7 +426,9 @@ func (h *SyncHandler) GetPendingCount(c *gin.Context) {
func (h *SyncHandler) GetPendingChanges(c *gin.Context) { func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
changes, err := h.localDB.GetPendingChanges() changes, err := h.localDB.GetPendingChanges()
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return return
} }
@@ -478,9 +445,8 @@ func (h *SyncHandler) RepairPendingChanges(c *gin.Context) {
slog.Error("repair pending changes failed", "error", err) slog.Error("repair pending changes failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "pending changes repair failed", "error": err.Error(),
}) })
_ = c.Error(err)
return return
} }
@@ -499,13 +465,8 @@ type SyncInfoResponse struct {
DBName string `json:"db_name"` DBName string `json:"db_name"`
// Status // Status
IsOnline bool `json:"is_online"` IsOnline bool `json:"is_online"`
LastSyncAt *time.Time `json:"last_sync_at"` LastSyncAt *time.Time `json:"last_sync_at"`
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
// Statistics // Statistics
LotCount int64 `json:"lot_count"` LotCount int64 `json:"lot_count"`
@@ -541,8 +502,8 @@ type SyncError struct {
// GetInfo returns sync information for modal // GetInfo returns sync information for modal
// GET /api/sync/info // GET /api/sync/info
func (h *SyncHandler) GetInfo(c *gin.Context) { func (h *SyncHandler) GetInfo(c *gin.Context) {
connStatus := h.connMgr.GetStatus() // Check online status by pinging MariaDB
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == "" isOnline := h.checkOnline()
// Get DB connection info // Get DB connection info
var dbHost, dbUser, dbName string var dbHost, dbUser, dbName string
@@ -554,12 +515,6 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
// Get sync times // Get sync times
lastPricelistSync := h.localDB.GetLastSyncTime() lastPricelistSync := h.localDB.GetLastSyncTime()
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
needPricelistSync := lastPricelistSync == nil || hasFailedSync
hasIncompleteServerSync := hasFailedSync
// Get local counts // Get local counts
configCount := h.localDB.CountConfigurations() configCount := h.localDB.CountConfigurations()
@@ -592,27 +547,22 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
syncErrors = syncErrors[:10] syncErrors = syncErrors[:10]
} }
readiness := h.getReadinessLocal() readiness := h.getReadinessCached(10 * time.Second)
c.JSON(http.StatusOK, SyncInfoResponse{ c.JSON(http.StatusOK, SyncInfoResponse{
DBHost: dbHost, DBHost: dbHost,
DBUser: dbUser, DBUser: dbUser,
DBName: dbName, DBName: dbName,
IsOnline: isOnline, IsOnline: isOnline,
LastSyncAt: lastPricelistSync, LastSyncAt: lastPricelistSync,
LastPricelistAttemptAt: lastPricelistAttemptAt, LotCount: componentCount,
LastPricelistSyncStatus: lastPricelistSyncStatus, LotLogCount: pricelistCount,
LastPricelistSyncError: lastPricelistSyncError, ConfigCount: configCount,
NeedPricelistSync: needPricelistSync, ProjectCount: projectCount,
HasIncompleteServerSync: hasIncompleteServerSync, PendingChanges: changes,
LotCount: componentCount, ErrorCount: errorCount,
LotLogCount: pricelistCount, Errors: syncErrors,
ConfigCount: configCount, Readiness: readiness,
ProjectCount: projectCount,
PendingChanges: changes,
ErrorCount: errorCount,
Errors: syncErrors,
Readiness: readiness,
}) })
} }
@@ -633,9 +583,14 @@ func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
return return
} }
// Keep current client heartbeat fresh so app version is available in the table.
h.syncService.RecordSyncHeartbeat()
users, err := h.syncService.ListUserSyncStatuses(threshold) users, err := h.syncService.ListUserSyncStatuses(threshold)
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return return
} }
@@ -664,33 +619,15 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
// Get pending count // Get pending count
pendingCount := h.localDB.GetPendingCount() pendingCount := h.localDB.GetPendingCount()
readiness := h.getReadinessLocal() readiness := h.getReadinessCached(10 * time.Second)
isBlocked := readiness != nil && readiness.Blocked isBlocked := readiness != nil && readiness.Blocked
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
hasIncompleteServerSync := hasFailedSync
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked) slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
data := gin.H{ data := gin.H{
"IsOffline": isOffline, "IsOffline": isOffline,
"PendingCount": pendingCount, "PendingCount": pendingCount,
"IsBlocked": isBlocked, "IsBlocked": isBlocked,
"HasFailedSync": hasFailedSync,
"HasIncompleteServerSync": hasIncompleteServerSync,
"SyncIssueTitle": func() string {
if hasIncompleteServerSync {
return "Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые не загружены локально."
}
if hasFailedSync {
if lastPricelistSyncError != "" {
return lastPricelistSyncError
}
return "Последняя синхронизация прайслистов завершилась ошибкой."
}
return ""
}(),
"BlockedReason": func() string { "BlockedReason": func() string {
if readiness == nil { if readiness == nil {
return "" return ""
@@ -702,41 +639,24 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil { if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
slog.Error("failed to render sync_status template", "error", err) slog.Error("failed to render sync_status template", "error", err)
_ = c.Error(err) c.String(http.StatusInternalServerError, "Template error: "+err.Error())
c.String(http.StatusInternalServerError, "Template error")
} }
} }
func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness { func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness {
h.readinessMu.Lock() h.readinessMu.Lock()
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < 10*time.Second { if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
cached := *h.readinessCached cached := *h.readinessCached
h.readinessMu.Unlock() h.readinessMu.Unlock()
return &cached return &cached
} }
h.readinessMu.Unlock() h.readinessMu.Unlock()
state, err := h.localDB.GetSyncGuardState() readiness, err := h.syncService.GetReadiness()
if err != nil || state == nil { if err != nil && readiness == nil {
return nil return nil
} }
// OFFLINE_UNVERIFIED_SCHEMA is only valid while actually offline.
// Suppress it when the connection manager reports online so the stale
// blocked state from a previous disconnection doesn't linger in the UI.
if state.ReasonCode == "OFFLINE_UNVERIFIED_SCHEMA" && h.checkOnline() {
return nil
}
readiness := &sync.SyncReadiness{
Status: state.Status,
Blocked: state.Status == sync.ReadinessBlocked,
ReasonCode: state.ReasonCode,
ReasonText: state.ReasonText,
RequiredMinAppVersion: state.RequiredMinAppVersion,
LastCheckedAt: state.LastCheckedAt,
}
h.readinessMu.Lock() h.readinessMu.Lock()
h.readinessCached = readiness h.readinessCached = readiness
h.readinessCachedAt = time.Now() h.readinessCachedAt = time.Now()
@@ -755,7 +675,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
} `json:"items"` } `json:"items"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -771,7 +691,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
} }
if err := h.syncService.PushPartnumberSeen(items); err != nil { if err := h.syncService.PushPartnumberSeen(items); err != nil {
RespondError(c, http.StatusServiceUnavailable, "service unavailable", err) c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return return
} }

View File

@@ -1,31 +1,24 @@
package handlers package handlers
import ( import (
"encoding/json"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"strings" "strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// VendorSpecHandler handles vendor BOM spec operations for a configuration. // VendorSpecHandler handles vendor BOM spec operations for a configuration.
type VendorSpecHandler struct { type VendorSpecHandler struct {
localDB *localdb.LocalDB localDB *localdb.LocalDB
configService *services.LocalConfigurationService
syncService *syncsvc.Service // optional; nil = no server push
} }
func NewVendorSpecHandler(localDB *localdb.LocalDB, syncService *syncsvc.Service) *VendorSpecHandler { func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
return &VendorSpecHandler{ return &VendorSpecHandler{localDB: localDB}
localDB: localDB,
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
syncService: syncService,
}
} }
// lookupConfig finds an active configuration by UUID using the standard localDB method. // lookupConfig finds an active configuration by UUID using the standard localDB method.
@@ -40,28 +33,6 @@ func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfigurati
return cfg, nil return cfg, nil
} }
// ParseText parses a pasted single-column text BOM (Inspur or Russian text BOM)
// using the same parsers as the vendor file-import path. It is stateless: no
// configuration is required. Returns the parsed rows and the detected format, or
// an empty result when the text is not a recognized single-column format (the
// client then falls back to manual column mapping).
// POST /api/vendor-spec/parse-text
func (h *VendorSpecHandler) ParseText(c *gin.Context) {
var body struct {
Text string `json:"text"`
}
if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}
rows, format := services.ParsePastedBOMText(body.Text)
if rows == nil {
rows = []localdb.VendorSpecItem{}
}
c.JSON(http.StatusOK, gin.H{"rows": rows, "format": format})
}
// GetVendorSpec returns the vendor spec (BOM) for a configuration. // GetVendorSpec returns the vendor spec (BOM) for a configuration.
// GET /api/configs/:uuid/vendor-spec // GET /api/configs/:uuid/vendor-spec
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) { func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
@@ -91,7 +62,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -109,62 +80,19 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
} }
spec := localdb.VendorSpec(body.VendorSpec) spec := localdb.VendorSpec(body.VendorSpec)
if _, err := h.configService.UpdateVendorSpecNoAuth(cfg.UUID, spec); err != nil { specJSON, err := json.Marshal(spec)
RespondError(c, http.StatusInternalServerError, "internal server error", err) if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
// Push manual lot mappings as suggestions to the server (best-effort, non-blocking).
h.pushLotSuggestions(body.VendorSpec)
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec}) c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
} }
// pushLotSuggestions sends manual PN→LOT mappings to qt_vendor_partnumber_seen.lot_suggestion.
// Errors are logged and silently dropped — they must not affect the HTTP response.
func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
if h.syncService == nil {
return
}
var items []syncsvc.SeenPartnumber
for _, row := range spec {
if row.VendorPartnumber == "" || len(row.LotMappings) == 0 {
continue
}
suggestion := make([]syncsvc.LotSuggestionEntry, 0, len(row.LotMappings))
for _, m := range row.LotMappings {
if m.LotName == "" {
continue
}
qty := m.QuantityPerPN
if qty < 1 {
qty = 1
}
suggestion = append(suggestion, syncsvc.LotSuggestionEntry{
LotName: m.LotName,
Qty: qty,
})
}
if len(suggestion) == 0 {
continue
}
items = append(items, syncsvc.SeenPartnumber{
Partnumber: row.VendorPartnumber,
Description: row.Description,
LotSuggestion: suggestion,
})
}
if len(items) == 0 {
return
}
if err := h.syncService.PushPartnumberSeen(items); err != nil {
slog.Warn("vendor_spec: failed to push lot suggestions to server", "error", err)
}
}
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping { func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(in) == 0 { if len(in) == 0 {
return nil return nil
@@ -210,7 +138,7 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -219,18 +147,14 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
resolved, err := resolver.Resolve(body.VendorSpec) resolved, err := resolver.Resolve(body.VendorSpec)
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
book, err := bookRepo.GetActiveBook() book, _ := bookRepo.GetActiveBook()
if err != nil {
slog.Warn("vendor spec resolve: no active partnumber book", "err", err)
book = nil
}
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo) aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -257,7 +181,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
} `json:"items"` } `json:"items"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -270,8 +194,14 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
}) })
} }
if _, err := h.configService.ApplyVendorSpecItemsNoAuth(cfg.UUID, newItems); err != nil { itemsJSON, err := json.Marshal(newItems)
RespondError(c, http.StatusInternalServerError, "internal server error", err) if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }

View File

@@ -1,13 +1,11 @@
package handlers package handlers
import ( import (
"fmt"
"html/template" "html/template"
"strconv" "strconv"
"strings" "strings"
qfassets "git.mchus.pro/mchus/quoteforge" qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -113,18 +111,15 @@ func NewWebHandler(_ string, localDB *localdb.LocalDB) (*WebHandler, error) {
} }
func (h *WebHandler) render(c *gin.Context, name string, data gin.H) { func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
data["AppVersion"] = appmeta.Version()
c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
tmpl, ok := h.templates[name] tmpl, ok := h.templates[name]
if !ok { if !ok {
_ = c.Error(fmt.Errorf("template %q not found", name)) c.String(500, "Template not found: %s", name)
c.String(500, "Template error")
return return
} }
// Execute the page template which will use base // Execute the page template which will use base
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil { if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
_ = c.Error(err) c.String(500, "Template error: %v", err)
c.String(500, "Template error")
} }
} }

View File

@@ -1,47 +0,0 @@
package handlers
import (
"errors"
"html/template"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestWebHandlerRenderHidesTemplateExecutionError(t *testing.T) {
gin.SetMode(gin.TestMode)
tmpl := template.Must(template.New("broken.html").Funcs(template.FuncMap{
"boom": func() (string, error) {
return "", errors.New("secret template failure")
},
}).Parse(`{{define "broken.html"}}{{boom}}{{end}}`))
handler := &WebHandler{
templates: map[string]*template.Template{
"broken.html": tmpl,
},
}
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodGet, "/broken", nil)
handler.render(ctx, "broken.html", gin.H{})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
if body := strings.TrimSpace(rec.Body.String()); body != "Template error" {
t.Fatalf("expected generic template error, got %q", body)
}
if len(ctx.Errors) != 1 {
t.Fatalf("expected logged template error, got %d", len(ctx.Errors))
}
if !strings.Contains(ctx.Errors.String(), "secret template failure") {
t.Fatalf("expected original error in gin context, got %q", ctx.Errors.String())
}
}

View File

@@ -28,9 +28,8 @@ 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()
// Build the component catalog from every runtime source of LOT names. // Query to join lot with qt_lot_metadata (metadata only, no pricing)
// Storage lots may exist in qt_lot_metadata / qt_pricelist_items before they appear in lot, // Use LEFT JOIN to include lots without metadata
// so the sync cannot start from lot alone.
type componentRow struct { type componentRow struct {
LotName string LotName string
LotDescription string LotDescription string
@@ -41,29 +40,15 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
var rows []componentRow var rows []componentRow
err := mariaDB.Raw(` err := mariaDB.Raw(`
SELECT SELECT
src.lot_name, l.lot_name,
COALESCE(MAX(NULLIF(TRIM(l.lot_description), '')), '') AS lot_description, l.lot_description,
COALESCE( COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
MAX(NULLIF(TRIM(c.code), '')), m.model
MAX(NULLIF(TRIM(l.lot_category), '')), FROM lot l
SUBSTRING_INDEX(src.lot_name, '_', 1) LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
) AS category,
MAX(NULLIF(TRIM(m.model), '')) AS model
FROM (
SELECT lot_name FROM lot
UNION
SELECT lot_name FROM qt_lot_metadata
WHERE is_hidden = FALSE OR is_hidden IS NULL
UNION
SELECT lot_name FROM qt_pricelist_items
) src
LEFT JOIN lot l ON l.lot_name = src.lot_name
LEFT JOIN qt_lot_metadata m
ON m.lot_name = src.lot_name
AND (m.is_hidden = FALSE OR m.is_hidden IS NULL)
LEFT JOIN qt_categories c ON m.category_id = c.id LEFT JOIN qt_categories c ON m.category_id = c.id
GROUP BY src.lot_name WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL
ORDER BY src.lot_name ORDER BY l.lot_name
`).Scan(&rows).Error `).Scan(&rows).Error
if err != nil { if err != nil {
return nil, fmt.Errorf("querying components from MariaDB: %w", err) return nil, fmt.Errorf("querying components from MariaDB: %w", err)
@@ -86,25 +71,18 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
existingMap[c.LotName] = true existingMap[c.LotName] = true
} }
// Prepare components for batch insert/update. // Prepare components for batch insert/update
// Source joins may duplicate the same lot_name, so collapse them before insert.
syncTime := time.Now() syncTime := time.Now()
components := make([]LocalComponent, 0, len(rows)) components := make([]LocalComponent, 0, len(rows))
componentIndex := make(map[string]int, len(rows))
newCount := 0 newCount := 0
for _, row := range rows { for _, row := range rows {
lotName := strings.TrimSpace(row.LotName)
if lotName == "" {
continue
}
category := "" category := ""
if row.Category != nil { if row.Category != nil {
category = strings.TrimSpace(*row.Category) category = *row.Category
} else { } else {
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU") // Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
parts := strings.SplitN(lotName, "_", 2) parts := strings.SplitN(row.LotName, "_", 2)
if len(parts) >= 1 { if len(parts) >= 1 {
category = parts[0] category = parts[0]
} }
@@ -112,34 +90,18 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
model := "" model := ""
if row.Model != nil { if row.Model != nil {
model = strings.TrimSpace(*row.Model) model = *row.Model
} }
comp := LocalComponent{ comp := LocalComponent{
LotName: lotName, LotName: row.LotName,
LotDescription: strings.TrimSpace(row.LotDescription), LotDescription: row.LotDescription,
Category: category, Category: category,
Model: model, Model: model,
} }
if idx, exists := componentIndex[lotName]; exists {
// Keep the first row, but fill any missing metadata from duplicates.
if components[idx].LotDescription == "" && comp.LotDescription != "" {
components[idx].LotDescription = comp.LotDescription
}
if components[idx].Category == "" && comp.Category != "" {
components[idx].Category = comp.Category
}
if components[idx].Model == "" && comp.Model != "" {
components[idx].Model = comp.Model
}
continue
}
componentIndex[lotName] = len(components)
components = append(components, comp) components = append(components, comp)
if !existingMap[lotName] { if !existingMap[row.LotName] {
newCount++ newCount++
} }
} }

View File

@@ -95,60 +95,3 @@ func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec) t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
} }
} }
func TestConfigurationFingerprintIncludesPricingSelectorsAndVendorSpec(t *testing.T) {
estimateID := uint(11)
warehouseID := uint(22)
competitorID := uint(33)
base := &LocalConfiguration{
UUID: "cfg-1",
Name: "Config",
ServerCount: 1,
Items: LocalConfigItems{{LotName: "LOT_A", Quantity: 1, UnitPrice: 100}},
PricelistID: &estimateID,
WarehousePricelistID: &warehouseID,
CompetitorPricelistID: &competitorID,
DisablePriceRefresh: true,
OnlyInStock: true,
VendorSpec: VendorSpec{
{
SortOrder: 10,
VendorPartnumber: "PN-1",
Quantity: 1,
},
},
}
baseFingerprint, err := BuildConfigurationSpecPriceFingerprint(base)
if err != nil {
t.Fatalf("base fingerprint: %v", err)
}
changedPricelist := *base
newEstimateID := uint(44)
changedPricelist.PricelistID = &newEstimateID
pricelistFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedPricelist)
if err != nil {
t.Fatalf("pricelist fingerprint: %v", err)
}
if pricelistFingerprint == baseFingerprint {
t.Fatalf("expected pricelist selector to affect fingerprint")
}
changedVendorSpec := *base
changedVendorSpec.VendorSpec = VendorSpec{
{
SortOrder: 10,
VendorPartnumber: "PN-2",
Quantity: 1,
},
}
vendorFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedVendorSpec)
if err != nil {
t.Fatalf("vendor fingerprint: %v", err)
}
if vendorFingerprint == baseFingerprint {
t.Fatalf("expected vendor spec to affect fingerprint")
}
}

View File

@@ -34,7 +34,6 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
PricelistID: cfg.PricelistID, PricelistID: cfg.PricelistID,
WarehousePricelistID: cfg.WarehousePricelistID, WarehousePricelistID: cfg.WarehousePricelistID,
CompetitorPricelistID: cfg.CompetitorPricelistID, CompetitorPricelistID: cfg.CompetitorPricelistID,
ConfigType: cfg.ConfigType,
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec), VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
DisablePriceRefresh: cfg.DisablePriceRefresh, DisablePriceRefresh: cfg.DisablePriceRefresh,
OnlyInStock: cfg.OnlyInStock, OnlyInStock: cfg.OnlyInStock,
@@ -83,7 +82,6 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
PricelistID: local.PricelistID, PricelistID: local.PricelistID,
WarehousePricelistID: local.WarehousePricelistID, WarehousePricelistID: local.WarehousePricelistID,
CompetitorPricelistID: local.CompetitorPricelistID, CompetitorPricelistID: local.CompetitorPricelistID,
ConfigType: local.ConfigType,
VendorSpec: localVendorSpecToModel(local.VendorSpec), VendorSpec: localVendorSpecToModel(local.VendorSpec),
DisablePriceRefresh: local.DisablePriceRefresh, DisablePriceRefresh: local.DisablePriceRefresh,
OnlyInStock: local.OnlyInStock, OnlyInStock: local.OnlyInStock,

View File

@@ -116,14 +116,6 @@ func New(dbPath string) (*LocalDB, error) {
return nil, fmt.Errorf("opening sqlite database: %w", err) return nil, fmt.Errorf("opening sqlite database: %w", err)
} }
// Enable WAL mode so background sync writes never block UI reads.
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
slog.Warn("failed to enable WAL mode", "error", err)
}
if err := db.Exec("PRAGMA synchronous=NORMAL").Error; err != nil {
slog.Warn("failed to set synchronous=NORMAL", "error", err)
}
if err := ensureLocalProjectsTable(db); err != nil { if err := ensureLocalProjectsTable(db); err != nil {
return nil, fmt.Errorf("ensure local_projects table: %w", err) return nil, fmt.Errorf("ensure local_projects table: %w", err)
} }
@@ -229,8 +221,6 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
&LocalSyncGuardState{}, &LocalSyncGuardState{},
&PendingChange{}, &PendingChange{},
&LocalPartnumberBook{}, &LocalPartnumberBook{},
&SyncLogEntry{},
&LocalQtSetting{},
) )
} }
@@ -498,10 +488,7 @@ func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string
// 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
if err := l.db.Model(&ConnectionSettings{}).Count(&count).Error; err != nil { l.db.Model(&ConnectionSettings{}).Count(&count)
slog.Error("localdb: HasSettings count failed", "err", err)
return false
}
return count > 0 return count > 0
} }
@@ -624,46 +611,6 @@ func (l *LocalDB) SaveProject(project *LocalProject) error {
return l.db.Save(project).Error return l.db.Save(project).Error
} }
// SaveProjectPreservingUpdatedAt stores a project without replacing UpdatedAt
// with the current local sync time.
func (l *LocalDB) SaveProjectPreservingUpdatedAt(project *LocalProject) error {
if project == nil {
return fmt.Errorf("project is nil")
}
if project.ID == 0 && strings.TrimSpace(project.UUID) != "" {
var existing LocalProject
err := l.db.Where("uuid = ?", project.UUID).First(&existing).Error
if err == nil {
project.ID = existing.ID
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if project.ID == 0 {
return l.db.Create(project).Error
}
return l.db.Model(&LocalProject{}).
Where("id = ?", project.ID).
UpdateColumns(map[string]interface{}{
"uuid": project.UUID,
"server_id": project.ServerID,
"owner_username": project.OwnerUsername,
"code": project.Code,
"variant": project.Variant,
"name": project.Name,
"tracker_url": project.TrackerURL,
"is_active": project.IsActive,
"is_system": project.IsSystem,
"created_at": project.CreatedAt,
"updated_at": project.UpdatedAt,
"synced_at": project.SyncedAt,
"sync_status": project.SyncStatus,
}).Error
}
func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) { func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) {
var projects []LocalProject var projects []LocalProject
query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername) query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername)
@@ -692,14 +639,6 @@ func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) {
return &project, nil return &project, nil
} }
func (l *LocalDB) GetProjectByCode(code string) (*LocalProject, error) {
var project LocalProject
if err := l.db.Where("LOWER(code) = LOWER(?) AND variant = ''", code).First(&project).Error; err != nil {
return nil, err
}
return &project, nil
}
func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) { func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) {
var project LocalProject var project LocalProject
if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil { if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil {
@@ -1056,18 +995,14 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
// CountConfigurations returns the number of local configurations // CountConfigurations returns the number of local configurations
func (l *LocalDB) CountConfigurations() int64 { func (l *LocalDB) CountConfigurations() int64 {
var count int64 var count int64
if err := l.db.Model(&LocalConfiguration{}).Count(&count).Error; err != nil { l.db.Model(&LocalConfiguration{}).Count(&count)
slog.Error("localdb: CountConfigurations failed", "err", err)
}
return count return count
} }
// CountProjects returns the number of local projects // CountProjects returns the number of local projects
func (l *LocalDB) CountProjects() int64 { func (l *LocalDB) CountProjects() int64 {
var count int64 var count int64
if err := l.db.Model(&LocalProject{}).Count(&count).Error; err != nil { l.db.Model(&LocalProject{}).Count(&count)
slog.Error("localdb: CountProjects failed", "err", err)
}
return count return count
} }
@@ -1091,26 +1026,6 @@ func (l *LocalDB) GetLastSyncTime() *time.Time {
return &t return &t
} }
func (l *LocalDB) getAppSettingValue(key string) (string, bool) {
var setting struct {
Value string
}
if err := l.db.Table("app_settings").
Where("key = ?", key).
First(&setting).Error; err != nil {
return "", false
}
return setting.Value, true
}
func (l *LocalDB) upsertAppSetting(tx *gorm.DB, key, value string, updatedAt time.Time) error {
return tx.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, key, value, updatedAt.Format(time.RFC3339)).Error
}
// SetLastSyncTime sets the last sync timestamp // SetLastSyncTime sets the last sync timestamp
func (l *LocalDB) SetLastSyncTime(t time.Time) error { func (l *LocalDB) SetLastSyncTime(t time.Time) error {
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions // Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
@@ -1121,134 +1036,6 @@ func (l *LocalDB) SetLastSyncTime(t time.Time) error {
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error `, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
} }
func (l *LocalDB) GetLastPricelistSyncAttemptAt() *time.Time {
value, ok := l.getAppSettingValue("last_pricelist_sync_attempt_at")
if !ok {
return nil
}
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil
}
return &t
}
func (l *LocalDB) GetLastPricelistSyncStatus() string {
value, ok := l.getAppSettingValue("last_pricelist_sync_status")
if !ok {
return ""
}
return strings.TrimSpace(value)
}
func (l *LocalDB) GetLastPricelistSyncError() string {
value, ok := l.getAppSettingValue("last_pricelist_sync_error")
if !ok {
return ""
}
return strings.TrimSpace(value)
}
func (l *LocalDB) SetPricelistSyncResult(status, errorText string, attemptedAt time.Time) error {
status = strings.TrimSpace(status)
errorText = strings.TrimSpace(errorText)
if status == "" {
status = "unknown"
}
return l.db.Transaction(func(tx *gorm.DB) error {
if err := l.upsertAppSetting(tx, "last_pricelist_sync_status", status, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_pricelist_sync_error", errorText, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_pricelist_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
return err
}
return nil
})
}
const syncLogMaxPerType = 100
// AppendSyncLog writes a sync result and prunes old entries beyond the per-type cap.
func (l *LocalDB) AppendSyncLog(syncType, status, errorText string, syncedCount int, startedAt time.Time, durationMs int64) {
entry := SyncLogEntry{
SyncType: syncType,
Status: status,
ErrorText: errorText,
SyncedCount: syncedCount,
StartedAt: startedAt,
DurationMs: durationMs,
}
if err := l.db.Create(&entry).Error; err != nil {
return
}
// Prune: keep only the most recent N entries for this sync_type
l.db.Exec(`
DELETE FROM sync_log
WHERE sync_type = ? AND id NOT IN (
SELECT id FROM sync_log WHERE sync_type = ? ORDER BY started_at DESC LIMIT ?
)
`, syncType, syncType, syncLogMaxPerType)
}
// GetSyncLog returns the most recent sync log entries, newest first.
func (l *LocalDB) GetSyncLog(limit int) ([]SyncLogEntry, error) {
var entries []SyncLogEntry
err := l.db.Order("started_at DESC").Limit(limit).Find(&entries).Error
return entries, err
}
func (l *LocalDB) GetLastComponentSyncAttemptAt() *time.Time {
value, ok := l.getAppSettingValue("last_component_sync_attempt_at")
if !ok {
return nil
}
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil
}
return &t
}
func (l *LocalDB) GetLastComponentSyncStatus() string {
value, ok := l.getAppSettingValue("last_component_sync_status")
if !ok {
return ""
}
return strings.TrimSpace(value)
}
func (l *LocalDB) GetLastComponentSyncError() string {
value, ok := l.getAppSettingValue("last_component_sync_error")
if !ok {
return ""
}
return strings.TrimSpace(value)
}
func (l *LocalDB) SetComponentSyncResult(status, errorText string, attemptedAt time.Time) error {
status = strings.TrimSpace(status)
errorText = strings.TrimSpace(errorText)
if status == "" {
status = "unknown"
}
return l.db.Transaction(func(tx *gorm.DB) error {
if err := l.upsertAppSetting(tx, "last_component_sync_status", status, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_component_sync_error", errorText, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_component_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
return err
}
return nil
})
}
// CountLocalPricelists returns the number of local pricelists // CountLocalPricelists returns the number of local pricelists
func (l *LocalDB) CountLocalPricelists() int64 { func (l *LocalDB) CountLocalPricelists() int64 {
var count int64 var count int64
@@ -1256,29 +1043,6 @@ func (l *LocalDB) CountLocalPricelists() int64 {
return count return count
} }
// CountAllPricelistItems returns total rows across all local_pricelist_items.
func (l *LocalDB) CountAllPricelistItems() int64 {
var count int64
l.db.Model(&LocalPricelistItem{}).Count(&count)
return count
}
// CountComponents returns the number of rows in local_components.
func (l *LocalDB) CountComponents() int64 {
var count int64
l.db.Model(&LocalComponent{}).Count(&count)
return count
}
// DBFileSizeBytes returns the size of the SQLite database file in bytes.
func (l *LocalDB) DBFileSizeBytes() int64 {
info, err := os.Stat(l.path)
if err != nil {
return 0
}
return info.Size()
}
// GetLatestLocalPricelist returns the most recently synced pricelist // GetLatestLocalPricelist returns the most recently synced pricelist
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) { func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
var pricelist LocalPricelist var pricelist LocalPricelist
@@ -1383,20 +1147,20 @@ func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (i
return count, nil return count, nil
} }
// SaveLocalPricelistItems saves pricelist items to local SQLite. // SaveLocalPricelistItems saves pricelist items to local SQLite
// Duplicate (pricelist_id, lot_name) rows are silently ignored.
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error { func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
} }
// Batch insert
batchSize := 500 batchSize := 500
for i := 0; i < len(items); i += batchSize { for i := 0; i < len(items); i += batchSize {
end := i + batchSize end := i + batchSize
if end > len(items) { if end > len(items) {
end = len(items) end = len(items)
} }
if err := l.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(items[i:end], batchSize).Error; err != nil { if err := l.db.CreateInBatches(items[i:end], batchSize).Error; err != nil {
return err return err
} }
} }
@@ -1446,32 +1210,6 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
return item.Price, nil return item.Price, nil
} }
// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query.
// Uses the composite index (pricelist_id, lot_name). Missing lots are omitted from the result.
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
result := make(map[string]float64, len(lotNames))
if len(lotNames) == 0 {
return result, nil
}
type row struct {
LotName string `gorm:"column:lot_name"`
Price float64 `gorm:"column:price"`
}
var rows []row
if err := l.db.Model(&LocalPricelistItem{}).
Select("lot_name, price").
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
if r.Price > 0 {
result[r.LotName] = r.Price
}
}
return result, nil
}
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID. // 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. // 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) { func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
@@ -1835,62 +1573,3 @@ func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requi
}), }),
}).Create(state).Error }).Create(state).Error
} }
// GetLocalPartnumberBookByServerID returns a local partnumber book by its server-side ID.
func (l *LocalDB) GetLocalPartnumberBookByServerID(serverID uint) (*LocalPartnumberBook, error) {
var book LocalPartnumberBook
if err := l.db.Where("server_id = ?", serverID).First(&book).Error; err != nil {
return nil, err
}
return &book, nil
}
// GetLocalPricelistItemsPage returns a paginated, searchable list of items for a pricelist.
func (l *LocalDB) GetLocalPricelistItemsPage(pricelistID uint, search string, page, perPage int) ([]LocalPricelistItem, int64, error) {
dbq := l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID)
if search != "" {
dbq = dbq.Where("lot_name LIKE ?", "%"+search+"%")
}
var total int64
if err := dbq.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("count pricelist items: %w", err)
}
offset := (page - 1) * perPage
var items []LocalPricelistItem
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
return nil, 0, fmt.Errorf("fetch pricelist items: %w", err)
}
return items, total, nil
}
// GetLocalComponentDescriptionsByLotNames returns a map of lot_name → lot_description for the given lots.
func (l *LocalDB) GetLocalComponentDescriptionsByLotNames(lotNames []string) (map[string]string, error) {
if len(lotNames) == 0 {
return map[string]string{}, nil
}
type row struct {
LotName string
LotDescription string
}
var rows []row
if err := l.db.Table("local_components").
Select("lot_name, lot_description").
Where("lot_name IN ?", lotNames).
Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("fetch component descriptions: %w", err)
}
m := make(map[string]string, len(rows))
for _, r := range rows {
m[r.LotName] = r.LotDescription
}
return m, nil
}
// GetSchemaMigrations returns all applied local schema migrations ordered by applied_at.
func (l *LocalDB) GetSchemaMigrations() ([]LocalSchemaMigration, error) {
var migrations []LocalSchemaMigration
if err := l.db.Order("applied_at ASC").Find(&migrations).Error; err != nil {
return nil, fmt.Errorf("fetch schema migrations: %w", err)
}
return migrations, nil
}

View File

@@ -119,11 +119,6 @@ var localMigrations = []localMigration{
name: "Convert local partnumber book cache to book membership + deduplicated PN catalog", name: "Convert local partnumber book cache to book membership + deduplicated PN catalog",
run: migrateLocalPartnumberBookCatalog, run: migrateLocalPartnumberBookCatalog,
}, },
{
id: "2026_03_13_pricelist_items_dedup_unique",
name: "Deduplicate local_pricelist_items and add unique index on (pricelist_id, lot_name)",
run: deduplicatePricelistItemsAndAddUniqueIndex,
},
} }
type localPartnumberCatalogRow struct { type localPartnumberCatalogRow struct {
@@ -1097,26 +1092,3 @@ func rebuildLocalPartnumberBookCatalog(tx *gorm.DB, catalog map[string]*localPar
} }
return nil return nil
} }
func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error {
// Remove duplicate (pricelist_id, lot_name) rows keeping only the row with the lowest id.
if err := tx.Exec(`
DELETE FROM local_pricelist_items
WHERE id NOT IN (
SELECT MIN(id) FROM local_pricelist_items
GROUP BY pricelist_id, lot_name
)
`).Error; err != nil {
return fmt.Errorf("deduplicate local_pricelist_items: %w", err)
}
// Add unique index to prevent future duplicates.
if err := tx.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot_unique
ON local_pricelist_items(pricelist_id, lot_name)
`).Error; err != nil {
return fmt.Errorf("create unique index on local_pricelist_items: %w", err)
}
slog.Info("deduplicated local_pricelist_items and added unique index")
return nil
}

View File

@@ -110,7 +110,6 @@ type LocalConfiguration struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at"` SyncedAt *time.Time `json:"synced_at"`
ConfigType string `gorm:"default:server" json:"config_type"` // "server" | "storage"
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified' SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"` OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
@@ -317,19 +316,6 @@ type VendorSpecLotMapping struct {
QuantityPerPN int `json:"quantity_per_pn"` QuantityPerPN int `json:"quantity_per_pn"`
} }
// SyncLogEntry records the outcome of a single sync operation for diagnostics.
type SyncLogEntry struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
SyncType string `gorm:"not null;index;size:32" json:"sync_type"` // components | pricelists | push | full
Status string `gorm:"not null;size:16" json:"status"` // ok | error | skipped
ErrorText string `gorm:"size:1000" json:"error_text,omitempty"`
SyncedCount int `gorm:"default:0" json:"synced_count"`
StartedAt time.Time `gorm:"not null;index" json:"started_at"`
DurationMs int64 `gorm:"default:0" json:"duration_ms"`
}
func (SyncLogEntry) TableName() string { return "sync_log" }
// VendorSpec is a JSON-encodable slice of VendorSpecItem // VendorSpec is a JSON-encodable slice of VendorSpecItem
type VendorSpec []VendorSpecItem type VendorSpec []VendorSpecItem
@@ -356,12 +342,3 @@ func (v *VendorSpec) Scan(value interface{}) error {
} }
return json.Unmarshal(bytes, v) return json.Unmarshal(bytes, v)
} }
// LocalQtSetting caches server-pushed settings from qt_settings (MariaDB) into local SQLite.
// Synced during component sync. Each row is a JSON-valued setting identified by name.
type LocalQtSetting struct {
Name string `gorm:"primaryKey;size:100"`
Value string `gorm:"type:text"`
}
func (LocalQtSetting) TableName() string { return "local_qt_settings" }

View File

@@ -1,53 +0,0 @@
package localdb
import (
"path/filepath"
"testing"
"time"
)
func TestSaveProjectPreservingUpdatedAtKeepsProvidedTimestamp(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "project_sync_timestamp.db")
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
createdAt := time.Date(2026, 2, 1, 10, 0, 0, 0, time.UTC)
updatedAt := time.Date(2026, 2, 3, 12, 30, 0, 0, time.UTC)
project := &LocalProject{
UUID: "project-1",
OwnerUsername: "tester",
Code: "OPS-1",
Variant: "Lenovo",
IsActive: true,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
SyncStatus: "synced",
}
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
t.Fatalf("save project: %v", err)
}
syncedAt := time.Date(2026, 3, 16, 8, 45, 0, 0, time.UTC)
project.SyncedAt = &syncedAt
project.SyncStatus = "synced"
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
t.Fatalf("save project second time: %v", err)
}
stored, err := local.GetProjectByUUID(project.UUID)
if err != nil {
t.Fatalf("get project: %v", err)
}
if !stored.UpdatedAt.Equal(updatedAt) {
t.Fatalf("updated_at changed during sync save: got %s want %s", stored.UpdatedAt, updatedAt)
}
if stored.SyncedAt == nil || !stored.SyncedAt.Equal(syncedAt) {
t.Fatalf("synced_at not updated correctly: got %+v want %s", stored.SyncedAt, syncedAt)
}
}

View File

@@ -1,122 +0,0 @@
package localdb
import (
"encoding/json"
"fmt"
"log/slog"
"gorm.io/gorm"
)
// ConfigTypeDef describes one device configuration type as synced from qt_settings.
type ConfigTypeDef struct {
Code string `json:"code"`
NameRu string `json:"name_ru"`
DisplayOrder int `json:"display_order"`
Categories []string `json:"categories"`
}
// TabSection is a named sub-group of categories within a configurator tab.
type TabSection struct {
Title string `json:"title"`
Categories []string `json:"categories"`
}
// TabDef describes one tab in the configurator as synced from qt_settings.
type TabDef struct {
Key string `json:"key"`
Label string `json:"label"`
SingleSelect bool `json:"single_select"`
Categories []string `json:"categories"`
Sections []TabSection `json:"sections,omitempty"`
}
// ConfiguratorSettings holds all four server-driven settings consumed by the configurator.
// Fields are nil/empty when the corresponding qt_settings key is absent or unparseable;
// callers are expected to apply hardcoded fallbacks in that case.
type ConfiguratorSettings struct {
ConfigTypes []ConfigTypeDef `json:"config_types"`
TabConfig []TabDef `json:"tab_config"`
AlwaysVisibleTabs []string `json:"always_visible_tabs"`
RequiredCategories map[string][]string `json:"required_categories"`
}
// SyncQtSettings reads all rows from qt_settings on MariaDB and replaces the
// local_qt_settings cache in a single SQLite transaction. Returns an error if
// the qt_settings table doesn't exist on the server (old server without the
// table) or on any query/write failure.
func (l *LocalDB) SyncQtSettings(mariaDB *gorm.DB) error {
var rows []LocalQtSetting
if err := mariaDB.
Table("qt_settings").
Select("name, value").
Find(&rows).Error; err != nil {
return fmt.Errorf("reading qt_settings from MariaDB: %w", err)
}
return l.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("DELETE FROM local_qt_settings").Error; err != nil {
return fmt.Errorf("clearing local_qt_settings: %w", err)
}
if len(rows) == 0 {
return nil
}
if err := tx.Create(&rows).Error; err != nil {
return fmt.Errorf("inserting local_qt_settings: %w", err)
}
slog.Info("qt_settings synced", "count", len(rows))
return nil
})
}
// GetQtSetting returns the raw JSON value for a named setting.
// found is false when the key does not exist.
func (l *LocalDB) GetQtSetting(name string) (value string, found bool, err error) {
var row LocalQtSetting
res := l.db.Where("name = ?", name).First(&row)
if res.Error != nil {
if res.Error == gorm.ErrRecordNotFound {
return "", false, nil
}
return "", false, res.Error
}
return row.Value, true, nil
}
// GetConfiguratorSettings reads all four known settings from local_qt_settings and
// parses them. Any missing or unparseable key is left as nil/zero in the result;
// the caller must apply fallbacks.
func (l *LocalDB) GetConfiguratorSettings() (*ConfiguratorSettings, error) {
out := &ConfiguratorSettings{}
keys := []string{"config_types", "tab_config", "always_visible_tabs", "required_categories"}
for _, key := range keys {
raw, found, err := l.GetQtSetting(key)
if err != nil {
return out, fmt.Errorf("reading setting %q: %w", key, err)
}
if !found || raw == "" {
continue
}
switch key {
case "config_types":
if err := json.Unmarshal([]byte(raw), &out.ConfigTypes); err != nil {
slog.Warn("failed to parse config_types setting", "error", err)
}
case "tab_config":
if err := json.Unmarshal([]byte(raw), &out.TabConfig); err != nil {
slog.Warn("failed to parse tab_config setting", "error", err)
}
case "always_visible_tabs":
if err := json.Unmarshal([]byte(raw), &out.AlwaysVisibleTabs); err != nil {
slog.Warn("failed to parse always_visible_tabs setting", "error", err)
}
case "required_categories":
if err := json.Unmarshal([]byte(raw), &out.RequiredCategories); err != nil {
slog.Warn("failed to parse required_categories setting", "error", err)
}
}
}
return out, nil
}

View File

@@ -112,16 +112,10 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
} }
type configurationSpecPriceFingerprint struct { type configurationSpecPriceFingerprint struct {
Items []configurationSpecPriceFingerprintItem `json:"items"` Items []configurationSpecPriceFingerprintItem `json:"items"`
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
TotalPrice *float64 `json:"total_price,omitempty"` TotalPrice *float64 `json:"total_price,omitempty"`
CustomPrice *float64 `json:"custom_price,omitempty"` CustomPrice *float64 `json:"custom_price,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"`
VendorSpec VendorSpec `json:"vendor_spec,omitempty"`
} }
type configurationSpecPriceFingerprintItem struct { type configurationSpecPriceFingerprintItem struct {
@@ -152,16 +146,10 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin
}) })
payload := configurationSpecPriceFingerprint{ payload := configurationSpecPriceFingerprint{
Items: items, Items: items,
ServerCount: localCfg.ServerCount, ServerCount: localCfg.ServerCount,
TotalPrice: localCfg.TotalPrice, TotalPrice: localCfg.TotalPrice,
CustomPrice: localCfg.CustomPrice, CustomPrice: localCfg.CustomPrice,
PricelistID: localCfg.PricelistID,
WarehousePricelistID: localCfg.WarehousePricelistID,
CompetitorPricelistID: localCfg.CompetitorPricelistID,
DisablePriceRefresh: localCfg.DisablePriceRefresh,
OnlyInStock: localCfg.OnlyInStock,
VendorSpec: localCfg.VendorSpec,
} }
raw, err := json.Marshal(payload) raw, err := json.Marshal(payload)

93
internal/models/alert.go Normal file
View File

@@ -0,0 +1,93 @@
package models
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
)
type AlertType string
const (
AlertHighDemandStalePrice AlertType = "high_demand_stale_price"
AlertPriceSpike AlertType = "price_spike"
AlertPriceDrop AlertType = "price_drop"
AlertNoRecentQuotes AlertType = "no_recent_quotes"
AlertTrendingNoPrice AlertType = "trending_no_price"
)
type AlertSeverity string
const (
SeverityLow AlertSeverity = "low"
SeverityMedium AlertSeverity = "medium"
SeverityHigh AlertSeverity = "high"
SeverityCritical AlertSeverity = "critical"
)
type AlertStatus string
const (
AlertStatusNew AlertStatus = "new"
AlertStatusAcknowledged AlertStatus = "acknowledged"
AlertStatusResolved AlertStatus = "resolved"
AlertStatusIgnored AlertStatus = "ignored"
)
type AlertDetails map[string]interface{}
func (d AlertDetails) Value() (driver.Value, error) {
return json.Marshal(d)
}
func (d *AlertDetails) Scan(value interface{}) error {
if value == nil {
*d = make(AlertDetails)
return nil
}
bytes, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(bytes, d)
}
type PricingAlert struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
LotName string `gorm:"size:255;not null" json:"lot_name"`
AlertType AlertType `gorm:"type:enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price');not null" json:"alert_type"`
Severity AlertSeverity `gorm:"type:enum('low','medium','high','critical');default:'medium'" json:"severity"`
Message string `gorm:"type:text;not null" json:"message"`
Details AlertDetails `gorm:"type:json" json:"details"`
Status AlertStatus `gorm:"type:enum('new','acknowledged','resolved','ignored');default:'new'" json:"status"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (PricingAlert) TableName() string {
return "qt_pricing_alerts"
}
type TrendDirection string
const (
TrendUp TrendDirection = "up"
TrendStable TrendDirection = "stable"
TrendDown TrendDirection = "down"
)
type ComponentUsageStats struct {
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
QuotesTotal int `gorm:"default:0" json:"quotes_total"`
QuotesLast30d int `gorm:"default:0" json:"quotes_last_30d"`
QuotesLast7d int `gorm:"default:0" json:"quotes_last_7d"`
TotalQuantity int `gorm:"default:0" json:"total_quantity"`
TotalRevenue float64 `gorm:"type:decimal(14,2);default:0" json:"total_revenue"`
TrendDirection TrendDirection `gorm:"type:enum('up','stable','down');default:'stable'" json:"trend_direction"`
TrendPercent float64 `gorm:"type:decimal(5,2);default:0" json:"trend_percent"`
LastUsedAt *time.Time `json:"last_used_at"`
}
func (ComponentUsageStats) TableName() string {
return "qt_component_usage_stats"
}

View File

@@ -13,32 +13,32 @@ func (Category) TableName() string {
return "qt_categories" return "qt_categories"
} }
// DefaultCategories defines the standard categories with display order. // DefaultCategories defines the standard categories with display order
// Canonical order: MB, CPU, MEM, RAID, storage drives, PCIe GPU, PCIe NICs, HBA, PSU, accessories, other. // Order: BB, CPU, MEM, GPU, SSD, RAID, HBA, NIC, PSU, RISERS, ACC, and others
// Display orders use gaps of 10 to allow future insertions without renumbering.
var DefaultCategories = []Category{ var DefaultCategories = []Category{
{Code: "MB", Name: "Motherboard", NameRu: "Материнская плата", DisplayOrder: 10}, {Code: "BB", Name: "Barebone", NameRu: "Шасси", DisplayOrder: 1, IsRequired: true},
{Code: "CPU", Name: "Processor", NameRu: "Процессор", DisplayOrder: 20, IsRequired: true}, {Code: "CPU", Name: "Processor", NameRu: "Процессор", DisplayOrder: 2, IsRequired: true},
{Code: "MEM", Name: "Memory", NameRu: "Оперативная память", DisplayOrder: 30, IsRequired: true}, {Code: "MEM", Name: "Memory", NameRu: "Оперативная память", DisplayOrder: 3, IsRequired: true},
{Code: "RAID", Name: "RAID Controller", NameRu: "RAID контроллер", DisplayOrder: 40}, {Code: "GPU", Name: "Graphics Card", NameRu: "Видеокарта", DisplayOrder: 4},
{Code: "SSD", Name: "SSD Storage", NameRu: "SSD накопитель", DisplayOrder: 50}, {Code: "SSD", Name: "SSD Storage", NameRu: "SSD накопитель", DisplayOrder: 5},
{Code: "HDD", Name: "HDD Storage", NameRu: "HDD накопитель", DisplayOrder: 51}, {Code: "RAID", Name: "RAID Controller", NameRu: "RAID контроллер", DisplayOrder: 6},
{Code: "M2", Name: "M.2 Storage", NameRu: "M.2 накопитель", DisplayOrder: 52}, {Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 7},
{Code: "EDSFF", Name: "EDSFF Storage", NameRu: "EDSFF накопитель", DisplayOrder: 53}, {Code: "NIC", Name: "Network Card", NameRu: "Сетевая карта", DisplayOrder: 8},
{Code: "HHHL", Name: "HHHL Storage", NameRu: "HHHL накопитель", DisplayOrder: 54}, {Code: "PSU", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 9},
{Code: "GPU", Name: "Graphics Card", NameRu: "Видеокарта", DisplayOrder: 60}, {Code: "RISERS", Name: "Risers", NameRu: "Райзеры", DisplayOrder: 10},
{Code: "NIC", Name: "Network Card", NameRu: "Сетевая карта", DisplayOrder: 70}, {Code: "ACC", Name: "Accessories", NameRu: "Аксессуары", DisplayOrder: 11},
{Code: "HCA", Name: "HCA Adapter", NameRu: "HCA адаптер", DisplayOrder: 71}, // Additional categories
{Code: "DPU", Name: "DPU", NameRu: "DPU", DisplayOrder: 72}, {Code: "MB", Name: "Motherboard", NameRu: "Материнская плата", DisplayOrder: 12},
{Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 80}, {Code: "HDD", Name: "HDD Storage", NameRu: "HDD накопитель", DisplayOrder: 13},
{Code: "PSU", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 90}, {Code: "HCA", Name: "HCA Adapter", NameRu: "HCA адаптер", DisplayOrder: 14},
{Code: "PS", Name: "Power Supply (Legacy)", NameRu: "Блок питания", DisplayOrder: 91}, {Code: "DPU", Name: "DPU", NameRu: "DPU", DisplayOrder: 15},
{Code: "ACC", Name: "Accessories", NameRu: "Аксессуары", DisplayOrder: 100}, {Code: "M2", Name: "M.2 Storage", NameRu: "M.2 накопитель", DisplayOrder: 16},
{Code: "RISERS", Name: "Risers", NameRu: "Райзеры", DisplayOrder: 101}, {Code: "EDSFF", Name: "EDSFF Storage", NameRu: "EDSFF накопитель", DisplayOrder: 17},
{Code: "CARD", Name: "Cards", NameRu: "Карты", DisplayOrder: 110}, {Code: "HHHL", Name: "HHHL Storage", NameRu: "HHHL накопитель", DisplayOrder: 18},
{Code: "BB", Name: "Barebone", NameRu: "Шасси", DisplayOrder: 120, IsRequired: true}, {Code: "PS", Name: "Power Supply (Legacy)", NameRu: "Блок питания", DisplayOrder: 19},
{Code: "CARD", Name: "Cards", NameRu: "Карты", DisplayOrder: 20},
} }
// MaxKnownDisplayOrder is the highest display order for known categories. // MaxKnownDisplayOrder is the highest display order for known categories
// New categories will get display order starting from this + 1. // New categories will get display order starting from this + 1
const MaxKnownDisplayOrder = 200 const MaxKnownDisplayOrder = 100

View File

@@ -111,7 +111,6 @@ type Configuration struct {
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"`
VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,omitempty"` VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,omitempty"`
ConfigType string `gorm:"size:20;default:server" json:"config_type"` // "server" | "storage"
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"` DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"` OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
Line int `gorm:"column:line_no;index" json:"line"` Line int `gorm:"column:line_no;index" json:"line"`
@@ -124,3 +123,16 @@ func (Configuration) TableName() string {
return "qt_configurations" return "qt_configurations"
} }
type PriceOverride struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
LotName string `gorm:"size:255;not null" json:"lot_name"`
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
ValidFrom time.Time `gorm:"type:date;not null" json:"valid_from"`
ValidUntil *time.Time `gorm:"type:date" json:"valid_until"`
Reason string `gorm:"type:text" json:"reason"`
CreatedBy uint `gorm:"not null" json:"created_by"`
}
func (PriceOverride) TableName() string {
return "qt_price_overrides"
}

View File

@@ -1,5 +1,7 @@
package models package models
import "time"
// Lot represents existing lot table // Lot represents existing lot table
type Lot struct { type Lot struct {
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"` LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
@@ -10,3 +12,58 @@ type Lot struct {
func (Lot) TableName() string { func (Lot) TableName() string {
return "lot" return "lot"
} }
// LotLog represents existing lot_log table (READ-ONLY)
type LotLog struct {
LotLogID uint `gorm:"column:lot_log_id;primaryKey;autoIncrement"`
Lot string `gorm:"column:lot;size:255;not null"`
Supplier string `gorm:"column:supplier;size:255;not null"`
Date time.Time `gorm:"column:date;type:date;not null"`
Price float64 `gorm:"column:price;not null"`
Quality string `gorm:"column:quality;size:255"`
Comments string `gorm:"column:comments;size:15000"`
}
func (LotLog) TableName() string {
return "lot_log"
}
// Supplier represents existing supplier table (READ-ONLY)
type Supplier struct {
SupplierName string `gorm:"column:supplier_name;primaryKey;size:255"`
SupplierComment string `gorm:"column:supplier_comment;size:10000"`
}
func (Supplier) TableName() string {
return "supplier"
}
// StockLog stores warehouse stock snapshots imported from external files.
type StockLog struct {
StockLogID uint `gorm:"column:stock_log_id;primaryKey;autoIncrement"`
Partnumber string `gorm:"column:partnumber;size:255;not null"`
Supplier *string `gorm:"column:supplier;size:255"`
Date time.Time `gorm:"column:date;type:date;not null"`
Price float64 `gorm:"column:price;not null"`
Quality *string `gorm:"column:quality;size:255"`
Comments *string `gorm:"column:comments;size:15000"`
Vendor *string `gorm:"column:vendor;size:255"`
Qty *float64 `gorm:"column:qty"`
}
func (StockLog) TableName() string {
return "stock_log"
}
// StockIgnoreRule contains import ignore pattern rules.
type StockIgnoreRule struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Target string `gorm:"column:target;size:20;not null" json:"target"` // partnumber|description
MatchType string `gorm:"column:match_type;size:20;not null" json:"match_type"` // exact|prefix|suffix
Pattern string `gorm:"column:pattern;size:500;not null" json:"pattern"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
}
func (StockIgnoreRule) TableName() string {
return "stock_ignore_rules"
}

View File

@@ -14,6 +14,9 @@ func AllModels() []interface{} {
&LotMetadata{}, &LotMetadata{},
&Project{}, &Project{},
&Configuration{}, &Configuration{},
&PriceOverride{},
&PricingAlert{},
&ComponentUsageStats{},
&Pricelist{}, &Pricelist{},
&PricelistItem{}, &PricelistItem{},
} }
@@ -28,9 +31,7 @@ func Migrate(db *gorm.DB) error {
errStr := err.Error() errStr := err.Error()
if strings.Contains(errStr, "Can't DROP") || if strings.Contains(errStr, "Can't DROP") ||
strings.Contains(errStr, "Duplicate key name") || strings.Contains(errStr, "Duplicate key name") ||
strings.Contains(errStr, "check that it exists") || strings.Contains(errStr, "check that it exists") {
strings.Contains(errStr, "Cannot change column") ||
strings.Contains(errStr, "used in a foreign key constraint") {
slog.Warn("migration warning (skipped)", "model", model, "error", errStr) slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
continue continue
} }
@@ -40,18 +41,12 @@ func Migrate(db *gorm.DB) error {
return nil return nil
} }
// SeedCategories upserts default categories, updating display_order on existing rows. // SeedCategories inserts default categories if not exist
func SeedCategories(db *gorm.DB) error { func SeedCategories(db *gorm.DB) error {
for _, cat := range DefaultCategories { for _, cat := range DefaultCategories {
var existing Category result := db.Where("code = ?", cat.Code).FirstOrCreate(&cat)
if err := db.Where("code = ?", cat.Code).First(&existing).Error; err != nil { if result.Error != nil {
if err := db.Create(&cat).Error; err != nil { return result.Error
return err
}
} else {
if err := db.Model(&existing).Update("display_order", cat.DisplayOrder).Error; err != nil {
return err
}
} }
} }
return nil return nil

View File

@@ -1,10 +0,0 @@
package models
// QtSetting is the MariaDB-side model for qt_settings.
// The table is managed by the server-side agent; QF only reads from it.
type QtSetting struct {
Name string `gorm:"primaryKey;size:100" json:"name"`
Value string `gorm:"type:text" json:"value"`
}
func (QtSetting) TableName() string { return "qt_settings" }

View File

@@ -0,0 +1,91 @@
package repository
import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type AlertRepository struct {
db *gorm.DB
}
func NewAlertRepository(db *gorm.DB) *AlertRepository {
return &AlertRepository{db: db}
}
func (r *AlertRepository) Create(alert *models.PricingAlert) error {
return r.db.Create(alert).Error
}
func (r *AlertRepository) GetByID(id uint) (*models.PricingAlert, error) {
var alert models.PricingAlert
err := r.db.First(&alert, id).Error
if err != nil {
return nil, err
}
return &alert, nil
}
func (r *AlertRepository) Update(alert *models.PricingAlert) error {
return r.db.Save(alert).Error
}
type AlertFilter struct {
Status models.AlertStatus
Severity models.AlertSeverity
Type models.AlertType
LotName string
}
func (r *AlertRepository) List(filter AlertFilter, offset, limit int) ([]models.PricingAlert, int64, error) {
var alerts []models.PricingAlert
var total int64
query := r.db.Model(&models.PricingAlert{})
if filter.Status != "" {
query = query.Where("status = ?", filter.Status)
}
if filter.Severity != "" {
query = query.Where("severity = ?", filter.Severity)
}
if filter.Type != "" {
query = query.Where("alert_type = ?", filter.Type)
}
if filter.LotName != "" {
query = query.Where("lot_name = ?", filter.LotName)
}
query.Count(&total)
err := query.
Order("FIELD(severity, 'critical', 'high', 'medium', 'low')").
Order("created_at DESC").
Offset(offset).
Limit(limit).
Find(&alerts).Error
return alerts, total, err
}
func (r *AlertRepository) CountByStatus(status models.AlertStatus) (int64, error) {
var count int64
err := r.db.Model(&models.PricingAlert{}).
Where("status = ?", status).
Count(&count).Error
return count, err
}
func (r *AlertRepository) UpdateStatus(id uint, status models.AlertStatus) error {
return r.db.Model(&models.PricingAlert{}).
Where("id = ?", id).
Update("status", status).Error
}
func (r *AlertRepository) ExistsByLotAndType(lotName string, alertType models.AlertType) (bool, error) {
var count int64
err := r.db.Model(&models.PricingAlert{}).
Where("lot_name = ? AND alert_type = ? AND status IN ('new', 'acknowledged')", lotName, alertType).
Count(&count).Error
return count > 0, err
}

View File

@@ -63,6 +63,11 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
Order("current_price " + sortDir) Order("current_price " + sortDir)
case "lot_name": case "lot_name":
query = query.Order("lot_name " + sortDir) query = query.Order("lot_name " + sortDir)
case "quote_count":
// Sort by quote count from lot_log table
query = query.
Select("qt_lot_metadata.*, (SELECT COUNT(*) FROM lot_log WHERE lot_log.lot = qt_lot_metadata.lot_name) as quote_count_sort").
Order("quote_count_sort " + sortDir)
default: default:
// Default: sort by popularity, no price goes last // Default: sort by popularity, no price goes last
query = query. query = query.

View File

@@ -157,7 +157,7 @@ func (r *PartnumberBookRepository) listCatalogItems(partnumbers localdb.LocalStr
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers)) query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers))
if search != "" { if search != "" {
trimmedSearch := "%" + search + "%" trimmedSearch := "%" + search + "%"
query = query.Where("partnumber LIKE ? OR CAST(lots_json AS TEXT) LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch) query = query.Where("partnumber LIKE ? OR lots_json LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
} }
var total int64 var total int64

View File

@@ -0,0 +1,124 @@
package repository
import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type PriceRepository struct {
db *gorm.DB
}
func NewPriceRepository(db *gorm.DB) *PriceRepository {
return &PriceRepository{db: db}
}
type PricePoint struct {
Price float64
Date time.Time
}
// GetPriceHistory returns price history from lot_log for a component
func (r *PriceRepository) GetPriceHistory(lotName string, periodDays int) ([]PricePoint, error) {
var points []PricePoint
since := time.Now().AddDate(0, 0, -periodDays)
err := r.db.Model(&models.LotLog{}).
Select("price, date").
Where("lot = ? AND date >= ?", lotName, since).
Order("date DESC").
Scan(&points).Error
return points, err
}
// GetLatestPrice returns the most recent price for a component
func (r *PriceRepository) GetLatestPrice(lotName string) (*PricePoint, error) {
var point PricePoint
err := r.db.Model(&models.LotLog{}).
Select("price, date").
Where("lot = ?", lotName).
Order("date DESC").
First(&point).Error
if err != nil {
return nil, err
}
return &point, nil
}
// GetPriceOverride returns active override for a component
func (r *PriceRepository) GetPriceOverride(lotName string) (*models.PriceOverride, error) {
var override models.PriceOverride
today := time.Now().Truncate(24 * time.Hour)
err := r.db.
Where("lot_name = ?", lotName).
Where("valid_from <= ?", today).
Where("valid_until IS NULL OR valid_until >= ?", today).
Order("valid_from DESC").
First(&override).Error
if err != nil {
return nil, err
}
return &override, nil
}
// CreatePriceOverride creates a new price override
func (r *PriceRepository) CreatePriceOverride(override *models.PriceOverride) error {
return r.db.Create(override).Error
}
// GetPriceOverrides returns all overrides for a component
func (r *PriceRepository) GetPriceOverrides(lotName string) ([]models.PriceOverride, error) {
var overrides []models.PriceOverride
err := r.db.
Where("lot_name = ?", lotName).
Order("valid_from DESC").
Find(&overrides).Error
return overrides, err
}
// DeletePriceOverride deletes an override
func (r *PriceRepository) DeletePriceOverride(id uint) error {
return r.db.Delete(&models.PriceOverride{}, id).Error
}
// GetQuoteCount returns the number of quotes in lot_log for a period
func (r *PriceRepository) GetQuoteCount(lotName string, periodDays int) (int64, error) {
var count int64
since := time.Now().AddDate(0, 0, -periodDays)
err := r.db.Model(&models.LotLog{}).
Where("lot = ? AND date >= ?", lotName, since).
Count(&count).Error
return count, err
}
// GetQuoteCounts returns quote counts for multiple lot names
func (r *PriceRepository) GetQuoteCounts(lotNames []string) (map[string]int64, error) {
type Result struct {
Lot string
Count int64
}
var results []Result
err := r.db.Model(&models.LotLog{}).
Select("lot, COUNT(*) as count").
Where("lot IN ?", lotNames).
Group("lot").
Scan(&results).Error
if err != nil {
return nil, err
}
counts := make(map[string]int64)
for _, r := range results {
counts[r.Lot] = r.Count
}
return counts, nil
}

View File

@@ -177,7 +177,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
if err != nil { if err != nil {
t.Fatalf("open sqlite: %v", err) t.Fatalf("open sqlite: %v", err)
} }
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}); err != nil { if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.StockLog{}); err != nil {
t.Fatalf("migrate: %v", err) t.Fatalf("migrate: %v", err)
} }
return NewPricelistRepository(db) return NewPricelistRepository(db)

View File

@@ -0,0 +1,115 @@
package repository
import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type StatsRepository struct {
db *gorm.DB
}
func NewStatsRepository(db *gorm.DB) *StatsRepository {
return &StatsRepository{db: db}
}
func (r *StatsRepository) GetByLotName(lotName string) (*models.ComponentUsageStats, error) {
var stats models.ComponentUsageStats
err := r.db.Where("lot_name = ?", lotName).First(&stats).Error
if err != nil {
return nil, err
}
return &stats, nil
}
func (r *StatsRepository) Upsert(stats *models.ComponentUsageStats) error {
return r.db.Save(stats).Error
}
func (r *StatsRepository) IncrementUsage(lotName string, quantity int, revenue float64) error {
now := time.Now()
result := r.db.Model(&models.ComponentUsageStats{}).
Where("lot_name = ?", lotName).
Updates(map[string]interface{}{
"quotes_total": gorm.Expr("quotes_total + 1"),
"quotes_last_30d": gorm.Expr("quotes_last_30d + 1"),
"quotes_last_7d": gorm.Expr("quotes_last_7d + 1"),
"total_quantity": gorm.Expr("total_quantity + ?", quantity),
"total_revenue": gorm.Expr("total_revenue + ?", revenue),
"last_used_at": now,
})
if result.RowsAffected == 0 {
stats := &models.ComponentUsageStats{
LotName: lotName,
QuotesTotal: 1,
QuotesLast30d: 1,
QuotesLast7d: 1,
TotalQuantity: quantity,
TotalRevenue: revenue,
LastUsedAt: &now,
}
return r.db.Create(stats).Error
}
return result.Error
}
func (r *StatsRepository) GetTopComponents(limit int) ([]models.ComponentUsageStats, error) {
var stats []models.ComponentUsageStats
err := r.db.
Order("quotes_last_30d DESC").
Limit(limit).
Find(&stats).Error
return stats, err
}
func (r *StatsRepository) GetTrendingComponents(limit int) ([]models.ComponentUsageStats, error) {
var stats []models.ComponentUsageStats
err := r.db.
Where("trend_direction = ? AND trend_percent > ?", models.TrendUp, 20).
Order("trend_percent DESC").
Limit(limit).
Find(&stats).Error
return stats, err
}
// ResetWeeklyCounters resets quotes_last_7d (run weekly via cron)
func (r *StatsRepository) ResetWeeklyCounters() error {
return r.db.Model(&models.ComponentUsageStats{}).
Where("1 = 1").
Update("quotes_last_7d", 0).Error
}
// ResetMonthlyCounters resets quotes_last_30d (run monthly via cron)
func (r *StatsRepository) ResetMonthlyCounters() error {
return r.db.Model(&models.ComponentUsageStats{}).
Where("1 = 1").
Update("quotes_last_30d", 0).Error
}
// UpdatePopularityScores recalculates popularity_score in qt_lot_metadata
// based on supplier quotes from lot_log table
func (r *StatsRepository) UpdatePopularityScores() error {
// Formula: popularity_score = quotes_last_30d * 3 + quotes_last_90d * 1 + quotes_total * 0.1
// This gives more weight to recent supplier activity
return r.db.Exec(`
UPDATE qt_lot_metadata m
LEFT JOIN (
SELECT
lot,
COUNT(*) as quotes_total,
SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as quotes_last_30d,
SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 90 DAY) THEN 1 ELSE 0 END) as quotes_last_90d
FROM lot_log
GROUP BY lot
) s ON m.lot_name = s.lot
SET m.popularity_score = COALESCE(
s.quotes_last_30d * 3 + s.quotes_last_90d * 1 + s.quotes_total * 0.1,
0
)
`).Error
}

View File

@@ -2,7 +2,6 @@ package services
import ( import (
"fmt" "fmt"
"log/slog"
"strings" "strings"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
@@ -12,15 +11,18 @@ import (
type ComponentService struct { type ComponentService struct {
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository
categoryRepo *repository.CategoryRepository categoryRepo *repository.CategoryRepository
statsRepo *repository.StatsRepository
} }
func NewComponentService( func NewComponentService(
componentRepo *repository.ComponentRepository, componentRepo *repository.ComponentRepository,
categoryRepo *repository.CategoryRepository, categoryRepo *repository.CategoryRepository,
statsRepo *repository.StatsRepository,
) *ComponentService { ) *ComponentService {
return &ComponentService{ return &ComponentService{
componentRepo: componentRepo, componentRepo: componentRepo,
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
statsRepo: statsRepo,
} }
} }
@@ -39,11 +41,10 @@ func ParsePartNumber(lotName string) (category, model string) {
} }
type ComponentListResult struct { type ComponentListResult struct {
Items []ComponentView `json:"items"` Components []ComponentView `json:"components"`
TotalCount int64 `json:"total_count"` Total int64 `json:"total"`
Page int `json:"page"` Page int `json:"page"`
PerPage int `json:"per_page"` PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
} }
type ComponentView struct { type ComponentView struct {
@@ -62,11 +63,10 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
// Components should be loaded via /api/sync/components first // Components should be loaded via /api/sync/components first
if s.componentRepo == nil { if s.componentRepo == nil {
return &ComponentListResult{ return &ComponentListResult{
Items: []ComponentView{}, Components: []ComponentView{},
TotalCount: 0, Total: 0,
Page: page, Page: page,
PerPage: perPage, PerPage: perPage,
TotalPages: 1,
}, nil }, nil
} }
@@ -107,16 +107,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
views[i] = view views[i] = view
} }
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
if totalPages < 1 {
totalPages = 1
}
return &ComponentListResult{ return &ComponentListResult{
Items: views, Components: views,
TotalCount: total, Total: total,
Page: page, Page: page,
PerPage: perPage, PerPage: perPage,
TotalPages: totalPages,
}, nil }, nil
} }
@@ -131,10 +126,8 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
return nil, err return nil, err
} }
// Track usage (best-effort) // Track usage
if err := s.componentRepo.IncrementRequestCount(lotName); err != nil { _ = s.componentRepo.IncrementRequestCount(lotName)
slog.Warn("component: could not increment request count", "lot", lotName, "err", err)
}
view := &ComponentView{ view := &ComponentView{
LotName: c.LotName, LotName: c.LotName,

View File

@@ -18,7 +18,6 @@ var (
// Used by handlers to work with both ConfigurationService and LocalConfigurationService // Used by handlers to work with both ConfigurationService and LocalConfigurationService
type ConfigurationGetter interface { type ConfigurationGetter interface {
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
GetByUUIDNoAuth(uuid string) (*models.Configuration, error)
} }
type ConfigurationService struct { type ConfigurationService struct {
@@ -59,7 +58,6 @@ type CreateConfigRequest struct {
PricelistID *uint `json:"pricelist_id,omitempty"` PricelistID *uint `json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"` WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"` CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
ConfigType string `json:"config_type,omitempty"` // "server" | "storage"
DisablePriceRefresh bool `json:"disable_price_refresh"` DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"` OnlyInStock bool `json:"only_in_stock"`
} }
@@ -105,18 +103,17 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
PricelistID: pricelistID, PricelistID: pricelistID,
WarehousePricelistID: req.WarehousePricelistID, WarehousePricelistID: req.WarehousePricelistID,
CompetitorPricelistID: req.CompetitorPricelistID, CompetitorPricelistID: req.CompetitorPricelistID,
ConfigType: req.ConfigType,
DisablePriceRefresh: req.DisablePriceRefresh, DisablePriceRefresh: req.DisablePriceRefresh,
OnlyInStock: req.OnlyInStock, OnlyInStock: req.OnlyInStock,
} }
if config.ConfigType == "" {
config.ConfigType = "server"
}
if err := s.configRepo.Create(config); err != nil { if err := s.configRepo.Create(config); err != nil {
return nil, err return nil, err
} }
// Record usage stats
_ = s.quoteService.RecordUsage(req.Items)
return config, nil return config, nil
} }

View File

@@ -13,17 +13,20 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
) )
type ExportService struct { type ExportService struct {
config config.ExportConfig config config.ExportConfig
localDB *localdb.LocalDB categoryRepo *repository.CategoryRepository
localDB *localdb.LocalDB
} }
func NewExportService(cfg config.ExportConfig, local *localdb.LocalDB) *ExportService { func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository, local *localdb.LocalDB) *ExportService {
return &ExportService{ return &ExportService{
config: cfg, config: cfg,
localDB: local, categoryRepo: categoryRepo,
localDB: local,
} }
} }
@@ -53,25 +56,11 @@ type ProjectExportData struct {
} }
type ProjectPricingExportOptions struct { type ProjectPricingExportOptions struct {
IncludeLOT bool `json:"include_lot"` IncludeLOT bool `json:"include_lot"`
IncludeBOM bool `json:"include_bom"` IncludeBOM bool `json:"include_bom"`
IncludeEstimate bool `json:"include_estimate"` IncludeEstimate bool `json:"include_estimate"`
IncludeStock bool `json:"include_stock"` IncludeStock bool `json:"include_stock"`
IncludeCompetitor bool `json:"include_competitor"` IncludeCompetitor bool `json:"include_competitor"`
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows
}
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
if o.SaleMarkup > 0 {
return o.SaleMarkup
}
return 1.3
}
func (o ProjectPricingExportOptions) isDDP() bool {
return strings.EqualFold(strings.TrimSpace(o.Basis), "ddp")
} }
type ProjectPricingExportData struct { type ProjectPricingExportData struct {
@@ -88,15 +77,14 @@ type ProjectPricingExportConfig struct {
} }
type ProjectPricingExportRow struct { type ProjectPricingExportRow struct {
LotDisplay string LotDisplay string
VendorPN string VendorPN string
Description string Description string
Quantity int Quantity int
BOMTotal *float64 BOMTotal *float64
Estimate *float64 Estimate *float64
Stock *float64 Stock *float64
Competitor *float64 Competitor *float64
ManualPrice *float64 // proportional share of the user-defined total price
} }
// ToCSV writes project export data in the new structured CSV format. // ToCSV writes project export data in the new structured CSV format.
@@ -126,7 +114,16 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
return fmt.Errorf("failed to write header: %w", err) return fmt.Errorf("failed to write header: %w", err)
} }
categoryOrder := defaultCategoryOrder() // Get category hierarchy for sorting
categoryOrder := make(map[string]int)
if s.categoryRepo != nil {
categories, err := s.categoryRepo.GetAll()
if err == nil {
for _, cat := range categories {
categoryOrder[cat.Code] = cat.DisplayOrder
}
}
}
for i, block := range data.Configs { for i, block := range data.Configs {
lineNo := block.Line lineNo := block.Line
@@ -203,30 +200,27 @@ func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
func sortConfigsByLine(configs []models.Configuration) []models.Configuration {
sorted := make([]models.Configuration, len(configs))
copy(sorted, configs)
sort.Slice(sorted, func(i, j int) bool {
li, lj := sorted[i].Line, sorted[j].Line
if li <= 0 {
li = int(^uint(0) >> 1)
}
if lj <= 0 {
lj = int(^uint(0) >> 1)
}
if li != lj {
return li < lj
}
if !sorted[i].CreatedAt.Equal(sorted[j].CreatedAt) {
return sorted[i].CreatedAt.After(sorted[j].CreatedAt)
}
return sorted[i].UUID > sorted[j].UUID
})
return sorted
}
func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) { func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
sortedConfigs := sortConfigsByLine(configs) sortedConfigs := make([]models.Configuration, len(configs))
copy(sortedConfigs, configs)
sort.Slice(sortedConfigs, func(i, j int) bool {
leftLine := sortedConfigs[i].Line
rightLine := sortedConfigs[j].Line
if leftLine <= 0 {
leftLine = int(^uint(0) >> 1)
}
if rightLine <= 0 {
rightLine = int(^uint(0) >> 1)
}
if leftLine != rightLine {
return leftLine < rightLine
}
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
}
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
})
blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs)) blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs))
for i := range sortedConfigs { for i := range sortedConfigs {
@@ -257,16 +251,18 @@ func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData
return fmt.Errorf("failed to write pricing header: %w", err) return fmt.Errorf("failed to write pricing header: %w", err)
} }
writeRows := opts.IncludeLOT || opts.IncludeBOM for idx, cfg := range data.Configs {
for _, cfg := range data.Configs {
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil { if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
return fmt.Errorf("failed to write config summary row: %w", err) return fmt.Errorf("failed to write config summary row: %w", err)
} }
if writeRows { for _, row := range cfg.Rows {
for _, row := range cfg.Rows { if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil { return fmt.Errorf("failed to write pricing row: %w", err)
return fmt.Errorf("failed to write pricing row: %w", err) }
} }
if idx < len(data.Configs)-1 {
if err := csvWriter.Write([]string{}); err != nil {
return fmt.Errorf("failed to write separator row: %w", err)
} }
} }
} }
@@ -289,7 +285,26 @@ func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectEx
// ProjectToExportData converts multiple configurations into ProjectExportData. // ProjectToExportData converts multiple configurations into ProjectExportData.
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData { func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
sortedConfigs := sortConfigsByLine(configs) sortedConfigs := make([]models.Configuration, len(configs))
copy(sortedConfigs, configs)
sort.Slice(sortedConfigs, func(i, j int) bool {
leftLine := sortedConfigs[i].Line
rightLine := sortedConfigs[j].Line
if leftLine <= 0 {
leftLine = int(^uint(0) >> 1)
}
if rightLine <= 0 {
rightLine = int(^uint(0) >> 1)
}
if leftLine != rightLine {
return leftLine < rightLine
}
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
}
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
})
blocks := make([]ConfigExportBlock, 0, len(configs)) blocks := make([]ConfigExportBlock, 0, len(configs))
for i := range sortedConfigs { for i := range sortedConfigs {
@@ -301,18 +316,6 @@ func (s *ExportService) ProjectToExportData(configs []models.Configuration) *Pro
} }
} }
// ConfigToPricingExportData is a single-config variant of ProjectToPricingExportData.
func (s *ExportService) ConfigToPricingExportData(cfg *models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
block, err := s.buildPricingExportBlock(cfg, opts)
if err != nil {
return nil, err
}
return &ProjectPricingExportData{
Configs: []ProjectPricingExportConfig{block},
CreatedAt: time.Now(),
}, nil
}
func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock { func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock {
// Batch-fetch categories from local data (pricelist items → local_components fallback) // Batch-fetch categories from local data (pricelist items → local_components fallback)
lotNames := make([]string, len(cfg.Items)) lotNames := make([]string, len(cfg.Items))
@@ -390,37 +393,17 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
description = componentDescriptions[rowMappings[0].LotName] description = componentDescriptions[rowMappings[0].LotName]
} }
if len(rowMappings) == 0 { pricingRow := ProjectPricingExportRow{
block.Rows = append(block.Rows, ProjectPricingExportRow{ LotDisplay: formatLotDisplay(rowMappings),
LotDisplay: "н/д", VendorPN: row.VendorPartnumber,
VendorPN: row.VendorPartnumber, Description: description,
Description: description, Quantity: exportPositiveInt(row.Quantity, 1),
Quantity: exportPositiveInt(row.Quantity, 1), BOMTotal: vendorRowTotal(row),
BOMTotal: vendorRowTotal(row), Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
}) Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
continue Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
}
// One export row per LOT mapping so that bundles (1 PN → N LOTs) appear
// as separate lines, matching the frontend pricing table layout.
pnQty := exportPositiveInt(row.Quantity, 1)
for i, mapping := range rowMappings {
lotQty := pnQty * mapping.QuantityPerPN
var bomTotal *float64
if i == 0 {
bomTotal = vendorRowTotal(row)
}
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: mapping.LotName,
VendorPN: row.VendorPartnumber,
Description: description,
Quantity: lotQty,
BOMTotal: bomTotal,
Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }),
Stock: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Stock }),
Competitor: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Competitor }),
})
} }
block.Rows = append(block.Rows, pricingRow)
} }
for _, item := range cfg.Items { for _, item := range cfg.Items {
@@ -441,25 +424,10 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity), Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
}) })
} }
if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
}
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
distributeManualPrice(block.Rows, *opts.ManualPrice)
}
return block, nil return block, nil
} }
catOrder := defaultCategoryOrder()
lotNames := make([]string, 0, len(cfg.Items))
for _, item := range cfg.Items { for _, item := range cfg.Items {
if item.LotName != "" {
lotNames = append(lotNames, item.LotName)
}
}
itemCategories := s.resolveCategories(cfg.PricelistID, lotNames)
sortedItems := sortConfigItemsByCategoryMap(cfg.Items, catOrder, itemCategories)
for _, item := range sortedItems {
if item.LotName == "" { if item.LotName == "" {
continue continue
} }
@@ -475,48 +443,9 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
}) })
} }
if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
}
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
distributeManualPrice(block.Rows, *opts.ManualPrice)
}
return block, nil return block, nil
} }
// sortConfigItemsByCategoryMap returns a copy of items sorted by category display order.
// categories maps lot_name → category code; catOrder maps category code → display order.
func sortConfigItemsByCategoryMap(items models.ConfigItems, catOrder map[string]int, categories map[string]string) models.ConfigItems {
sorted := make(models.ConfigItems, len(items))
copy(sorted, items)
sort.SliceStable(sorted, func(i, j int) bool {
orderI, hasI := categoryDisplayOrder(catOrder, categories[sorted[i].LotName])
orderJ, hasJ := categoryDisplayOrder(catOrder, categories[sorted[j].LotName])
if hasI && hasJ {
return orderI < orderJ
}
return hasI && !hasJ
})
return sorted
}
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
for i := range rows {
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
rows[i].Stock = scaleFloatPtr(rows[i].Stock, factor)
rows[i].Competitor = scaleFloatPtr(rows[i].Competitor, factor)
}
}
func scaleFloatPtr(v *float64, factor float64) *float64 {
if v == nil {
return nil
}
result := *v * factor
return &result
}
// resolveCategories returns lot_name → category map. // resolveCategories returns lot_name → category map.
// Primary source: pricelist items (lot_category). Fallback: local_components table. // Primary source: pricelist items (lot_category). Fallback: local_components table.
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string { func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
@@ -557,30 +486,20 @@ func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string)
return categories return categories
} }
// defaultCategoryOrder returns an uppercase category code → display_order map from models.DefaultCategories.
func defaultCategoryOrder() map[string]int {
m := make(map[string]int, len(models.DefaultCategories))
for _, cat := range models.DefaultCategories {
m[strings.ToUpper(cat.Code)] = cat.DisplayOrder
}
return m
}
func categoryDisplayOrder(categoryOrder map[string]int, category string) (int, bool) {
order, ok := categoryOrder[strings.ToUpper(strings.TrimSpace(category))]
return order, ok
}
// sortItemsByCategory sorts items by category display order (items without category go to the end). // sortItemsByCategory sorts items by category display order (items without category go to the end).
func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) { func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
sort.SliceStable(items, func(i, j int) bool { for i := 0; i < len(items)-1; i++ {
orderI, hasI := categoryDisplayOrder(categoryOrder, items[i].Category) for j := i + 1; j < len(items); j++ {
orderJ, hasJ := categoryDisplayOrder(categoryOrder, items[j].Category) orderI, hasI := categoryOrder[items[i].Category]
if hasI && hasJ { orderJ, hasJ := categoryOrder[items[j].Category]
return orderI < orderJ
if !hasI && hasJ {
items[i], items[j] = items[j], items[i]
} else if hasI && hasJ && orderI > orderJ {
items[i], items[j] = items[j], items[i]
}
} }
return hasI && !hasJ }
})
} }
type pricingLevels struct { type pricingLevels struct {
@@ -620,52 +539,45 @@ func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg
} }
} }
estimatePrices := s.batchLookupPrices(estimateID, lots)
stockPrices := s.batchLookupPrices(warehouseID, lots)
competitorPrices := s.batchLookupPrices(competitorID, lots)
for _, lot := range lots { for _, lot := range lots {
level := pricingLevels{} level := pricingLevels{}
if p, ok := estimatePrices[lot]; ok { level.Estimate = s.lookupPricePointer(estimateID, lot)
level.Estimate = floatPtr(p) level.Stock = s.lookupPricePointer(warehouseID, lot)
} level.Competitor = s.lookupPricePointer(competitorID, lot)
if p, ok := stockPrices[lot]; ok {
level.Stock = floatPtr(p)
}
if p, ok := competitorPrices[lot]; ok {
level.Competitor = floatPtr(p)
}
result[lot] = level result[lot] = level
} }
return result return result
} }
// batchLookupPrices fetches prices for all lots from a pricelist in a single query. func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 {
func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string) map[string]float64 { if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" {
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || len(lots) == 0 {
return nil return nil
} }
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID) localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
if err != nil { if err != nil {
return nil return nil
} }
prices, err := s.localDB.GetLocalPricesForLots(localPL.ID, lots) price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
if err != nil { if err != nil || price <= 0 {
return nil return nil
} }
return prices return floatPtr(price)
} }
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string { func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
lots := collectPricingLots(cfg, localCfg, true) lots := collectPricingLots(cfg, localCfg, true)
if s.localDB == nil || len(lots) == 0 { result := make(map[string]string, len(lots))
return map[string]string{} if s.localDB == nil {
return result
} }
descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots) for _, lot := range lots {
if err != nil { component, err := s.localDB.GetLocalComponent(lot)
return map[string]string{} if err != nil {
continue
}
result[lot] = component.LotDescription
} }
return descriptions return result
} }
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string { func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
@@ -749,52 +661,6 @@ func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.V
return floatPtr(total) return floatPtr(total)
} }
// distributeManualPrice sets ManualPrice on each row proportionally based on the
// row's Estimate share. The last row with a price absorbs rounding remainder so
// the sum of ManualPrice values always equals manualPrice exactly.
func distributeManualPrice(rows []ProjectPricingExportRow, manualPrice float64) {
if manualPrice <= 0 || len(rows) == 0 {
return
}
totalEstimate := 0.0
for _, row := range rows {
if row.Estimate != nil && *row.Estimate > 0 {
totalEstimate += *row.Estimate
}
}
if totalEstimate <= 0 {
return
}
lastIdx := -1
for i, row := range rows {
if row.Estimate != nil && *row.Estimate > 0 {
lastIdx = i
}
}
assigned := 0.0
for i, row := range rows {
if row.Estimate == nil || *row.Estimate <= 0 {
continue
}
var share float64
if i == lastIdx {
share = math.Round((manualPrice-assigned)*100) / 100
} else {
share = math.Round((*row.Estimate/totalEstimate)*manualPrice*100) / 100
assigned += share
}
rows[i].ManualPrice = floatPtr(share)
}
}
func computeSingleLotTotal(priceMap map[string]pricingLevels, lotName string, qty int, selector func(pricingLevels) *float64) *float64 {
price := selector(priceMap[lotName])
if price == nil || *price <= 0 {
return nil
}
return floatPtr(*price * float64(qty))
}
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 { func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
if unitPrice == nil || *unitPrice <= 0 { if unitPrice == nil || *unitPrice <= 0 {
return nil return nil
@@ -815,7 +681,7 @@ func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quanti
} }
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string { func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
headers := make([]string, 0, 9) headers := make([]string, 0, 8)
headers = append(headers, "Line Item") headers = append(headers, "Line Item")
if opts.IncludeLOT { if opts.IncludeLOT {
headers = append(headers, "LOT") headers = append(headers, "LOT")
@@ -833,14 +699,11 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
if opts.IncludeCompetitor { if opts.IncludeCompetitor {
headers = append(headers, "Конкуренты") headers = append(headers, "Конкуренты")
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
headers = append(headers, "Ручная цена")
}
return headers return headers
} }
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string { func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
record := make([]string, 0, 9) record := make([]string, 0, 8)
record = append(record, "") record = append(record, "")
if opts.IncludeLOT { if opts.IncludeLOT {
record = append(record, emptyDash(row.LotDisplay)) record = append(record, emptyDash(row.LotDisplay))
@@ -862,20 +725,17 @@ func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions
if opts.IncludeCompetitor { if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(row.Competitor)) record = append(record, formatMoneyValue(row.Competitor))
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
record = append(record, formatMoneyValue(row.ManualPrice))
}
return record return record
} }
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string { func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
record := make([]string, 0, 9) record := make([]string, 0, 8)
record = append(record, fmt.Sprintf("%d", cfg.Line)) record = append(record, fmt.Sprintf("%d", cfg.Line))
if opts.IncludeLOT { if opts.IncludeLOT {
record = append(record, "") record = append(record, "")
} }
record = append(record, record = append(record,
emptyDash(cfg.Article), "",
emptyDash(cfg.Name), emptyDash(cfg.Name),
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)), fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
) )
@@ -891,12 +751,19 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
if opts.IncludeCompetitor { if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor }))) record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
record = append(record, formatMoneyValue(opts.ManualPrice))
}
return record return record
} }
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
switch len(mappings) {
case 0:
return "н/д"
case 1:
return mappings[0].LotName
default:
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
}
}
func formatMoneyValue(value *float64) string { func formatMoneyValue(value *float64) string {
if value == nil { if value == nil {

View File

@@ -33,7 +33,7 @@ func newTestProjectData(items []ExportItem, article string, serverCount int) *Pr
} }
func TestToCSV_UTF8BOM(t *testing.T) { func TestToCSV_UTF8BOM(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil) svc := NewExportService(config.ExportConfig{}, nil, nil)
data := newTestProjectData([]ExportItem{ data := newTestProjectData([]ExportItem{
{ {
@@ -63,7 +63,7 @@ func TestToCSV_UTF8BOM(t *testing.T) {
} }
func TestToCSV_SemicolonDelimiter(t *testing.T) { func TestToCSV_SemicolonDelimiter(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil) svc := NewExportService(config.ExportConfig{}, nil, nil)
data := newTestProjectData([]ExportItem{ data := newTestProjectData([]ExportItem{
{ {
@@ -130,7 +130,7 @@ func TestToCSV_SemicolonDelimiter(t *testing.T) {
} }
func TestToCSV_ServerRow(t *testing.T) { func TestToCSV_ServerRow(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil) svc := NewExportService(config.ExportConfig{}, nil, nil)
data := newTestProjectData([]ExportItem{ data := newTestProjectData([]ExportItem{
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0}, {LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
@@ -175,7 +175,7 @@ func TestToCSV_ServerRow(t *testing.T) {
} }
func TestToCSV_CategorySorting(t *testing.T) { func TestToCSV_CategorySorting(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil) svc := NewExportService(config.ExportConfig{}, nil, nil)
data := newTestProjectData([]ExportItem{ data := newTestProjectData([]ExportItem{
{LotName: "LOT-001", Category: "CAT-A", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0}, {LotName: "LOT-001", Category: "CAT-A", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
@@ -214,7 +214,7 @@ func TestToCSV_CategorySorting(t *testing.T) {
} }
func TestToCSV_EmptyData(t *testing.T) { func TestToCSV_EmptyData(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil) svc := NewExportService(config.ExportConfig{}, nil, nil)
data := &ProjectExportData{ data := &ProjectExportData{
Configs: []ConfigExportBlock{}, Configs: []ConfigExportBlock{},
@@ -247,7 +247,7 @@ func TestToCSV_EmptyData(t *testing.T) {
} }
func TestToCSVBytes_BackwardCompat(t *testing.T) { func TestToCSVBytes_BackwardCompat(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil) svc := NewExportService(config.ExportConfig{}, nil, nil)
data := newTestProjectData([]ExportItem{ data := newTestProjectData([]ExportItem{
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0}, {LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
@@ -270,7 +270,7 @@ func TestToCSVBytes_BackwardCompat(t *testing.T) {
} }
func TestToCSV_WriterError(t *testing.T) { func TestToCSV_WriterError(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil) svc := NewExportService(config.ExportConfig{}, nil, nil)
data := newTestProjectData([]ExportItem{ data := newTestProjectData([]ExportItem{
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0}, {LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
@@ -284,7 +284,7 @@ func TestToCSV_WriterError(t *testing.T) {
} }
func TestToCSV_MultipleBlocks(t *testing.T) { func TestToCSV_MultipleBlocks(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil) svc := NewExportService(config.ExportConfig{}, nil, nil)
data := &ProjectExportData{ data := &ProjectExportData{
Configs: []ConfigExportBlock{ Configs: []ConfigExportBlock{
@@ -359,7 +359,7 @@ func TestToCSV_MultipleBlocks(t *testing.T) {
} }
func TestProjectToExportData_SortsByLine(t *testing.T) { func TestProjectToExportData_SortsByLine(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil) svc := NewExportService(config.ExportConfig{}, nil, nil)
configs := []models.Configuration{ configs := []models.Configuration{
{ {
@@ -445,7 +445,7 @@ func TestFormatPriceComma(t *testing.T) {
} }
func TestToPricingCSV_UsesSelectedColumns(t *testing.T) { func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil) svc := NewExportService(config.ExportConfig{}, nil, nil)
data := &ProjectPricingExportData{ data := &ProjectPricingExportData{
Configs: []ProjectPricingExportConfig{ Configs: []ProjectPricingExportConfig{
{ {
@@ -499,7 +499,7 @@ func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read summary row: %v", err) t.Fatalf("read summary row: %v", err)
} }
expectedSummary := []string{"10", "", "ART-1", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"} expectedSummary := []string{"10", "", "", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
for i, want := range expectedSummary { for i, want := range expectedSummary {
if summary[i] != want { if summary[i] != want {
t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i]) t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i])
@@ -519,7 +519,7 @@ func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
} }
func TestProjectToPricingExportData_UsesCartRowsWithoutBOM(t *testing.T) { func TestProjectToPricingExportData_UsesCartRowsWithoutBOM(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil) svc := NewExportService(config.ExportConfig{}, nil, nil)
configs := []models.Configuration{ configs := []models.Configuration{
{ {
UUID: "cfg-1", UUID: "cfg-1",

View File

@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"strings" "strings"
"time" "time"
@@ -50,13 +49,11 @@ func NewLocalConfigurationService(
// Create creates a new configuration in local SQLite and queues it for sync // Create creates a new configuration in local SQLite and queues it for sync
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) { func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
// If online, trigger pricelist sync in the background — do not block config creation // If online, check for new pricelists first
if s.isOnline() { if s.isOnline() {
go func() { if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil { // Log but don't fail - we can still use local pricelists
// Log but don't fail - we can still use local pricelists }
}
}()
} }
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID) projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
@@ -102,14 +99,10 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
PricelistID: pricelistID, PricelistID: pricelistID,
WarehousePricelistID: req.WarehousePricelistID, WarehousePricelistID: req.WarehousePricelistID,
CompetitorPricelistID: req.CompetitorPricelistID, CompetitorPricelistID: req.CompetitorPricelistID,
ConfigType: req.ConfigType,
DisablePriceRefresh: req.DisablePriceRefresh, DisablePriceRefresh: req.DisablePriceRefresh,
OnlyInStock: req.OnlyInStock, OnlyInStock: req.OnlyInStock,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
if cfg.ConfigType == "" {
cfg.ConfigType = "server"
}
// Convert to local model // Convert to local model
localCfg := localdb.ConfigurationToLocal(cfg) localCfg := localdb.ConfigurationToLocal(cfg)
@@ -119,6 +112,9 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
} }
cfg.Line = localCfg.Line cfg.Line = localCfg.Line
// Record usage stats
_ = s.quoteService.RecordUsage(req.Items)
return cfg, nil return cfg, nil
} }
@@ -403,38 +399,17 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
// Refresh local pricelists when online. // Refresh local pricelists when online and use latest active/local pricelist for recalculation.
if s.isOnline() { if s.isOnline() {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil { _ = s.syncService.SyncPricelistsIfNeeded()
slog.Warn("local configuration: background pricelist sync failed", "err", err)
}
} }
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Use the pricelist stored in the config; fall back to latest if unavailable.
var pricelist *localdb.LocalPricelist
if localCfg.PricelistID != nil && *localCfg.PricelistID > 0 {
if pl, err := s.localDB.GetLocalPricelistByServerID(*localCfg.PricelistID); err == nil {
pricelist = pl
}
}
if pricelist == nil {
if pl, err := s.localDB.GetLatestLocalPricelist(); err == nil {
pricelist = pl
}
}
// Capture fingerprint of the current state before any mutations.
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
}
preRefreshCfg := *localCfg
// Update prices for all items from pricelist // 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 pricelist != nil { if latestErr == nil && latestPricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName) price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
if err == nil && price > 0 { if err == nil && price > 0 {
updatedItems[i] = localdb.LocalConfigItem{ updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName, LotName: item.LotName,
@@ -459,8 +434,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
} }
localCfg.TotalPrice = &total localCfg.TotalPrice = &total
if pricelist != nil { if latestErr == nil && latestPricelist != nil {
localCfg.PricelistID = &pricelist.ServerID localCfg.PricelistID = &latestPricelist.ServerID
} }
// Set price update timestamp and mark for sync // Set price update timestamp and mark for sync
@@ -469,18 +444,6 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
localCfg.UpdatedAt = now localCfg.UpdatedAt = now
localCfg.SyncStatus = "pending" localCfg.SyncStatus = "pending"
// Before saving the new prices, snapshot the pre-refresh state so the revision
// history shows a clear before/after for every price update.
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
}
if preRefreshFP != postRefreshFP {
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
}
}
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername) cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
if err != nil { if err != nil {
return nil, fmt.Errorf("refresh prices with version: %w", err) return nil, fmt.Errorf("refresh prices with version: %w", err)
@@ -799,10 +762,8 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
return templates[start:end], total, nil return templates[start:end], total, nil
} }
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check. // RefreshPricesNoAuth updates all component prices in the configuration without ownership check
// pricelistServerID optionally specifies which pricelist to use; if nil, the config's stored func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
// pricelist is used; if that is also absent, the latest local pricelist is used as a fallback.
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistServerID *uint) (*models.Configuration, error) {
// Get configuration from local SQLite // Get configuration from local SQLite
localCfg, err := s.localDB.GetConfigurationByUUID(uuid) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil { if err != nil {
@@ -810,47 +771,15 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
} }
if s.isOnline() { if s.isOnline() {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil { _ = s.syncService.SyncPricelistsIfNeeded()
slog.Warn("local configuration: background pricelist sync failed", "err", err)
}
} }
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Resolve which pricelist to use:
// 1. Explicitly requested pricelist (from UI selection)
// 2. Pricelist stored in the configuration
// 3. Latest local pricelist as last-resort fallback
var targetServerID *uint
if pricelistServerID != nil && *pricelistServerID > 0 {
targetServerID = pricelistServerID
} else if localCfg.PricelistID != nil && *localCfg.PricelistID > 0 {
targetServerID = localCfg.PricelistID
}
var pricelist *localdb.LocalPricelist
if targetServerID != nil {
if pl, err := s.localDB.GetLocalPricelistByServerID(*targetServerID); err == nil {
pricelist = pl
}
}
if pricelist == nil {
// Fallback: use latest local pricelist
if pl, err := s.localDB.GetLatestLocalPricelist(); err == nil {
pricelist = pl
}
}
// Capture fingerprint of the current state before any mutations.
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
}
preRefreshCfg := *localCfg
// Update prices for all items from pricelist // 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 pricelist != nil { if latestErr == nil && latestPricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName) price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
if err == nil && price > 0 { if err == nil && price > 0 {
updatedItems[i] = localdb.LocalConfigItem{ updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName, LotName: item.LotName,
@@ -875,8 +804,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
} }
localCfg.TotalPrice = &total localCfg.TotalPrice = &total
if pricelist != nil { if latestErr == nil && latestPricelist != nil {
localCfg.PricelistID = &pricelist.ServerID localCfg.PricelistID = &latestPricelist.ServerID
} }
// Set price update timestamp and mark for sync // Set price update timestamp and mark for sync
@@ -885,18 +814,6 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
localCfg.UpdatedAt = now localCfg.UpdatedAt = now
localCfg.SyncStatus = "pending" localCfg.SyncStatus = "pending"
// Before saving the new prices, snapshot the pre-refresh state so the revision
// history shows a clear before/after for every price update.
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
if err != nil {
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
}
if preRefreshFP != postRefreshFP {
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ""); err != nil {
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
}
}
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "") cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil { if err != nil {
return nil, fmt.Errorf("refresh prices without auth with version: %w", err) return nil, fmt.Errorf("refresh prices without auth with version: %w", err)
@@ -904,16 +821,6 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
return cfg, nil return cfg, nil
} }
// SnapshotCurrentState creates a revision of the current configuration state without modifying it.
// Called before a client-side price refresh so the revision history has a clear before/after.
func (s *LocalConfigurationService) SnapshotCurrentState(uuid string) error {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
return s.snapshotPreRefreshTx(localCfg, "")
}
// UpdateServerCount updates server count and recalculates total price without creating a new version. // UpdateServerCount updates server count and recalculates total price without creating a new version.
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) { func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
if serverCount < 1 { if serverCount < 1 {
@@ -1298,55 +1205,21 @@ func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, nex
current.ServerModel != next.ServerModel || current.ServerModel != next.ServerModel ||
current.SupportCode != next.SupportCode || current.SupportCode != next.SupportCode ||
current.Article != next.Article || current.Article != next.Article ||
current.DisablePriceRefresh != next.DisablePriceRefresh ||
current.OnlyInStock != next.OnlyInStock ||
current.IsActive != next.IsActive || current.IsActive != next.IsActive ||
current.Line != next.Line { current.Line != next.Line {
return true return true
} }
if !equalStringPtr(current.ProjectUUID, next.ProjectUUID) { if !equalUintPtr(current.PricelistID, next.PricelistID) ||
!equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) ||
!equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) ||
!equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
return true return true
} }
return false return false
} }
func (s *LocalConfigurationService) UpdateVendorSpecNoAuth(uuid string, spec localdb.VendorSpec) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
localCfg.VendorSpec = spec
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, fmt.Errorf("update vendor spec without auth with version: %w", err)
}
return cfg, nil
}
func (s *LocalConfigurationService) ApplyVendorSpecItemsNoAuth(uuid string, items localdb.LocalConfigItems) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
localCfg.Items = items
total := items.Total()
if localCfg.ServerCount > 1 {
total *= float64(localCfg.ServerCount)
}
localCfg.TotalPrice = &total
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, fmt.Errorf("apply vendor spec items without auth with version: %w", err)
}
return cfg, nil
}
func equalStringPtr(a, b *string) bool { func equalStringPtr(a, b *string) bool {
if a == nil && b == nil { if a == nil && b == nil {
return true return true
@@ -1480,25 +1353,12 @@ func (s *LocalConfigurationService) appendVersionTx(
localCfg *localdb.LocalConfiguration, localCfg *localdb.LocalConfiguration,
operation string, operation string,
createdBy string, createdBy string,
) (*localdb.LocalConfigurationVersion, error) {
return s.appendVersionTxNote(tx, localCfg, operation, createdBy, "")
}
func (s *LocalConfigurationService) appendVersionTxNote(
tx *gorm.DB,
localCfg *localdb.LocalConfiguration,
operation string,
createdBy string,
noteOverride string,
) (*localdb.LocalConfigurationVersion, error) { ) (*localdb.LocalConfigurationVersion, error) {
snapshot, err := s.buildConfigurationSnapshot(localCfg) snapshot, err := s.buildConfigurationSnapshot(localCfg)
if err != nil { if err != nil {
return nil, fmt.Errorf("build snapshot: %w", err) return nil, fmt.Errorf("build snapshot: %w", err)
} }
changeNote := fmt.Sprintf("%s via local-first flow", operation) changeNote := fmt.Sprintf("%s via local-first flow", operation)
if noteOverride != "" {
changeNote = noteOverride
}
var createdByPtr *string var createdByPtr *string
if createdBy != "" { if createdBy != "" {
@@ -1539,35 +1399,6 @@ func (s *LocalConfigurationService) appendVersionTxNote(
return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID) return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID)
} }
// snapshotPreRefreshTx creates a revision of the current configuration state before a price
// refresh so the history clearly shows what existed before prices were updated.
// Called only when prices are about to change (fingerprints differ).
func (s *LocalConfigurationService) snapshotPreRefreshTx(localCfg *localdb.LocalConfiguration, createdBy string) error {
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
var locked localdb.LocalConfiguration
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("uuid = ?", localCfg.UUID).
First(&locked).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConfigNotFound
}
return fmt.Errorf("lock row for pre-refresh snapshot: %w", err)
}
version, err := s.appendVersionTxNote(tx, localCfg, "update", createdBy, "до обновления цен")
if err != nil {
return fmt.Errorf("append pre-refresh version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current_version_id for pre-refresh snapshot: %w", err)
}
return nil
})
}
func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) { func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) {
return localdb.BuildConfigurationSnapshot(localCfg) return localdb.BuildConfigurationSnapshot(localCfg)
} }

View File

@@ -137,77 +137,6 @@ func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
} }
} }
func TestUpdateNoAuthCreatesRevisionWhenPricingSettingsChanged(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "pricing",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
Name: "pricing",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
DisablePriceRefresh: true,
OnlyInStock: true,
}); err != nil {
t.Fatalf("update pricing settings: %v", err)
}
versions := loadVersions(t, local, created.UUID)
if len(versions) != 2 {
t.Fatalf("expected 2 versions after pricing settings change, got %d", len(versions))
}
if versions[1].VersionNo != 2 {
t.Fatalf("expected latest version_no=2, got %d", versions[1].VersionNo)
}
}
func TestUpdateVendorSpecNoAuthCreatesRevision(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "bom",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
spec := localdb.VendorSpec{
{
VendorPartnumber: "PN-001",
Quantity: 2,
SortOrder: 10,
LotMappings: []localdb.VendorSpecLotMapping{
{LotName: "CPU_A", QuantityPerPN: 1},
},
},
}
if _, err := service.UpdateVendorSpecNoAuth(created.UUID, spec); err != nil {
t.Fatalf("update vendor spec: %v", err)
}
versions := loadVersions(t, local, created.UUID)
if len(versions) != 2 {
t.Fatalf("expected 2 versions after vendor spec change, got %d", len(versions))
}
cfg, err := local.GetConfigurationByUUID(created.UUID)
if err != nil {
t.Fatalf("load config after vendor spec update: %v", err)
}
if len(cfg.VendorSpec) != 1 || cfg.VendorSpec[0].VendorPartnumber != "PN-001" {
t.Fatalf("expected saved vendor spec, got %+v", cfg.VendorSpec)
}
}
func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) { func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) {
service, local := newLocalConfigServiceForTest(t) service, local := newLocalConfigServiceForTest(t)

View File

@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"regexp"
"strings" "strings"
"time" "time"
@@ -21,14 +20,8 @@ var (
ErrProjectForbidden = errors.New("access to project forbidden") ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist") ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant") ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
) )
// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs.
var projectCodeRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
type ProjectService struct { type ProjectService struct {
localDB *localdb.LocalDB localDB *localdb.LocalDB
} }
@@ -69,13 +62,7 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
if code == "" { if code == "" {
return nil, fmt.Errorf("project code is required") return nil, fmt.Errorf("project code is required")
} }
if !projectCodeRe.MatchString(code) {
return nil, ErrProjectCodeInvalidChars
}
variant := strings.TrimSpace(req.Variant) variant := strings.TrimSpace(req.Variant)
if err := validateProjectVariantName(variant); err != nil {
return nil, err
}
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil { if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
return nil, err return nil, err
} }
@@ -114,21 +101,10 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
if code == "" { if code == "" {
return nil, fmt.Errorf("project code is required") return nil, fmt.Errorf("project code is required")
} }
if !projectCodeRe.MatchString(code) {
return nil, ErrProjectCodeInvalidChars
}
localProject.Code = code localProject.Code = code
} }
if req.Variant != nil { if req.Variant != nil {
newVariant := strings.TrimSpace(*req.Variant) localProject.Variant = strings.TrimSpace(*req.Variant)
// Block renaming of the main variant (empty Variant) — there must always be a main.
if strings.TrimSpace(localProject.Variant) == "" && newVariant != "" {
return nil, ErrCannotRenameMainVariant
}
localProject.Variant = newVariant
if err := validateProjectVariantName(localProject.Variant); err != nil {
return nil, err
}
} }
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil { if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
return nil, err return nil, err
@@ -190,13 +166,6 @@ func normalizeProjectVariant(variant string) string {
return strings.ToLower(strings.TrimSpace(variant)) return strings.ToLower(strings.TrimSpace(variant))
} }
func validateProjectVariantName(variant string) error {
if normalizeProjectVariant(variant) == "main" {
return ErrReservedMainVariant
}
return nil
}
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)
} }
@@ -293,15 +262,6 @@ func (s *ProjectService) GetByUUID(projectUUID, ownerUsername string) (*models.P
return localdb.LocalToProject(localProject), nil return localdb.LocalToProject(localProject), nil
} }
// GetByCode finds the main variant of a project by its code (case-insensitive).
func (s *ProjectService) GetByCode(code string) (*models.Project, error) {
localProject, err := s.localDB.GetProjectByCode(code)
if err != nil {
return nil, ErrProjectNotFound
}
return localdb.LocalToProject(localProject), nil
}
func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) { func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) {
project, err := s.GetByUUID(projectUUID, ownerUsername) project, err := s.GetByUUID(projectUUID, ownerUsername)
if err != nil { if err != nil {

View File

@@ -1,60 +0,0 @@
package services
import (
"errors"
"path/filepath"
"testing"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
)
func TestProjectServiceCreateRejectsReservedMainVariant(t *testing.T) {
local, err := newProjectTestLocalDB(t)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
service := NewProjectService(local)
_, err = service.Create("tester", &CreateProjectRequest{
Code: "OPS-1",
Variant: "main",
})
if !errors.Is(err, ErrReservedMainVariant) {
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
}
}
func TestProjectServiceUpdateRejectsReservedMainVariant(t *testing.T) {
local, err := newProjectTestLocalDB(t)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
service := NewProjectService(local)
created, err := service.Create("tester", &CreateProjectRequest{
Code: "OPS-1",
Variant: "Lenovo",
})
if err != nil {
t.Fatalf("create project: %v", err)
}
mainName := "main"
_, err = service.Update(created.UUID, "tester", &UpdateProjectRequest{
Variant: &mainName,
})
if !errors.Is(err, ErrReservedMainVariant) {
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
}
}
func newProjectTestLocalDB(t *testing.T) (*localdb.LocalDB, error) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "project_test.db")
local, err := localdb.New(dbPath)
if err != nil {
return nil, err
}
t.Cleanup(func() { _ = local.Close() })
return local, nil
}

View File

@@ -19,6 +19,7 @@ var (
type QuoteService struct { type QuoteService struct {
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository
statsRepo *repository.StatsRepository
pricelistRepo *repository.PricelistRepository pricelistRepo *repository.PricelistRepository
localDB *localdb.LocalDB localDB *localdb.LocalDB
pricingService priceResolver pricingService priceResolver
@@ -33,12 +34,14 @@ type priceResolver interface {
func NewQuoteService( func NewQuoteService(
componentRepo *repository.ComponentRepository, componentRepo *repository.ComponentRepository,
statsRepo *repository.StatsRepository,
pricelistRepo *repository.PricelistRepository, pricelistRepo *repository.PricelistRepository,
localDB *localdb.LocalDB, localDB *localdb.LocalDB,
pricingService priceResolver, pricingService priceResolver,
) *QuoteService { ) *QuoteService {
return &QuoteService{ return &QuoteService{
componentRepo: componentRepo, componentRepo: componentRepo,
statsRepo: statsRepo,
pricelistRepo: pricelistRepo, pricelistRepo: pricelistRepo,
localDB: localDB, localDB: localDB,
pricingService: pricingService, pricingService: pricingService,
@@ -385,14 +388,13 @@ func (s *QuoteService) lookupPricesByPricelistID(pricelistID uint, lotNames []st
} }
} }
// Fallback path (usually offline): batch local lookup (single query via index). // Fallback path (usually offline): local per-lot lookup.
if s.localDB != nil { if s.localDB != nil {
if localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID); err == nil { for _, lotName := range missing {
if batchPrices, err := s.localDB.GetLocalPricesForLots(localPL.ID, missing); err == nil { price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
for lotName, price := range batchPrices { if found && price > 0 {
result[lotName] = price result[lotName] = price
loaded[lotName] = price loaded[lotName] = price
}
} }
} }
s.updateCache(pricelistID, missing, loaded) s.updateCache(pricelistID, missing, loaded)
@@ -501,3 +503,18 @@ func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string
return 0, false return 0, false
} }
// RecordUsage records that components were used in a quote
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
if s.statsRepo == nil {
// Offline mode: usage stats are unavailable and should not block config saves.
return nil
}
for _, item := range items {
revenue := item.UnitPrice * float64(item.Quantity)
if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil {
return err
}
}
return nil
}

View File

@@ -13,7 +13,7 @@ import (
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) { func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
db := newPriceLevelsTestDB(t) db := newPriceLevelsTestDB(t)
repo := repository.NewPricelistRepository(db) repo := repository.NewPricelistRepository(db)
service := NewQuoteService(nil, repo, nil, nil) service := NewQuoteService(nil, nil, repo, nil, nil)
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100) estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
_ = estimate _ = estimate
@@ -57,7 +57,7 @@ func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) { func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
db := newPriceLevelsTestDB(t) db := newPriceLevelsTestDB(t)
repo := repository.NewPricelistRepository(db) repo := repository.NewPricelistRepository(db)
service := NewQuoteService(nil, repo, nil, nil) service := NewQuoteService(nil, nil, repo, nil, nil)
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80) olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90) seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)

View File

@@ -1,31 +1,20 @@
package sync package sync
import ( import (
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
) )
// SeenPartnumber represents an unresolved vendor partnumber to report. // SeenPartnumber represents an unresolved vendor partnumber to report.
type SeenPartnumber struct { type SeenPartnumber struct {
Partnumber string Partnumber string
Description string Description string
Ignored bool Ignored bool
LotSuggestion []LotSuggestionEntry // optional; set when user manually mapped PN → LOT in UI
}
// LotSuggestionEntry is one suggested LOT mapping for a vendor partnumber.
// JSON shape mirrors qt_partnumber_book_items.lots_json: {"lot_name", "qty"}.
type LotSuggestionEntry struct {
LotName string `json:"lot_name"`
Qty int `json:"qty"`
} }
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB. // PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
// When LotSuggestion is provided the column is updated too; if the column does not exist yet // Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
// (migration pending) the write is retried without it and a warning is logged — the app never panics.
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error { func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
@@ -41,43 +30,7 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
if item.Partnumber == "" { if item.Partnumber == "" {
continue continue
} }
err := mariaDB.Exec(`
if len(item.LotSuggestion) > 0 {
suggJSON, marshalErr := json.Marshal(item.LotSuggestion)
if marshalErr != nil {
slog.Error("partnumber_seen: failed to marshal lot_suggestion, skipping suggestion",
"partnumber", item.Partnumber, "error", marshalErr)
suggJSON = nil
}
if suggJSON != nil {
err = mariaDB.Exec(`
INSERT INTO qt_vendor_partnumber_seen
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
VALUES
('manual', '', ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
lot_suggestion = VALUES(lot_suggestion),
last_seen_at = NOW(3)
`, item.Partnumber, item.Description, item.Ignored, now, string(suggJSON)).Error
if err == nil {
continue
}
// Column not yet migrated — fall through to insert without lot_suggestion.
if !isUnknownColumnError(err) {
slog.Error("partnumber_seen: failed to upsert with lot_suggestion",
"partnumber", item.Partnumber, "error", err)
continue
}
slog.Warn("partnumber_seen: lot_suggestion column missing (migration pending), inserting without it",
"partnumber", item.Partnumber)
}
}
// Insert without lot_suggestion (baseline behaviour or fallback).
err = mariaDB.Exec(`
INSERT INTO qt_vendor_partnumber_seen INSERT INTO qt_vendor_partnumber_seen
(source_type, vendor, partnumber, description, is_ignored, last_seen_at) (source_type, vendor, partnumber, description, is_ignored, last_seen_at)
VALUES VALUES
@@ -87,18 +40,10 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
`, item.Partnumber, item.Description, item.Ignored, now).Error `, item.Partnumber, item.Description, item.Ignored, now).Error
if err != nil { if err != nil {
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err) slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
// Continue with remaining items
} }
} }
slog.Info("partnumber_seen pushed to server", "count", len(items)) slog.Info("partnumber_seen pushed to server", "count", len(items))
return nil return nil
} }
// isUnknownColumnError returns true when MariaDB reports that a column does not exist.
func isUnknownColumnError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unknown column") || strings.Contains(msg, "1054")
}

View File

@@ -1,7 +1,6 @@
package sync package sync
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -77,11 +76,6 @@ func (s *Service) GetReadiness() (*SyncReadiness, error) {
) )
} }
s.schemaOnce.Do(func() {
if err := ensureClientSchemaStateTable(mariaDB); err != nil {
slog.Warn("qt_client_schema_state migration skipped (no DDL rights — run server migrate)", "error", err)
}
})
if err := s.reportClientSchemaState(mariaDB, now); err != nil { if err := s.reportClientSchemaState(mariaDB, now); err != nil {
slog.Warn("failed to report client schema state", "error", err) slog.Warn("failed to report client schema state", "error", err)
} }
@@ -147,51 +141,35 @@ func ensureClientSchemaStateTable(db *gorm.DB) error {
} }
if tableExists(db, "qt_client_schema_state") { if tableExists(db, "qt_client_schema_state") {
// Each ALTER is guarded by a column existence check so users without DDL if err := db.Exec(`
// rights don't get a permission error on every sync cycle — the server ALTER TABLE qt_client_schema_state
// migration tool is the authoritative path for schema changes. ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER username
if !columnExists(db, "qt_client_schema_state", "hostname") { `).Error; err != nil {
if err := db.Exec(` return fmt.Errorf("add qt_client_schema_state.hostname: %w", err)
ALTER TABLE qt_client_schema_state
ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER username
`).Error; err != nil {
return fmt.Errorf("add qt_client_schema_state.hostname: %w", err)
}
if err := db.Exec(`
ALTER TABLE qt_client_schema_state
DROP PRIMARY KEY,
ADD PRIMARY KEY (username, hostname)
`).Error; err != nil && !isDuplicatePrimaryKeyDefinition(err) {
return fmt.Errorf("set qt_client_schema_state primary key: %w", err)
}
} }
type colMigration struct { if err := db.Exec(`
column string ALTER TABLE qt_client_schema_state
stmt string DROP PRIMARY KEY,
ADD PRIMARY KEY (username, hostname)
`).Error; err != nil && !isDuplicatePrimaryKeyDefinition(err) {
return fmt.Errorf("set qt_client_schema_state primary key: %w", err)
} }
migrations := []colMigration{
{"last_sync_at", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version"}, for _, stmt := range []string{
{"last_sync_status", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_status VARCHAR(32) NULL AFTER last_sync_at"}, "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version",
{"pending_changes_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_changes_count INT NOT NULL DEFAULT 0 AFTER last_sync_status"}, "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_status VARCHAR(32) NULL AFTER last_sync_at",
{"pending_errors_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_errors_count INT NOT NULL DEFAULT 0 AFTER pending_changes_count"}, "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_changes_count INT NOT NULL DEFAULT 0 AFTER last_sync_status",
{"configurations_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_count"}, "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_errors_count INT NOT NULL DEFAULT 0 AFTER pending_changes_count",
{"projects_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS projects_count INT NOT NULL DEFAULT 0 AFTER configurations_count"}, "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_count",
{"estimate_pricelist_version", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS estimate_pricelist_version VARCHAR(128) NULL AFTER projects_count"}, "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS projects_count INT NOT NULL DEFAULT 0 AFTER configurations_count",
{"warehouse_pricelist_version", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS warehouse_pricelist_version VARCHAR(128) NULL AFTER estimate_pricelist_version"}, "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS estimate_pricelist_version VARCHAR(128) NULL AFTER projects_count",
{"competitor_pricelist_version", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version"}, "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS warehouse_pricelist_version VARCHAR(128) NULL AFTER estimate_pricelist_version",
{"last_sync_error_code", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version"}, "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version",
{"last_sync_error_text", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code"}, "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version",
{"local_pricelist_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS local_pricelist_count INT NOT NULL DEFAULT 0 AFTER last_sync_error_text"}, "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code",
{"pricelist_items_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pricelist_items_count INT NOT NULL DEFAULT 0 AFTER local_pricelist_count"}, } {
{"components_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS components_count INT NOT NULL DEFAULT 0 AFTER pricelist_items_count"}, if err := db.Exec(stmt).Error; err != nil {
{"db_size_bytes", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS db_size_bytes BIGINT NOT NULL DEFAULT 0 AFTER components_count"},
}
for _, m := range migrations {
if columnExists(db, "qt_client_schema_state", m.column) {
continue
}
if err := db.Exec(m.stmt).Error; err != nil {
return fmt.Errorf("expand qt_client_schema_state: %w", err) return fmt.Errorf("expand qt_client_schema_state: %w", err)
} }
} }
@@ -199,17 +177,6 @@ func ensureClientSchemaStateTable(db *gorm.DB) error {
return nil return nil
} }
func columnExists(db *gorm.DB, tableName, columnName string) bool {
var count int64
if err := db.Raw(`
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
`, tableName, columnName).Scan(&count).Error; err != nil {
return false
}
return count > 0
}
func tableExists(db *gorm.DB, tableName string) bool { func tableExists(db *gorm.DB, tableName string) bool {
var count int64 var count int64
// For MariaDB/MySQL, check information_schema // For MariaDB/MySQL, check information_schema
@@ -226,6 +193,9 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") { if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
return nil return nil
} }
if err := ensureClientSchemaStateTable(mariaDB); err != nil {
return err
}
username := strings.TrimSpace(s.localDB.GetDBUser()) username := strings.TrimSpace(s.localDB.GetDBUser())
if username == "" { if username == "" {
return nil return nil
@@ -245,10 +215,6 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse") warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
competitorVersion := latestPricelistVersion(s.localDB, "competitor") competitorVersion := latestPricelistVersion(s.localDB, "competitor")
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB) lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
localPricelistCount := s.localDB.CountLocalPricelists()
pricelistItemsCount := s.localDB.CountAllPricelistItems()
componentsCount := s.localDB.CountComponents()
dbSizeBytes := s.localDB.DBFileSizeBytes()
return mariaDB.Exec(` return mariaDB.Exec(`
INSERT INTO qt_client_schema_state ( INSERT INTO qt_client_schema_state (
username, hostname, app_version, username, hostname, app_version,
@@ -256,10 +222,9 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
configurations_count, projects_count, configurations_count, projects_count,
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version, estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
last_sync_error_code, last_sync_error_text, last_sync_error_code, last_sync_error_text,
local_pricelist_count, pricelist_items_count, components_count, db_size_bytes,
last_checked_at, updated_at last_checked_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
app_version = VALUES(app_version), app_version = VALUES(app_version),
last_sync_at = VALUES(last_sync_at), last_sync_at = VALUES(last_sync_at),
@@ -273,10 +238,6 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
competitor_pricelist_version = VALUES(competitor_pricelist_version), competitor_pricelist_version = VALUES(competitor_pricelist_version),
last_sync_error_code = VALUES(last_sync_error_code), last_sync_error_code = VALUES(last_sync_error_code),
last_sync_error_text = VALUES(last_sync_error_text), last_sync_error_text = VALUES(last_sync_error_text),
local_pricelist_count = VALUES(local_pricelist_count),
pricelist_items_count = VALUES(pricelist_items_count),
components_count = VALUES(components_count),
db_size_bytes = VALUES(db_size_bytes),
last_checked_at = VALUES(last_checked_at), last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at) updated_at = VALUES(updated_at)
`, username, hostname, appmeta.Version(), `, username, hostname, appmeta.Version(),
@@ -284,7 +245,6 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
configurationsCount, projectsCount, configurationsCount, projectsCount,
estimateVersion, warehouseVersion, competitorVersion, estimateVersion, warehouseVersion, competitorVersion,
lastSyncErrorCode, lastSyncErrorText, lastSyncErrorCode, lastSyncErrorText,
localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes,
checkedAt, checkedAt).Error checkedAt, checkedAt).Error
} }
@@ -321,37 +281,14 @@ func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText)) return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
} }
var errored []localdb.PendingChange var pending localdb.PendingChange
if err := local.DB(). if err := local.DB().
Where("TRIM(COALESCE(last_error, '')) <> ''"). Where("TRIM(COALESCE(last_error, '')) <> ''").
Order("id DESC"). Order("id DESC").
Limit(20). First(&pending).Error; err == nil {
Find(&errored).Error; err != nil || len(errored) == 0 { return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
return nil, nil
} }
return nil, nil
type errorEntry struct {
Type string `json:"type"`
UUID string `json:"uuid"`
Op string `json:"op"`
Attempts int `json:"attempts"`
Error string `json:"error"`
}
entries := make([]errorEntry, 0, len(errored))
for _, ch := range errored {
entries = append(entries, errorEntry{
Type: ch.EntityType,
UUID: ch.EntityUUID,
Op: ch.Operation,
Attempts: ch.Attempts,
Error: strings.TrimSpace(ch.LastError),
})
}
detail, jsonErr := json.Marshal(entries)
if jsonErr != nil {
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(errored[0].LastError))
}
return optionalString("PENDING_CHANGE_ERROR"), optionalString(string(detail))
} }
func optionalString(value string) *string { func optionalString(value string) *string {

View File

@@ -7,7 +7,6 @@ import (
"log/slog" "log/slog"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta" "git.mchus.pro/mchus/quoteforge/internal/appmeta"
@@ -17,18 +16,15 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
var ErrOffline = errors.New("database is offline") var ErrOffline = errors.New("database is offline")
// Service handles synchronization between MariaDB and local SQLite // Service handles synchronization between MariaDB and local SQLite
type Service struct { type Service struct {
connMgr *db.ConnectionManager connMgr *db.ConnectionManager
localDB *localdb.LocalDB localDB *localdb.LocalDB
directDB *gorm.DB directDB *gorm.DB
pricelistMu sync.Mutex // prevents concurrent pricelist syncs
schemaOnce sync.Once // ensures ensureClientSchemaStateTable runs at most once per process
} }
// NewService creates a new sync service // NewService creates a new sync service
@@ -49,15 +45,10 @@ func NewServiceWithDB(mariaDB *gorm.DB, localDB *localdb.LocalDB) *Service {
// SyncStatus represents the current sync status // SyncStatus represents the current sync status
type SyncStatus struct { type SyncStatus struct {
LastSyncAt *time.Time `json:"last_sync_at"` LastSyncAt *time.Time `json:"last_sync_at"`
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` ServerPricelists int `json:"server_pricelists"`
LastSyncStatus string `json:"last_sync_status,omitempty"` LocalPricelists int `json:"local_pricelists"`
LastSyncError string `json:"last_sync_error,omitempty"` NeedsSync bool `json:"needs_sync"`
ServerPricelists int `json:"server_pricelists"`
LocalPricelists int `json:"local_pricelists"`
NeedsSync bool `json:"needs_sync"`
IncompleteServerSync bool `json:"incomplete_server_sync"`
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
} }
type UserSyncStatus struct { type UserSyncStatus struct {
@@ -224,7 +215,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
existing.SyncStatus = "synced" existing.SyncStatus = "synced"
existing.SyncedAt = &now existing.SyncedAt = &now
if err := s.localDB.SaveProjectPreservingUpdatedAt(existing); err != nil { if err := s.localDB.SaveProject(existing); err != nil {
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err) return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
} }
result.Updated++ result.Updated++
@@ -234,7 +225,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
localProject := localdb.ProjectToLocal(&project) localProject := localdb.ProjectToLocal(&project)
localProject.SyncStatus = "synced" localProject.SyncStatus = "synced"
localProject.SyncedAt = &now localProject.SyncedAt = &now
if err := s.localDB.SaveProjectPreservingUpdatedAt(localProject); err != nil { if err := s.localDB.SaveProject(localProject); err != nil {
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err) return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
} }
result.Imported++ result.Imported++
@@ -249,23 +240,30 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
// GetStatus returns the current sync status // GetStatus returns the current sync status
func (s *Service) GetStatus() (*SyncStatus, error) { func (s *Service) GetStatus() (*SyncStatus, error) {
lastSync := s.localDB.GetLastSyncTime() lastSync := s.localDB.GetLastSyncTime()
lastAttempt := s.localDB.GetLastPricelistSyncAttemptAt()
lastSyncStatus := s.localDB.GetLastPricelistSyncStatus() // Count server pricelists (only if already connected, don't reconnect)
lastSyncError := s.localDB.GetLastPricelistSyncError() serverCount := 0
connStatus := s.getConnectionStatus()
if connStatus.IsConnected {
if mariaDB, err := s.getDB(); err == nil && mariaDB != nil {
pricelistRepo := repository.NewPricelistRepository(mariaDB)
activeCount, err := pricelistRepo.CountActive()
if err == nil {
serverCount = int(activeCount)
}
}
}
// Count local pricelists
localCount := s.localDB.CountLocalPricelists() localCount := s.localDB.CountLocalPricelists()
hasFailedSync := strings.EqualFold(lastSyncStatus, "failed")
needsSync := lastSync == nil || hasFailedSync needsSync, _ := s.NeedSync()
return &SyncStatus{ return &SyncStatus{
LastSyncAt: lastSync, LastSyncAt: lastSync,
LastAttemptAt: lastAttempt, ServerPricelists: serverCount,
LastSyncStatus: lastSyncStatus, LocalPricelists: int(localCount),
LastSyncError: lastSyncError, NeedsSync: needsSync,
ServerPricelists: 0,
LocalPricelists: int(localCount),
NeedsSync: needsSync,
IncompleteServerSync: hasFailedSync,
KnownServerChangesMiss: hasFailedSync,
}, nil }, nil
} }
@@ -274,56 +272,60 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
func (s *Service) NeedSync() (bool, error) { func (s *Service) NeedSync() (bool, error) {
lastSync := s.localDB.GetLastSyncTime() lastSync := s.localDB.GetLastSyncTime()
// If never synced, always need sync. // If never synced, need sync
if lastSync == nil { if lastSync == nil {
return true, nil return true, nil
} }
// When online, compare actual server versions regardless of elapsed time. // If last sync was more than 1 hour ago, suggest sync
// This prevents a stale "failed" status from the past from triggering
// endless sync retries when all pricelists are already up to date.
connStatus := s.getConnectionStatus()
if connStatus.IsConnected {
mariaDB, err := s.getDB()
if err == nil {
pricelistRepo := repository.NewPricelistRepository(mariaDB)
sources := []models.PricelistSource{
models.PricelistSourceEstimate,
models.PricelistSourceWarehouse,
models.PricelistSourceCompetitor,
}
allMatch := true
for _, source := range sources {
latestServer, err := pricelistRepo.GetLatestActiveBySource(string(source))
if err != nil {
continue
}
latestLocal, err := s.localDB.GetLatestLocalPricelistBySource(string(source))
if err != nil {
return true, nil
}
if latestServer.ID != latestLocal.ServerID {
return true, nil
}
}
if allMatch {
return false, nil
}
}
}
// Offline fallback: suggest sync if last successful sync was more than 1 hour ago.
if time.Since(*lastSync) > time.Hour { if time.Since(*lastSync) > time.Hour {
return true, nil return true, nil
} }
// Check if there are new pricelists on server (only if already connected)
connStatus := s.getConnectionStatus()
if !connStatus.IsConnected {
// If offline, can't check server, no need to sync
return false, nil
}
mariaDB, err := s.getDB()
if err != nil {
// If offline, can't check server, no need to sync
return false, nil
}
pricelistRepo := repository.NewPricelistRepository(mariaDB)
sources := []models.PricelistSource{
models.PricelistSourceEstimate,
models.PricelistSourceWarehouse,
models.PricelistSourceCompetitor,
}
for _, source := range sources {
latestServer, err := pricelistRepo.GetLatestActiveBySource(string(source))
if err != nil {
// No active pricelist for this source yet.
continue
}
latestLocal, err := s.localDB.GetLatestLocalPricelistBySource(string(source))
if err != nil {
// No local pricelist for an existing source on server.
return true, nil
}
// If server has newer pricelist for this source, need sync.
if latestServer.ID != latestLocal.ServerID {
return true, nil
}
}
return false, nil return false, nil
} }
// SyncPricelists synchronizes all active pricelists from server to local SQLite // SyncPricelists synchronizes all active pricelists from server to local SQLite
func (s *Service) SyncPricelists() (int, error) { func (s *Service) SyncPricelists() (int, error) {
slog.Info("starting pricelist sync") slog.Info("starting pricelist sync")
plSyncStart := time.Now()
if _, err := s.EnsureReadinessForSync(); err != nil { if _, err := s.EnsureReadinessForSync(); err != nil {
return 0, err return 0, err
} }
@@ -331,8 +333,6 @@ func (s *Service) SyncPricelists() (int, error) {
// Get database connection // Get database connection
mariaDB, err := s.getDB() mariaDB, err := s.getDB()
if err != nil { if err != nil {
s.recordPricelistSyncFailure(err)
s.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, plSyncStart, time.Since(plSyncStart).Milliseconds())
return 0, fmt.Errorf("database not available: %w", err) return 0, fmt.Errorf("database not available: %w", err)
} }
@@ -342,7 +342,6 @@ func (s *Service) SyncPricelists() (int, error) {
// Get active pricelists from server (up to 100) // Get active pricelists from server (up to 100)
serverPricelists, _, err := pricelistRepo.ListActive(0, 100) serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
if err != nil { if err != nil {
s.recordPricelistSyncFailure(err)
return 0, fmt.Errorf("getting active server pricelists: %w", err) return 0, fmt.Errorf("getting active server pricelists: %w", err)
} }
serverPricelistIDs := make([]uint, 0, len(serverPricelists)) serverPricelistIDs := make([]uint, 0, len(serverPricelists))
@@ -351,30 +350,14 @@ func (s *Service) SyncPricelists() (int, error) {
} }
synced := 0 synced := 0
var syncErr error
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 {
existing.Source = pl.Source
existing.Version = pl.Version
existing.Name = pl.Notification
existing.CreatedAt = pl.CreatedAt
existing.SyncedAt = time.Now()
if err := s.localDB.SaveLocalPricelist(existing); err != nil {
if syncErr == nil {
syncErr = fmt.Errorf("refresh existing pricelist %s: %w", pl.Version, err)
}
slog.Warn("failed to refresh existing local pricelist header", "version", pl.Version, "error", err)
continue
}
// Backfill items for legacy/partial local caches where only pricelist metadata exists. // Backfill items for legacy/partial local caches where only pricelist metadata exists.
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 { if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
itemCount, err := s.SyncPricelistItems(existing.ID) itemCount, err := s.SyncPricelistItems(existing.ID)
if err != nil { if err != nil {
if syncErr == nil {
syncErr = fmt.Errorf("sync items for existing pricelist %s: %w", pl.Version, err)
}
slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err) slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err)
} else { } else {
slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount) slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount)
@@ -394,15 +377,19 @@ func (s *Service) SyncPricelists() (int, error) {
IsUsed: false, IsUsed: false,
} }
itemCount, err := s.syncNewPricelistSnapshot(localPL) if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
if err != nil { slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
if syncErr == nil {
syncErr = fmt.Errorf("sync new pricelist %s: %w", pl.Version, err)
}
slog.Warn("failed to sync pricelist snapshot", "version", pl.Version, "error", err)
continue continue
} }
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
// Sync items for the newly created pricelist
itemCount, err := s.SyncPricelistItems(localPL.ID)
if err != nil {
slog.Warn("failed to sync pricelist items", "version", pl.Version, "error", err)
// Continue even if items sync fails - we have the pricelist metadata
} else {
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
}
synced++ synced++
} }
@@ -417,123 +404,14 @@ func (s *Service) SyncPricelists() (int, error) {
// Backfill lot_category for used pricelists (older local caches may miss the column values). // Backfill lot_category for used pricelists (older local caches may miss the column values).
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs) s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
if syncErr != nil {
s.recordPricelistSyncFailure(syncErr)
s.localDB.AppendSyncLog("pricelists", "error", syncErr.Error(), synced, plSyncStart, time.Since(plSyncStart).Milliseconds())
return synced, syncErr
}
// Update last sync time // Update last sync time
now := time.Now() s.localDB.SetLastSyncTime(time.Now())
s.localDB.SetLastSyncTime(now) s.RecordSyncHeartbeat()
s.recordPricelistSyncSuccess(now)
s.localDB.AppendSyncLog("pricelists", "ok", "", synced, plSyncStart, time.Since(plSyncStart).Milliseconds())
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists)) slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
return synced, nil return synced, nil
} }
func (s *Service) recordPricelistSyncSuccess(at time.Time) {
if s.localDB == nil {
return
}
if err := s.localDB.SetPricelistSyncResult("success", "", at); err != nil {
slog.Warn("failed to persist pricelist sync success state", "error", err)
}
}
func (s *Service) recordPricelistSyncFailure(syncErr error) {
if s.localDB == nil || syncErr == nil {
return
}
s.markConnectionBroken(syncErr)
if err := s.localDB.SetPricelistSyncResult("failed", syncErr.Error(), time.Now()); err != nil {
slog.Warn("failed to persist pricelist sync failure state", "error", err)
}
}
func (s *Service) markConnectionBroken(err error) {
if err == nil || s.connMgr == nil {
return
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "i/o timeout"),
strings.Contains(msg, "invalid connection"),
strings.Contains(msg, "bad connection"),
strings.Contains(msg, "connection reset"),
strings.Contains(msg, "broken pipe"),
strings.Contains(msg, "unexpected eof"):
s.connMgr.MarkOffline(err)
}
}
func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int, error) {
if localPL == nil {
return 0, fmt.Errorf("local pricelist is nil")
}
localItems, err := s.fetchServerPricelistItems(localPL.ServerID)
if err != nil {
return 0, err
}
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "server_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"source": localPL.Source,
"version": localPL.Version,
"name": localPL.Name,
"created_at": localPL.CreatedAt,
"synced_at": localPL.SyncedAt,
"is_used": localPL.IsUsed,
}),
}).Create(localPL).Error; err != nil {
return fmt.Errorf("save local pricelist: %w", err)
}
if localPL.ID == 0 {
if err := tx.Where("server_id = ?", localPL.ServerID).First(localPL).Error; err != nil {
return fmt.Errorf("reload local pricelist: %w", err)
}
}
for i := range localItems {
localItems[i].PricelistID = localPL.ID
}
if err := replaceLocalPricelistItemsTx(tx, localPL.ID, localItems); err != nil {
return fmt.Errorf("save local pricelist items: %w", err)
}
return nil
}); err != nil {
return 0, err
}
slog.Info("synced pricelist items", "pricelist_id", localPL.ID, "items", len(localItems))
return len(localItems), nil
}
func replaceLocalPricelistItemsTx(tx *gorm.DB, pricelistID uint, items []localdb.LocalPricelistItem) error {
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&localdb.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
}
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) { func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
if s.localDB == nil || pricelistRepo == nil { if s.localDB == nil || pricelistRepo == nil {
return return
@@ -611,29 +489,58 @@ func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.
} }
} }
// ListUserSyncStatuses returns users who have recorded a client schema state check. // RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
// Only users with write rights are expected to be able to update this table.
func (s *Service) RecordSyncHeartbeat() {
username := strings.TrimSpace(s.localDB.GetDBUser())
if username == "" {
return
}
mariaDB, err := s.getDB()
if err != nil || mariaDB == nil {
return
}
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
slog.Warn("sync heartbeat: failed to ensure table", "error", err)
return
}
now := time.Now().UTC()
if err := mariaDB.Exec(`
INSERT INTO qt_pricelist_sync_status (username, last_sync_at, updated_at, app_version)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_sync_at = VALUES(last_sync_at),
updated_at = VALUES(updated_at),
app_version = VALUES(app_version)
`, username, now, now, appmeta.Version()).Error; err != nil {
slog.Debug("sync heartbeat: skipped", "username", username, "error", err)
}
}
// ListUserSyncStatuses returns users who have recorded sync heartbeat.
func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) { func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) {
mariaDB, err := s.getDB() mariaDB, err := s.getDB()
if err != nil || mariaDB == nil { if err != nil || mariaDB == nil {
return nil, ErrOffline return nil, ErrOffline
} }
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
return nil, fmt.Errorf("ensure sync status table: %w", err)
}
type row struct { type row struct {
Username string `gorm:"column:username"` Username string `gorm:"column:username"`
LastCheckedAt time.Time `gorm:"column:last_checked_at"` LastSyncAt time.Time `gorm:"column:last_sync_at"`
AppVersion string `gorm:"column:app_version"` AppVersion string `gorm:"column:app_version"`
} }
var rows []row var rows []row
if err := mariaDB.Raw(` if err := mariaDB.Raw(`
SELECT s.username, s.last_checked_at, COALESCE(s.app_version, '') AS app_version SELECT username, last_sync_at, COALESCE(app_version, '') AS app_version
FROM qt_client_schema_state s FROM qt_pricelist_sync_status
INNER JOIN ( ORDER BY last_sync_at DESC, username ASC
SELECT username, MAX(last_checked_at) AS max_checked
FROM qt_client_schema_state
GROUP BY username
) latest ON s.username = latest.username AND s.last_checked_at = latest.max_checked
GROUP BY s.username
ORDER BY s.last_checked_at DESC, s.username ASC
`).Scan(&rows).Error; err != nil { `).Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("load sync status rows: %w", err) return nil, fmt.Errorf("load sync status rows: %w", err)
} }
@@ -653,7 +560,7 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn
continue continue
} }
isOnline := now.Sub(r.LastCheckedAt) <= onlineThreshold isOnline := now.Sub(r.LastSyncAt) <= onlineThreshold
if _, connected := activeUsers[username]; connected { if _, connected := activeUsers[username]; connected {
isOnline = true isOnline = true
delete(activeUsers, username) delete(activeUsers, username)
@@ -663,7 +570,7 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn
result = append(result, UserSyncStatus{ result = append(result, UserSyncStatus{
Username: username, Username: username,
LastSyncAt: r.LastCheckedAt, LastSyncAt: r.LastSyncAt,
AppVersion: appVersion, AppVersion: appVersion,
IsOnline: isOnline, IsOnline: isOnline,
}) })
@@ -717,6 +624,36 @@ func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, e
return users, nil return users, nil
} }
func ensureUserSyncStatusTable(db *gorm.DB) error {
// Check if table exists instead of trying to create (avoids permission issues)
if !tableExists(db, "qt_pricelist_sync_status") {
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
username VARCHAR(100) NOT NULL,
last_sync_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
app_version VARCHAR(64) NULL,
PRIMARY KEY (username),
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
)
`).Error; err != nil {
return fmt.Errorf("create qt_pricelist_sync_status table: %w", err)
}
}
// Backward compatibility for environments where table was created without app_version.
// Only try to add column if table exists.
if tableExists(db, "qt_pricelist_sync_status") {
if err := db.Exec(`
ALTER TABLE qt_pricelist_sync_status
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
`).Error; err != nil {
// Log but don't fail if alter fails (column might already exist)
slog.Debug("failed to add app_version column", "error", err)
}
}
return nil
}
// SyncPricelistItems synchronizes items for a specific pricelist // SyncPricelistItems synchronizes items for a specific pricelist
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) { func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
@@ -733,43 +670,36 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
return int(existingCount), nil return int(existingCount), nil
} }
localItems, err := s.fetchServerPricelistItems(localPL.ServerID)
if err != nil {
return 0, err
}
for i := range localItems {
localItems[i].PricelistID = localPricelistID
}
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
return 0, fmt.Errorf("saving local pricelist items: %w", err)
}
slog.Info("synced pricelist items", "pricelist_id", localPricelistID, "items", len(localItems))
return len(localItems), nil
}
func (s *Service) fetchServerPricelistItems(serverPricelistID uint) ([]localdb.LocalPricelistItem, error) {
// Get database connection // Get database connection
mariaDB, err := s.getDB() mariaDB, err := s.getDB()
if err != nil { if err != nil {
return nil, fmt.Errorf("database not available: %w", err) return 0, fmt.Errorf("database not available: %w", err)
} }
// Create repository // Create repository
pricelistRepo := repository.NewPricelistRepository(mariaDB) pricelistRepo := repository.NewPricelistRepository(mariaDB)
// Get items from server // Get items from server
serverItems, _, err := pricelistRepo.GetItems(serverPricelistID, 0, 10000, "") serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
if err != nil { if err != nil {
return nil, fmt.Errorf("getting server pricelist items: %w", err) return 0, fmt.Errorf("getting server pricelist items: %w", err)
} }
// 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 {
localItems[i] = *localdb.PricelistItemToLocal(&item, 0) localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
}
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
slog.Warn("pricelist stock enrichment skipped", "pricelist_id", localPricelistID, "error", err)
} }
return localItems, nil if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
return 0, fmt.Errorf("saving local pricelist items: %w", err)
}
slog.Info("synced pricelist items", "pricelist_id", localPricelistID, "items", len(localItems))
return len(localItems), nil
} }
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID // SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
@@ -781,6 +711,111 @@ func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, err
return s.SyncPricelistItems(localPL.ID) return s.SyncPricelistItems(localPL.ID)
} }
func (s *Service) enrichLocalPricelistItemsWithStock(mariaDB *gorm.DB, items []localdb.LocalPricelistItem) error {
if len(items) == 0 {
return nil
}
bookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
book, err := bookRepo.GetActiveBook()
if err != nil || book == nil {
return nil
}
bookItems, err := bookRepo.GetBookItems(book.ID)
if err != nil {
return err
}
if len(bookItems) == 0 {
return nil
}
partnumberToLots := make(map[string][]string, len(bookItems))
for _, item := range bookItems {
pn := strings.TrimSpace(item.Partnumber)
if pn == "" {
continue
}
seenLots := make(map[string]struct{}, len(item.LotsJSON))
for _, lot := range item.LotsJSON {
lotName := strings.TrimSpace(lot.LotName)
if lotName == "" {
continue
}
key := strings.ToLower(lotName)
if _, exists := seenLots[key]; exists {
continue
}
seenLots[key] = struct{}{}
partnumberToLots[pn] = append(partnumberToLots[pn], lotName)
}
}
if len(partnumberToLots) == 0 {
return nil
}
type stockRow struct {
Partnumber string `gorm:"column:partnumber"`
Qty *float64 `gorm:"column:qty"`
}
rows := make([]stockRow, 0)
if err := mariaDB.Raw(`
SELECT s.partnumber, s.qty
FROM stock_log s
INNER JOIN (
SELECT partnumber, MAX(date) AS max_date
FROM stock_log
GROUP BY partnumber
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
WHERE s.qty IS NOT NULL
`).Scan(&rows).Error; err != nil {
return err
}
lotTotals := make(map[string]float64, len(items))
lotPartnumbers := make(map[string][]string, len(items))
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
for _, row := range rows {
pn := strings.TrimSpace(row.Partnumber)
if pn == "" || row.Qty == nil {
continue
}
lots := partnumberToLots[pn]
if len(lots) == 0 {
continue
}
for _, lotName := range lots {
lotTotals[lotName] += *row.Qty
if _, ok := seenPartnumbers[lotName]; !ok {
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
}
key := strings.ToLower(pn)
if _, exists := seenPartnumbers[lotName][key]; exists {
continue
}
seenPartnumbers[lotName][key] = struct{}{}
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
}
}
for i := range items {
lotName := strings.TrimSpace(items[i].LotName)
if qty, ok := lotTotals[lotName]; ok {
qtyCopy := qty
items[i].AvailableQty = &qtyCopy
}
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
sort.Slice(partnumbers, func(a, b int) bool {
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
})
items[i].Partnumbers = append(localdb.LocalStringList{}, partnumbers...)
}
}
return nil
}
// GetLocalPriceForLot returns the price for a lot from a local pricelist // GetLocalPriceForLot returns the price for a lot from a local pricelist
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) { func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName) return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
@@ -812,15 +847,9 @@ func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.Local
return localPL, nil return localPL, nil
} }
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed. // SyncPricelistsIfNeeded checks for new pricelists and syncs if needed
// If a sync is already in progress, returns immediately without blocking. // This should be called before creating a new configuration when online
func (s *Service) SyncPricelistsIfNeeded() error { func (s *Service) SyncPricelistsIfNeeded() error {
if !s.pricelistMu.TryLock() {
slog.Debug("pricelist sync already in progress, skipping")
return nil
}
defer s.pricelistMu.Unlock()
needSync, err := s.NeedSync() needSync, err := s.NeedSync()
if err != nil { if err != nil {
slog.Warn("failed to check if sync needed", "error", err) slog.Warn("failed to check if sync needed", "error", err)
@@ -829,16 +858,6 @@ func (s *Service) SyncPricelistsIfNeeded() error {
if !needSync { if !needSync {
slog.Debug("pricelists are up to date, no sync needed") slog.Debug("pricelists are up to date, no sync needed")
// Clear stale "failed" status: if NeedSync confirmed all active server pricelists
// are present locally, any lingering failure flag is outdated.
if strings.EqualFold(s.localDB.GetLastPricelistSyncStatus(), "failed") {
now := time.Now()
if err := s.localDB.SetPricelistSyncResult("success", "", now); err != nil {
slog.Warn("failed to clear stale pricelist sync failure flag", "error", err)
} else {
s.localDB.SetLastSyncTime(now)
}
}
return nil return nil
} }
@@ -851,11 +870,6 @@ func (s *Service) SyncPricelistsIfNeeded() error {
return nil return nil
} }
// maxPendingChangeAttempts is the number of failed attempts after which a pending change
// is considered unrecoverable and removed from the queue. Applies only to changes that
// fail with a non-transient error (e.g. corrupt payload, unknown operation).
const maxPendingChangeAttempts = 20
// PushPendingChanges pushes all pending changes to the server // PushPendingChanges pushes all pending changes to the server
func (s *Service) PushPendingChanges() (int, error) { func (s *Service) PushPendingChanges() (int, error) {
if _, err := s.EnsureReadinessForSync(); err != nil { if _, err := s.EnsureReadinessForSync(); err != nil {
@@ -869,14 +883,6 @@ func (s *Service) PushPendingChanges() (int, error) {
slog.Info("purged orphan configuration pending changes", "removed", removed) slog.Info("purged orphan configuration pending changes", "removed", removed)
} }
// Auto-repair locally-fixable problems (e.g. stale project references)
// before attempting to push, so that repaired changes succeed on this cycle.
if repaired, _, repairErr := s.localDB.RepairPendingChanges(); repairErr != nil {
slog.Warn("auto-repair of errored pending changes failed", "error", repairErr)
} else if repaired > 0 {
slog.Info("auto-repaired errored pending changes", "repaired", repaired)
}
changes, err := s.localDB.GetPendingChanges() changes, err := s.localDB.GetPendingChanges()
if err != nil { if err != nil {
return 0, fmt.Errorf("getting pending changes: %w", err) return 0, fmt.Errorf("getting pending changes: %w", err)
@@ -895,16 +901,9 @@ func (s *Service) PushPendingChanges() (int, error) {
for _, change := range sortedChanges { for _, change := range sortedChanges {
err := s.pushSingleChange(&change) err := s.pushSingleChange(&change)
if err != nil { if err != nil {
s.markConnectionBroken(err)
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err) slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
newAttempts := change.Attempts + 1 // Increment attempts
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error()) s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())
if newAttempts >= maxPendingChangeAttempts {
slog.Error("abandoning pending change after max attempts",
"id", change.ID, "type", change.EntityType, "op", change.Operation,
"attempts", newAttempts, "last_error", err.Error())
syncedIDs = append(syncedIDs, change.ID)
}
continue continue
} }
@@ -931,11 +930,7 @@ func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
case "configuration": case "configuration":
return s.pushConfigurationChange(change) return s.pushConfigurationChange(change)
default: default:
// Unknown entity type: this change was queued by a newer or different build return fmt.Errorf("unknown entity type: %s", change.EntityType)
// and cannot be processed. Remove it from the queue.
slog.Warn("dropping pending change with unknown entity type",
"id", change.ID, "type", change.EntityType, "op", change.Operation)
return nil
} }
} }
@@ -1013,7 +1008,7 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
localProject.SyncStatus = "synced" localProject.SyncStatus = "synced"
now := time.Now() now := time.Now()
localProject.SyncedAt = &now localProject.SyncedAt = &now
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject) _ = s.localDB.SaveProject(localProject)
} }
return nil return nil
@@ -1068,10 +1063,7 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
case "delete": case "delete":
return s.pushConfigurationDelete(change) return s.pushConfigurationDelete(change)
default: default:
// Unknown operation: queued by a newer or different build. Drop from queue. return fmt.Errorf("unknown operation: %s", change.Operation)
slog.Warn("dropping pending change with unknown operation",
"id", change.ID, "type", change.EntityType, "op", change.Operation)
return nil
} }
} }
@@ -1271,30 +1263,24 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID) localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
if localErr != nil { if localErr != nil {
// Project not found locally either: stale reference (project was deleted). return err
// Fall through to system project so this configuration is not stuck forever.
slog.Warn("configuration references missing project, assigning to system project",
"cfg_uuid", cfg.UUID,
"project_uuid", *cfg.ProjectUUID,
)
} else {
modelProject := localdb.LocalToProject(localProject)
if modelProject.OwnerUsername == "" {
modelProject.OwnerUsername = cfg.OwnerUsername
}
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
return createErr
}
if modelProject.ID > 0 {
serverID := modelProject.ID
localProject.ServerID = &serverID
localProject.SyncStatus = "synced"
now := time.Now()
localProject.SyncedAt = &now
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
}
return nil
} }
modelProject := localdb.LocalToProject(localProject)
if modelProject.OwnerUsername == "" {
modelProject.OwnerUsername = cfg.OwnerUsername
}
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
return createErr
}
if modelProject.ID > 0 {
serverID := modelProject.ID
localProject.ServerID = &serverID
localProject.SyncStatus = "synced"
now := time.Now()
localProject.SyncedAt = &now
_ = s.localDB.SaveProject(localProject)
}
return nil
} }
systemProject := &models.Project{} systemProject := &models.Project{}
@@ -1605,25 +1591,3 @@ func (s *Service) getConnectionStatus() db.ConnectionStatus {
} }
return s.connMgr.GetStatus() return s.connMgr.GetStatus()
} }
// SyncComponentsIfEmpty syncs components from MariaDB when local_components is empty.
// Used by the background worker on first run to populate the catalog for new users.
func (s *Service) SyncComponentsIfEmpty() error {
if s.localDB.CountComponents() > 0 {
return nil
}
mariaDB, err := s.getDB()
if err != nil {
_ = s.localDB.SetComponentSyncResult("error", err.Error(), time.Now())
return err
}
result, err := s.localDB.SyncComponents(mariaDB)
now := time.Now()
if err != nil {
_ = s.localDB.SetComponentSyncResult("error", err.Error(), now)
return err
}
_ = s.localDB.SetComponentSyncResult("ok", "", now)
slog.Info("background sync: initial component sync completed", "synced", result.TotalSynced)
return nil
}

View File

@@ -17,6 +17,7 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
&models.Pricelist{}, &models.Pricelist{},
&models.PricelistItem{}, &models.PricelistItem{},
&models.Lot{}, &models.Lot{},
&models.StockLog{},
); err != nil { ); err != nil {
t.Fatalf("migrate server tables: %v", err) t.Fatalf("migrate server tables: %v", err)
} }
@@ -102,3 +103,103 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory) t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
} }
} }
func TestSyncPricelistItems_EnrichesStockFromLocalPartnumberBook(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
if err := serverDB.AutoMigrate(
&models.Pricelist{},
&models.PricelistItem{},
&models.Lot{},
&models.StockLog{},
); err != nil {
t.Fatalf("migrate server tables: %v", err)
}
serverPL := models.Pricelist{
Source: "warehouse",
Version: "2026-03-07-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,
}).Error; err != nil {
t.Fatalf("create server pricelist item: %v", err)
}
qty := 7.0
if err := serverDB.Create(&models.StockLog{
Partnumber: "CPU-PN-1",
Date: time.Now(),
Price: 100,
Qty: &qty,
}).Error; err != nil {
t.Fatalf("create stock log: %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.DB().Create(&localdb.LocalPartnumberBook{
ServerID: 1,
Version: "2026-03-07-001",
CreatedAt: time.Now(),
IsActive: true,
PartnumbersJSON: localdb.LocalStringList{"CPU-PN-1"},
}).Error; err != nil {
t.Fatalf("create local partnumber book: %v", err)
}
if err := local.DB().Create(&localdb.LocalPartnumberBookItem{
Partnumber: "CPU-PN-1",
LotsJSON: localdb.LocalPartnumberBookLots{
{LotName: "CPU_A", Qty: 1},
},
Description: "CPU PN",
}).Error; err != nil {
t.Fatalf("create local partnumber book item: %v", err)
}
svc := syncsvc.NewServiceWithDB(serverDB, local)
if _, err := svc.SyncPricelistItems(localPL.ID); err != nil {
t.Fatalf("sync pricelist items: %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].AvailableQty == nil {
t.Fatalf("expected available_qty to be set")
}
if *items[0].AvailableQty != 7 {
t.Fatalf("expected available_qty=7, got %v", *items[0].AvailableQty)
}
if len(items[0].Partnumbers) != 1 || items[0].Partnumbers[0] != "CPU-PN-1" {
t.Fatalf("expected partnumbers [CPU-PN-1], got %v", items[0].Partnumbers)
}
}

View File

@@ -1,15 +1,12 @@
package sync_test package sync_test
import ( import (
"errors"
"strings"
"testing" "testing"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync" syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"gorm.io/gorm"
) )
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) { func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
@@ -86,58 +83,3 @@ func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
t.Fatalf("expected server pricelist to be synced locally: %v", err) t.Fatalf("expected server pricelist to be synced locally: %v", err)
} }
} }
func TestSyncPricelistsDoesNotPersistHeaderWithoutItems(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
t.Fatalf("migrate server pricelist tables: %v", err)
}
serverPL := models.Pricelist{
Source: "estimate",
Version: "2026-03-17-001",
Notification: "server",
CreatedBy: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-1 * time.Hour),
}
if err := serverDB.Create(&serverPL).Error; err != nil {
t.Fatalf("create server pricelist: %v", err)
}
if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil {
t.Fatalf("create server pricelist item: %v", err)
}
const callbackName = "test:fail_qt_pricelist_items_query"
if err := serverDB.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
if db.Statement != nil && db.Statement.Table == "qt_pricelist_items" {
_ = db.AddError(errors.New("forced pricelist item fetch failure"))
}
}); err != nil {
t.Fatalf("register query callback: %v", err)
}
defer serverDB.Callback().Query().Remove(callbackName)
svc := syncsvc.NewServiceWithDB(serverDB, local)
synced, err := svc.SyncPricelists()
if err == nil {
t.Fatalf("expected sync error when item fetch fails")
}
if synced != 0 {
t.Fatalf("expected synced=0 on incomplete sync, got %d", synced)
}
if !strings.Contains(err.Error(), "forced pricelist item fetch failure") {
t.Fatalf("expected item fetch error, got %v", err)
}
if _, err := local.GetLocalPricelistByServerID(serverPL.ID); err == nil {
t.Fatalf("expected pricelist header not to be persisted without items")
}
if got := local.CountLocalPricelists(); got != 0 {
t.Fatalf("expected no local pricelists after failed sync, got %d", got)
}
if ts := local.GetLastSyncTime(); ts != nil {
t.Fatalf("expected last_pricelist_sync to stay unset on incomplete sync, got %v", ts)
}
}

View File

@@ -1,118 +0,0 @@
package sync
import (
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestSyncNewPricelistSnapshotUpsertsExistingServerID(t *testing.T) {
local := newLocalDBForUpsertTest(t)
serverDB := newServerDBForUpsertTest(t)
if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
t.Fatalf("migrate server pricelist tables: %v", err)
}
serverPL := models.Pricelist{
Source: "estimate",
Version: "B-2026-04-28-001",
Notification: "server-current",
CreatedBy: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-1 * time.Hour),
}
if err := serverDB.Create(&serverPL).Error; err != nil {
t.Fatalf("create server pricelist: %v", err)
}
if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil {
t.Fatalf("create server pricelist item: %v", err)
}
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: serverPL.ID,
Source: "estimate",
Version: "old-version",
Name: "stale-local",
CreatedAt: time.Now().Add(-24 * time.Hour),
SyncedAt: time.Now().Add(-24 * time.Hour),
IsUsed: false,
}); err != nil {
t.Fatalf("seed stale local pricelist: %v", err)
}
staleLocal, err := local.GetLocalPricelistByServerID(serverPL.ID)
if err != nil {
t.Fatalf("get stale local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: staleLocal.ID, LotName: "OLD_LOT", Price: 99},
}); err != nil {
t.Fatalf("seed stale local pricelist items: %v", err)
}
svc := NewServiceWithDB(serverDB, local)
localPL := &localdb.LocalPricelist{
ServerID: serverPL.ID,
Source: serverPL.Source,
Version: serverPL.Version,
Name: serverPL.Notification,
CreatedAt: serverPL.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}
itemCount, err := svc.syncNewPricelistSnapshot(localPL)
if err != nil {
t.Fatalf("sync new pricelist snapshot: %v", err)
}
if itemCount != 1 {
t.Fatalf("expected 1 synced item, got %d", itemCount)
}
refreshed, err := local.GetLocalPricelistByServerID(serverPL.ID)
if err != nil {
t.Fatalf("get refreshed local pricelist: %v", err)
}
if refreshed.Version != serverPL.Version {
t.Fatalf("expected local version %q, got %q", serverPL.Version, refreshed.Version)
}
if refreshed.Name != serverPL.Notification {
t.Fatalf("expected local name %q, got %q", serverPL.Notification, refreshed.Name)
}
items, err := local.GetLocalPricelistItems(refreshed.ID)
if err != nil {
t.Fatalf("load refreshed local items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 local item after refresh, got %d", len(items))
}
if items[0].LotName != "CPU_A" {
t.Fatalf("expected refreshed item CPU_A, got %q", items[0].LotName)
}
}
func newLocalDBForUpsertTest(t *testing.T) *localdb.LocalDB {
t.Helper()
localPath := filepath.Join(t.TempDir(), "local.db")
local, err := localdb.New(localPath)
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
return local
}
func newServerDBForUpsertTest(t *testing.T) *gorm.DB {
t.Helper()
serverPath := filepath.Join(t.TempDir(), "server.db")
db, err := gorm.Open(sqlite.Open(serverPath), &gorm.Config{})
if err != nil {
t.Fatalf("open server sqlite: %v", err)
}
return db
}

View File

@@ -434,14 +434,54 @@ func newServerDBForSyncTest(t *testing.T) *gorm.DB {
if err != nil { if err != nil {
t.Fatalf("open server sqlite: %v", err) t.Fatalf("open server sqlite: %v", err)
} }
if err := db.AutoMigrate( if err := db.Exec(`
&models.Project{}, CREATE TABLE qt_projects (
&models.Configuration{}, id INTEGER PRIMARY KEY AUTOINCREMENT,
&models.Pricelist{}, uuid TEXT NOT NULL UNIQUE,
&models.PricelistItem{}, owner_username TEXT NOT NULL,
&models.Lot{}, code TEXT NOT NULL,
); err != nil { variant TEXT NOT NULL DEFAULT '',
t.Fatalf("migrate server test schema: %v", err) name TEXT NOT 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
);`).Error; err != nil {
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(`
CREATE TABLE qt_configurations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
user_id INTEGER NULL,
owner_username TEXT NOT NULL,
project_uuid TEXT NULL,
app_version TEXT NULL,
name TEXT NOT NULL,
items TEXT NOT NULL,
total_price REAL NULL,
custom_price REAL NULL,
notes TEXT NULL,
is_template INTEGER NOT NULL DEFAULT 0,
server_count INTEGER NOT NULL DEFAULT 1,
server_model TEXT NULL,
support_code TEXT NULL,
article TEXT NULL,
pricelist_id INTEGER NULL,
warehouse_pricelist_id INTEGER NULL,
competitor_pricelist_id INTEGER NULL,
disable_price_refresh INTEGER NOT NULL DEFAULT 0,
only_in_stock INTEGER NOT NULL DEFAULT 0,
line_no INTEGER NULL,
price_updated_at DATETIME NULL,
vendor_spec TEXT NULL,
created_at DATETIME
);`).Error; err != nil {
t.Fatalf("create qt_configurations: %v", err)
} }
return db return db
} }

View File

@@ -80,11 +80,6 @@ func (w *Worker) runSync() {
return return
} }
// Populate component catalog on first run (empty local_components)
if err := w.service.SyncComponentsIfEmpty(); err != nil {
w.logger.Warn("background sync: initial component sync failed", "error", err)
}
// Push pending changes first // Push pending changes first
pushed, err := w.service.PushPendingChanges() pushed, err := w.service.PushPendingChanges()
if err != nil { if err != nil {
@@ -100,10 +95,8 @@ func (w *Worker) runSync() {
return return
} }
// Pull partnumber books together with pricelists // Mark user's sync heartbeat (used for online/offline status in UI).
if _, err := w.service.PullPartnumberBooks(); err != nil { w.service.RecordSyncHeartbeat()
w.logger.Warn("background sync: failed to pull partnumber books", "error", err)
}
w.logger.Info("background sync cycle completed") w.logger.Info("background sync cycle completed")
} }

View File

@@ -2,11 +2,9 @@ package services
import ( import (
"bytes" "bytes"
"encoding/csv"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -49,8 +47,7 @@ type importedConfiguration struct {
ServerModel string ServerModel string
Article string Article string
CurrencyCode string CurrencyCode string
Rows []localdb.VendorSpecItem // vendor BOM formats (CFXML, Inspur) Rows []localdb.VendorSpecItem
DirectItems localdb.LocalConfigItems // direct LOT formats (QuoteForge CSV)
TotalPrice *float64 TotalPrice *float64
} }
@@ -127,21 +124,7 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
return nil, ErrProjectNotFound return nil, ErrProjectNotFound
} }
var workspace *importedWorkspace workspace, err := parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
switch {
case IsCFXMLWorkspace(data):
workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
case IsQuoteForgeCSV(data):
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
case IsInspurBOM(data):
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
case IsNxBOM(data):
workspace, err = parseNxBOM(data, filepath.Base(sourceFileName))
case IsTextBOM(data):
workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
default:
return nil, fmt.Errorf("unsupported vendor export format")
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -157,28 +140,10 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
for _, imported := range workspace.Configurations { for _, imported := range workspace.Configurations {
now := time.Now() now := time.Now()
cfgUUID := uuid.NewString() cfgUUID := uuid.NewString()
groupRows, items, totalPrice, estimatePricelistID, err := s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
var groupRows localdb.VendorSpec if err != nil {
var items localdb.LocalConfigItems return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, err)
var totalPrice *float64
var estimatePricelistID *uint
if len(imported.DirectItems) > 0 {
items = imported.DirectItems
estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate")
if estimatePricelist != nil {
estimatePricelistID = &estimatePricelist.ServerID
}
val := items.Total() * float64(maxInt(imported.ServerCount, 1))
totalPrice = &val
} else {
var prepErr error
groupRows, items, totalPrice, estimatePricelistID, prepErr = s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
if prepErr != nil {
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, prepErr)
}
} }
localCfg := &localdb.LocalConfiguration{ localCfg := &localdb.LocalConfiguration{
UUID: cfgUUID, UUID: cfgUUID,
ProjectUUID: &projectUUID, ProjectUUID: &projectUUID,
@@ -274,17 +239,13 @@ func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *loca
} }
sort.Strings(order) sort.Strings(order)
var priceMap map[string]float64
if estimatePricelist != nil && local != nil && len(order) > 0 {
priceMap, _ = local.GetLocalPricesForLots(estimatePricelist.ID, order)
}
items := make(localdb.LocalConfigItems, 0, len(order)) items := make(localdb.LocalConfigItems, 0, len(order))
for _, lotName := range order { for _, lotName := range order {
unitPrice := 0.0 unitPrice := 0.0
if priceMap != nil { if estimatePricelist != nil && local != nil {
unitPrice = priceMap[lotName] if price, err := local.GetLocalPriceForLot(estimatePricelist.ID, lotName); err == nil && price > 0 {
unitPrice = price
}
} }
items = append(items, localdb.LocalConfigItem{ items = append(items, localdb.LocalConfigItem{
LotName: lotName, LotName: lotName,
@@ -597,427 +558,3 @@ func normalizeTopLevelQuantity(raw string, serverCount int) int {
func IsCFXMLWorkspace(data []byte) bool { func IsCFXMLWorkspace(data []byte) bool {
return bytes.Contains(data, []byte("<CFXML>")) || bytes.Contains(data, []byte("<CFXML ")) return bytes.Contains(data, []byte("<CFXML>")) || bytes.Contains(data, []byte("<CFXML "))
} }
func IsInspurBOM(data []byte) bool {
for _, line := range bytes.Split(data, []byte("\n")) {
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 {
continue
}
idx := bytes.LastIndexByte(trimmed, '*')
if idx <= 0 {
continue
}
suffix := bytes.TrimSpace(trimmed[idx+1:])
if len(suffix) > 0 && allDigits(suffix) {
return true
}
}
return false
}
func allDigits(b []byte) bool {
if len(b) == 0 {
return false
}
for _, c := range b {
if c < '0' || c > '9' {
return false
}
}
return true
}
func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
lines := strings.Split(string(data), "\n")
rows := make([]localdb.VendorSpecItem, 0, len(lines))
sortOrder := 10
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
line = strings.TrimPrefix(line, "|")
line = strings.TrimSpace(line)
if line == "" {
continue
}
pn := line
qty := 1
if idx := strings.LastIndex(line, "*"); idx > 0 {
suffix := strings.TrimSpace(line[idx+1:])
if n, err := strconv.Atoi(suffix); err == nil && n > 0 {
pn = strings.TrimSpace(line[:idx])
qty = n
}
}
if pn == "" {
continue
}
rows = append(rows, localdb.VendorSpecItem{
SortOrder: sortOrder,
VendorPartnumber: pn,
Quantity: qty,
})
sortOrder += 10
}
if len(rows) == 0 {
return nil, fmt.Errorf("Inspur BOM has no importable rows")
}
name := strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
if name == "" {
name = "Inspur Import"
}
return &importedWorkspace{
SourceFormat: "Inspur",
SourceFileName: sourceFileName,
Configurations: []importedConfiguration{
{
GroupID: "inspur-0",
Name: name,
Line: 10,
ServerCount: 1,
Rows: rows,
},
},
}, nil
}
// nxBOMItemLine matches a quantity-first BOM line: "<qty>x <description>"
// where the quantity prefix is digits followed immediately by "x" (case-insensitive).
// Parentheses, commas, and hyphens inside the description are preserved.
var nxBOMItemLine = regexp.MustCompile(`(?i)^(\d+)[xX]\s+(.+\S)\s*$`)
// IsNxBOM reports whether data looks like a quantity-first "Nx" BOM where each
// item line begins with "<qty>x <description>" (e.g. "2x Intel Xeon 8570 ...").
func IsNxBOM(data []byte) bool {
for _, raw := range strings.Split(string(data), "\n") {
if nxBOMItemLine.MatchString(strings.TrimSpace(raw)) {
return true
}
}
return false
}
// parseNxBOM parses a quantity-first "Nx" BOM into a single configuration.
// An optional header line ending with ", в составе:" supplies server_model and name.
// Each "<qty>x <description>" line becomes one vendor spec row; description is stored
// as both vendor_partnumber and description so rows resolve through the active
// partnumber book when matched and otherwise stay unresolved and editable in the UI.
func parseNxBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
lines := strings.Split(string(data), "\n")
rows := make([]localdb.VendorSpecItem, 0, len(lines))
sortOrder := 10
serverModel := ""
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
if fields := strings.Fields(m[1]); len(fields) > 0 {
serverModel = fields[len(fields)-1]
}
continue
}
m := nxBOMItemLine.FindStringSubmatch(line)
if m == nil {
continue
}
qty, err := strconv.Atoi(m[1])
if err != nil || qty <= 0 {
continue
}
description := strings.TrimSpace(m[2])
if description == "" {
continue
}
rows = append(rows, localdb.VendorSpecItem{
SortOrder: sortOrder,
VendorPartnumber: description,
Quantity: qty,
Description: description,
})
sortOrder += 10
}
if len(rows) == 0 {
return nil, fmt.Errorf("Nx BOM has no importable rows")
}
name := serverModel
if name == "" {
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
}
if name == "" {
name = "Nx BOM Import"
}
return &importedWorkspace{
SourceFormat: "Nx",
SourceFileName: sourceFileName,
Configurations: []importedConfiguration{
{
GroupID: "nx-0",
Name: name,
Line: 10,
ServerCount: 1,
ServerModel: serverModel,
Rows: rows,
},
},
}, nil
}
// textBOMItemLine matches a human-readable BOM line of the form
// "<description> - <quantity> шт." where the separator may be a hyphen,
// en-dash or em-dash and the quantity may have an optional space before "шт".
// The quantity anchor at the end keeps internal hyphens/digits in the
// description (e.g. "8-GPU-2304GB") from being mistaken for the separator.
var textBOMItemLine = regexp.MustCompile(`(?i)^(.*\S)\s*[-–—]\s*(\d+)\s*шт\.?\s*$`)
// textBOMHeaderLine matches a configuration header ending with ", в составе:"
// regardless of the leading words (e.g. "Сервер <model>" or
// "Вычислительный GPU сервер <model>"). The captured group is everything before
// the comma; the model is its last whitespace-separated token.
var textBOMHeaderLine = regexp.MustCompile(`(?i)^(.*?)\s*,\s*в\s+составе`)
// ParsePastedBOMText detects and parses a single-column text BOM (Inspur or
// Russian text BOM) pasted into the configurator. It shares the same detectors
// and parsers as the vendor file-import path, so paste and upload behave
// identically. It returns the parsed vendor spec rows and the detected format,
// or (nil, "") when the text is not a recognized single-column format and the
// caller should fall back to manual column mapping.
func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) {
data := []byte(text)
var ws *importedWorkspace
var err error
switch {
case IsInspurBOM(data):
ws, err = parseInspurBOM(data, "")
case IsNxBOM(data):
ws, err = parseNxBOM(data, "")
case IsTextBOM(data):
ws, err = parseTextBOM(data, "")
default:
return nil, ""
}
if err != nil || ws == nil || len(ws.Configurations) == 0 {
return nil, ""
}
return ws.Configurations[0].Rows, ws.SourceFormat
}
// IsTextBOM reports whether data looks like a human-readable Russian text BOM,
// i.e. it contains at least one "<description> - <quantity> шт." line.
func IsTextBOM(data []byte) bool {
for _, raw := range strings.Split(string(data), "\n") {
if textBOMItemLine.MatchString(strings.TrimSpace(raw)) {
return true
}
}
return false
}
// parseTextBOM parses a human-readable Russian text BOM into a single configuration.
// The optional "Сервер <model>, в составе:" header provides the configuration name and
// server model. Each "<description> - <quantity> шт." line becomes one vendor spec row.
// The format carries no partnumbers, so rows stay unresolved and editable in the UI
// until mapped through the active partnumber book.
func parseTextBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
lines := strings.Split(string(data), "\n")
rows := make([]localdb.VendorSpecItem, 0, len(lines))
sortOrder := 10
serverModel := ""
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
if fields := strings.Fields(m[1]); len(fields) > 0 {
serverModel = fields[len(fields)-1]
}
continue
}
m := textBOMItemLine.FindStringSubmatch(line)
if m == nil {
continue
}
description := strings.TrimSpace(m[1])
qty, err := strconv.Atoi(m[2])
if err != nil || qty <= 0 || description == "" {
continue
}
rows = append(rows, localdb.VendorSpecItem{
SortOrder: sortOrder,
VendorPartnumber: description,
Quantity: qty,
Description: description,
})
sortOrder += 10
}
if len(rows) == 0 {
return nil, fmt.Errorf("text BOM has no importable rows")
}
name := serverModel
if name == "" {
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
}
if name == "" {
name = "Text BOM Import"
}
return &importedWorkspace{
SourceFormat: "Text",
SourceFileName: sourceFileName,
Configurations: []importedConfiguration{
{
GroupID: "text-0",
Name: name,
Line: 10,
ServerCount: 1,
ServerModel: serverModel,
Rows: rows,
},
},
}, nil
}
// IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export.
// The file starts (after optional UTF-8 BOM) with the header line:
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."
func IsQuoteForgeCSV(data []byte) bool {
trimmed := bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
firstLine := trimmed
if idx := bytes.IndexByte(trimmed, '\n'); idx >= 0 {
firstLine = trimmed[:idx]
}
return bytes.HasPrefix(bytes.TrimSpace(firstLine), []byte("Line;Type;p/n;"))
}
// parseQuoteForgeCSV parses a QuoteForge own CSV export back into importable configurations.
// Each server block (row where Line column is non-empty) becomes one importedConfiguration
// with DirectItems populated from the component rows that follow it.
func parseQuoteForgeCSV(data []byte, sourceFileName string) (*importedWorkspace, error) {
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
r := csv.NewReader(bytes.NewReader(data))
r.Comma = ';'
r.FieldsPerRecord = -1
r.LazyQuotes = true
records, err := r.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse QuoteForge CSV: %w", err)
}
if len(records) == 0 {
return nil, fmt.Errorf("QuoteForge CSV is empty")
}
// Skip header row (first row whose first cell is "Line")
startIdx := 0
if len(records[0]) > 0 && strings.EqualFold(strings.TrimSpace(records[0][0]), "line") {
startIdx = 1
}
var configs []importedConfiguration
var current *importedConfiguration
blockIdx := 0
for _, record := range records[startIdx:] {
if csvAllEmpty(record) {
continue
}
lineCol := strings.TrimSpace(csvCol(record, 0))
pn := strings.TrimSpace(csvCol(record, 2))
if lineCol != "" {
// New server block
if current != nil {
configs = append(configs, *current)
}
blockIdx++
serverCount := maxInt(parseInt(strings.TrimSpace(csvCol(record, 5))), 1)
article := pn
name := article
if name == "" {
name = fmt.Sprintf("Config %d", blockIdx)
}
current = &importedConfiguration{
GroupID: fmt.Sprintf("qfcsv-%d", blockIdx),
Name: name,
Line: blockIdx * 10,
ServerCount: serverCount,
Article: article,
DirectItems: make(localdb.LocalConfigItems, 0),
}
} else if pn != "" && current != nil {
// Component row
qty := maxInt(parseInt(strings.TrimSpace(csvCol(record, 4))), 1)
unitPrice := parseCSVPrice(strings.TrimSpace(csvCol(record, 6)))
current.DirectItems = append(current.DirectItems, localdb.LocalConfigItem{
LotName: pn,
Quantity: qty,
UnitPrice: unitPrice,
})
}
}
if current != nil {
configs = append(configs, *current)
}
if len(configs) == 0 {
return nil, fmt.Errorf("QuoteForge CSV has no importable configurations")
}
return &importedWorkspace{
SourceFormat: "QuoteForgeCSV",
SourceFileName: sourceFileName,
Configurations: configs,
}, nil
}
// csvCol returns record[idx] or "" when idx is out of range.
func csvCol(record []string, idx int) string {
if idx < len(record) {
return record[idx]
}
return ""
}
// csvAllEmpty reports whether every cell in the record is blank.
func csvAllEmpty(record []string) bool {
for _, cell := range record {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
// parseCSVPrice parses a price string in QuoteForge CSV format:
// comma as decimal separator, optional space as thousands separator.
// Returns 0 on any parse failure.
func parseCSVPrice(s string) float64 {
if s == "" || s == "—" {
return 0
}
// Remove thousands separators (space, non-breaking space)
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, "", "")
// Replace comma decimal separator with dot
s = strings.ReplaceAll(s, ",", ".")
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return v
}

View File

@@ -2,7 +2,6 @@ package services
import ( import (
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
@@ -359,462 +358,3 @@ func TestImportVendorWorkspaceToProject_AutoResolvesAndAppliesEstimate(t *testin
t.Fatalf("expected resolved rows for CPU and LIC in vendor spec") t.Fatalf("expected resolved rows for CPU and LIC in vendor spec")
} }
} }
func TestParseInspurBOM(t *testing.T) {
const sample = `|CPU_AMD_9535-EPYC2.4_64C_256M_300W*1
|Mem_64G_DDR5-6400MHz_ECC-RDIMM*1
|2.5 NVMe Bays*4
|2.5 or 3.5 SATA Bays*8
|FrontHDModuleBackPlane_4SAS or 4U.2_3.5x4_GEN5*1
|FrontHDModuleBackPlane_4SAS or 4U.2_3.5x4_GEN5*2
|RAID_IAG_2RO_9230_N_M.2_PCIE2_HS *1
|SSD_SA_480M2TD_MZNL3480HCLR_T2_6_PM893*2
|NIC_100Gbps_2Port_LC_Nvidia_CX6DX_PCIe_GEN4*1
|Riser_X16+X8+X8_G5-J4J6-A*1
|PowerSupply_1300W_Titanium_220VACor240VDC_GaN*2
|PowerCord_1.5m_C14_C13_CN+CNHK+CNTW+US+UK+EU+AU+SG+ZA+RU+KR*2
|Rail_Slider-Drop-in_760mm_2U-EN*1
|PKACCY_470x285x63_Box-Blankspace_General*1
|Chassis_3.5x12_6PCIE*1
|MB_AMD_Non*1
|Fan_23000rpm_6056*6
|Software-KSManage*1
|TPM_2.0_NON-MainLand_SPI-INF*1
|【CA&SA】KR2180E3-A0 3 years RTV HK Service*1
|【CA&SA】KR2180E3-A0 3 years Data Media Retention Service*1`
workspace, err := parseInspurBOM([]byte(sample), "KR2180E3-A0.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if workspace.SourceFormat != "Inspur" {
t.Fatalf("expected SourceFormat Inspur, got %q", workspace.SourceFormat)
}
if len(workspace.Configurations) != 1 {
t.Fatalf("expected 1 configuration, got %d", len(workspace.Configurations))
}
cfg := workspace.Configurations[0]
if cfg.Name != "KR2180E3-A0" {
t.Fatalf("expected name KR2180E3-A0, got %q", cfg.Name)
}
if cfg.ServerCount != 1 {
t.Fatalf("expected ServerCount 1, got %d", cfg.ServerCount)
}
const wantRows = 21
if len(cfg.Rows) != wantRows {
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
}
rowsByPN := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
for _, r := range cfg.Rows {
rowsByPN[r.VendorPartnumber] = r
}
cpu, ok := rowsByPN["CPU_AMD_9535-EPYC2.4_64C_256M_300W"]
if !ok {
t.Fatal("expected CPU row not found")
}
if cpu.Quantity != 1 {
t.Fatalf("CPU: expected qty 1, got %d", cpu.Quantity)
}
psu, ok := rowsByPN["PowerSupply_1300W_Titanium_220VACor240VDC_GaN"]
if !ok {
t.Fatal("expected PSU row not found")
}
if psu.Quantity != 2 {
t.Fatalf("PSU: expected qty 2, got %d", psu.Quantity)
}
fan, ok := rowsByPN["Fan_23000rpm_6056"]
if !ok {
t.Fatal("expected Fan row not found")
}
if fan.Quantity != 6 {
t.Fatalf("Fan: expected qty 6, got %d", fan.Quantity)
}
// RAID partnumber has trailing space before *, must be trimmed
raid, ok := rowsByPN["RAID_IAG_2RO_9230_N_M.2_PCIE2_HS"]
if !ok {
t.Fatal("expected RAID row not found (check whitespace trimming)")
}
if raid.Quantity != 1 {
t.Fatalf("RAID: expected qty 1, got %d", raid.Quantity)
}
}
func TestParseInspurBOMWithoutPipe(t *testing.T) {
const sample = `CPU_AMD_9535*2
Mem_64G_DDR5*4
PowerSupply_1300W*2`
workspace, err := parseInspurBOM([]byte(sample), "config.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(workspace.Configurations[0].Rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(workspace.Configurations[0].Rows))
}
if workspace.Configurations[0].Rows[0].VendorPartnumber != "CPU_AMD_9535" {
t.Fatalf("unexpected pn: %q", workspace.Configurations[0].Rows[0].VendorPartnumber)
}
if workspace.Configurations[0].Rows[0].Quantity != 2 {
t.Fatalf("unexpected qty: %d", workspace.Configurations[0].Rows[0].Quantity)
}
}
func TestParseQuoteForgeCSV(t *testing.T) {
// Format mirrors ToCSV output: col[0]=Line, col[1]=Type, col[2]=p/n,
// col[3]=Description, col[4]=Qty(1pcs), col[5]=Qty(total), col[6]=Price(1pcs), col[7]=Price(total)
const sample = "\xEF\xBB\xBF" + // UTF-8 BOM
"Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)\n" +
"10;;DL380-ARTICLE;;;2;10470;20 940\n" +
";MEMORY;MB_INTEL_A1;;1;;2074,5;\n" +
";CPU;CPU_XEON_X;;2;;5100;\n" +
"\n" +
"20;;DL380-ARTICLE-2;;;1;8000;8 000\n" +
";STORAGE;SSD_NVMe;;4;;1200;\n"
workspace, err := parseQuoteForgeCSV([]byte(sample), "project.csv")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if workspace.SourceFormat != "QuoteForgeCSV" {
t.Fatalf("expected SourceFormat QuoteForgeCSV, got %q", workspace.SourceFormat)
}
if len(workspace.Configurations) != 2 {
t.Fatalf("expected 2 configurations, got %d", len(workspace.Configurations))
}
cfg1 := workspace.Configurations[0]
if cfg1.Article != "DL380-ARTICLE" {
t.Fatalf("cfg1 article: want DL380-ARTICLE, got %q", cfg1.Article)
}
if cfg1.ServerCount != 2 {
t.Fatalf("cfg1 server_count: want 2, got %d", cfg1.ServerCount)
}
if len(cfg1.DirectItems) != 2 {
t.Fatalf("cfg1 items: want 2, got %d", len(cfg1.DirectItems))
}
if cfg1.DirectItems[0].LotName != "MB_INTEL_A1" || cfg1.DirectItems[0].Quantity != 1 {
t.Fatalf("cfg1 item[0]: %+v", cfg1.DirectItems[0])
}
if cfg1.DirectItems[1].LotName != "CPU_XEON_X" || cfg1.DirectItems[1].Quantity != 2 {
t.Fatalf("cfg1 item[1]: %+v", cfg1.DirectItems[1])
}
if cfg1.DirectItems[1].UnitPrice != 5100 {
t.Fatalf("cfg1 item[1] price: want 5100, got %v", cfg1.DirectItems[1].UnitPrice)
}
cfg2 := workspace.Configurations[1]
if cfg2.Article != "DL380-ARTICLE-2" {
t.Fatalf("cfg2 article: want DL380-ARTICLE-2, got %q", cfg2.Article)
}
if cfg2.ServerCount != 1 {
t.Fatalf("cfg2 server_count: want 1, got %d", cfg2.ServerCount)
}
if len(cfg2.DirectItems) != 1 || cfg2.DirectItems[0].LotName != "SSD_NVMe" {
t.Fatalf("cfg2 items: %+v", cfg2.DirectItems)
}
}
func TestIsQuoteForgeCSV(t *testing.T) {
withBOM := "\xEF\xBB\xBFLine;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)\n10;;ART;;1;;100;\n"
noBOM := "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)\n"
cases := []struct {
input string
want bool
}{
{withBOM, true},
{noBOM, true},
{"<CFXML>\n</CFXML>", false},
{"|CPU*1\n|PSU*2", false},
{"", false},
{"Line;other;columns\n", false},
}
for _, tc := range cases {
got := IsQuoteForgeCSV([]byte(tc.input))
if got != tc.want {
t.Errorf("IsQuoteForgeCSV(%q) = %v, want %v", tc.input[:min(len(tc.input), 40)], got, tc.want)
}
}
}
func TestParseCSVPrice(t *testing.T) {
cases := []struct {
input string
want float64
}{
{"2074,5", 2074.5},
{"5100", 5100},
{"104 700", 104700},
{"20 940", 20940},
{"—", 0},
{"", 0},
{"abc", 0},
}
for _, tc := range cases {
got := parseCSVPrice(tc.input)
if got != tc.want {
t.Errorf("parseCSVPrice(%q) = %v, want %v", tc.input, got, tc.want)
}
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func TestIsInspurBOM(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"|CPU_AMD*1\n|PSU*2", true},
{"CPU_AMD*1", true},
{"<CFXML>\n</CFXML>", false},
{"just text\nno stars", false},
{"pn*abc", false},
{"", false},
}
for _, tc := range cases {
got := IsInspurBOM([]byte(tc.input))
if got != tc.want {
t.Errorf("IsInspurBOM(%q) = %v, want %v", tc.input, got, tc.want)
}
}
}
func TestIsTextBOM(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"CPU Intel 6760P - 2 шт.", true},
{"Fan 18Krpm 8086 - 20 шт.\nRail L-Type 665mm - 1 шт.", true},
{"NVIDIA transceiver - 8шт.", true}, // no space before шт
{"Сервер KR9288X3, в составе:\nFan - 4 шт.", true},
{"|CPU_AMD*1\n|PSU*2", false}, // Inspur
{"<CFXML>\n</CFXML>", false},
{"just text\nno quantities", false},
{"CPU - 2 pcs.", false}, // not Russian шт
{"", false},
}
for _, tc := range cases {
got := IsTextBOM([]byte(tc.input))
if got != tc.want {
t.Errorf("IsTextBOM(%q) = %v, want %v", tc.input, got, tc.want)
}
}
}
func TestParseTextBOM(t *testing.T) {
const sample = `Сервер KR9288X3, в составе:
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
incl. onboard 800G XDR - 8 шт.
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
SSD 960G U.2 16GTps 2.5in RAID_1 - 2 шт.
SSD 3.84T U.2 16GTps 2.5in R-Standard - 2 шт.
NIC 25Gbps 2Port LC Nvidia CX6LX PCIe MM GEN4 - 1 шт.
PowerSupply 3200W Titanium 220VACor240VDC - 2 шт.
PowerSupply 3300W Titanium 220VACor240VDC - 6 шт.
PowerCord 1.9M C20 C19 - 14 шт.
Rail L-Type 665mm - 1 шт.
Chassis 2.5x12 gpu - 1 шт.
Fan 18Krpm 8086 - 20 шт.
NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up to 50m, flat top - 8шт.`
workspace, err := parseTextBOM([]byte(sample), "spec.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if workspace.SourceFormat != "Text" {
t.Fatalf("expected SourceFormat Text, got %q", workspace.SourceFormat)
}
if len(workspace.Configurations) != 1 {
t.Fatalf("expected 1 configuration, got %d", len(workspace.Configurations))
}
cfg := workspace.Configurations[0]
if cfg.Name != "KR9288X3" {
t.Fatalf("expected name KR9288X3 (from header), got %q", cfg.Name)
}
if cfg.ServerModel != "KR9288X3" {
t.Fatalf("expected ServerModel KR9288X3, got %q", cfg.ServerModel)
}
if cfg.ServerCount != 1 {
t.Fatalf("expected ServerCount 1, got %d", cfg.ServerCount)
}
const wantRows = 14
if len(cfg.Rows) != wantRows {
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
}
rowsByDesc := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
for _, r := range cfg.Rows {
rowsByDesc[r.Description] = r
if r.VendorPartnumber != r.Description {
t.Fatalf("expected VendorPartnumber to mirror Description, got pn=%q desc=%q", r.VendorPartnumber, r.Description)
}
}
// Description with internal hyphens and digits must not be split early.
gpu, ok := rowsByDesc["GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E"]
if !ok {
t.Fatal("expected GPU row not found (check hyphen handling)")
}
if gpu.Quantity != 1 {
t.Fatalf("GPU: expected qty 1, got %d", gpu.Quantity)
}
mem, ok := rowsByDesc["Mem 128G DDR5-6400MHz ECC-RDIMM"]
if !ok {
t.Fatal("expected Mem row not found")
}
if mem.Quantity != 16 {
t.Fatalf("Mem: expected qty 16, got %d", mem.Quantity)
}
// Quantity with no space before "шт" and commas/hyphens in description.
xcvr, ok := rowsByDesc["NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up to 50m, flat top"]
if !ok {
t.Fatal("expected transceiver row not found (check no-space quantity)")
}
if xcvr.Quantity != 8 {
t.Fatalf("transceiver: expected qty 8, got %d", xcvr.Quantity)
}
}
func TestParseTextBOMVariantHeaderAndLeadingSpace(t *testing.T) {
// Header does not start with "Сервер"; some lines have leading/trailing spaces;
// descriptions contain commas and internal hyphens.
const sample = `Вычислительный GPU сервер G5500V7, в составе:
Серверное шасси G5500 V7 (12NVMe + 8SAS/SATA) - 1 шт.
Процессор Intel 8558P 48C 2.7G 260MB 350W - 2 шт.
Модуль оперативной памяти Mem 128G DDR5-5600MHz ECC-RDIMM - 16 шт.
Накопитель SSD 2.5" NVMe 3.84TB - 8 шт.
Накопитель SSD 2.5" SATA 3.84TB - 2 шт.
Адаптер SAS/SATA RAID0,1,10 12Gb/s-no Cache - 1 шт.
Адаптер 25GE(CX6-Lx)-Dual Port SFP28 - 1 шт.
Сетевая карта 4 x 1G, Base-T - 1 шт.
Адаптер HBA Emulex LPe32002 2 Port 32GFC - 1 шт.
Крепежный комплект Ball Bearing Rail Kit - 1 шт.
Кабельный органайзер Cable Management Arm - 1 шт.
Кабель питания PowerCord 3m C20 C19 - 4 шт.
Видеокарта (видеоускоритель) NVIDIA RTX PRO 6000 Server Edition 96GB GDDR7 - 8 шт.
Блок питания 3000W Titanium AC Power Supply - 4 шт.`
workspace, err := parseTextBOM([]byte(sample), "spec.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := workspace.Configurations[0]
if cfg.ServerModel != "G5500V7" {
t.Fatalf("expected ServerModel G5500V7 (last token before comma), got %q", cfg.ServerModel)
}
if cfg.Name != "G5500V7" {
t.Fatalf("expected name G5500V7, got %q", cfg.Name)
}
const wantRows = 14
if len(cfg.Rows) != wantRows {
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
}
for _, r := range cfg.Rows {
if r.VendorPartnumber != strings.TrimSpace(r.VendorPartnumber) {
t.Fatalf("vendor_partnumber has surrounding whitespace: %q", r.VendorPartnumber)
}
if r.Description != strings.TrimSpace(r.Description) {
t.Fatalf("description has surrounding whitespace: %q", r.Description)
}
if r.VendorPartnumber == "" {
t.Fatal("empty vendor_partnumber")
}
}
rowsByDesc := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
for _, r := range cfg.Rows {
rowsByDesc[r.VendorPartnumber] = r
}
// Leading-space line must yield a trimmed P/N.
sata, ok := rowsByDesc[`Накопитель SSD 2.5" SATA 3.84TB`]
if !ok {
t.Fatal("expected SATA SSD row not found (check leading-space trimming)")
}
if sata.Quantity != 2 {
t.Fatalf("SATA SSD: expected qty 2, got %d", sata.Quantity)
}
// Commas inside the description must not break parsing.
raid, ok := rowsByDesc["Адаптер SAS/SATA RAID0,1,10 12Gb/s-no Cache"]
if !ok {
t.Fatal("expected RAID adapter row not found (check commas in description)")
}
if raid.Quantity != 1 {
t.Fatalf("RAID adapter: expected qty 1, got %d", raid.Quantity)
}
gpu, ok := rowsByDesc["Видеокарта (видеоускоритель) NVIDIA RTX PRO 6000 Server Edition 96GB GDDR7"]
if !ok {
t.Fatal("expected GPU row not found")
}
if gpu.Quantity != 8 {
t.Fatalf("GPU: expected qty 8, got %d", gpu.Quantity)
}
}
func TestParsePastedBOMText(t *testing.T) {
t.Run("text BOM", func(t *testing.T) {
rows, format := ParsePastedBOMText("Сервер X1, в составе:\nCPU Intel 6760P - 2 шт.\nMem 128G - 16 шт.")
if format != "Text" {
t.Fatalf("expected format Text, got %q", format)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
if rows[0].VendorPartnumber != "CPU Intel 6760P" || rows[0].Quantity != 2 {
t.Fatalf("unexpected first row: %+v", rows[0])
}
})
t.Run("inspur BOM", func(t *testing.T) {
rows, format := ParsePastedBOMText("|CPU_AMD*1\n|PSU*2")
if format != "Inspur" {
t.Fatalf("expected format Inspur, got %q", format)
}
if len(rows) != 2 || rows[1].Quantity != 2 {
t.Fatalf("unexpected rows: %+v", rows)
}
})
t.Run("unrecognized falls through", func(t *testing.T) {
rows, format := ParsePastedBOMText("col a\tcol b\nfoo\tbar")
if rows != nil || format != "" {
t.Fatalf("expected nil/empty for unrecognized text, got rows=%+v format=%q", rows, format)
}
})
}
func TestParseTextBOMNameFromFilename(t *testing.T) {
const sample = "CPU Intel 6760P - 2 шт.\nMem 128G - 16 шт."
workspace, err := parseTextBOM([]byte(sample), "my-config.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := workspace.Configurations[0]
if cfg.Name != "my-config" {
t.Fatalf("expected name my-config (from filename), got %q", cfg.Name)
}
if cfg.ServerModel != "" {
t.Fatalf("expected empty ServerModel without header, got %q", cfg.ServerModel)
}
if len(cfg.Rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(cfg.Rows))
}
}

41
memory.md Normal file
View File

@@ -0,0 +1,41 @@
# 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).
UI rule (2026-02-19):
- In all breadcrumbs, truncate long specification/configuration names to 16 characters using ellipsis.

View File

@@ -1,9 +1,3 @@
-- Tables affected: lot
-- recovery.not-started: check first; ADD COLUMN fails if lot_category already exists
-- recovery.partial: DROP INDEX IF EXISTS idx_lot_category ON lot; ALTER TABLE lot DROP COLUMN lot_category;
-- recovery.completed: no action needed
-- verify: lot_category column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='lot' AND column_name='lot_category' HAVING COUNT(*)=0
-- Migration: Add lot_category column to lot table -- Migration: Add lot_category column to lot table
-- Run this migration manually on the database -- Run this migration manually on the database

View File

@@ -1,8 +1,2 @@
-- Tables affected: qt_configurations
-- recovery.not-started: check first; ADD COLUMN fails if custom_price already exists
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN custom_price;
-- recovery.completed: no action needed
-- verify: custom_price column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='custom_price' HAVING COUNT(*)=0
-- Add custom_price column to qt_configurations table -- Add custom_price column to qt_configurations table
ALTER TABLE qt_configurations ADD COLUMN custom_price DECIMAL(12,2) NULL COMMENT 'User-defined custom total price'; ALTER TABLE qt_configurations ADD COLUMN custom_price DECIMAL(12,2) NULL COMMENT 'User-defined custom total price';

View File

@@ -1,8 +1,2 @@
-- Tables affected: qt_lot_metadata
-- recovery.not-started: check first; ADD COLUMN fails if is_hidden already exists
-- recovery.partial: ALTER TABLE qt_lot_metadata DROP COLUMN is_hidden;
-- recovery.completed: no action needed
-- verify: is_hidden column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_lot_metadata' AND column_name='is_hidden' HAVING COUNT(*)=0
-- Add is_hidden column to qt_lot_metadata table -- Add is_hidden column to qt_lot_metadata table
ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator'; ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator';

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_configurations
-- recovery.not-started: check first; ADD COLUMN fails if price_updated_at already exists
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN price_updated_at;
-- recovery.completed: no action needed
-- verify: price_updated_at column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='price_updated_at' HAVING COUNT(*)=0
-- Add price_updated_at column to qt_configurations table -- Add price_updated_at column to qt_configurations table
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_configurations
-- recovery.not-started: check first; ADD COLUMN fails if owner_username already exists
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN owner_username;
-- recovery.completed: no action needed
-- verify: owner_username column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='owner_username' HAVING COUNT(*)=0
-- Store configuration owner as username (instead of relying on numeric user_id) -- Store configuration owner as username (instead of relying on numeric user_id)
ALTER TABLE qt_configurations ALTER TABLE qt_configurations
ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id, ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id,

View File

@@ -1,9 +1,3 @@
-- Tables affected: local_configuration_versions (SQLite), local_configurations (SQLite)
-- recovery.not-started: safe to re-run only if table does not exist; fails if table or column already present
-- recovery.partial: roll back: DROP TABLE IF EXISTS local_configuration_versions; run SQLite migration recovery
-- recovery.completed: no action needed
-- verify: local_configuration_versions table missing | SELECT 1 FROM sqlite_master WHERE type='table' AND name='local_configuration_versions' HAVING COUNT(*)=0
-- Add full-snapshot versioning for local configurations (SQLite) -- Add full-snapshot versioning for local configurations (SQLite)
-- 1) Create local_configuration_versions -- 1) Create local_configuration_versions
-- 2) Add current_version_id to local_configurations -- 2) Add current_version_id to local_configurations

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; uses INFORMATION_SCHEMA check before DROP FOREIGN KEY
-- recovery.partial: no rollback needed; FK was dropped intentionally
-- recovery.completed: no action needed
-- verify: user_id column is still NOT NULL | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='user_id' AND is_nullable='NO' HAVING COUNT(*)>0
-- Detach qt_configurations from qt_users (ownership is owner_username text) -- Detach qt_configurations from qt_users (ownership is owner_username text)
-- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks. -- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks.

Some files were not shown because too many files have changed in this diff Show More