Compare commits

..

190 Commits

Author SHA1 Message Date
Mikhail Chusavitin
7f6be786a8 feat: redesign project pricing export — FOB/DDP basis, variant filename, article column
- Add FOB/DDP basis to export options; DDP multiplies all prices ×1.3
- Rename export file from "pricing" to "{FOB|DDP} {variant}" (e.g. "FOB v1")
- Fix server article missing from CSV summary row (PN вендора column)
- Skip per-row breakdown when neither LOT nor BOM is selected
- Remove empty separator rows between configurations
- Redesign export modal: split into Артикул / Цены / Базис поставки sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 17:55:26 +03:00
Mikhail Chusavitin
a360992a01 perf: enable WAL mode, batch price lookup, add DB diagnostics to schema_state
- Set PRAGMA journal_mode=WAL + synchronous=NORMAL on SQLite open;
  eliminates read blocking during background pricelist sync writes
- Replace N+1 per-lot price loop in QuoteService local fallback with
  GetLocalPricesForLots batch query (120 queries → 3 per price-levels call)
- Add CountAllPricelistItems, CountComponents, DBFileSizeBytes to LocalDB
- Report local_pricelist_count, pricelist_items_count, components_count,
  db_size_bytes in qt_client_schema_state for performance diagnostics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:53:36 +03:00
Mikhail Chusavitin
1ea21ece33 docs: add MariaDB user permissions reference to bible-local
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:22:16 +03:00
Mikhail Chusavitin
7ae804d2d3 fix: prevent config creation hang on pricelist sync
SyncPricelistsIfNeeded was called synchronously in Create(), blocking
the HTTP response for several seconds while pricelist data was fetched.
Users clicking multiple times caused 6+ duplicate configurations.

- Run SyncPricelistsIfNeeded in a goroutine so Create() returns immediately
- Add TryLock mutex to SyncPricelistsIfNeeded to skip concurrent calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:34:57 +03:00
Mikhail Chusavitin
da5414c708 fix: handle ErrCannotRenameMainVariant in PATCH /api/projects/:uuid
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:22:45 +03:00
Mikhail Chusavitin
7a69c1513d chore: rename page titles from QuoteForge to OFS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:41:42 +03:00
Mikhail Chusavitin
f448111e77 fix: block renaming main project variant; dynamic page titles
- Add ErrCannotRenameMainVariant; ProjectService.Update now returns
  this error if the caller tries to change the Variant of a main
  project (empty Variant) — ensures there is always exactly one main
- Handle ErrCannotRenameMainVariant in PUT /api/projects/:uuid with 400
- Set document.title dynamically from breadcrumb data:
  - Configurator: "CODE / variant / Config name — QuoteForge"
  - Project detail: "CODE / variant — QuoteForge"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:29:02 +03:00
Mikhail Chusavitin
a5dafd37d3 chore: update bible submodule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:20:26 +03:00
Mikhail Chusavitin
3661e345b1 fix: pricelist selection preserved when opening configurations
- Remove 'auto (latest active)' option from pricelist dropdowns; new
  configs pre-select the first active pricelist instead
- Stop resetting stored pricelist_id to null when it is not in the
  active list (deactivated pricelists are shown as inactive options)
- RefreshPricesNoAuth now accepts an optional pricelist_id; uses the
  UI-selected pricelist, then the config's stored pricelist, then
  latest as a last-resort fallback — no longer silently overwrites
  the stored pricelist on every price refresh
- Same fix applied to RefreshPrices (with auth)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:24:57 +03:00
Mikhail Chusavitin
f915866f83 docs: document final RFQ_LOG MariaDB schema (2026-03-21)
Expand 03-database.md with complete table structure reference for all
23 tables in the final schema: active QuoteForge tables, competitor
subsystem, legacy RFQ tables, and server-side-only tables.

Also clarifies access patterns per group and notes removal of
qt_client_local_migrations from the schema.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:24:03 +03:00
Mikhail Chusavitin
c34a42aaf5 Show build version in page footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:51:13 +03:00
Mikhail Chusavitin
7de0f359b6 Pricing tab: per-LOT row expansion with rowspan grouping
- Reorder columns: PN вендора / Описание / LOT / Кол-во / Estimate / Склад / Конкуренты / Ручная цена
- Explode multi-LOT BOM rows into individual LOT sub-rows; PN вендора + Описание use rowspan to span the group
- Rename "Своя цена" → "Ручная цена", "Проставить цены BOM" → "BOM Цена"
- CSV export reads PN/Desc/LOT from data attributes to handle rowspan offset correctly
- Document pricing tab layout contract in bible-local/02-architecture.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:53:32 +03:00
Mikhail Chusavitin
a8d8d7dfa9 Treat current configuration as main 2026-03-17 18:43:49 +03:00
Mikhail Chusavitin
20ce0124be Vendor frontend assets locally 2026-03-17 18:41:53 +03:00
Mikhail Chusavitin
b0a106415f Make sync status non-blocking 2026-03-17 18:34:28 +03:00
Mikhail Chusavitin
a054fc7564 Version BOM and pricing changes 2026-03-17 18:24:09 +03:00
Mikhail Chusavitin
68cd087356 Fix incomplete pricelist sync status 2026-03-17 12:05:02 +03:00
Mikhail Chusavitin
579ff46a7f fix(release): preserve release notes template - v1.5.4 2026-03-16 08:33:53 +03:00
Mikhail Chusavitin
35c5600b36 fix(qfs): project ui, config naming, sync timestamps - v1.5.4 2026-03-16 08:32:15 +03:00
Mikhail Chusavitin
c599897142 Simplify project documentation and release notes 2026-03-15 16:43:06 +03:00
Mikhail Chusavitin
c964d66e64 Harden local runtime safety and error handling 2026-03-15 16:28:32 +03:00
Mikhail Chusavitin
f0e6bba7e9 Remove partnumbers column from all pricelist views (data mixed across sources)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:24:15 +03:00
Mikhail Chusavitin
61d7e493bd Hide partnumbers column for competitor pricelist (data not linked locally)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:23:20 +03:00
Mikhail Chusavitin
f930c79b34 Remove Поставщик column from pricelist detail (placeholder data)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:22:26 +03:00
Mikhail Chusavitin
a0a57e0969 Redesign pricelist detail: differentiated layout by source type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 13:14:14 +03:00
Mikhail Chusavitin
b3003c4858 Redesign pricing tab: split into purchase/sale tables with unit prices
- Split into two sections: Цена покупки and Цена продажи
- All price cells show unit price (per 1 pcs); totals only in footer
- Added note "Цены указаны за 1 шт." next to each table heading
- Buy table: Своя цена redistributes proportionally with green/red coloring vs estimate; footer shows % diff
- Sale table: configurable uplift (default 1.3) applied to estimate; Склад/Конкуренты fixed at ×1.3
- Footer Склад/Конкуренты marked red with asterisk tooltip when coverage is partial
- CSV export updated: all 8 columns, SPEC-BUY/SPEC-SALE suffix, no % annotation in totals

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:55:17 +03:00
Mikhail Chusavitin
e2da8b4253 Fix competitor price display and pricelist item deduplication
- Render competitor prices in Pricing tab (all three row branches)
- Add footer total accumulation for competitor column
- Deduplicate local_pricelist_items via migration + unique index
- Use ON CONFLICT DO NOTHING in SaveLocalPricelistItems to prevent duplicates on concurrent sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:33:04 +03:00
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
96 changed files with 1223 additions and 3963 deletions

5
.gitignore vendored
View File

@@ -1,10 +1,5 @@
# QuoteForge
config.yaml
# Data exports and imports with real supplier/pricing data
*_import.sql
*_export.csv
test_export.csv
.env
.env.*
*.pem

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: 1977730d93...52444350c1

View File

@@ -106,17 +106,6 @@ Rules:
- 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
Configurations have a `config_type` field: `"server"` (default) or `"storage"`.
Rules:
- `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;
- storage configurations use the same vendor_spec + PN→LOT + pricing flow as server configurations;
- 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.
- `DKC` = контроллерная полка (модель СХД + тип дисков + кол-во слотов + кол-во контроллеров); `CTL` = контроллер (кэш + встроенные порты); `ENC` = дисковая полка без контроллера.
## Vendor BOM contract
Vendor BOM is stored in `vendor_spec` on the configuration row.

View File

@@ -29,15 +29,17 @@ Rules:
## MariaDB
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-03-21.
### QuoteForge tables (qt_*)
### QuoteForge tables (qt_* and stock_*)
Runtime read:
- `qt_categories` — pricelist categories
- `qt_lot_metadata` — component metadata, price settings
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
- `qt_pricelist_items` — pricelist rows
- `stock_log` — raw supplier price log, source for pricelist generation
- `stock_ignore_rules` — patterns to skip during stock import
- `qt_partnumber_books` — partnumber book headers
- `qt_partnumber_book_items` — PN→LOT catalog payload
@@ -67,20 +69,18 @@ QuoteForge references competitor pricelists only via `qt_pricelists` (source='co
### 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
- `lot_log` — original supplier price log (superseded by `stock_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;
- stock enrichment happens during sync and is persisted into SQLite;
- 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.
- `qt_client_local_migrations` was removed from the schema on 2026-03-21 (was in earlier drafts).
## MariaDB Table Structures
@@ -370,6 +370,8 @@ GRANT SELECT ON RFQ_LOG.qt_categories TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.stock_log TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.stock_ignore_rules TO 'qfs_user'@'%';
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'@'%';
@@ -378,6 +380,7 @@ GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_projects TO 'qfs_user'@'%';
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'qfs_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'qfs_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'qfs_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
FLUSH PRIVILEGES;

View File

@@ -62,21 +62,3 @@ Imported configuration fields:
- `article` or `support_code` from `ProprietaryProductIdentifier`
Imported BOM rows become `vendor_spec` rows and are resolved through the active local partnumber book when possible.
## Inspur BOM import
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts Inspur text BOM exports.
Format: one component per line, `<partnumber>*<quantity>`. A leading `|` character is optional and stripped. Trailing whitespace around `*` is normalised.
Example:
```
|CPU_AMD_9535-EPYC2.4_64C_256M_300W*1
|PowerSupply_1300W_Titanium_220VACor240VDC_GaN*2
```
Rules:
- 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.

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

@@ -14,7 +14,6 @@ Project-specific architecture and operational contracts.
| [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 |
## Rules

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

@@ -2,8 +2,8 @@ package main
import (
"flag"
"fmt"
"log"
"log/slog"
"time"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
@@ -153,7 +153,7 @@ func main() {
log.Printf(" Skipped: %d", skipped)
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 {

View File

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

View File

@@ -2,8 +2,8 @@ package main
import (
"flag"
"fmt"
"log"
"log/slog"
"sort"
"time"
@@ -161,7 +161,7 @@ func printPlan(plan []updatePlanRow, apply bool) {
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.")
fmt.Println("Re-run with -apply to write server updated_at into local SQLite.")
}
}

View File

@@ -289,7 +289,8 @@ func main() {
}
func showStartupConsoleWarning() {
slog.Warn(startupConsoleWarning)
// Visible in console output.
fmt.Println(startupConsoleWarning)
// Keep the warning always visible in the console window title when supported.
fmt.Printf("\033]0;%s\007", startupConsoleWarning)
}
@@ -331,9 +332,7 @@ func setConfigDefaults(cfg *config.Config) {
cfg.Server.ReadTimeout = 30 * time.Second
}
if cfg.Server.WriteTimeout == 0 {
// Sync operations (pricelist download over slow VPN) can take several minutes.
// Loopback-only binding means there is no risk of holding connections from external clients.
cfg.Server.WriteTimeout = 10 * time.Minute
cfg.Server.WriteTimeout = 30 * time.Second
}
if cfg.Backup.Time == "" {
cfg.Backup.Time = "00:00"
@@ -395,7 +394,7 @@ func ensureDefaultConfigFile(configPath string) error {
port: 8080
mode: "release"
read_timeout: 30s
write_timeout: 10m
write_timeout: 30s
backup:
time: "00:00"
@@ -679,7 +678,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
syncService = sync.NewService(connMgr, local)
componentService := services.NewComponentService(nil, nil, 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 := func() bool {
@@ -787,8 +786,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
}
supportBundleHandler := handlers.NewSupportBundleHandler(local, connMgr, syncService, cfg.Logging.FilePath)
// Setup handler (for reconfiguration)
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig)
if err != nil {
@@ -908,8 +905,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusOK, gin.H{"message": "pong"})
})
api.GET("/support-bundle", supportBundleHandler.DownloadBundle)
// Components (public read)
components := api.Group("/components")
{
@@ -1275,7 +1270,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
})
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
configs.POST("/:uuid/export/pricing", exportHandler.ExportConfigPricingCSV)
// Vendor spec (BOM) endpoints
configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec)
@@ -1725,7 +1719,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return
}
if !services.IsCFXMLWorkspace(data) && !services.IsQuoteForgeCSV(data) && !services.IsInspurBOM(data) {
if !services.IsCFXMLWorkspace(data) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
return
}

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

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

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

@@ -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{
Items: components,
TotalCount: total,
Components: components,
Total: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
})
}
@@ -95,12 +90,6 @@ func (h *ComponentHandler) Get(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()
if err == nil && len(codes) > 0 {
categories := make([]models.Category, 0, len(codes))
@@ -109,15 +98,7 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
if trimmed == "" {
continue
}
order := orderMap[strings.ToUpper(trimmed)]
if order == 0 {
order = models.MaxKnownDisplayOrder + 1
}
categories = append(categories, models.Category{
Code: trimmed,
Name: trimmed,
DisplayOrder: order,
})
categories = append(categories, models.Category{Code: trimmed, Name: trimmed})
}
c.JSON(http.StatusOK, categories)
return

View File

@@ -60,7 +60,7 @@ type ProjectExportOptionsRequest struct {
func (h *ExportHandler) ExportCSV(c *gin.Context) {
var req ExportRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}
@@ -68,7 +68,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
// Validate before streaming (can return JSON error)
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
}
@@ -150,7 +150,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
uuid := c.Param("uuid")
// 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 {
RespondError(c, http.StatusNotFound, "resource not found", err)
return
@@ -160,7 +160,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
// Validate before streaming (can return JSON error)
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
}
@@ -206,7 +206,7 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
}
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
}
@@ -223,69 +223,12 @@ 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) {
projectUUID := c.Param("uuid")
var req ProjectExportOptionsRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}
@@ -301,7 +244,7 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
return
}
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
}

View File

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

View File

@@ -51,11 +51,8 @@ func (h *PartnumberBooksHandler) List(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"items": summaries,
"total_count": len(summaries),
"page": 1,
"per_page": len(summaries),
"total_pages": 1,
"books": summaries,
"total": len(summaries),
})
}
@@ -65,7 +62,7 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid book ID"})
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"})
return
}
@@ -80,8 +77,9 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
perPage = 100
}
book, err := h.localDB.GetLocalPartnumberBookByServerID(uint(id))
if err != nil {
// Find local book by server_id
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"})
return
}
@@ -92,20 +90,15 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
return
}
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
if totalPages < 1 {
totalPages = 1
}
c.JSON(http.StatusOK, gin.H{
"book_id": book.ServerID,
"version": book.Version,
"is_active": book.IsActive,
"partnumbers": book.PartnumbersJSON,
"items": items,
"total_count": total,
"total": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
"search": search,
"book_total": bookRepo.CountBookItems(book.ID),
"lot_count": bookRepo.CountDistinctLots(book.ID),

View File

@@ -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{
"items": summaries,
"total_count": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
"pricelists": summaries,
"total": total,
"page": page,
"per_page": perPage,
})
}
@@ -124,7 +119,7 @@ func (h *PricelistHandler) Get(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}
@@ -151,7 +146,7 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}
@@ -170,21 +165,40 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
if perPage < 1 {
perPage = 50
}
items, total, err := h.localDB.GetLocalPricelistItemsPage(localPL.ID, strings.TrimSpace(search), page, perPage)
if err != nil {
var items []localdb.LocalPricelistItem
dbq := h.localDB.DB().Model(&localdb.LocalPricelistItem{}).Where("pricelist_id = ?", localPL.ID)
if strings.TrimSpace(search) != "" {
dbq = dbq.Where("lot_name LIKE ?", "%"+strings.TrimSpace(search)+"%")
}
var total int64
if err := dbq.Count(&total).Error; err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
offset := (page - 1) * perPage
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
lotNames := make([]string, len(items))
for i, item := range items {
lotNames[i] = item.LotName
}
descMap, err := h.localDB.GetLocalComponentDescriptionsByLotNames(lotNames)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
type compRow struct {
LotName string
LotDescription string
}
var comps []compRow
if len(lotNames) > 0 {
h.localDB.DB().Table("local_components").
Select("lot_name, lot_description").
Where("lot_name IN ?", lotNames).
Scan(&comps)
}
descMap := make(map[string]string, len(comps))
for _, c := range comps {
descMap[c.LotName] = c.LotDescription
}
resultItems := make([]gin.H, 0, len(items))
@@ -203,14 +217,12 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
})
}
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
c.JSON(http.StatusOK, gin.H{
"source": localPL.Source,
"items": resultItems,
"total_count": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
"source": localPL.Source,
"items": resultItems,
"total": total,
"page": page,
"per_page": perPage,
})
}
@@ -218,7 +230,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}

View File

@@ -141,21 +141,21 @@ func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
}
var resp struct {
Items []struct {
Pricelists []struct {
ID uint `json:"id"`
} `json:"items"`
TotalCount int `json:"total_count"`
} `json:"pricelists"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp.TotalCount != 1 {
t.Fatalf("expected total=1, got %d", resp.TotalCount)
if resp.Total != 1 {
t.Fatalf("expected total=1, got %d", resp.Total)
}
if len(resp.Items) != 1 {
t.Fatalf("expected 1 pricelist, got %d", len(resp.Items))
if len(resp.Pricelists) != 1 {
t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists))
}
if resp.Items[0].ID != 10 {
t.Fatalf("expected pricelist id=10, got %d", resp.Items[0].ID)
if resp.Pricelists[0].ID != 10 {
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) {
var req services.QuoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}
result, err := h.quoteService.ValidateAndCalculate(&req)
if err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}
@@ -34,13 +34,13 @@ func (h *QuoteHandler) Validate(c *gin.Context) {
func (h *QuoteHandler) Calculate(c *gin.Context) {
var req services.QuoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}
result, err := h.quoteService.ValidateAndCalculate(&req)
if err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}
@@ -53,13 +53,13 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
var req services.PriceLevelsRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}
result, err := h.quoteService.CalculatePriceLevels(&req)
if err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}

View File

@@ -1,6 +1,7 @@
package handlers
import (
"errors"
"fmt"
"html/template"
"log/slog"
@@ -14,6 +15,9 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"github.com/gin-gonic/gin"
mysqlDriver "github.com/go-sql-driver/mysql"
gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type SetupHandler struct {
@@ -23,6 +27,8 @@ type SetupHandler struct {
restartSig chan struct{}
}
var errPermissionProbeRollback = errors.New("permission probe rollback")
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b },
@@ -87,7 +93,7 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
}
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
lotCount, canWrite, err := db.ValidateMariaDBConnection(dsn)
lotCount, canWrite, err := validateMariaDBConnection(dsn)
if err != nil {
_ = c.Error(err)
c.JSON(http.StatusOK, gin.H{
@@ -129,7 +135,7 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
// Test connection first
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
if _, _, err := db.ValidateMariaDBConnection(dsn); err != nil {
if _, _, err := validateMariaDBConnection(dsn); err != nil {
_ = c.Error(err)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
@@ -208,3 +214,46 @@ func buildMySQLDSN(host string, port int, database, user, password string, timeo
return cfg.FormatDSN()
}
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

@@ -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

@@ -187,11 +187,8 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
return
}
now := time.Now()
result, err := h.localDB.SyncComponents(mariaDB)
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)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
@@ -200,8 +197,6 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
_ = c.Error(err)
return
}
_ = h.localDB.SetComponentSyncResult("ok", "", now)
h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds())
c.JSON(http.StatusOK, SyncResultResponse{
Success: true,
@@ -221,7 +216,6 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
startTime := time.Now()
synced, err := h.syncService.SyncPricelists()
if err != nil {
h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, startTime, time.Since(startTime).Milliseconds())
slog.Error("pricelist sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
@@ -230,7 +224,6 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
_ = c.Error(err)
return
}
h.localDB.AppendSyncLog("pricelists", "ok", "", synced, startTime, time.Since(startTime).Milliseconds())
c.JSON(http.StatusOK, SyncResultResponse{
Success: true,
@@ -238,6 +231,7 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
Synced: synced,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite.
@@ -265,6 +259,7 @@ func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
Synced: pulled,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// SyncAllResponse represents result of full sync
@@ -318,11 +313,8 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
return
}
compNow := time.Now()
compResult, err := h.localDB.SyncComponents(mariaDB)
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)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
@@ -331,15 +323,11 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
_ = c.Error(err)
return
}
_ = h.localDB.SetComponentSyncResult("ok", "", compNow)
h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds())
componentsSynced = compResult.TotalSynced
// Sync pricelists
plNow := time.Now()
pricelistsSynced, err = h.syncService.SyncPricelists()
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)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
@@ -350,7 +338,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
_ = c.Error(err)
return
}
h.localDB.AppendSyncLog("pricelists", "ok", "", pricelistsSynced, plNow, time.Since(plNow).Milliseconds())
projectsResult, err := h.syncService.ImportProjectsToLocal()
if err != nil {
@@ -397,6 +384,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
ConfigurationsSkipped: configsResult.Skipped,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// checkOnline checks if MariaDB is accessible
@@ -429,6 +417,7 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
Synced: pushed,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// GetPendingCount returns the number of pending changes
@@ -617,6 +606,9 @@ func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
return
}
// Keep current client heartbeat fresh so app version is available in the table.
h.syncService.RecordSyncHeartbeat()
users, err := h.syncService.ListUserSyncStatuses(threshold)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
@@ -705,13 +697,6 @@ func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness {
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,
@@ -739,7 +724,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
} `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}

View File

@@ -2,7 +2,6 @@ package handlers
import (
"errors"
"log/slog"
"net/http"
"strings"
@@ -66,7 +65,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
}
if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}
@@ -137,7 +136,7 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
}
if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}
@@ -150,11 +149,7 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
return
}
book, err := bookRepo.GetActiveBook()
if err != nil {
slog.Warn("vendor spec resolve: no active partnumber book", "err", err)
book = nil
}
book, _ := bookRepo.GetActiveBook()
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
@@ -184,7 +179,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
} `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}

View File

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

View File

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

View File

@@ -229,7 +229,6 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
&LocalSyncGuardState{},
&PendingChange{},
&LocalPartnumberBook{},
&SyncLogEntry{},
)
}
@@ -497,10 +496,7 @@ func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string
// HasSettings returns true if connection settings exist
func (l *LocalDB) HasSettings() bool {
var count int64
if err := l.db.Model(&ConnectionSettings{}).Count(&count).Error; err != nil {
slog.Error("localdb: HasSettings count failed", "err", err)
return false
}
l.db.Model(&ConnectionSettings{}).Count(&count)
return count > 0
}
@@ -1047,18 +1043,14 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
// CountConfigurations returns the number of local configurations
func (l *LocalDB) CountConfigurations() int64 {
var count int64
if err := l.db.Model(&LocalConfiguration{}).Count(&count).Error; err != nil {
slog.Error("localdb: CountConfigurations failed", "err", err)
}
l.db.Model(&LocalConfiguration{}).Count(&count)
return count
}
// CountProjects returns the number of local projects
func (l *LocalDB) CountProjects() int64 {
var count int64
if err := l.db.Model(&LocalProject{}).Count(&count).Error; err != nil {
slog.Error("localdb: CountProjects failed", "err", err)
}
l.db.Model(&LocalProject{}).Count(&count)
return count
}
@@ -1161,85 +1153,6 @@ func (l *LocalDB) SetPricelistSyncResult(status, errorText string, attemptedAt t
})
}
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
func (l *LocalDB) CountLocalPricelists() int64 {
var count int64
@@ -1826,62 +1739,3 @@ func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requi
}),
}).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

@@ -110,7 +110,6 @@ type LocalConfiguration struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_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'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
@@ -317,19 +316,6 @@ type VendorSpecLotMapping struct {
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
type VendorSpec []VendorSpecItem

View File

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

View File

@@ -111,7 +111,6 @@ type Configuration struct {
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,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"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
Line int `gorm:"column:line_no;index" json:"line"`

View File

@@ -1,5 +1,7 @@
package models
import "time"
// Lot represents existing lot table
type Lot struct {
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
@@ -10,3 +12,58 @@ type Lot struct {
func (Lot) TableName() string {
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

@@ -31,9 +31,7 @@ func Migrate(db *gorm.DB) error {
errStr := err.Error()
if strings.Contains(errStr, "Can't DROP") ||
strings.Contains(errStr, "Duplicate key name") ||
strings.Contains(errStr, "check that it exists") ||
strings.Contains(errStr, "Cannot change column") ||
strings.Contains(errStr, "used in a foreign key constraint") {
strings.Contains(errStr, "check that it exists") {
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
continue
}
@@ -43,18 +41,12 @@ func Migrate(db *gorm.DB) error {
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 {
for _, cat := range DefaultCategories {
var existing Category
if err := db.Where("code = ?", cat.Code).First(&existing).Error; err != nil {
if err := db.Create(&cat).Error; err != nil {
return err
}
} else {
if err := db.Model(&existing).Update("display_order", cat.DisplayOrder).Error; err != nil {
return err
}
result := db.Where("code = ?", cat.Code).FirstOrCreate(&cat)
if result.Error != nil {
return result.Error
}
}
return nil

View File

@@ -63,6 +63,11 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
Order("current_price " + sortDir)
case "lot_name":
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: sort by popularity, no price goes last
query = query.

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 {
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)
}
return NewPricelistRepository(db)

View File

@@ -91,3 +91,25 @@ func (r *StatsRepository) ResetMonthlyCounters() error {
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 (
"fmt"
"log/slog"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/models"
@@ -42,11 +41,10 @@ func ParsePartNumber(lotName string) (category, model string) {
}
type ComponentListResult struct {
Items []ComponentView `json:"items"`
TotalCount int64 `json:"total_count"`
Components []ComponentView `json:"components"`
Total int64 `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
}
type ComponentView struct {
@@ -65,11 +63,10 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
// Components should be loaded via /api/sync/components first
if s.componentRepo == nil {
return &ComponentListResult{
Items: []ComponentView{},
TotalCount: 0,
Components: []ComponentView{},
Total: 0,
Page: page,
PerPage: perPage,
TotalPages: 1,
}, nil
}
@@ -110,16 +107,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
views[i] = view
}
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
if totalPages < 1 {
totalPages = 1
}
return &ComponentListResult{
Items: views,
TotalCount: total,
Components: views,
Total: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
}, nil
}
@@ -134,10 +126,8 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
return nil, err
}
// Track usage (best-effort)
if err := s.componentRepo.IncrementRequestCount(lotName); err != nil {
slog.Warn("component: could not increment request count", "lot", lotName, "err", err)
}
// Track usage
_ = s.componentRepo.IncrementRequestCount(lotName)
view := &ComponentView{
LotName: c.LotName,

View File

@@ -2,7 +2,6 @@ package services
import (
"errors"
"log/slog"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
@@ -19,7 +18,6 @@ var (
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
type ConfigurationGetter interface {
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
GetByUUIDNoAuth(uuid string) (*models.Configuration, error)
}
type ConfigurationService struct {
@@ -60,7 +58,6 @@ type CreateConfigRequest struct {
PricelistID *uint `json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
ConfigType string `json:"config_type,omitempty"` // "server" | "storage"
DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"`
}
@@ -106,22 +103,16 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
PricelistID: pricelistID,
WarehousePricelistID: req.WarehousePricelistID,
CompetitorPricelistID: req.CompetitorPricelistID,
ConfigType: req.ConfigType,
DisablePriceRefresh: req.DisablePriceRefresh,
OnlyInStock: req.OnlyInStock,
}
if config.ConfigType == "" {
config.ConfigType = "server"
}
if err := s.configRepo.Create(config); err != nil {
return nil, err
}
// Record usage stats (best-effort)
if err := s.quoteService.RecordUsage(req.Items); err != nil {
slog.Warn("configuration: could not record usage stats", "err", err)
}
// Record usage stats
_ = s.quoteService.RecordUsage(req.Items)
return config, nil
}

View File

@@ -13,17 +13,20 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
type ExportService struct {
config config.ExportConfig
localDB *localdb.LocalDB
config config.ExportConfig
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{
config: cfg,
localDB: local,
config: cfg,
categoryRepo: categoryRepo,
localDB: local,
}
}
@@ -124,7 +127,16 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
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 {
lineNo := block.Line
@@ -201,30 +213,27 @@ func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
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) {
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))
for i := range sortedConfigs {
@@ -287,7 +296,26 @@ func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectEx
// ProjectToExportData converts multiple configurations into 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))
for i := range sortedConfigs {
@@ -299,18 +327,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 {
// Batch-fetch categories from local data (pricelist items → local_components fallback)
lotNames := make([]string, len(cfg.Items))
@@ -504,30 +520,20 @@ func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string)
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).
func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
sort.SliceStable(items, func(i, j int) bool {
orderI, hasI := categoryDisplayOrder(categoryOrder, items[i].Category)
orderJ, hasJ := categoryDisplayOrder(categoryOrder, items[j].Category)
if hasI && hasJ {
return orderI < orderJ
for i := 0; i < len(items)-1; i++ {
for j := i + 1; j < len(items); j++ {
orderI, hasI := categoryOrder[items[i].Category]
orderJ, hasJ := categoryOrder[items[j].Category]
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 {
@@ -567,52 +573,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 {
level := pricingLevels{}
if p, ok := estimatePrices[lot]; ok {
level.Estimate = floatPtr(p)
}
if p, ok := stockPrices[lot]; ok {
level.Stock = floatPtr(p)
}
if p, ok := competitorPrices[lot]; ok {
level.Competitor = floatPtr(p)
}
level.Estimate = s.lookupPricePointer(estimateID, lot)
level.Stock = s.lookupPricePointer(warehouseID, lot)
level.Competitor = s.lookupPricePointer(competitorID, lot)
result[lot] = level
}
return result
}
// batchLookupPrices fetches prices for all lots from a pricelist in a single query.
func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string) map[string]float64 {
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || len(lots) == 0 {
func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 {
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" {
return nil
}
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
if err != nil {
return nil
}
prices, err := s.localDB.GetLocalPricesForLots(localPL.ID, lots)
if err != nil {
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
if err != nil || price <= 0 {
return nil
}
return prices
return floatPtr(price)
}
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
lots := collectPricingLots(cfg, localCfg, true)
if s.localDB == nil || len(lots) == 0 {
return map[string]string{}
result := make(map[string]string, len(lots))
if s.localDB == nil {
return result
}
descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots)
if err != nil {
return map[string]string{}
for _, lot := range lots {
component, err := s.localDB.GetLocalComponent(lot)
if err != nil {
continue
}
result[lot] = component.LotDescription
}
return descriptions
return result
}
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {

View File

@@ -33,7 +33,7 @@ func newTestProjectData(items []ExportItem, article string, serverCount int) *Pr
}
func TestToCSV_UTF8BOM(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
svc := NewExportService(config.ExportConfig{}, nil, nil)
data := newTestProjectData([]ExportItem{
{
@@ -63,7 +63,7 @@ func TestToCSV_UTF8BOM(t *testing.T) {
}
func TestToCSV_SemicolonDelimiter(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
svc := NewExportService(config.ExportConfig{}, nil, nil)
data := newTestProjectData([]ExportItem{
{
@@ -130,7 +130,7 @@ func TestToCSV_SemicolonDelimiter(t *testing.T) {
}
func TestToCSV_ServerRow(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
svc := NewExportService(config.ExportConfig{}, nil, nil)
data := newTestProjectData([]ExportItem{
{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) {
svc := NewExportService(config.ExportConfig{}, nil)
svc := NewExportService(config.ExportConfig{}, nil, nil)
data := newTestProjectData([]ExportItem{
{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) {
svc := NewExportService(config.ExportConfig{}, nil)
svc := NewExportService(config.ExportConfig{}, nil, nil)
data := &ProjectExportData{
Configs: []ConfigExportBlock{},
@@ -247,7 +247,7 @@ func TestToCSV_EmptyData(t *testing.T) {
}
func TestToCSVBytes_BackwardCompat(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
svc := NewExportService(config.ExportConfig{}, nil, nil)
data := newTestProjectData([]ExportItem{
{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) {
svc := NewExportService(config.ExportConfig{}, nil)
svc := NewExportService(config.ExportConfig{}, nil, nil)
data := newTestProjectData([]ExportItem{
{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) {
svc := NewExportService(config.ExportConfig{}, nil)
svc := NewExportService(config.ExportConfig{}, nil, nil)
data := &ProjectExportData{
Configs: []ConfigExportBlock{
@@ -359,7 +359,7 @@ func TestToCSV_MultipleBlocks(t *testing.T) {
}
func TestProjectToExportData_SortsByLine(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
svc := NewExportService(config.ExportConfig{}, nil, nil)
configs := []models.Configuration{
{
@@ -445,7 +445,7 @@ func TestFormatPriceComma(t *testing.T) {
}
func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
svc := NewExportService(config.ExportConfig{}, nil, nil)
data := &ProjectPricingExportData{
Configs: []ProjectPricingExportConfig{
{
@@ -499,7 +499,7 @@ func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
if err != nil {
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 {
if summary[i] != want {
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) {
svc := NewExportService(config.ExportConfig{}, nil)
svc := NewExportService(config.ExportConfig{}, nil, nil)
configs := []models.Configuration{
{
UUID: "cfg-1",

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
@@ -102,14 +101,10 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
PricelistID: pricelistID,
WarehousePricelistID: req.WarehousePricelistID,
CompetitorPricelistID: req.CompetitorPricelistID,
ConfigType: req.ConfigType,
DisablePriceRefresh: req.DisablePriceRefresh,
OnlyInStock: req.OnlyInStock,
CreatedAt: time.Now(),
}
if cfg.ConfigType == "" {
cfg.ConfigType = "server"
}
// Convert to local model
localCfg := localdb.ConfigurationToLocal(cfg)
@@ -119,10 +114,8 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
}
cfg.Line = localCfg.Line
// Record usage stats (best-effort)
if err := s.quoteService.RecordUsage(req.Items); err != nil {
slog.Warn("local configuration: could not record usage stats", "err", err)
}
// Record usage stats
_ = s.quoteService.RecordUsage(req.Items)
return cfg, nil
}
@@ -410,9 +403,7 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
// Refresh local pricelists when online.
if s.isOnline() {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
slog.Warn("local configuration: background pricelist sync failed", "err", err)
}
_ = s.syncService.SyncPricelistsIfNeeded()
}
// Use the pricelist stored in the config; fall back to latest if unavailable.
@@ -796,9 +787,7 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
}
if s.isOnline() {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
slog.Warn("local configuration: background pricelist sync failed", "err", err)
}
_ = s.syncService.SyncPricelistsIfNeeded()
}
// Resolve which pricelist to use:

View File

@@ -76,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 {
slog.Warn("failed to report client schema state", "error", err)
}
@@ -146,51 +141,39 @@ func ensureClientSchemaStateTable(db *gorm.DB) error {
}
if tableExists(db, "qt_client_schema_state") {
// Each ALTER is guarded by a column existence check so users without DDL
// rights don't get a permission error on every sync cycle — the server
// migration tool is the authoritative path for schema changes.
if !columnExists(db, "qt_client_schema_state", "hostname") {
if err := db.Exec(`
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)
}
if err := db.Exec(`
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)
}
type colMigration struct {
column string
stmt string
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)
}
migrations := []colMigration{
{"last_sync_at", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version"},
{"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_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"},
{"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"},
{"configurations_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_count"},
{"projects_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS projects_count INT NOT NULL DEFAULT 0 AFTER configurations_count"},
{"estimate_pricelist_version", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS estimate_pricelist_version VARCHAR(128) NULL AFTER projects_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"},
{"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_code", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_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"},
{"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"},
{"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"},
{"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 {
for _, stmt := range []string{
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version",
"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 pending_changes_count INT NOT NULL DEFAULT 0 AFTER last_sync_status",
"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 configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_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 estimate_pricelist_version VARCHAR(128) NULL AFTER projects_count",
"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 competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version",
"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 last_sync_error_text TEXT NULL AFTER last_sync_error_code",
"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 pricelist_items_count INT NOT NULL DEFAULT 0 AFTER local_pricelist_count",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS components_count INT NOT NULL DEFAULT 0 AFTER pricelist_items_count",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS db_size_bytes BIGINT NOT NULL DEFAULT 0 AFTER components_count",
} {
if err := db.Exec(stmt).Error; err != nil {
return fmt.Errorf("expand qt_client_schema_state: %w", err)
}
}
@@ -198,17 +181,6 @@ func ensureClientSchemaStateTable(db *gorm.DB) error {
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 {
var count int64
// For MariaDB/MySQL, check information_schema
@@ -225,6 +197,9 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
return nil
}
if err := ensureClientSchemaStateTable(mariaDB); err != nil {
return err
}
username := strings.TrimSpace(s.localDB.GetDBUser())
if username == "" {
return nil

View File

@@ -17,7 +17,6 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var ErrOffline = errors.New("database is offline")
@@ -28,7 +27,6 @@ type Service struct {
localDB *localdb.LocalDB
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
@@ -274,56 +272,60 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
func (s *Service) NeedSync() (bool, error) {
lastSync := s.localDB.GetLastSyncTime()
// If never synced, always need sync.
// If never synced, need sync
if lastSync == nil {
return true, nil
}
// When online, compare actual server versions regardless of elapsed time.
// 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 last sync was more than 1 hour ago, suggest sync
if time.Since(*lastSync) > time.Hour {
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
}
// SyncPricelists synchronizes all active pricelists from server to local SQLite
func (s *Service) SyncPricelists() (int, error) {
slog.Info("starting pricelist sync")
plSyncStart := time.Now()
if _, err := s.EnsureReadinessForSync(); err != nil {
return 0, err
}
@@ -332,7 +334,6 @@ func (s *Service) SyncPricelists() (int, error) {
mariaDB, err := s.getDB()
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)
}
@@ -356,18 +357,6 @@ func (s *Service) SyncPricelists() (int, error) {
// Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
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.
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
itemCount, err := s.SyncPricelistItems(existing.ID)
@@ -419,7 +408,6 @@ func (s *Service) SyncPricelists() (int, error) {
if syncErr != nil {
s.recordPricelistSyncFailure(syncErr)
s.localDB.AppendSyncLog("pricelists", "error", syncErr.Error(), synced, plSyncStart, time.Since(plSyncStart).Milliseconds())
return synced, syncErr
}
@@ -427,7 +415,7 @@ func (s *Service) SyncPricelists() (int, error) {
now := time.Now()
s.localDB.SetLastSyncTime(now)
s.recordPricelistSyncSuccess(now)
s.localDB.AppendSyncLog("pricelists", "ok", "", synced, plSyncStart, time.Since(plSyncStart).Milliseconds())
s.RecordSyncHeartbeat()
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
return synced, nil
@@ -480,29 +468,24 @@ func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int
}
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 {
if err := tx.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)
}
if len(localItems) == 0 {
return nil
}
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)
batchSize := 500
for i := 0; i < len(localItems); i += batchSize {
end := i + batchSize
if end > len(localItems) {
end = len(localItems)
}
if err := tx.CreateInBatches(localItems[i:end], batchSize).Error; err != nil {
return fmt.Errorf("save local pricelist items: %w", err)
}
}
return nil
}); err != nil {
@@ -513,27 +496,6 @@ func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int
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) {
if s.localDB == nil || pricelistRepo == nil {
return
@@ -611,29 +573,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) {
mariaDB, err := s.getDB()
if err != nil || mariaDB == nil {
return nil, ErrOffline
}
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
return nil, fmt.Errorf("ensure sync status table: %w", err)
}
type row struct {
Username string `gorm:"column:username"`
LastCheckedAt time.Time `gorm:"column:last_checked_at"`
AppVersion string `gorm:"column:app_version"`
Username string `gorm:"column:username"`
LastSyncAt time.Time `gorm:"column:last_sync_at"`
AppVersion string `gorm:"column:app_version"`
}
var rows []row
if err := mariaDB.Raw(`
SELECT s.username, s.last_checked_at, COALESCE(s.app_version, '') AS app_version
FROM qt_client_schema_state s
INNER JOIN (
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
SELECT username, last_sync_at, COALESCE(app_version, '') AS app_version
FROM qt_pricelist_sync_status
ORDER BY last_sync_at DESC, username ASC
`).Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("load sync status rows: %w", err)
}
@@ -653,7 +644,7 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn
continue
}
isOnline := now.Sub(r.LastCheckedAt) <= onlineThreshold
isOnline := now.Sub(r.LastSyncAt) <= onlineThreshold
if _, connected := activeUsers[username]; connected {
isOnline = true
delete(activeUsers, username)
@@ -663,7 +654,7 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn
result = append(result, UserSyncStatus{
Username: username,
LastSyncAt: r.LastCheckedAt,
LastSyncAt: r.LastSyncAt,
AppVersion: appVersion,
IsOnline: isOnline,
})
@@ -717,6 +708,36 @@ func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, e
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
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
@@ -768,6 +789,9 @@ func (s *Service) fetchServerPricelistItems(serverPricelistID uint) ([]localdb.L
for i, item := range serverItems {
localItems[i] = *localdb.PricelistItemToLocal(&item, 0)
}
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
slog.Warn("pricelist stock enrichment skipped", "server_pricelist_id", serverPricelistID, "error", err)
}
return localItems, nil
}
@@ -781,6 +805,111 @@ func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, err
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
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
@@ -829,16 +958,6 @@ func (s *Service) SyncPricelistsIfNeeded() error {
if !needSync {
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
}
@@ -1573,25 +1692,3 @@ func (s *Service) getConnectionStatus() db.ConnectionStatus {
}
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.PricelistItem{},
&models.Lot{},
&models.StockLog{},
); err != nil {
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)
}
}
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,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 {
t.Fatalf("open server sqlite: %v", err)
}
if err := db.AutoMigrate(
&models.Project{},
&models.Configuration{},
&models.Pricelist{},
&models.PricelistItem{},
&models.Lot{},
); err != nil {
t.Fatalf("migrate server test schema: %v", err)
if err := db.Exec(`
CREATE TABLE qt_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
owner_username TEXT NOT NULL,
code TEXT NOT NULL,
variant TEXT NOT NULL DEFAULT '',
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
}

View File

@@ -80,11 +80,6 @@ func (w *Worker) runSync() {
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
pushed, err := w.service.PushPendingChanges()
if err != nil {
@@ -100,5 +95,8 @@ func (w *Worker) runSync() {
return
}
// Mark user's sync heartbeat (used for online/offline status in UI).
w.service.RecordSyncHeartbeat()
w.logger.Info("background sync cycle completed")
}

View File

@@ -2,7 +2,6 @@ package services
import (
"bytes"
"encoding/csv"
"encoding/xml"
"fmt"
"path/filepath"
@@ -48,8 +47,7 @@ type importedConfiguration struct {
ServerModel string
Article string
CurrencyCode string
Rows []localdb.VendorSpecItem // vendor BOM formats (CFXML, Inspur)
DirectItems localdb.LocalConfigItems // direct LOT formats (QuoteForge CSV)
Rows []localdb.VendorSpecItem
TotalPrice *float64
}
@@ -126,17 +124,7 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
return nil, ErrProjectNotFound
}
var workspace *importedWorkspace
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))
default:
return nil, fmt.Errorf("unsupported vendor export format")
}
workspace, err := parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
if err != nil {
return nil, err
}
@@ -152,28 +140,10 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
for _, imported := range workspace.Configurations {
now := time.Now()
cfgUUID := uuid.NewString()
var groupRows localdb.VendorSpec
var items localdb.LocalConfigItems
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)
}
groupRows, items, totalPrice, estimatePricelistID, err := s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
if err != nil {
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, err)
}
localCfg := &localdb.LocalConfiguration{
UUID: cfgUUID,
ProjectUUID: &projectUUID,
@@ -269,17 +239,13 @@ func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *loca
}
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))
for _, lotName := range order {
unitPrice := 0.0
if priceMap != nil {
unitPrice = priceMap[lotName]
if estimatePricelist != nil && local != nil {
if price, err := local.GetLocalPriceForLot(estimatePricelist.ID, lotName); err == nil && price > 0 {
unitPrice = price
}
}
items = append(items, localdb.LocalConfigItem{
LotName: lotName,
@@ -592,222 +558,3 @@ func normalizeTopLevelQuantity(raw string, serverCount int) int {
func IsCFXMLWorkspace(data []byte) bool {
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
}
// 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

@@ -358,233 +358,3 @@ func TestImportVendorWorkspaceToProject_AutoResolvesAndAppliesEstimate(t *testin
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)
}
}
}

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
-- 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
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
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
ALTER TABLE qt_configurations
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)
ALTER TABLE qt_configurations
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)
-- 1) Create local_configuration_versions
-- 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)
-- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks.

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_configurations
-- recovery.not-started: check first; ADD COLUMN fails if app_version already exists
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN app_version;
-- recovery.completed: no action needed
-- verify: app_version column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='app_version' HAVING COUNT(*)=0
-- Track application version used for configuration writes (create/update via sync)
ALTER TABLE qt_configurations
ADD COLUMN app_version VARCHAR(64) NULL DEFAULT NULL AFTER owner_username;

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_projects, qt_configurations
-- recovery.not-started: check first; CREATE TABLE and ADD COLUMN fail if already exist
-- recovery.partial: ALTER TABLE qt_configurations DROP FOREIGN KEY fk_qt_configurations_project_uuid; ALTER TABLE qt_configurations DROP COLUMN project_uuid; DROP TABLE IF EXISTS qt_projects;
-- recovery.completed: no action needed
-- verify: qt_projects table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='qt_projects' HAVING COUNT(*)=0
-- Add projects and attach configurations to projects
CREATE TABLE qt_projects (

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_pricelist_sync_status
-- recovery.not-started: safe to re-run; CREATE TABLE IF NOT EXISTS
-- recovery.partial: DROP TABLE IF EXISTS qt_pricelist_sync_status;
-- recovery.completed: no action needed
-- verify: qt_pricelist_sync_status table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='qt_pricelist_sync_status' HAVING COUNT(*)=0
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
username VARCHAR(100) NOT NULL,
last_sync_at DATETIME NOT NULL,

View File

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

View File

@@ -1,8 +1,2 @@
-- Tables affected: qt_pricelist_sync_status
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_pricelist_sync_status DROP COLUMN app_version;
-- recovery.completed: no action needed
-- verify: app_version column in qt_pricelist_sync_status missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_pricelist_sync_status' AND column_name='app_version' HAVING COUNT(*)=0
ALTER TABLE qt_pricelist_sync_status
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL;

View File

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

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_pricelists
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_pricelists DROP COLUMN source;
-- recovery.completed: no action needed
-- verify: source column in qt_pricelists missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_pricelists' AND column_name='source' HAVING COUNT(*)=0
ALTER TABLE qt_pricelists
ADD COLUMN IF NOT EXISTS source ENUM('estimate', 'warehouse', 'competitor') NOT NULL DEFAULT 'estimate' AFTER id;

View File

@@ -1,9 +1,3 @@
-- Tables affected: stock_log
-- recovery.not-started: safe to re-run; CREATE TABLE IF NOT EXISTS
-- recovery.partial: DROP TABLE IF EXISTS stock_log;
-- recovery.completed: no action needed
-- verify: stock_log table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='stock_log' HAVING COUNT(*)=0
CREATE TABLE IF NOT EXISTS stock_log (
stock_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
lot VARCHAR(255) NOT NULL,

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN warehouse_pricelist_id, DROP COLUMN competitor_pricelist_id;
-- recovery.completed: no action needed
-- verify: warehouse_pricelist_id column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='warehouse_pricelist_id' HAVING COUNT(*)=0
-- Add per-source pricelist bindings for configurations
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS warehouse_pricelist_id BIGINT UNSIGNED NULL AFTER pricelist_id,

View File

@@ -1,9 +1,3 @@
-- Tables affected: stock_ignore_rules
-- recovery.not-started: safe to re-run; CREATE TABLE IF NOT EXISTS
-- recovery.partial: DROP TABLE IF EXISTS stock_ignore_rules;
-- recovery.completed: no action needed
-- verify: stock_ignore_rules table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='stock_ignore_rules' HAVING COUNT(*)=0
CREATE TABLE IF NOT EXISTS stock_ignore_rules (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
target VARCHAR(20) NOT NULL,

View File

@@ -1,8 +1,2 @@
-- Tables affected: stock_log
-- recovery.not-started: check first; CHANGE COLUMN fails if partnumber already exists
-- recovery.partial: ALTER TABLE stock_log CHANGE COLUMN partnumber lot VARCHAR(255) NOT NULL;
-- recovery.completed: no action needed
-- verify: partnumber column in stock_log missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='stock_log' AND column_name='partnumber' HAVING COUNT(*)=0
ALTER TABLE stock_log
CHANGE COLUMN lot partnumber VARCHAR(255) NOT NULL;

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN only_in_stock;
-- recovery.completed: no action needed
-- verify: only_in_stock column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='only_in_stock' HAVING COUNT(*)=0
-- Add only_in_stock toggle to configuration settings persisted in MariaDB.
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS only_in_stock BOOLEAN NOT NULL DEFAULT FALSE AFTER disable_price_refresh;

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_pricelist_items
-- recovery.not-started: safe to re-run; uses INFORMATION_SCHEMA check before adding index
-- recovery.partial: DROP INDEX IF EXISTS idx_qt_pricelist_items_pricelist_lot ON qt_pricelist_items;
-- recovery.completed: no action needed
-- verify: composite index on qt_pricelist_items missing | SELECT 1 FROM information_schema.STATISTICS WHERE table_schema=DATABASE() AND table_name='qt_pricelist_items' AND index_name='idx_qt_pricelist_items_pricelist_lot' HAVING COUNT(*)=0
-- Ensure fast lookup for /api/quote/price-levels batched queries:
-- SELECT ... FROM qt_pricelist_items WHERE pricelist_id = ? AND lot_name IN (...)
SET @has_idx := (

View File

@@ -1,8 +1,2 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN article;
-- recovery.completed: no action needed
-- verify: article column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='article' HAVING COUNT(*)=0
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count;

View File

@@ -1,8 +1,2 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN server_model;
-- recovery.completed: no action needed
-- verify: server_model column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='server_model' HAVING COUNT(*)=0
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count;

View File

@@ -1,8 +1,2 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN support_code;
-- recovery.completed: no action needed
-- verify: support_code column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='support_code' HAVING COUNT(*)=0
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model;

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_projects
-- recovery.not-started: check first; idempotent backfill but ADD COLUMN fails if code already exists
-- recovery.partial: ALTER TABLE qt_projects DROP INDEX idx_qt_projects_code; ALTER TABLE qt_projects DROP COLUMN code;
-- recovery.completed: no action needed
-- verify: code column in qt_projects missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='code' HAVING COUNT(*)=0
-- Add project code and enforce uniqueness
ALTER TABLE qt_projects

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_projects
-- recovery.not-started: check first; ADD COLUMN fails if variant already exists
-- recovery.partial: DROP INDEX IF EXISTS idx_qt_projects_code_variant ON qt_projects; ALTER TABLE qt_projects DROP COLUMN variant;
-- recovery.completed: no action needed
-- verify: variant column in qt_projects missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='variant' HAVING COUNT(*)=0
-- Add project variant and reset codes from project names
ALTER TABLE qt_projects

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_projects
-- recovery.not-started: safe to re-run; MODIFY COLUMN is idempotent
-- recovery.partial: ALTER TABLE qt_projects MODIFY COLUMN name VARCHAR(200) NOT NULL;
-- recovery.completed: no action needed
-- verify: name column in qt_projects is still NOT NULL | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='name' AND is_nullable='NO' HAVING COUNT(*)>0
-- Allow NULL project names
ALTER TABLE qt_projects

View File

@@ -1,9 +1,3 @@
-- Tables affected: qt_configurations
-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS
-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN line_no;
-- recovery.completed: no action needed
-- verify: line_no column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='line_no' HAVING COUNT(*)=0
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS line_no INT NULL AFTER only_in_stock;

View File

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

View File

@@ -1,38 +0,0 @@
# QuoteForge v1.10
Дата релиза: 2026-06-02
Тег: `v1.10`
## Что нового
### Новые возможности
- **Support Bundle** — кнопка-иконка в шапке рядом с именем пользователя скачивает ZIP-архив с диагностикой: версия приложения, статистика локальной БД, статус подключения с TCP-пингом до сервера, история синхронизаций (`sync_log`), список скачанных прайслистов, системные метрики (память, диск), состояние readiness-блокировки и лог-файл приложения.
- **Автосинхронизация компонентов** — при первом запуске с пустой таблицей компонентов фоновый воркер автоматически скачивает каталог из MariaDB. Новые пользователи сразу видят все вкладки и автокомплит артикулов без ручной синхронизации.
- **Импорт собственного CSV QuoteForge** — файлы, экспортированные из QuoteForge, можно импортировать обратно в конфигурацию.
- **Кнопка «Обновить цены» на странице варианта проекта** — обновление цен доступно прямо со страницы проекта.
- **Импорт BOM Inspur в формате PN×qty** — поддержка формата `артикул × количество` при импорте Inspur-спецификаций.
### Исправления
- **«Не докачано» исчезает когда всё на месте** — исправлена логика `NeedSync`: при наличии подключения всегда сравниваем реальные версии с сервером, не ориентируясь на давность последнего синка. Устраняет ситуацию когда бейдж висел вечно после сетевого сбоя, хотя все прайслисты уже были скачаны.
- **Синхронизация прайслистов через медленное соединение** — увеличен `WriteTimeout` с 30 с до 10 мин. При высокой задержке до сервера (VPN, >300 мс) кнопка «Синхронизировать» больше не зависает без обратной связи.
- **ALTER-спам в логах** — устранены повторяющиеся WARN `ALTER command denied` каждые 5 минут. DDL на `qt_client_schema_state` выполняется не более одного раза за жизнь процесса и только если колонки реально отсутствуют.
- **Галочка «Создать копию»** включена по умолчанию в обоих диалогах клонирования.
- **Сортировка категорий в CSV-экспорте** — исправлена сортировка без учёта регистра и правильный порядок (MB → CPU → MEM → RAID → диски → GPU → NIC → HBA → PSU → ACC).
- **`/api/categories` возвращал `display_order: 0`** для всех категорий — исправлено.
## Для администраторов
При обновлении с v1.9 выполните миграцию на сервере:
```bash
go run ./cmd/qfs -migrate
```
Это добавит колонку `hostname` в `qt_client_schema_state`, после чего клиентские приложения перестанут получать ошибку `ALTER command denied`.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -1,13 +0,0 @@
# QuoteForge v1.7
Дата релиза: 2026-04-23
Тег: `v1.7`
Предыдущий релиз: `v1.6.2`
## Ключевые изменения
- все вкладки estimate (storage, pci, power, accessories, sw, other) теперь используют редактируемый autocomplete-input для существующих позиций — поведение идентично вкладке base;
- LOT-поля в BOM-таблицах переведены на общий autocomplete dropdown вместо datalist;
- кнопка ✕ в BOM снимает сопоставление BOM→LOT вместо удаления строки;
- кнопка «Пересчитать эстимейт» переименована в «Перенести в эстимейт».

View File

@@ -1,13 +0,0 @@
# QuoteForge v1.8
Дата релиза: 2026-04-28
Тег: `v1.8`
Предыдущий релиз: `v1.7`
## Ключевые изменения
- исправлен sync прайслистов при конфликте `local_pricelists.server_id`: сохранение локального снапшота стало idempotent через upsert;
- сохранение нового локального снапшота прайслиста теперь атомарно заменяет строки внутри одной транзакции;
- sync обновляет метаданные уже существующих локальных прайслистов;
- устаревшие sync/export тесты приведены к актуальному контракту, `go test ./...` проходит полностью.

View File

@@ -35,16 +35,6 @@
hx-swap="innerHTML">
</div>
<span id="db-user" class="text-sm text-gray-600"></span>
<a href="/api/support-bundle"
title="Скачать Support Bundle"
class="text-gray-400 hover:text-gray-600 transition-colors"
download>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</a>
</div>
</div>
</div>
@@ -60,43 +50,6 @@
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
<!-- Dead-man's switch overlay: shown when backend process stops responding -->
<div id="backend-offline-overlay" class="hidden fixed inset-0 z-[9999] bg-black/80 flex items-center justify-center p-6">
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-8 text-center">
<div class="text-red-500 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<h2 class="text-xl font-bold text-gray-900 mb-3">Приложение остановлено</h2>
<p class="text-gray-600 mb-4">Консольное окно QuoteForge было закрыто — без него программа не работает.</p>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6 text-sm text-amber-800">
Запустите программу заново и нажмите «Обновить страницу».
</div>
<button onclick="window.location.reload()" class="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
Обновить страницу
</button>
</div>
</div>
<!-- Price Diff Modal -->
<div id="price-diff-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col">
<div class="p-5 border-b border-gray-200 flex-shrink-0 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">Изменение цен</h3>
<button onclick="closePriceDiffModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="price-diff-modal-body" class="overflow-y-auto flex-1 p-5 space-y-5"></div>
<div class="p-4 border-t border-gray-200 flex-shrink-0 flex justify-end">
<button onclick="closePriceDiffModal()" class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 text-sm">Закрыть</button>
</div>
</div>
</div>
<!-- Sync Info Modal -->
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
@@ -535,155 +488,11 @@
// }
// }
// ==================== SHARED PRICE REFRESH UTILITIES ====================
async function fetchLatestEstimatePricelistId() {
try {
const resp = await fetch('/api/pricelists?active_only=true&source=estimate&per_page=1');
if (!resp.ok) return null;
const data = await resp.json();
const list = data.pricelists || data.items || data;
if (Array.isArray(list) && list.length > 0) return Number(list[0].id);
} catch(_) {}
return null;
}
function _fmtMoneyDiff(value) {
if (!Number.isFinite(Number(value))) return 'N/A';
return '$ ' + Math.round(Number(value)).toLocaleString('ru-RU');
}
function _fmtArrow(prev, next) {
const diff = next - prev;
if (Math.abs(diff) < 0.5) return '';
const pct = prev > 0 ? Math.round((diff / prev) * 100) : 0;
const sign = diff > 0 ? '+' : '';
const color = diff > 0 ? 'text-red-600' : 'text-green-600';
return ` <span class="${color} text-xs font-medium">(${sign}${pct}%)</span>`;
}
function _buildDiffRow(lot, qty, prev, next) {
const prevLine = prev * qty;
const nextLine = next * qty;
const delta = next - prev;
const arrowColor = delta > 0 ? 'text-red-600' : 'text-green-600';
return `<tr class="border-b border-gray-100 last:border-0">
<td class="py-1.5 pr-3 text-sm text-gray-700 font-mono">${lot}</td>
<td class="py-1.5 px-2 text-sm text-right text-gray-500">${qty}</td>
<td class="py-1.5 px-2 text-sm text-right whitespace-nowrap">
<span class="text-gray-400 line-through text-xs">${_fmtMoneyDiff(prev)}</span>
<span class="${arrowColor} font-medium ml-1">${_fmtMoneyDiff(next)}</span>
</td>
<td class="py-1.5 pl-2 text-sm text-right whitespace-nowrap">
<span class="text-gray-400 line-through text-xs">${_fmtMoneyDiff(prevLine)}</span>
<span class="${arrowColor} font-medium ml-1">${_fmtMoneyDiff(nextLine)}</span>
</td>
</tr>`;
}
function showPriceDiffModal(results) {
const body = document.getElementById('price-diff-modal-body');
if (!body) return;
const sections = results.filter(r => !r.skipped);
if (sections.length === 0) {
body.innerHTML = '<p class="text-gray-500 text-sm text-center py-4">Обновление цен отключено для всех конфигураций</p>';
document.getElementById('price-diff-modal').classList.remove('hidden');
return;
}
let html = '';
let anyChanges = false;
for (const r of sections) {
if (r.error) {
html += `<div class="text-sm text-red-600 bg-red-50 rounded px-3 py-2">${r.configName ? `<span class="font-medium">${r.configName}:</span> ` : ''}Ошибка обновления цен</div>`;
continue;
}
const diffs = (r.itemDiffs || []).filter(d => Math.abs(d.prevPrice - d.newPrice) > 0.01);
const totalDelta = (r.newTotal || 0) - (r.prevTotal || 0);
if (results.length > 1) {
html += `<div class="text-sm font-semibold text-gray-800 mb-1">${r.configName || '—'}</div>`;
}
if (diffs.length === 0) {
html += `<div class="text-sm text-gray-500 bg-gray-50 rounded px-3 py-2 mb-2">Изменений нет</div>`;
} else {
anyChanges = true;
html += `<div class="overflow-x-auto mb-2">
<table class="w-full text-left">
<thead>
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase">
<th class="pb-1 pr-3 font-medium">Компонент</th>
<th class="pb-1 px-2 text-right font-medium">Кол.</th>
<th class="pb-1 px-2 text-right font-medium">Цена / шт.</th>
<th class="pb-1 pl-2 text-right font-medium">Сумма</th>
</tr>
</thead>
<tbody>${diffs.map(d => _buildDiffRow(d.lot_name, d.quantity, d.prevPrice, d.newPrice)).join('')}</tbody>
</table>
</div>`;
}
const totalColor = totalDelta > 0 ? 'text-red-600' : totalDelta < 0 ? 'text-green-600' : 'text-gray-600';
const totalArrow = _fmtArrow(r.prevTotal || 0, r.newTotal || 0);
html += `<div class="flex justify-between items-center text-sm bg-gray-50 rounded px-3 py-2 mb-1">
<span class="text-gray-600 font-medium">Итог конфигурации</span>
<span>
<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span>
<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
</span>
</div>`;
}
if (!anyChanges && sections.every(r => !r.error)) {
html = '<div class="text-sm text-gray-500 text-center py-6">Цены актуальны — изменений нет</div>' + html;
}
body.innerHTML = html;
document.getElementById('price-diff-modal').classList.remove('hidden');
}
function closePriceDiffModal() {
document.getElementById('price-diff-modal')?.classList.add('hidden');
}
// Call functions immediately to ensure they run even before DOMContentLoaded
// This ensures username and admin link are visible ASAP
loadDBUser();
checkWritePermission();
// Dead-man's switch: detect if the backend process has stopped
(function() {
const POLL_MS = 5000;
const FAIL_THRESHOLD = 2;
let failCount = 0;
async function checkBackend() {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 3000);
const resp = await fetch('/health', { signal: ctrl.signal });
clearTimeout(timer);
if (resp.ok) {
failCount = 0;
document.getElementById('backend-offline-overlay').classList.add('hidden');
} else {
failCount++;
}
} catch (_) {
failCount++;
}
if (failCount >= FAIL_THRESHOLD) {
document.getElementById('backend-offline-overlay').classList.remove('hidden');
}
}
setInterval(checkBackend, POLL_MS);
})();
// Load last sync time - removed since dropdown is gone
// document.addEventListener('DOMContentLoaded', loadLastSyncTime);
</script>

View File

@@ -53,19 +53,6 @@
<h2 class="text-xl font-semibold mb-4">Новая конфигурация</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label>
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
<button type="button" id="type-server-btn" onclick="setCreateType('server')"
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
Сервер
</button>
<button type="button" id="type-storage-btn" onclick="setCreateType('storage')"
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
СХД
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
<input type="text" id="opportunity-number" placeholder="Например: Сервер для проекта X"
@@ -247,7 +234,7 @@ function renderConfigs(configs) {
configs.forEach(c => {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('ru-RU', {minimumFractionDigits: 2}) : '—';
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1;
const author = c.owner_username || (c.user && c.user.username) || '—';
const projectName = c.project_uuid && projectNameByUUID[c.project_uuid]
@@ -258,7 +245,7 @@ function renderConfigs(configs) {
let pricePerUnit = '—';
if (c.total_price && serverCount > 0) {
const unitPrice = c.total_price / serverCount;
pricePerUnit = '$' + unitPrice.toLocaleString('ru-RU', {minimumFractionDigits: 2});
pricePerUnit = '$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2});
}
html += '<tr class="hover:bg-gray-50">';
@@ -531,19 +518,7 @@ async function cloneConfig() {
}
}
let createConfigType = 'server';
function setCreateType(type) {
createConfigType = type;
document.getElementById('type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' +
(type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200');
document.getElementById('type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' +
(type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
}
function openCreateModal() {
createConfigType = 'server';
setCreateType('server');
document.getElementById('opportunity-number').value = '';
document.getElementById('create-project-input').value = '';
document.getElementById('create-modal').classList.remove('hidden');
@@ -598,8 +573,7 @@ async function createConfigWithProject(name, projectUUID) {
items: [],
notes: '',
server_count: 1,
project_uuid: projectUUID || null,
config_type: createConfigType
project_uuid: projectUUID || null
})
});

View File

@@ -108,10 +108,6 @@
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Accessories
</button>
<button onclick="switchTab('sw')" data-tab="sw"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
SW
</button>
<button onclick="switchTab('other')" data-tab="other"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Other
@@ -194,7 +190,7 @@
Сохранить BOM
</button>
<button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700">
Перенести в эстимейт
Пересчитать эстимейт
</button>
</div>
</div>
@@ -369,7 +365,7 @@
// Tab configuration - will be populated dynamically
let TAB_CONFIG = {
base: {
categories: ['MB', 'CPU', 'MEM', 'ENC', 'DKC', 'CTL'],
categories: ['MB', 'CPU', 'MEM'],
singleSelect: true,
label: 'Base'
},
@@ -383,14 +379,13 @@ let TAB_CONFIG = {
]
},
pci: {
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'],
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA'],
singleSelect: false,
label: 'PCI',
sections: [
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
{ title: 'HBA', categories: ['HBA'] },
{ title: 'HIC', categories: ['HIC'] }
{ title: 'HBA', categories: ['HBA'] }
]
},
power: {
@@ -403,11 +398,6 @@ let TAB_CONFIG = {
singleSelect: false,
label: 'Accessories'
},
sw: {
categories: ['SW'],
singleSelect: false,
label: 'SW'
},
other: {
categories: [],
singleSelect: false,
@@ -421,7 +411,6 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
// State
let configUUID = '{{.ConfigUUID}}';
let configType = 'server';
let configName = '';
let currentVersionNo = 1;
let projectUUID = '';
@@ -542,8 +531,7 @@ let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks o
// Autocomplete state
let autocompleteInput = null;
let autocompleteCategory = null;
let autocompleteMode = null; // 'single', 'multi', 'section', 'edit-item'
let autocompleteEditCategories = null;
let autocompleteMode = null; // 'single', 'multi', 'section'
let autocompleteIndex = -1;
let autocompleteFiltered = [];
@@ -805,8 +793,6 @@ document.addEventListener('DOMContentLoaded', async function() {
const config = await resp.json();
configName = config.name;
configType = config.config_type || 'server';
applyConfigTypeToTabs();
currentVersionNo = config.current_version_no || 1;
projectUUID = config.project_uuid || '';
await loadProjectIndex();
@@ -838,7 +824,6 @@ document.addEventListener('DOMContentLoaded', async function() {
serverModelForQuote = config.server_model || '';
supportCode = config.support_code || '';
currentArticle = config.article || '';
restorePricingStateFromNotes(config.notes || '');
// Restore custom price if saved
if (config.custom_price) {
@@ -889,7 +874,7 @@ async function loadAllComponents() {
try {
const resp = await fetch('/api/components?per_page=5000');
const data = await resp.json();
allComponents = data.items || [];
allComponents = data.components || [];
window._bomAllComponents = allComponents;
} catch(e) {
console.error('Failed to load components', e);
@@ -940,7 +925,7 @@ async function loadActivePricelists(force = false) {
try {
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
const data = await resp.json();
activePricelistsBySource[source] = data.items || [];
activePricelistsBySource[source] = data.pricelists || [];
// Do not reset the stored pricelist — it may be inactive but must be preserved
} catch (e) {
activePricelistsBySource[source] = [];
@@ -1154,87 +1139,6 @@ function switchTab(tab) {
renderTab();
}
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
// Storage-only categories — hidden for server configs
const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC'];
// Server-only categories — hidden for storage configs
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID'];
const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU'];
const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU'];
function applyConfigTypeToTabs() {
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
const storageSections = [
{ title: 'RAID Контроллеры', categories: ['RAID'] },
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
];
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
const pciSections = [
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
{ title: 'HBA', categories: ['HBA'] },
{ title: 'HIC', categories: ['HIC'] }
];
const powerCategories = ['PS', 'PSU'];
TAB_CONFIG.base.categories = baseCategories.filter(c => {
if (configType === 'storage') {
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
}
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
});
TAB_CONFIG.storage.categories = storageCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
});
TAB_CONFIG.storage.sections = storageSections.filter(section => {
if (configType === 'storage') {
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
}
return true;
});
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC';
});
TAB_CONFIG.pci.sections = pciSections.filter(section => {
if (configType === 'storage') {
return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat));
}
return section.title !== 'HIC';
});
TAB_CONFIG.power.categories = powerCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true;
});
// Rebuild assigned categories index
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
}
function updateTabVisibility() {
for (const tabId of Object.keys(TAB_CONFIG)) {
if (ALWAYS_VISIBLE_TABS.has(tabId)) continue;
const btn = document.querySelector(`[data-tab="${tabId}"]`);
if (!btn) continue;
const hasComponents = getComponentsForTab(tabId).length > 0;
const hasCartItems = cart.some(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase();
return getTabForCategory(cat) === tabId;
});
const visible = hasComponents || hasCartItems;
btn.classList.toggle('hidden', !visible);
// If the current tab just got hidden, fall back to base
if (!visible && currentTab === tabId) {
switchTab('base');
}
}
}
function getComponentsForTab(tab) {
const config = TAB_CONFIG[tab];
return allComponents.filter(comp => {
@@ -1270,7 +1174,7 @@ function renderSingleSelectTab(categories) {
if (currentTab === 'base') {
html += `
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель системы для партномера:</label>
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label>
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
</div>
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
@@ -1390,16 +1294,7 @@ function renderMultiSelectTab(components) {
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 min-w-48">
<div class="autocomplete-wrapper relative">
<input type="text"
value="${escapeHtml(item.lot_name)}"
class="w-full px-2 py-1 border rounded text-sm font-mono"
onfocus="showAutocompleteEditItem('${item.lot_name}', this)"
oninput="filterAutocompleteEditItem(this.value)"
onkeydown="handleAutocompleteKeyEditItem(event)">
</div>
</td>
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center">
@@ -1493,10 +1388,6 @@ function renderMultiSelectTabWithSections(sections) {
<tbody class="divide-y">
`;
// Add empty row for new item in this section
const sectionId = section.categories.join('-');
const categoriesStr = section.categories.join(',');
// Render existing cart items for this section
sectionItems.forEach((item) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name);
@@ -1504,17 +1395,7 @@ function renderMultiSelectTabWithSections(sections) {
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 min-w-48">
<div class="autocomplete-wrapper relative">
<input type="text"
value="${escapeHtml(item.lot_name)}"
data-categories="${categoriesStr}"
class="w-full px-2 py-1 border rounded text-sm font-mono"
onfocus="showAutocompleteEditItem('${item.lot_name}', this)"
oninput="filterAutocompleteEditItem(this.value)"
onkeydown="handleAutocompleteKeyEditItem(event)">
</div>
</td>
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center">
@@ -1535,6 +1416,8 @@ function renderMultiSelectTabWithSections(sections) {
});
// Add empty row for new item in this section
const sectionId = section.categories.join('-');
const categoriesStr = section.categories.join(',');
html += `
<tr class="hover:bg-gray-50 bg-gray-50">
<td class="px-3 py-2" colspan="2">
@@ -1662,10 +1545,6 @@ function renderAutocomplete() {
onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`;
} else if (autocompleteMode === 'multi') {
onmousedown = `selectAutocompleteItemMulti(${idx})`;
} else if (autocompleteMode === 'bom') {
onmousedown = `selectAutocompleteItemBOM(${idx}, ${autocompleteCategory})`;
} else if (autocompleteMode === 'edit-item') {
onmousedown = `selectAutocompleteEditItem(${idx})`;
} else {
// single mode
onmousedown = `selectAutocompleteItem(${idx})`;
@@ -1947,138 +1826,6 @@ function selectAutocompleteItemSection(index, sectionId) {
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
// Autocomplete for editing an existing cart item's LOT (multi/section tabs)
async function showAutocompleteEditItem(lotName, input) {
autocompleteInput = input;
autocompleteCategory = lotName;
autocompleteMode = 'edit-item';
autocompleteIndex = -1;
autocompleteEditCategories = input.dataset.categories
? input.dataset.categories.split(',').map(c => c.trim().toUpperCase())
: null;
const components = getComponentsForTab(currentTab);
await ensurePricesLoaded(components);
filterAutocompleteEditItem(input.value);
}
function filterAutocompleteEditItem(search) {
const searchLower = (search || '').toLowerCase();
const components = autocompleteEditCategories
? allComponents.filter(c => autocompleteEditCategories.includes(getComponentCategory(c)))
: getComponentsForTab(currentTab);
autocompleteFiltered = components.filter(c => {
if (!hasComponentPrice(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
return (c.lot_name + ' ' + (c.description || '')).toLowerCase().includes(searchLower);
}).sort((a, b) => {
const d = (b.popularity_score || 0) - (a.popularity_score || 0);
return d !== 0 ? d : a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeyEditItem(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteEditItem(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteEditItem(index) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const lotName = autocompleteCategory;
const oldItem = cart.find(i => i.lot_name === lotName);
const qty = oldItem?.quantity || 1;
cart = cart.filter(i => i.lot_name !== lotName);
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
// Autocomplete for BOM LOT mapping
function showAutocompleteBOM(rowIdx, input) {
autocompleteInput = input;
autocompleteCategory = rowIdx;
autocompleteMode = 'bom';
autocompleteIndex = -1;
filterAutocompleteBOM(rowIdx, input.value);
}
function filterAutocompleteBOM(rowIdx, search) {
const searchLower = (search || '').toLowerCase();
autocompleteFiltered = (window._bomAllComponents || allComponents).filter(c => {
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
}).sort((a, b) => {
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeyBOM(event, rowIdx) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemBOM(autocompleteIndex, rowIdx);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItemBOM(index, rowIdx) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
if (!row) return;
row.manual_lot = comp.lot_name;
hideAutocomplete();
resolveBOM();
}
function clearSingleSelect(category) {
cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
@@ -2123,14 +1870,12 @@ function updateMultiQuantity(lotName, value) {
function removeFromCart(lotName) {
cart = cart.filter(i => i.lot_name !== lotName);
updateTabVisibility();
renderTab();
updateCartUI();
triggerAutoSave();
}
function updateCartUI() {
updateTabVisibility();
window._currentCart = cart; // expose for BOM/Pricing tabs
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
document.getElementById('cart-total').textContent = formatMoney(total);
@@ -2280,58 +2025,6 @@ function getCurrentArticle() {
return currentArticle || '';
}
function buildPricingState() {
const buyCustom = parseDecimalInput(document.getElementById('pricing-custom-price-buy')?.value || '');
const saleUplift = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
const saleCustom = parseDecimalInput(document.getElementById('pricing-custom-price-sale')?.value || '');
return {
buy_custom_price: buyCustom > 0 ? buyCustom : null,
sale_uplift: saleUplift > 0 ? saleUplift : null,
sale_custom_price: saleCustom > 0 ? saleCustom : null,
};
}
function serializeConfigNotes() {
return JSON.stringify({
pricing_ui: buildPricingState()
});
}
function restorePricingStateFromNotes(notesRaw) {
if (!notesRaw) return;
let parsed;
try {
parsed = JSON.parse(notesRaw);
} catch (_) {
return;
}
const pricing = parsed?.pricing_ui;
if (!pricing || typeof pricing !== 'object') return;
const buyInput = document.getElementById('pricing-custom-price-buy');
if (buyInput) {
buyInput.value = typeof pricing.buy_custom_price === 'number' && pricing.buy_custom_price > 0
? pricing.buy_custom_price.toFixed(2)
: '';
}
const upliftInput = document.getElementById('pricing-uplift-sale');
if (upliftInput) {
upliftInput.value = typeof pricing.sale_uplift === 'number' && pricing.sale_uplift > 0
? formatUpliftInput(pricing.sale_uplift)
: '';
}
const saleInput = document.getElementById('pricing-custom-price-sale');
if (saleInput) {
saleInput.value = typeof pricing.sale_custom_price === 'number' && pricing.sale_custom_price > 0
? pricing.sale_custom_price.toFixed(2)
: '';
}
}
function getAutosaveStorageKey() {
return `qf_config_autosave_${configUUID || 'default'}`;
}
@@ -2345,7 +2038,7 @@ function buildSavePayload() {
name: configName,
items: cart,
custom_price: customPrice,
notes: serializeConfigNotes(),
notes: '',
server_count: serverCount,
server_model: serverModelForQuote,
support_code: supportCode,
@@ -2584,7 +2277,7 @@ function formatDiffPercent(baseTotal, compareTotal, compareLabel) {
}
const pct = ((baseTotal - compareTotal) / compareTotal) * 100;
const sign = pct > 0 ? '+' : '';
return `${sign}${pct.toFixed(1).replace('.', ',')}% от ${compareLabel}`;
return `${sign}${pct.toFixed(1)}% от ${compareLabel}`;
}
function getTotalClass(current, references) {
@@ -2709,7 +2402,7 @@ function calculateCustomPrice() {
// Show discount info
discountInfoEl.classList.remove('hidden');
discountPercentEl.textContent = discountPercent.toFixed(1).replace('.', ',') + '%';
discountPercentEl.textContent = discountPercent.toFixed(1) + '%';
// Update discount color based on value
const discountEl = discountPercentEl;
@@ -2817,82 +2510,73 @@ async function exportCSVWithCustomPrice() {
}
async function refreshPrices() {
// RBAC disabled - no token check required
if (!configUUID) return;
if (disablePriceRefresh) {
showToast('Обновление цен отключено в настройках', 'error');
return;
}
const refreshBtn = document.getElementById('refresh-prices-btn');
const previousLabel = refreshBtn ? refreshBtn.textContent : '';
try {
if (refreshBtn) {
refreshBtn.disabled = true;
refreshBtn.textContent = 'Обновление...';
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
const refreshPayload = {};
if (selectedPricelistIds.estimate) {
refreshPayload.pricelist_id = selectedPricelistIds.estimate;
}
await loadActivePricelists(true);
['estimate', 'warehouse', 'competitor'].forEach(source => {
const latest = activePricelistsBySource[source]?.[0];
if (latest && latest.id) {
selectedPricelistIds[source] = Number(latest.id);
resolvedAutoPricelistIds[source] = null;
}
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(refreshPayload)
});
syncPriceSettingsControls();
renderPricelistSettingsSummary();
persistLocalPriceSettings();
// Snapshot prices before refresh for diff
const beforePricesMap = {};
let beforeTotal = 0;
for (const item of cart) {
const p = getDisplayPrice(item);
beforePricesMap[item.lot_name] = { price: p, qty: item.quantity };
beforeTotal += p * item.quantity;
if (!resp.ok) {
showToast('Ошибка обновления цен', 'error');
return;
}
beforeTotal *= serverCount;
await saveConfig(false);
const config = await resp.json();
// Update cart with new prices
if (config.items && config.items.length > 0) {
cart = config.items.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity,
unit_price: item.unit_price,
estimate_price: item.unit_price,
warehouse_price: null,
competitor_price: null,
description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name)
}));
}
// Update price update date
if (config.price_updated_at) {
updatePriceUpdateDate(config.price_updated_at);
}
if (config.pricelist_id) {
if (selectedPricelistIds.estimate) {
selectedPricelistIds.estimate = config.pricelist_id;
} else {
resolvedAutoPricelistIds.estimate = Number(config.pricelist_id);
}
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
await loadActivePricelists();
}
syncPriceSettingsControls();
renderPricelistSettingsSummary();
if (selectedPricelistIds.estimate) {
persistLocalPriceSettings();
}
}
// Re-render UI
await refreshPriceLevels({ force: true, noCache: true });
renderTab();
updateCartUI();
// Compute diff after refresh
const itemDiffs = [];
let afterTotal = 0;
for (const item of cart) {
const newPrice = getDisplayPrice(item);
afterTotal += newPrice * item.quantity;
const before = beforePricesMap[item.lot_name];
if (before && Math.abs(before.price - newPrice) > 0.01) {
itemDiffs.push({ lot_name: item.lot_name, quantity: item.quantity, prevPrice: before.price, newPrice });
}
}
afterTotal *= serverCount;
if (configUUID) {
const configResp = await fetch('/api/configs/' + configUUID);
if (configResp.ok) {
const config = await configResp.json();
if (config.price_updated_at) updatePriceUpdateDate(config.price_updated_at);
}
}
showToast('Цены обновлены', 'success');
showPriceDiffModal([{ configName: configName || 'Конфигурация', prevTotal: beforeTotal, newTotal: afterTotal, serverCount, itemDiffs }]);
} catch(e) {
showToast('Ошибка обновления цен', 'error');
} finally {
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.textContent = previousLabel || 'Обновить цены';
updateRefreshPricesButtonState();
}
}
}
@@ -3054,62 +2738,11 @@ function _normalizeBomRawRows(rows) {
});
}
function _parseInspurBOMText(text) {
const lines = text.split(/\r?\n/);
const result = [];
for (const raw of lines) {
const line = raw.trim();
if (!line) continue;
const clean = line.startsWith('|') ? line.slice(1).trim() : line;
if (!clean) continue;
const starIdx = clean.lastIndexOf('*');
if (starIdx > 0) {
const suffix = clean.slice(starIdx + 1).trim();
if (/^\d+$/.test(suffix)) {
result.push([clean.slice(0, starIdx).trim(), suffix]);
continue;
}
}
result.push([clean, '1']);
}
return result;
}
function _isInspurBOMText(text) {
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
if (!lines.length) return false;
let matches = 0;
for (const line of lines) {
const t = line.trim();
const idx = t.lastIndexOf('*');
if (idx > 0 && /^\d+$/.test(t.slice(idx + 1).trim())) matches++;
}
return matches > 0 && matches >= Math.ceil(lines.length * 0.5);
}
function handleBOMPaste(event) {
event.preventDefault();
const text = event.clipboardData.getData('text/plain');
if (!text || !text.trim()) return;
if (_isInspurBOMText(text)) {
const parsed = _parseInspurBOMText(text);
if (!parsed.length) return;
bomImportRaw = {
mode: 'raw',
rows: parsed,
columnTypes: ['pn', 'qty'],
ignoredRows: {},
rowErrors: {},
uiError: ''
};
bomRows = [];
_setBomUIError('');
rebuildBOMRowsFromRaw();
renderBOMTable();
return;
}
const lines = text.split(/\r?\n/).filter(l => l.length > 0);
if (!lines.length) return;
const rows = lines.map(l => l.split('\t').map(c => c.trim()));
@@ -3185,18 +2818,6 @@ function deleteBOMRawRow(rowIdx) {
rebuildBOMRowsFromRaw();
}
function clearBOMLotMapping(rowIdx) {
const row = bomRows.find(r => r.source_row_index === rowIdx);
if (!row) return;
row.manual_lot = '';
row.resolved_lot = '';
row.resolution_source = 'unresolved';
row.lot_allocations = [];
row.bundle_enabled = false;
renderBOMTable();
debouncedResolveBOM();
}
function _bomRawLotCell(rowIdx) {
if (!bomImportRaw || bomImportRaw.mode !== 'raw') return '—';
if (bomImportRaw.ignoredRows?.[rowIdx]) return '<span class="text-gray-400">—</span>';
@@ -3218,12 +2839,13 @@ function _bomRawLotCell(rowIdx) {
if (isUnresolved) {
const val = map.manual_lot || '';
return `<div class="autocomplete-wrapper relative"><input type="text" placeholder="Введите артикул..."
value="${escapeHtml(val)}"
class="w-full min-w-28 px-2 py-1 border rounded text-xs font-mono"
onfocus="showAutocompleteBOM(${rowIdx}, this)"
oninput="filterAutocompleteBOM(${rowIdx}, this.value); setBOMManualLotDraft(${rowIdx}, this.value, this)"
onkeydown="handleAutocompleteKeyBOM(event, ${rowIdx})"></div>
const invalid = val && !_bomLotValid(val);
return `<input type="text" placeholder="LOT..." value="${escapeHtml(val)}"
class="w-full min-w-28 px-2 py-1 border rounded text-xs ${invalid ? 'border-red-400 bg-red-50' : ''}"
list="lot-autocomplete-list"
oninput="setBOMManualLotDraft(${rowIdx}, this.value, this)"
onchange="commitBOMManualLot(${rowIdx}, this)"
onblur="commitBOMManualLot(${rowIdx}, this)">
${renderBOMLotAllocationsEditor(rowIdx)}`;
}
let suffix = '';
@@ -3607,12 +3229,12 @@ function _renderBOMParsedTable() {
let lotCell = '';
if (isUnresolved) {
lotCell = `<div class="autocomplete-wrapper relative"><input type="text" placeholder="Введите артикул..."
value="${escapeHtml(row.manual_lot || '')}"
class="w-full px-2 py-1 border rounded text-sm font-mono focus:ring-1 focus:ring-blue-400"
onfocus="showAutocompleteBOM(${row.source_row_index}, this)"
oninput="filterAutocompleteBOM(${row.source_row_index}, this.value); bomRows.find(r=>r.source_row_index===${row.source_row_index}).manual_lot=this.value;"
onkeydown="handleAutocompleteKeyBOM(event, ${row.source_row_index})"></div>${renderBOMLotAllocationsEditor(idx)}`;
lotCell = `<input type="text" placeholder="Введите LOT..." value="${escapeHtml(row.manual_lot || '')}"
class="w-full px-2 py-1 border rounded text-sm focus:ring-1 focus:ring-blue-400"
oninput="bomRows[${idx}].manual_lot = this.value; this.classList.toggle('border-red-400', this.value && !_bomLotValid(this.value));"
onchange="if(_bomLotValid(this.value)){bomRows[${idx}].manual_lot=this.value;resolveBOM(); this.classList.remove('border-red-400');}else{this.value=bomRows[${idx}].manual_lot||'';}"
onblur="if(this.value && !_bomLotValid(this.value)){this.value=bomRows[${idx}].manual_lot||'';}"
list="lot-autocomplete-list">${renderBOMLotAllocationsEditor(idx)}`;
} else {
let suffix = '';
if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`;
@@ -3702,7 +3324,7 @@ function _renderBOMRawTable() {
<td class="w-12 px-1 py-1 border-b text-center align-top whitespace-nowrap">
<button type="button" title="Добавить LOT в bundle" onclick="addBOMAllocation(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-blue-600">+</button>
<button type="button" title="${ignored ? 'Не игнорировать' : 'Игнорировать'}" onclick="toggleBOMRawRowIgnored(${rowIdx})" class="inline-block text-xs px-1 ${ignored ? 'text-blue-600' : 'text-gray-400 hover:text-gray-700'}">${ignored ? '◉' : '○'}</button>
<button type="button" title="Снять сопоставление" onclick="clearBOMLotMapping(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button>
<button type="button" title="Удалить строку" onclick="deleteBOMRawRow(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button>
</td>`;
tbody.appendChild(tr);
});
@@ -4272,7 +3894,7 @@ function applyCustomPrice(table) {
if (est <= 0) return '';
const pct = ((est - custom) / est * 100);
const sign = pct >= 0 ? '-' : '+';
return ` (${sign}${Math.abs(pct).toFixed(1).replace('.', ',')}%)`;
return ` (${sign}${Math.abs(pct).toFixed(1)}%)`;
};
const _pctClass = (custom, est) => custom <= est ? 'text-green-600' : 'text-red-600';
@@ -4326,17 +3948,14 @@ function applyCustomPrice(table) {
function onBuyCustomPriceInput() {
applyCustomPrice('buy');
triggerAutoSave();
}
function onSaleCustomPriceInput() {
applyCustomPrice('sale');
triggerAutoSave();
}
function onSaleMarkupInput() {
renderPricingTab();
triggerAutoSave();
}
function setPricingCustomPriceFromVendor() {
@@ -4365,7 +3984,7 @@ function setPricingCustomPriceFromVendor() {
const totalEl = document.getElementById('pricing-total-buy-vendor');
if (hasAny) {
document.getElementById('pricing-custom-price-buy').value = total.toFixed(2);
const pct = estimateTotal > 0 ? ` (-${((estimateTotal - total) / estimateTotal * 100).toFixed(1).replace('.', ',')}%)` : '';
const pct = estimateTotal > 0 ? ` (-${((estimateTotal - total) / estimateTotal * 100).toFixed(1)}%)` : '';
totalEl.textContent = formatCurrency(total) + pct;
totalEl.className = totalEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
totalEl.classList.add(total <= estimateTotal ? 'text-green-600' : 'text-red-600');
@@ -4375,33 +3994,72 @@ function setPricingCustomPriceFromVendor() {
}
}
async function exportPricingCSV(table) {
if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; }
const basis = table === 'sale' ? 'ddp' : 'fob';
try {
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
include_lot: true,
include_bom: true,
include_estimate: true,
include_stock: true,
include_competitor: true,
basis: basis,
}),
});
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = getFilenameFromResponse(resp) || `${configName || 'config'} SPEC-${basis.toUpperCase()}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch(e) {
showToast('Ошибка экспорта', 'error');
}
function exportPricingCSV(table) {
const bodyId = table === 'sale' ? 'pricing-body-sale' : 'pricing-body-buy';
const rowClass = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy';
const totalIds = table === 'sale'
? { est: 'pricing-total-sale-estimate', wh: 'pricing-total-sale-warehouse', comp: 'pricing-total-sale-competitor', vendor: 'pricing-total-sale-vendor' }
: { est: 'pricing-total-buy-estimate', wh: 'pricing-total-buy-warehouse', comp: 'pricing-total-buy-competitor', vendor: 'pricing-total-buy-vendor' };
const rows = document.querySelectorAll(`#${bodyId} tr.${rowClass}`);
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
const csvDelimiter = ';';
const cleanExportCell = value => {
const text = String(value || '').replace(/\s+/g, ' ').trim();
if (!text || text === '—') return text || '';
return text
.replace(/\s*\(.*\)$/, '')
.replace(/\s*\*+\s*$/, '')
.trim();
};
const csvEscape = v => {
if (v == null) return '';
const s = String(v).replace(/"/g, '""');
return /[;"\n\r]/.test(s) ? `"${s}"` : s;
};
const headers = ['PN вендора', 'Описание', 'LOT', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
const lines = [headers.map(csvEscape).join(csvDelimiter)];
rows.forEach(tr => {
// PN вендора, Описание, LOT are stored in dataset to handle rowspan correctly
const pn = cleanExportCell(tr.dataset.vendorPn || '');
const desc = cleanExportCell(tr.dataset.desc || '');
const lot = cleanExportCell(tr.dataset.lot || '');
// Qty..Ручная цена: cells at offset 2 for group-start rows, offset 0 for sub-rows
const isGroupStart = tr.dataset.groupStart === 'true';
const cells = tr.querySelectorAll('td');
const o = isGroupStart ? 2 : 0;
const cols = [pn, desc, lot,
cleanExportCell(cells[o]?.textContent),
cleanExportCell(cells[o+1]?.textContent),
cleanExportCell(cells[o+2]?.textContent),
cleanExportCell(cells[o+3]?.textContent),
cleanExportCell(cells[o+4]?.textContent),
];
lines.push(cols.map(csvEscape).join(csvDelimiter));
});
// Totals row
const tEst = cleanExportCell(document.getElementById(totalIds.est)?.textContent);
const tWh = cleanExportCell(document.getElementById(totalIds.wh)?.textContent);
const tComp = cleanExportCell(document.getElementById(totalIds.comp)?.textContent);
const tVendor = cleanExportCell(document.getElementById(totalIds.vendor)?.textContent);
lines.push(['', '', '', 'Итого:', tEst, tWh, tComp, tVendor].map(csvEscape).join(csvDelimiter));
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const today = new Date();
const datePart = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
const codePart = (projectCode || 'NO-PROJECT').trim();
const namePart = (configName || 'config').trim();
const suffix = table === 'sale' ? 'SALE' : 'BUY';
a.download = `${datePart} (${codePart}) ${namePart} SPEC-${suffix}.csv`;
a.click();
URL.revokeObjectURL(url);
}
function escapeHtml(str) {

View File

@@ -127,7 +127,7 @@ async function loadBooks() {
return;
}
allBooks = data.items || [];
allBooks = data.books || [];
document.getElementById('books-list-loading').classList.add('hidden');
if (!allBooks.length) {
@@ -213,7 +213,7 @@ async function loadActiveBookItemsPage(page = 1, search = '', book = null) {
activeItems = data.items || [];
itemsPage = data.page || page;
itemsTotal = Number(data.total_count || 0);
itemsTotal = Number(data.total || 0);
itemsSearch = data.search || search || '';
document.getElementById('card-version').textContent = targetBook.version;

View File

@@ -137,7 +137,7 @@
toggleWarehouseColumns();
renderItems(data.items || []);
renderItemsPagination(data.total_count, data.page, data.per_page);
renderItemsPagination(data.total, data.page, data.per_page);
} catch (e) {
document.getElementById('items-body').innerHTML = `
<tr>
@@ -243,7 +243,7 @@
const descMax = stock ? 30 : 60;
const html = items.map(item => {
const price = item.price.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const description = item.lot_description || '-';
const truncatedDesc = description.length > descMax ? description.substring(0, descMax) + '...' : description;

View File

@@ -83,8 +83,8 @@
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
const data = await resp.json();
renderPricelists(data.items || []);
renderPagination(data.total_count, data.page, data.per_page);
renderPricelists(data.pricelists || []);
renderPagination(data.total, data.page, data.per_page);
} catch (e) {
document.getElementById('pricelists-body').innerHTML = `
<tr>

View File

@@ -40,9 +40,6 @@
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Конфигурация
</button>
<button id="refresh-all-prices-btn" onclick="refreshPrices()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
Обновить цены
</button>
<button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium">
Импорт
</button>
@@ -80,19 +77,6 @@
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Новая конфигурация в проекте</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label>
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
<button type="button" id="pd-type-server-btn" onclick="pdSetCreateType('server')"
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
Сервер
</button>
<button type="button" id="pd-type-storage-btn" onclick="pdSetCreateType('storage')"
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
СХД
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
<input type="text" id="create-name" placeholder="Например: OPP-2026-001"
@@ -108,14 +92,14 @@
<div id="vendor-import-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Импорт конфигураций</h2>
<h2 class="text-xl font-semibold mb-4">Импорт выгрузки вендора</h2>
<div class="space-y-4">
<div class="text-sm text-gray-600">
Поддерживаемые форматы: CFXML-выгрузка вендора (.xml), собственный CSV-экспорт QuoteForge (.csv), текстовый BOM Inspur (.txt).
Загружает `CFXML`-выгрузку в текущий проект и создаёт несколько конфигураций, если они есть в файле.
</div>
<div>
<label for="vendor-import-file" class="block text-sm font-medium text-gray-700 mb-1">Файл</label>
<input id="vendor-import-file" type="file" accept=".xml,.csv,.txt,text/xml,application/xml,text/csv,text/plain"
<label for="vendor-import-file" class="block text-sm font-medium text-gray-700 mb-1">Файл выгрузки</label>
<input id="vendor-import-file" type="file" accept=".xml,text/xml,application/xml"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-amber-500 focus:border-amber-500">
</div>
<div id="vendor-import-status" class="hidden text-sm rounded border px-3 py-2"></div>
@@ -339,7 +323,7 @@ function escapeHtml(text) {
function formatMoneyNoDecimals(value) {
const safe = Number.isFinite(Number(value)) ? Number(value) : 0;
return '$' + Math.round(safe).toLocaleString('ru-RU');
return '$' + Math.round(safe).toLocaleString('en-US');
}
function resolveProjectTrackerURL(projectData) {
@@ -592,19 +576,7 @@ async function loadConfigs() {
}
}
let pdCreateConfigType = 'server';
function pdSetCreateType(type) {
pdCreateConfigType = type;
document.getElementById('pd-type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' +
(type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
document.getElementById('pd-type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' +
(type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
}
function openCreateModal() {
pdCreateConfigType = 'server';
pdSetCreateType('server');
document.getElementById('create-name').value = '';
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
@@ -911,7 +883,7 @@ async function importVendorWorkspace() {
const input = document.getElementById('vendor-import-file');
const submit = document.getElementById('vendor-import-submit');
if (!input || !input.files || !input.files[0]) {
setVendorImportStatus('Выберите файл для импорта', 'error');
setVendorImportStatus('Выберите XML-файл выгрузки', 'error');
return;
}
@@ -962,7 +934,7 @@ async function createConfig() {
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1, config_type: pdCreateConfigType})
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
});
if (!resp.ok) {
alert('Не удалось создать конфигурацию');
@@ -1572,93 +1544,6 @@ async function exportProject() {
}
}
async function refreshPrices() {
const configs = (allConfigs || []).filter(c => c.is_active !== false);
if (!configs.length) {
showToast('Нет активных конфигураций', 'error');
return;
}
const btn = document.getElementById('refresh-all-prices-btn');
if (btn) {
btn.disabled = true;
btn.textContent = 'Обновление...';
btn.className = 'py-2 bg-gray-300 text-gray-500 rounded-lg cursor-not-allowed font-medium';
}
try {
const latestEstimatePricelistId = await fetchLatestEstimatePricelistId();
const diffResults = [];
for (const cfg of configs) {
if (cfg.disable_price_refresh) {
diffResults.push({ configName: cfg.name, skipped: true });
continue;
}
const prevTotal = cfg.total_price || 0;
const prevItemsMap = {};
if (cfg.items) {
for (const item of cfg.items) prevItemsMap[item.lot_name] = item.unit_price;
}
try {
const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined;
const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : {},
body,
});
if (!resp.ok) {
diffResults.push({ configName: cfg.name, error: true });
continue;
}
const updated = await resp.json();
cfg.total_price = updated.total_price ?? cfg.total_price;
if (updated.items) cfg.items = updated.items;
const totalCell = document.querySelector('[data-total-uuid="' + cfg.uuid + '"]');
if (totalCell && updated.total_price != null) {
totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
const sc = cfg.server_count || 1;
const row = totalCell.closest('tr');
if (row) {
const cells = row.querySelectorAll('td');
if (cells[4]) cells[4].textContent = formatMoneyNoDecimals(sc > 0 ? updated.total_price / sc : 0);
}
}
const itemDiffs = [];
if (updated.items) {
for (const item of updated.items) {
const prevPrice = prevItemsMap[item.lot_name];
if (prevPrice !== undefined && Math.abs(prevPrice - item.unit_price) > 0.01) {
itemDiffs.push({ lot_name: item.lot_name, quantity: item.quantity, prevPrice, newPrice: item.unit_price });
}
}
}
diffResults.push({ configName: cfg.name, prevTotal, newTotal: updated.total_price || 0, serverCount: cfg.server_count || 1, itemDiffs });
} catch(_) {
diffResults.push({ configName: cfg.name, error: true });
}
}
updateFooterTotal();
showToast('Цены обновлены', 'success');
showPriceDiffModal(diffResults);
} catch(e) {
showToast('Ошибка обновления цен', 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = 'Обновить цены';
btn.className = 'py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium';
}
}
}
document.addEventListener('DOMContentLoaded', async function() {
applyStatusModeUI();
const ok = await loadProject();

View File

@@ -81,7 +81,7 @@ function escapeHtml(text) {
}
function formatMoney(v) {
return '$' + (v || 0).toLocaleString('ru-RU', {minimumFractionDigits: 2});
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2});
}
function formatDateTime(value) {

View File

@@ -16,13 +16,6 @@
<p class="text-gray-600 mt-2">Настройка подключения к базе данных</p>
</div>
<div class="bg-amber-50 border border-amber-300 rounded-md p-3 mb-4 flex items-start gap-2">
<svg class="w-5 h-5 text-amber-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
<p class="text-sm text-amber-800"><span class="font-semibold">Важно:</span> не закрывайте консольное окно приложения — без него программа не работает.</p>
</div>
<form id="setup-form" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Хост сервера</label>
@@ -92,28 +85,7 @@
</p>
</div>
<!-- Dead-man's switch overlay -->
<div id="backend-offline-overlay" class="hidden fixed inset-0 z-[9999] bg-black/80 flex items-center justify-center p-6">
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-8 text-center">
<div class="text-red-500 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<h2 class="text-xl font-bold text-gray-900 mb-3">Приложение остановлено</h2>
<p class="text-gray-600 mb-4">Консольное окно QuoteForge было закрыто — без него программа не работает.</p>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6 text-sm text-amber-800">
Запустите программу заново и нажмите «Обновить страницу».
</div>
<button onclick="window.location.reload()" class="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
Обновить страницу
</button>
</div>
</div>
<script>
let awaitingRestart = false;
function showStatus(message, type) {
const status = document.getElementById('status');
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800', 'bg-yellow-100', 'text-yellow-800');
@@ -188,7 +160,6 @@
}
async function requestRestartAndWait() {
awaitingRestart = true;
showStatus('Перезапуск приложения...', 'info');
try {
await fetch('/api/restart', { method: 'POST' });
@@ -234,35 +205,6 @@
showStatus('Ошибка сети: ' + e.message, 'error');
}
});
// Dead-man's switch: detect if the backend process has stopped
(function() {
const POLL_MS = 5000;
const FAIL_THRESHOLD = 2;
let failCount = 0;
async function checkBackend() {
if (awaitingRestart) return;
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 3000);
const resp = await fetch('/health', { signal: ctrl.signal });
clearTimeout(timer);
if (resp.ok) {
failCount = 0;
document.getElementById('backend-offline-overlay').classList.add('hidden');
} else {
failCount++;
}
} catch (_) {
failCount++;
}
if (failCount >= FAIL_THRESHOLD) {
document.getElementById('backend-offline-overlay').classList.remove('hidden');
}
}
setInterval(checkBackend, POLL_MS);
})();
</script>
</body>
</html>