Compare commits

..

234 Commits

Author SHA1 Message Date
Mikhail Chusavitin
d204e337b5 feat: сохранять ручные PN→LOT маппинги как lot_suggestion в qt_vendor_partnumber_seen
При сохранении vendor-spec строки с заполненным lot_mappings автоматически
отправляются на сервер и пишутся в новый столбец lot_suggestion. Столбец
хранит JSON-массив [{lot_name, qty}] — тот же формат, что qt_partnumber_book_items.lots_json.

Если миграция ещё не прошла (столбец отсутствует), приложение логирует WARN
и записывает строку без столбца; сбоя нет.

Контракт для инструмента создания partnumber-books описан в bible-local/11-lot-suggestions.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 15:39:53 +03:00
Mikhail Chusavitin
d340bf80af docs: release notes v1.14
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:19:58 +03:00
Mikhail Chusavitin
24c34eb0e1 fix: текстовый BOM работает в пасте конфигуратора через единый серверный парсер
Паста BOM на странице конфигурирования теперь распознаёт текстовый и
Inspur-форматы: вместо дублирования парсера на JS добавлен stateless
эндпоинт POST /api/vendor-spec/parse-text, который использует те же
детекторы и парсеры, что и импорт файла (KISS — один парсер на оба
входа). JS-копии _parseInspurBOMText/_isInspurBOMText удалены.

Заголовок конфигурации определяется по маркеру ", в составе:" с любым
префиксом ("Сервер X3" и "Вычислительный GPU сервер X3" → модель X3);
строки тримятся, пробел в начале не попадает в P/N; запятые и дефисы
внутри описания сохраняются (RAID0,1,10; 8-GPU-2304GB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:16:55 +03:00
Mikhail Chusavitin
6f2c261350 chore: обновить сабмодуль bible до 5244435
Включает контракты build-version-display, local-first-recovery и
автоматизацию резервных копий миграций.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:07:48 +03:00
Mikhail Chusavitin
7233a0780f feat: импорт человекочитаемого текстового BOM (формат "<описание> - N шт.")
Новый формат vendor-import: опциональный заголовок "Сервер <модель>,
в составе:" и строки вида "<описание> - <кол-во> шт." (дефис/тире,
пробел перед "шт" и точка опциональны). Количество якорится в конце
строки, поэтому дефисы и цифры внутри описания (8-GPU-2304GB) сохраняются.

Описание пишется и в vendor_partnumber, и в description: строки
резолвятся через активную книгу партномеров, иначе остаются
нерезолвленными и редактируемыми. Весь файл — одна конфигурация.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:06:26 +03:00
Mikhail Chusavitin
360c754952 refactor: удалить мёртвые таблицы qt_price_overrides, qt_pricing_alerts, qt_component_usage_stats
Удалены модели, репозитории и авто-миграции для трёх таблиц, которые
никогда не использовались в продакшн-коде. Убраны StatsRepository и
RecordUsage из сервисов, сигнатуры конструкторов упрощены.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:54:42 +03:00
184f54b663 refactor: привести кодовую базу в соответствие с канонами bible
- 400 → 422 для всех ошибок валидации входных данных (handlers: export, quote, sync, vendor_spec, partnumber_books, pricelist)
- SQL-запросы вынесены из handlers в localdb (partnumber_books, pricelist, support_bundle); ValidateMariaDBConnection перенесён в internal/db/validate.go
- List-ответы унифицированы: ключ items, поля total_count/page/per_page/total_pages (component, pricelist, partnumber_books); шаблоны обновлены
- Молчаливые ошибки заменены на slog.Warn/Error (support_bundle, vendor_spec, component, configuration, local_configuration, localdb)
- N+1 запросы устранены: batch-запросы в export.go и vendor_workspace_import.go
- fmt.Println → slog в cmd/ (qfs, migrate, migrate_ops_projects, migrate_project_updated_at)
- Заголовки recovery/verify добавлены во все 28 SQL-миграций
- Добавлены bible-local/runtime-flows.md и bible-local/decisions/
- Обновлён субмодуль bible до v0.2.0-13

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:38:01 +03:00
e548305396 fix: экспорт конфига через GetByUUIDNoAuth, формат чисел с запятой как разделителем
- ExportConfigCSV и ExportConfigPricingCSV: GetByUUID → GetByUUIDNoAuth,
  чтобы не падать с ErrConfigForbidden на конфигах с чужим OriginalUsername
- ConfigurationGetter: добавлен GetByUUIDNoAuth в интерфейс
- Шаблоны: toLocaleString('en-US') → 'ru-RU' во всех местах отображения цен;
  процентные значения: .toFixed(1) → .toFixed(1).replace('.', ',')

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 05:00:37 +03:00
09d694234d feat: кнопка "Обновить цены" использует последний скачанный прайслист без синхронизации и показывает diff
- убрать вызовы /api/sync/components и /api/sync/pricelists из обеих кнопок
- брать самый свежий прайслист из уже скачанных (active_only)
- проверять галочку disable_price_refresh (пропускать конфиг если включена)
- показывать модальное окно diff: компонент / цена за шт. / сумма (было → стало) + итог конфиги
- общие утилиты (fetchLatestEstimatePricelistId, showPriceDiffModal) вынесены в base.html
- обе кнопки вызывают refreshPrices() без дублирования кода

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 04:26:25 +03:00
56782fa718 refactor: удалить неиспользуемые модели StockLog, StockIgnoreRule, Supplier
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:56:55 +03:00
2bd57591ea docs: убрать stock_log и stock_ignore_rules из списка прав БД
Таблицы не используются в коде (только объявлены модели), stock_log не существует в БД.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:56:05 +03:00
a81947b852 docs: убрать qt_pricelist_sync_status из списка прав БД
Таблица удалена в 3992dbf, грант на неё больше не требуется.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:46:24 +03:00
6146f6aec7 fix: галочка "Создать копию" снята по умолчанию (программный checked не триггерил change-обработчик имени)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:46:23 +03:00
3992dbf919 refactor: убрать qt_pricelist_sync_status, lot_log и лишние права БД
- Удалить все записи в qt_pricelist_sync_status (RecordSyncHeartbeat и
  ensureUserSyncStatusTable); ListUserSyncStatuses переведён на
  qt_client_schema_state
- Подавлять устаревший OFFLINE_UNVERIFIED_SCHEMA в UI когда соединение
  уже восстановлено
- Удалить все обращения к lot_log: repository/price.go, сортировка
  quote_count в component.go, UpdatePopularityScores в stats.go,
  модель LotLog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:18:52 +03:00
1de66d6f33 docs: release notes для v1.10 2026-06-02 14:31:32 +03:00
5d5af07fc5 fix: NeedSync проверяет версии сервера когда онлайн, игнорируя 1-часовой порог
Раньше NeedSync возвращал true сразу если last_sync > 1 часа назад —
до сравнения версий дело не доходило. Это приводило к бесконечным
повторным попыткам синка когда все прайслисты уже скачаны, но
last_pricelist_status застрял в "failed" из-за предыдущего сбоя.

Теперь когда онлайн — всегда сравниваем реальные версии с сервером.
Если все источники совпадают — возвращаем false независимо от времени
последнего синка. Фолбэк на 1-часовой порог только в офлайн-режиме.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:06:18 +03:00
8d965bfee9 fix: сбрасывать stale pricelist "failed" когда NeedSync подтверждает актуальность
После сетевого сбоя во время синка прайслистов last_pricelist_status
мог оставаться "failed" навсегда, даже если все прайслисты реально
скачались и NeedSync() возвращает false (всё актуально).

В SyncPricelistsIfNeeded: если NeedSync() == false и статус "failed" —
сбрасываем в success и обновляем last_sync_time, чтобы UI убрал "Не докачано".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:55:43 +03:00
c5909c6a36 fix: WriteTimeout 30s → 10m для совместимости с медленными соединениями
Скачивание нового прайслиста через VPN с высокой задержкой (>600 мс RTT)
превышало WriteTimeout=30s — браузер не получал ответ на POST /api/sync/all
и пользователь видел зависание без фидбека.

Сервер слушает только loopback, внешних клиентов нет — длинный таймаут
не создаёт угрозы безопасности.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:40:24 +03:00
0072f2a15f fix: ALTER spam в логах — DDL на qt_client_schema_state только при нужде
Раньше ensureClientSchemaStateTable запускался на каждом цикле синка
(каждые 5 минут) и пытался ALTER TABLE, даже если все колонки уже были.
Для пользователей без DDL-прав это давало WARN-спам в каждом цикле.

Два изменения:
- schemaOnce (sync.Once) на Service: ensureClientSchemaStateTable
  вызывается не более одного раза за жизнь процесса
- columnExists() проверяет information_schema.COLUMNS перед каждым
  ALTER — если колонка уже есть, ALTER пропускается без ошибки

Если таблица уже мигрирована сервером, клиент молча пропускает все DDL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:02:40 +03:00
452811f393 feat: sync_log таблица и список прайслистов в Support Bundle
- Добавлена таблица sync_log (до 100 записей на тип): фиксирует каждый
  запуск синхронизации с типом, статусом, ошибкой, кол-вом и временем
- AppendSyncLog вызывается из SyncComponents, SyncPricelists (service и
  handler), SyncAll и SyncComponentsIfEmpty
- Bundle теперь включает sync_log.json (200 последних записей) и
  pricelists.json (все скачанные прайслисты, сгруппированные по source)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:57:28 +03:00
84cab011d3 feat: автосинхронизация компонентов для новых пользователей и Support Bundle
- Воркер теперь запускает SyncComponents при пустой local_components,
  чтобы новый пользователь получил каталог компонентов без ручного действия
- Результат синхронизации компонентов персистируется в app_settings
  (last_component_sync_status/error/attempt_at) по аналогии с прайслистами
- Добавлен эндпоинт GET /api/support-bundle: скачивает ZIP с диагностикой
  (app_info, local_db_stats, db_connection с TCP-пингом, sync_readiness,
  system_metrics с памятью и диском, schema_migrations, app.log)
- Кнопка-иконка в шапке рядом с юзернеймом для скачивания бандла

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:50:41 +03:00
c951ceb44b fix: галочка "Создать копию" теперь включена по умолчанию в обоих диалогах
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:27:06 +03:00
caf1732cd3 fix: сортировка категорий в CSV-экспорте без учёта регистра
Поиск в categoryOrder не нормализовал регистр — категория "mem" не совпадала
с ключом "MEM", получала порядок 9999 и строки шли в произвольном порядке.
Заодно заменён bubble-sort на sort.SliceStable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:14:11 +03:00
e58f5774ab fix: /api/categories возвращал display_order=0 для всех категорий
Коды брались из локальных компонентов, но display_order не проставлялся —
поэтому categoryOrderMap на фронте был пустым и порядок в итоговой
конфигурации не соблюдался. Теперь display_order берётся из DefaultCategories.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:09:15 +03:00
ddc00523e0 refactor: убрать categoryRepo из ExportService, порядок из DefaultCategories
Категория лота приходит из прайслиста — запрашивать её из серверной БД
нарушало принцип local-first. Сигнатура NewExportService упрощена,
все call-sites обновлены.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:06:39 +03:00
ff262822e1 fix: использовать DefaultCategories как fallback для сортировки в CSV-экспорте
categoryRepo всегда nil (передаётся null при инициализации), поэтому
categoryOrder был пустым и сортировка по категориям не работала.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:05:09 +03:00
6049334323 refactor: переработать порядок категорий (MB→CPU→MEM→RAID→drives→GPU→NIC→HBA→PSU→ACC)
SeedCategories теперь обновляет display_order у существующих записей,
поэтому новый порядок применяется при следующем запуске без ручных миграций.
MaxKnownDisplayOrder повышен до 200.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:03:19 +03:00
5d4e1b44f6 feat: импорт собственного CSV QuoteForge + fix обновления цен
Добавлен парсер собственного CSV-экспорта QuoteForge (IsQuoteForgeCSV /
parseQuoteForgeCSV). Формат: UTF-8 BOM + заголовок Line;Type;p/n;...,
блоки сервер → компоненты. DirectItems создаются напрямую без прохода
через VendorSpecResolver. Модальное окно импорта принимает .csv/.txt/.xml.

Fix кнопки «Обновить цены» на странице варианта: после синхронизации
прайс-листов запрашивается актуальный estimate-прайслист и передаётся
явным pricelist_id в каждый POST /api/configs/:uuid/refresh-prices.
Ранее использовался устаревший ID, сохранённый в конфигурации.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:54:08 +03:00
6b56cad248 feat: кнопка «Обновить цены» на странице варианта проекта
Добавлена кнопка «Обновить цены» в панель действий страницы варианта.
При нажатии синхронизирует прайс-листы с сервером (если онлайн), затем
последовательно вызывает POST /api/configs/:uuid/refresh-prices для каждой
активной конфигурации — тот же механизм, что и кнопка внутри конфигурации.
Цены и итоговая сумма обновляются в таблице без перезагрузки страницы.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:46:01 +03:00
67a761345f feat: поддержка импорта BOM Inspur в формате PN*qty
Добавлен парсер для текстового формата Inspur (опциональный '|' в начале
строки, разделитель '*' перед количеством). На BOM-вкладке вставка такого
текста автоматически определяется и разбивается на колонки P/N + Qty без
ручного выбора типов. На бэкенде тот же формат поддерживается через
POST /api/projects/:uuid/vendor-import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:04:10 +03:00
Mikhail Chusavitin
55acbe138b refactor: унифицировать CSV-экспорт, перенести pricing на сервер
- Вынести sortConfigsByLine() — устранить дублирование sort.Slice
  в ProjectToExportData и ProjectToPricingExportData
- Добавить ConfigToPricingExportData() и ExportConfigPricingCSV handler
- Зарегистрировать POST /api/configs/:uuid/export/pricing
- Заменить клиентский DOM-скрапинг exportPricingCSV() на fetch к новому
  endpoint; артикул теперь включается через pricingConfigSummaryRow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 12:37:47 +03:00
Mikhail Chusavitin
e1f34ae81b fix: compressArticle used hard-coded indices, PSU rendered as NIC when GPU absent
compressArticle assumed a fixed segment layout [model,cpu,mem,gpu,disk,net,psu,support].
Build() skips empty segments, so without GPU the PSU slot shifted to index 5. Step 2 of
compression called compressNetSegment on the PSU value ("2x1kW") which returned "2xNIC".

Replace positional indexing with namedSeg{group,value} tagged segments; compressArticle
now looks up each segment by group name via findSegGroup(), regardless of array length.

Regression test TestBuild_CompressArticle_NoGPU_PSUNotNIC reproduces the exact config
from OPS-2445 (NF5280M6 + 2xPSU, no GPU) that triggered the bug.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 12:36:55 +03:00
Mikhail Chusavitin
860ffa0231 feat: add dead-man's switch overlay and console warning
- Poll /health every 5s; show full-screen overlay after 2 consecutive
  failures telling the user the console was closed
- Auto-hide overlay when backend comes back online
- Added to base.html (all main pages) and setup.html (first-run/settings)
- setup.html: suppress false-positive overlay during intentional restart
  via awaitingRestart flag
- setup.html: add amber warning banner that the console must stay open
- .gitignore: block *_import.sql and *_export.csv to prevent future
  accidental commits of real supplier/pricing data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:44:28 +03:00
Mikhail Chusavitin
6dbaccdf6f release: v1.8 2026-04-28 16:56:45 +03:00
Mikhail Chusavitin
66ff7e25a6 Fix pricelist sync upsert and refresh tests 2026-04-28 16:54:36 +03:00
dc37afe178 release: v1.7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:03:17 +03:00
c698a6b70a feat: унифицировать autocomplete для LOT на всех вкладках
- Все вкладки (storage, pci, power, accessories, sw, other) теперь
  используют редактируемый autocomplete-input для существующих позиций,
  как на вкладке base; выбор заменяет позицию с сохранением количества
- LOT-поле в BOM-таблицах переведено на общий autocomplete dropdown
  вместо datalist
- Кнопка ✕ в BOM снимает сопоставление вместо удаления строки
- Кнопка «Пересчитать эстимейт» переименована в «Перенести в эстимейт»

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:00:45 +03:00
Mikhail Chusavitin
e35b3179d0 Restore RAID section for server storage tab 2026-04-16 09:28:14 +03:00
Mikhail Chusavitin
2e5a5e22d8 Remove obsolete storage components guide docx 2026-04-15 18:58:10 +03:00
Mikhail Chusavitin
f18df01618 Persist pricing state and refresh storage sync 2026-04-15 18:56:40 +03:00
Mikhail Chusavitin
df3cd62cb5 Fix storage sync and configurator category visibility 2026-04-15 18:40:34 +03:00
Mikhail Chusavitin
89ce001906 fix: abbreviate GPU architecture suffixes in article token
Ampere, Hopper, Blackwell now produce AMP/HOP/BWL suffixes (like ADA)
so RTX cards across generations are distinguishable: RTX6000ADA vs
RTX6000BWL. LOVELACE remains a skip token as it duplicates ADA info.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:08:47 +03:00
Mikhail Chusavitin
6a41c957cc fix: include model number and ADA suffix in GPU article token
RTX 6000 ADA and A6000 are distinct cards — RTX_4000_ADA_SFF now
produces RTX4000ADA instead of RTX, avoiding visual ambiguity with
the segment separator (10xRTX4000ADA vs 10xRTX-1x…).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:07:25 +03:00
Mikhail Chusavitin
19b1abf4c8 feat: add СХД configuration type with storage-specific tabs and LOT catalog guide
- Add config_type field ("server"|"storage") to Configuration and LocalConfiguration
- Create modal: Сервер/СХД segmented control in configs.html and project_detail.html
- Configurator: ENC/DKC/CTL categories in Base tab, HIC section in PCI tab hidden for server configs
- Add SW tab (categories: SW) to configurator, visible only when components present
- TAB_CONFIG.pci: add HIC section for storage HIC adapters (separate from server HBA/NIC)
- Migration 029: ALTER TABLE qt_configurations ADD COLUMN config_type
- Fix: skip Error 1833 (Cannot change column used in FK) in GORM AutoMigrate
- Operator guide: docs/storage-components-guide.md with LOT naming rules and DE4000H catalog template

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:01:23 +03:00
Mikhail Chusavitin
7966ece7a6 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
ae7d8911c6 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
48f03a21fa 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
82dcee74c5 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
7aa7b68020 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
ad8cdb0b85 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
1745c8fdd6 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
f844288fb5 chore: update bible submodule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:20:26 +03:00
Mikhail Chusavitin
65641ae49a 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
1064e2b985 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
0b0b38c29d 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
ba105f8743 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
1ddb60f8c6 Treat current configuration as main 2026-03-17 18:43:49 +03:00
Mikhail Chusavitin
b0dd206c29 Vendor frontend assets locally 2026-03-17 18:41:53 +03:00
Mikhail Chusavitin
c20da96788 Make sync status non-blocking 2026-03-17 18:34:28 +03:00
Mikhail Chusavitin
5848eebf4c Version BOM and pricing changes 2026-03-17 18:24:09 +03:00
Mikhail Chusavitin
407ef52d28 Fix incomplete pricelist sync status 2026-03-17 12:05:02 +03:00
Mikhail Chusavitin
463836802b fix(release): preserve release notes template - v1.5.4 2026-03-16 08:33:53 +03:00
Mikhail Chusavitin
73e7f0ce11 fix(qfs): project ui, config naming, sync timestamps - v1.5.4 2026-03-16 08:32:15 +03:00
Mikhail Chusavitin
98d8b40282 Simplify project documentation and release notes 2026-03-15 16:43:06 +03:00
Mikhail Chusavitin
8e7da97394 Harden local runtime safety and error handling 2026-03-15 16:28:32 +03:00
Mikhail Chusavitin
fba9f2972a 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
b1fb3db2e0 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
72a21e6335 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
c02286a407 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
63d14dac76 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
a3c26f015b 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
84013c9dc4 Local-first runtime cleanup and recovery hardening 2026-03-07 23:18:07 +03:00
Mikhail Chusavitin
9f8e050349 Document legacy BOM tables 2026-03-07 21:13:08 +03:00
Mikhail Chusavitin
d026c28ea7 Add vendor workspace import and pricing export workflow 2026-03-07 21:03:40 +03:00
Mikhail Chusavitin
e39c69e5a4 Merge branch 'feature/vendor-spec-import' 2026-03-06 10:54:05 +03:00
Mikhail Chusavitin
08a8113949 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
3a3a4665b0 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
d82590c34b 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
4db8ca1140 Update bible submodule to latest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 12:37:18 +03:00
e3a1268f74 Merge feature/vendor-spec-import into main (v1.4) 2026-03-04 12:35:40 +03:00
beecaaceb7 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
564f5bad36 Update bible submodule to latest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:27:45 +03:00
648943b2c3 Update bible paths kit/ → rules/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:57:50 +03:00
22eae2b272 Update bible submodule to latest 2026-03-01 16:41:42 +03:00
2011d3fc77 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
7f4c7328bc Fix pricing tab warehouse totals and guard custom price DOM access 2026-02-27 16:53:34 +03:00
Mikhail Chusavitin
ed0916d3d1 fix(bom): preserve local vendor spec on config import 2026-02-27 10:11:20 +03:00
Mikhail Chusavitin
370bd949a5 refactor(bom): enforce canonical lot_mappings persistence 2026-02-27 09:47:46 +03:00
Mikhail Chusavitin
2b63f9ec14 feat(bom): canonical lot mappings and updated vendor spec docs 2026-02-25 19:07:27 +03:00
Mikhail Chusavitin
69049eba69 Fix project line numbering and reorder bootstrap 2026-02-25 17:19:26 +03:00
Mikhail Chusavitin
aca66df25e feat(projects): compact table layout for dates and names 2026-02-25 17:19:26 +03:00
Mikhail Chusavitin
ea5223dee5 fix(pricelists): tolerate restricted DB grants and use embedded assets only 2026-02-25 17:19:26 +03:00
Mikhail Chusavitin
c76a31f171 fix(sync): backfill missing items for existing local pricelists 2026-02-25 17:18:57 +03:00
Mikhail Chusavitin
36699a609f feat(projects): compact table layout for dates and names 2026-02-24 15:42:04 +03:00
Mikhail Chusavitin
e03b8db271 Merge branch 'stable'
# Conflicts:
#	bible/03-database.md
2026-02-24 15:13:41 +03:00
Mikhail Chusavitin
180d10914d fix(pricelists): tolerate restricted DB grants and use embedded assets only 2026-02-24 15:09:12 +03:00
Mikhail Chusavitin
f984d045d2 fix(sync): backfill missing items for existing local pricelists 2026-02-24 14:54:38 +03:00
512b9ca04b 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
130af59e0f 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
521709e0e2 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
0ceff6cf66 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
fb2a33a71d 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
c6c0a53e6e 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
5bad4f86e4 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
e3d322d1f1 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
f3d8e653f8 Implement persistent Line ordering for project specs and update bible 2026-02-21 07:09:38 +03:00
Mikhail Chusavitin
c1993a37cf Fix auto pricelist resolution and latest-price selection; update Bible 2026-02-20 19:15:24 +03:00
Mikhail Chusavitin
daeb0b0bd7 docs(bible): require updates on user-requested commits 2026-02-20 15:39:00 +03:00
Mikhail Chusavitin
b67a1ae8a5 Add persistent startup console warning 2026-02-20 14:37:21 +03:00
Mikhail Chusavitin
b405ef9c44 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
aad322dd71 docs: remove local absolute paths from v1.3.2 notes 2026-02-19 18:47:29 +03:00
d0becda71b docs: add release notes for v1.3.2 2026-02-19 18:43:03 +03:00
2e37197a7e Harden local config updates and error logging 2026-02-19 18:41:45 +03:00
530aa0ae48 Deduplicate configuration revisions and update revisions UI 2026-02-19 14:09:00 +03:00
81203fc7a7 chore: save current changes 2026-02-18 07:02:17 +03:00
eeef5ae25c 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
1606143b9f 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
8be424aa1c 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
8db8dce080 Add project variants and UI updates 2026-02-13 19:27:48 +03:00
Mikhail Chusavitin
da4da760d8 Fix project selection and add project settings UI 2026-02-13 12:51:53 +03:00
Mikhail Chusavitin
4f6a62f5dc Fix article category fallback for pricelist gaps 2026-02-12 16:47:49 +03:00
Mikhail Chusavitin
032857017e Document backup implementation guide 2026-02-11 19:50:35 +03:00
Mikhail Chusavitin
3e7418e524 Add scheduled rotating local backups 2026-02-11 19:48:40 +03:00
Mikhail Chusavitin
3c8dc246de docs: add release notes for v1.3.0 2026-02-11 19:27:16 +03:00
Mikhail Chusavitin
3ab9ca1e73 Refine article compression and simplify generator 2026-02-11 19:24:25 +03:00
Mikhail Chusavitin
678061430c Allow cross-user project updates 2026-02-11 19:24:16 +03:00
Mikhail Chusavitin
92bca0d0be Add article generation and pricelist categories 2026-02-11 19:16:01 +03:00
Mikhail Chusavitin
6d39ca7eba 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
ca6c5fcdfd 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
7bfb909295 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
ac201c65bf 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
410957e4f1 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
1bdf405e37 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
5986d2d505 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
2418cec9c3 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
befc70a7e4 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
024a540ad0 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
0a984a5085 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
f3a767d3ed 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
1ed1ee3e51 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
af3768a05c 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
432d8c57c2 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
1ec8034689 pricing: enrich pricelist items with stock and tighten CORS 2026-02-08 10:27:36 +03:00
ff87a2636a security: harden secret hygiene and pre-commit scanning 2026-02-08 10:27:23 +03:00
a0bfc49fa6 Add pricelist type column and commit pending changes 2026-02-08 10:03:24 +03:00
d942623354 sync: clean stale local pricelists and migrate runtime config handling 2026-02-08 10:01:27 +03:00
ceba2f258d Stop tracking ignored release artifacts 2026-02-08 08:55:21 +03:00
20c5d617d5 Remove admin pricing stack and prepare v1.0.4 release 2026-02-07 21:23:23 +03:00
18988c20f1 refactor lot matching into shared module 2026-02-07 06:22:56 +03:00
207ecfc032 Implement warehouse/lot pricing updates and configurator performance fixes 2026-02-07 05:20:35 +03:00
ba36c3aae7 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
0e3bbc15b1 Refine stock import UX with suggestions, ignore rules, and inline mapping controls 2026-02-06 19:58:42 +03:00
Mikhail Chusavitin
76e1f95842 Fix stock mappings JSON fields and enable row selection for editing 2026-02-06 19:39:39 +03:00
Mikhail Chusavitin
0d9abdbd50 Add stock pricelist admin flow with mapping placeholders and warehouse details 2026-02-06 19:37:12 +03:00
Mikhail Chusavitin
fe07f8dcd4 WIP: save current pricing and pricelist changes 2026-02-06 19:07:22 +03:00
Mikhail Chusavitin
4a89feab12 configs: save pending template changes 2026-02-06 16:43:04 +03:00
Mikhail Chusavitin
9c5f5dd3f8 projects: add tracker_url and project create modal 2026-02-06 16:42:32 +03:00
Mikhail Chusavitin
4b9ce9589c Add tracker link on project detail page 2026-02-06 16:31:34 +03:00
Mikhail Chusavitin
e6ed7abbb1 Fix local pricelist uniqueness and preserve config project on update 2026-02-06 16:00:23 +03:00
Mikhail Chusavitin
fc38286140 Make full sync push pending and pull projects/configurations 2026-02-06 15:25:07 +03:00
Mikhail Chusavitin
729463157d Prepare v1.0.3 release notes 2026-02-06 14:04:06 +03:00
Mikhail Chusavitin
aa1177cbe1 Add projects table controls and sync status tab with app version 2026-02-06 14:02:21 +03:00
Mikhail Chusavitin
9c75b03c89 sync: recover missing server config during update push 2026-02-06 13:41:01 +03:00
Mikhail Chusavitin
bb9ee13edc Fix MySQL DSN escaping for setup passwords and clarify DB user setup 2026-02-06 13:27:57 +03:00
Mikhail Chusavitin
ea56660fc3 update stale files list 2026-02-06 13:03:59 +03:00
Mikhail Chusavitin
466e0e8506 Apply remaining pricelist and local-first updates 2026-02-06 13:01:40 +03:00
Mikhail Chusavitin
cab8671692 Use admin price-refresh logic for pricelist recalculation 2026-02-06 13:00:27 +03:00
Mikhail Chusavitin
95cf376f18 fix: skip startup sql migrations when not needed or no permissions 2026-02-06 11:56:55 +03:00
Mikhail Chusavitin
e43e3b2e6b feat: add projects flow and consolidate default project handling 2026-02-06 11:39:12 +03:00
Mikhail Chusavitin
46951e8492 Update pricelist repository, service, and tests 2026-02-06 10:14:24 +03:00
Mikhail Chusavitin
a09bbc689e Enforce pricelist write checks and auto-restart on DB settings change 2026-02-05 15:44:54 +03:00
Mikhail Chusavitin
de7115f130 Purge orphan sync queue entries before push 2026-02-05 15:17:06 +03:00
Mikhail Chusavitin
751b860afa Handle stale configuration sync events when local row is missing 2026-02-05 15:11:43 +03:00
Mikhail Chusavitin
798e0e1023 Drop qt_users dependency for configs and track app version 2026-02-05 15:07:23 +03:00
Mikhail Chusavitin
843295be3f Добавил шаблон для создания пользователя в БД 2026-02-05 10:55:02 +03:00
Mikhail Chusavitin
3dde221a5e Fix sync owner mapping before pushing configurations 2026-02-05 10:43:34 +03:00
Mikhail Chusavitin
a8b2fde04c Implement local DB migrations and archived configuration lifecycle 2026-02-04 18:52:56 +03:00
Mikhail Chusavitin
c1f936825e Store configuration owner by MariaDB username 2026-02-04 12:20:41 +03:00
Mikhail Chusavitin
564c6c1b34 Recover DB connection automatically after network returns 2026-02-04 11:43:31 +03:00
Mikhail Chusavitin
9d50c57c25 Add server-to-local configuration import in web UI 2026-02-04 11:31:23 +03:00
Mikhail Chusavitin
e6bd46368a Store config in user state and clean old release notes 2026-02-04 11:21:48 +03:00
Mikhail Chusavitin
a80d203946 Log binary version and executable path on startup 2026-02-04 10:21:18 +03:00
Mikhail Chusavitin
111f83095b Fix missing config handling and auto-restart after setup 2026-02-04 10:19:35 +03:00
Mikhail Chusavitin
d45158b08d Store local DB in user state dir as qfs.db 2026-02-04 10:03:17 +03:00
Mikhail Chusavitin
6314013356 Ignore local Go cache directory 2026-02-04 09:55:36 +03:00
Mikhail Chusavitin
1212574b1c Fix offline usage tracking and active pricelist sync 2026-02-04 09:54:13 +03:00
9d5c875fdc Merge feature/phase2-sqlite-sync into main 2026-02-03 22:04:17 +03:00
832d6f2b58 Embed assets and fix offline/sync/pricing issues 2026-02-03 21:58:02 +03:00
Mikhail Chusavitin
60f839221c docs: add release notes for v0.2.7 2026-02-03 11:39:23 +03:00
Mikhail Chusavitin
74e391387f 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
d4e238b585 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
3a6d0c0369 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
3c422e6076 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
c3719d39ad 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
613ef3a340 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
1aad7220dd 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
26d2207ffa 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
cd4bb32625 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
59120c5597 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
f7099c3adc 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
d0ef775b03 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
7b8f15a931 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
ff15f71b36 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
233ecebcb9 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
15f100a517 feat: improve admin pricing modal quote count display to show period and total counts 2026-02-02 21:34:51 +03:00
Mikhail Chusavitin
b6e3e38f8e add todo 2026-02-02 19:44:45 +03:00
Mikhail Chusavitin
50b5e67b8a 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
988ccb571e 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
733d628c86 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
bb87c6ca19 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
ed1d6e642c 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
2f0648f2d4 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
1d5e358f80 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
78fd25472f 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
2b5c81001a 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
f9096ccad2 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
d26a8c5604 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
087701fe99 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
5a9c5371ba 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
6b5f826f4b 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
630245b720 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
32413838aa 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
228f64d205 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
47ce3314b4 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
ba5b14c72f 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
0a93973d02 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
6b44de48a2 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
cc73a7a11e Add Go binaries to .gitignore
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 10:31:43 +03:00
3821095254 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
d224eea893 Add cron job functionality and Docker integration 2026-01-31 00:31:43 +03:00
d294154a6e Update documentation to reflect actual implementation 2026-01-31 00:06:46 +03:00
31c8d4ba28 delete dangling files 2026-01-30 23:51:24 +03:00
e5bcb1cd86 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
4b486c8b32 Add meta component pricing functionality and admin UI enhancements 2026-01-30 20:49:59 +03:00
173 changed files with 16245 additions and 5957 deletions

12
.gitignore vendored
View File

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

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "bible"]
path = bible
url = https://git.mchus.pro/mchus/bible.git

11
AGENTS.md Normal file
View File

@@ -0,0 +1,11 @@
# QuoteForge — Instructions for Codex
## Shared Engineering Rules
Read `bible/` — shared rules for all projects (CSV, logging, DB, tables, background tasks, code style).
Start with `bible/rules/patterns/` for specific contracts.
## Project Architecture
Read `bible-local/` — QuoteForge specific architecture.
Read order: `bible-local/README.md` → relevant files for the task.
Every architectural decision specific to this project must be recorded in `bible-local/`.

View File

@@ -1,88 +1,17 @@
# QuoteForge - Claude Code Instructions
# QuoteForge Instructions for Claude
## Overview
Корпоративный конфигуратор серверов с offline-first архитектурой.
Приложение работает через локальную SQLite базу, синхронизация с MariaDB выполняется фоново.
## Shared Engineering Rules
Read `bible/` — shared rules for all projects (CSV, logging, DB, tables, background tasks, code style).
Start with `bible/rules/patterns/` for specific contracts.
## Product Scope
- Конфигуратор компонентов и расчёт КП
- Проекты и конфигурации
- Read-only просмотр прайслистов из локального кэша
- Sync (pull компонентов/прайслистов, push локальных изменений)
## Project Architecture
Read `bible-local/` — QuoteForge specific architecture.
Read order: `bible-local/README.md` → relevant files for the task.
Из области исключены:
- admin pricing UI/API
- stock import
- alerts
- cron/importer утилиты
Every architectural decision specific to this project must be recorded in `bible-local/`.
## Architecture
- Local-first: чтение и запись происходят в SQLite
- MariaDB используется как сервер синхронизации
- Background worker: периодический sync push+pull
- Система ревизий конфигураций: immutable snapshots при каждом сохранении (local_configuration_versions)
## Guardrails
- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
- Runtime-конфиг читается из user state (`config.yaml`) или через `-config` / `QFS_CONFIG_PATH`; не хранить рабочий `config.yaml` в репозитории.
- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
- CSV-экспорт: имя файла должно содержать **код проекта** (`project.Code`), а не название (`project.Name`). Формат: `YYYY-MM-DD (КодПроекта) ИмяКонфигурации Артикул.csv`.
- UI: во всех breadcrumbs длинные названия спецификаций/конфигураций сокращать до 16 символов с многоточием.
## Key SQLite Data
- `connection_settings`
- `local_components`
- `local_pricelists`, `local_pricelist_items`
- `local_configurations`
- `local_configuration_versions` — immutable snapshots (ревизии) при каждом сохранении
- `local_projects`
- `pending_changes`
## API Endpoints
| Group | Endpoints |
|-------|-----------|
| Setup | `GET /setup`, `POST /setup`, `POST /setup/test`, `GET /setup/status` |
| Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` |
| Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` |
| Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` |
| Configs | CRUD + refresh/clone/reactivate/rename/project binding + versions/rollback via `/api/configs/*` |
| Projects | CRUD + nested configs + `DELETE /api/projects/:uuid` (delete variant) via `/api/projects/*` |
| Sync | `GET /api/sync/status`, `GET /api/sync/readiness`, `GET /api/sync/info`, `GET /api/sync/users-status`, `POST /api/sync/components`, `POST /api/sync/pricelists`, `POST /api/sync/all`, `POST /api/sync/push`, `GET /api/sync/pending`, `GET /api/sync/pending/count` |
| Export | `POST /api/export/csv` |
## Web Routes
- `/configs`
- `/configurator`
- `/configs/:uuid/revisions`
- `/projects`
- `/projects/:uuid`
- `/pricelists`
- `/pricelists/:id`
- `/setup`
## Release Notes & Change Log
Release notes are maintained in `releases/memory/` directory organized by version tags (e.g., `v1.2.1.md`).
Before working on the codebase, review the most recent release notes to understand recent changes.
- Check `releases/memory/` for detailed changelog between tags
- Each release file documents commits, breaking changes, and migration notes
## Commands
```bash
# Development
go run ./cmd/qfs
make run
# Build
make build-release
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
# Verification
go build ./cmd/qfs
go vet ./...
go build ./cmd/qfs && go vet ./... # verify
go run ./cmd/qfs # run
make build-release # release build
```
## Code Style
- gofmt
- structured logging (`slog`)
- explicit error wrapping with context

486
README.md
View File

@@ -1,477 +1,53 @@
# QuoteForge
**Server Configuration & Quotation Tool**
Local-first desktop web app for server configuration, quotation, and project work.
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП).
Приложение работает в strict local-first режиме: пользовательские операции выполняются через локальную SQLite, MariaDB используется для синхронизации и серверного администрирования прайслистов.
Runtime model:
- user work is stored in local SQLite;
- MariaDB is used only for setup checks and background sync;
- HTTP server binds to loopback only.
![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat&logo=go)
![License](https://img.shields.io/badge/License-Proprietary-red)
![Status](https://img.shields.io/badge/Status-In%20Development-yellow)
## What the app does
## Возможности
- configuration editor with price refresh from synced pricelists;
- projects with variants and ordered configurations;
- vendor BOM import and PN -> LOT resolution;
- revision history with rollback;
- rotating local backups.
### Для пользователей
- 📱 **Mobile-first интерфейс** — удобная работа с телефона и планшета
- 🖥️ **Конфигуратор серверов** — пошаговый выбор компонентов с проверкой совместимости
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
- 🔌 **Полная офлайн-работа** — можно продолжать работу без сети и синхронизировать позже
- 🛡️ **Защищенная синхронизация** — sync блокируется preflight-проверкой, если локальная схема не готова
### Для ценовых администраторов
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
- 🎯 **Система алертов** — уведомления о популярных компонентах с устаревшими ценами
- 📉 **Аналитика использования** — какие компоненты востребованы в КП
- ⚙️ **Гибкие настройки** — периоды расчёта, методы, ручные переопределения
### Индикация актуальности цен
| Цвет | Статус | Условие |
|------|--------|---------|
| 🟢 Зелёный | Свежая | < 30 дней, ≥ 3 источника |
| 🟡 Жёлтый | Нормальная | 30-60 дней |
| 🟠 Оранжевый | Устаревающая | 60-90 дней |
| 🔴 Красный | Устаревшая | > 90 дней или нет данных |
## Технологии
- **Backend:** Go 1.22+, Gin, GORM
- **Frontend:** HTML, Tailwind CSS, htmx
- **Database:** SQLite (runtime/local-first), MariaDB 11+ (sync + server admin)
- **Export:** excelize (XLSX), encoding/csv
## Требования
- Go 1.22 или выше
- MariaDB 11.x (или MySQL 8.x)
- ~50 MB дискового пространства
## Установка
### 1. Клонирование репозитория
## Run
```bash
git clone https://github.com/your-company/quoteforge.git
cd quoteforge
go run ./cmd/qfs
```
### 2. Настройка runtime-конфига (опционально)
`config.yaml` создаётся автоматически при первом старте в той же user-state папке, где находится `qfs.db`.
Если найден старый формат, приложение автоматически мигрирует файл в актуальный runtime-формат
(оставляя только используемые секции `server` и `logging`).
При необходимости можно создать/отредактировать файл вручную:
```yaml
server:
host: "0.0.0.0"
port: 8080
mode: "release"
logging:
level: "info"
format: "json"
output: "stdout"
```
### 3. Миграции базы данных
Useful commands:
```bash
go run ./cmd/qfs -migrate
go test ./...
go vet ./...
make build-release
```
### Мигратор OPS -> проекты (preview/apply)
On first run the app creates a minimal `config.yaml`, starts on `http://127.0.0.1:8080`, and opens `/setup` if DB credentials were not saved yet.
Переносит квоты, чьи названия начинаются с `OPS-xxxx` (где `x` — цифра), в проект `OPS-xxxx`.
Если проекта нет, он будет создан; если архивный — реактивирован.
## Documentation
Сначала всегда смотрите preview:
- Shared engineering rules: [bible/README.md](bible/README.md)
- Project architecture: [bible-local/README.md](bible-local/README.md)
- Release notes: `releases/<version>/RELEASE_NOTES.md`
```bash
go run ./cmd/migrate_ops_projects
```
`bible-local/` is the source of truth for QuoteForge-specific architecture. If code changes behavior, update the matching file there in the same commit.
Применение изменений:
```bash
go run ./cmd/migrate_ops_projects -apply
```
Без интерактивного подтверждения:
```bash
go run ./cmd/migrate_ops_projects -apply -yes
```
### Права БД для пользователя приложения
#### Полный набор прав для обычного пользователя
Чтобы выдать существующему пользователю все необходимые права (без переоздания):
```sql
-- Справочные таблицы (только чтение)
GRANT SELECT ON RFQ_LOG.lot TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
-- Таблицы конфигураций и проектов (чтение и запись)
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
-- Таблицы синхронизации (только чтение для миграций, чтение+запись для статуса)
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
-- Применить изменения
FLUSH PRIVILEGES;
-- Проверка выданных прав
SHOW GRANTS FOR '<DB_USER>'@'%';
```
#### Таблицы и их назначение
| Таблица | Назначение | Права | Примечание |
|---------|-----------|-------|-----------|
| `lot` | Справочник компонентов | SELECT | Существующая таблица |
| `qt_lot_metadata` | Расширенные данные компонентов | SELECT | Метаданные компонентов |
| `qt_categories` | Категории компонентов | SELECT | Справочник |
| `qt_pricelists` | Прайслисты | SELECT | Управляется сервером |
| `qt_pricelist_items` | Позиции прайслистов | SELECT | Управляется сервером |
| `qt_configurations` | Сохранённые конфигурации | SELECT, INSERT, UPDATE | Основная таблица работы |
| `qt_projects` | Проекты | SELECT, INSERT, UPDATE | Для группировки конфигураций |
| `qt_client_local_migrations` | Справочник миграций БД | SELECT | Только чтение (управляется админом) |
| `qt_client_schema_state` | Состояние локальной схемы | SELECT, INSERT, UPDATE | Отслеживание примененных миграций |
| `qt_pricelist_sync_status` | Статус синхронизации | SELECT, INSERT, UPDATE | Отслеживание активности синхронизации |
#### При создании нового пользователя
Если нужно создать нового пользователя с нуля:
```sql
-- 1) Создать пользователя
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
-- 2) Выдать все необходимые права
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
-- 3) Применить изменения
FLUSH PRIVILEGES;
-- 4) Проверить права
SHOW GRANTS FOR 'quote_user'@'%';
```
#### Важные замечания
- **Таблицы синхронизации** должны быть созданы администратором БД один раз. Приложение не требует прав CREATE TABLE.
- **Прайслисты** (`qt_pricelists`, `qt_pricelist_items`) — справочные таблицы, управляются сервером, пользователь имеет только SELECT.
- **Конфигурации и проекты** — таблицы, в которые пишет само приложение (INSERT, UPDATE при сохранении изменений).
- **Таблицы миграций** нужны для синхронизации: приложение читает список миграций и отчитывается о применённых.
- Если видите ошибку `Access denied for user ...@'<ip>'`, проверьте наличие конфликтующих записей пользователя с разными хостами (user@localhost vs user@'%').
### 4. Импорт метаданных компонентов
```bash
go run ./cmd/importer
```
### 5. Запуск
```bash
# Development
go run ./cmd/qfs
# Production (with Makefile - recommended)
make build-release # Builds with version info
./bin/qfs -version # Check version
# Production (manual)
VERSION=$(git describe --tags --always --dirty)
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
./bin/qfs -version
```
**Makefile команды:**
```bash
make build-release # Оптимизированная сборка с версией
make build-all # Сборка для всех платформ (Linux, macOS, Windows)
make build-windows # Только для Windows
make run # Запуск dev сервера
make test # Запуск тестов
make install-hooks # Установить git hooks (блокировка коммита с секретами)
make clean # Очистка bin/
make help # Показать все команды
```
Приложение будет доступно по адресу: http://localhost:8080
### Локальная SQLite база (state)
Локальная база приложения хранится в профиле пользователя и не зависит от расположения бинарника.
Имя файла: `qfs.db`.
- macOS: `~/Library/Application Support/QuoteForge/qfs.db`
- Linux: `$XDG_STATE_HOME/quoteforge/qfs.db` (или `~/.local/state/quoteforge/qfs.db`)
- Windows: `%LOCALAPPDATA%\\QuoteForge\\qfs.db`
Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`.
#### Sync readiness guard
Перед `push/pull` выполняется preflight-проверка:
- доступен ли сервер (MariaDB);
- можно ли проверить и применить централизованные миграции локальной БД;
- подходит ли версия приложения под `min_app_version` миграций.
Если проверка не пройдена:
- локальная работа (CRUD) продолжается;
- sync API возвращает `423 Locked` с `reason_code` и `reason_text`;
- в UI показывается красный индикатор и причина блокировки в модалке синхронизации.
#### Схема потоков данных синхронизации
## Repository map
```text
[ SERVER / MariaDB ]
┌───────────────────────────┐
│ qt_projects │
│ qt_configurations │
│ qt_pricelists │
│ qt_pricelist_items │
│ qt_pricelist_sync_status │
└─────────────┬─────────────┘
pull (projects/configs/pricelists)
┌──────────────────┴──────────────────┐
│ │
[ CLIENT A / local SQLite ] [ CLIENT B / local SQLite ]
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ local_projects │ │ local_projects │
│ local_configurations │ │ local_configurations │
│ local_pricelists │ │ local_pricelists │
│ local_pricelist_items │ │ local_pricelist_items │
│ pending_changes (proj/config) │ │ pending_changes (proj/config) │
└───────────────┬───────────────┘ └───────────────┬───────────────┘
│ │
push (projects/configurations only) push (projects/configurations only)
│ │
└──────────────────┬────────────────────┘
[ SERVER / MariaDB ]
cmd/ entry points and migration tools
internal/ application code
web/ templates and static assets
bible/ shared engineering rules
bible-local/ project architecture and contracts
releases/ packaged release artifacts and release notes
config.example.yaml runtime config reference
```
По сущностям:
- Конфигурации: `Client <-> Server <-> Other Clients`
- Проекты: `Client <-> Server <-> Other Clients`
- Прайслисты: `Server -> Clients only` (локальный push отсутствует)
- Локальная очистка прайслистов на клиенте: удаляются записи, которых нет на сервере и которые не используются активными локальными конфигурациями
### Версионность конфигураций (local-first)
Для `local_configurations` используется append-only versioning через полные snapshot-версии:
- таблица: `local_configuration_versions`
- для каждого изменения создаётся новая версия (`version_no = max + 1`)
- `local_configurations.current_version_id` указывает на активную версию
- старые версии не изменяются и не удаляются в обычном потоке
- rollback не "перематывает" историю, а создаёт новую версию из выбранного snapshot
При backfill (миграция `006_add_local_configuration_versions.sql`) для существующих конфигураций создаётся `v1` и проставляется `current_version_id`.
#### Rollback
Rollback выполняется API-методом:
```bash
POST /api/configs/:uuid/rollback
{
"target_version": 3,
"note": "optional"
}
```
Результат:
- создаётся новая версия `vN` с `data` из целевой версии
- `change_note = "rollback to v{target_version}"` (+ note, если передан)
- `current_version_id` переключается на новую версию
- конфигурация уходит в `sync_status = pending`
### Локальный config.yaml
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
Если файла нет, он создаётся автоматически. Если формат устарел, он автоматически мигрируется в runtime-формат (`server` + `logging`).
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
## Docker
```bash
# Сборка образа
docker build -t quoteforge .
# Запуск с docker-compose
docker-compose up -d
```
## Структура проекта
```
quoteforge/
├── cmd/
│ ├── server/main.go # Main HTTP server
│ └── importer/main.go # Import metadata from lot table
├── internal/
│ ├── config/ # Конфигурация
│ ├── models/ # GORM модели
│ ├── handlers/ # HTTP handlers
│ ├── services/ # Бизнес-логика
│ ├── middleware/ # Auth, CORS, etc.
│ └── repository/ # Работа с БД
├── web/
│ ├── templates/ # HTML шаблоны
│ └── static/ # CSS, JS, изображения
├── migrations/ # SQL миграции
├── config.example.yaml # Пример конфигурации
├── releases/
│ └── memory/ # Changelog между тегами (v1.2.1.md, v1.2.2.md, ...)
└── go.mod
```
## Releases & Changelog
Change log между версиями хранится в `releases/memory/` каталоге в файлах вида `v{major}.{minor}.{patch}.md`.
Каждый файл содержит:
- Список коммитов между версиями
- Описание изменений и их влияния
- Breaking changes и заметки о миграции
**Перед работой над кодом проверьте последний файл в этой папке, чтобы понять текущее состояние проекта.**
## Роли пользователей
| Роль | Описание |
|------|----------|
| `viewer` | Просмотр, создание квот, экспорт |
| `editor` | + сохранение конфигураций |
| `pricing_admin` | + управление ценами и алертами |
| `admin` | Полный доступ, управление пользователями |
## API
Документация API доступна по адресу `/api/docs` (в разработке).
Основные endpoints:
```
POST /api/auth/login # Авторизация
GET /api/components # Список компонентов
POST /api/quote/calculate # Расчёт цены
POST /api/export/xlsx # Экспорт в Excel
GET /api/configs # Сохранённые конфигурации
GET /api/configs/:uuid/versions # Список версий конфигурации
GET /api/configs/:uuid/versions/:version # Получить конкретную версию
POST /api/configs/:uuid/rollback # Rollback на указанную версию
POST /api/configs/:uuid/reactivate # Вернуть архивную конфигурацию в активные
GET /api/sync/readiness # Статус readiness guard (ready|blocked|unknown)
GET /api/sync/status # Сводный статус синхронизации
GET /api/sync/info # Данные для модалки синхронизации
POST /api/sync/push # Push pending changes (423, если blocked)
POST /api/sync/all # Full sync push+pull (423, если blocked)
POST /api/sync/components # Pull components (423, если blocked)
POST /api/sync/pricelists # Pull pricelists (423, если blocked)
```
### Краткая карта sync API
| Endpoint | Назначение | Поток |
|----------|------------|-------|
| `POST /api/sync/push` | Отправить локальные pending-изменения | `SQLite -> MariaDB` |
| `POST /api/sync/components` | Подтянуть справочник компонентов | `MariaDB -> SQLite` |
| `POST /api/sync/pricelists` | Подтянуть прайслисты и позиции | `MariaDB -> SQLite` |
| `POST /api/sync/all` | Полный цикл: push + pull + импорт проектов/конфигураций | `двунаправленно` |
| `GET /api/sync/readiness` | Статус preflight/readiness | `read-only` |
| `GET /api/sync/status` / `GET /api/sync/info` | Сводка статуса и данных синхронизации | `read-only` |
#### Sync payload для versioning
События в `pending_changes` для конфигураций содержат:
- `configuration_uuid`
- `operation` (`create` / `update` / `rollback`)
- `current_version_id` и `current_version_no`
- `snapshot` (текущее состояние конфигурации)
- `idempotency_key` и `conflict_policy` (`last_write_wins`)
Это позволяет push-слою отправлять на сервер актуальное состояние и готовит основу для будущего conflict resolution.
## Разработка
```bash
# Запуск в режиме разработки (hot reload)
go run ./cmd/qfs
# Запуск тестов
go test ./...
# Сборка для Linux
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
```
## Переменные окружения
| Переменная | Описание | По умолчанию |
|------------|----------|--------------|
| `QF_DB_HOST` | Хост базы данных | localhost |
| `QF_DB_PORT` | Порт базы данных | 3306 |
| `QF_DB_NAME` | Имя базы данных | RFQ_LOG |
| `QF_DB_USER` | Пользователь БД | — |
| `QF_DB_PASSWORD` | Пароль БД | — |
| `QF_JWT_SECRET` | Секрет для JWT | — |
| `QF_SERVER_PORT` | Порт сервера | 8080 |
| `QFS_DB_PATH` | Полный путь к локальной SQLite БД | OS-specific user state dir |
| `QFS_STATE_DIR` | Каталог state (если `QFS_DB_PATH` не задан) | OS-specific user state dir |
| `QFS_CONFIG_PATH` | Полный путь к `config.yaml` | OS-specific user state dir |
| `QFS_BACKUP_DIR` | Каталог для ротационных бэкапов локальных данных | `<db dir>/backups` |
| `QFS_BACKUP_DISABLE` | Отключить автоматические бэкапы (`1/true/yes`) | — |
## Интеграция с существующей БД
QuoteForge интегрируется с существующей базой RFQ_LOG:
- `lot` — справочник компонентов (только чтение)
- `lot_log` — история цен от поставщиков (только чтение)
- `supplier` — справочник поставщиков (только чтение)
Новые таблицы QuoteForge имеют префикс `qt_`:
- `qt_users` — пользователи приложения
- `qt_lot_metadata` — расширенные данные компонентов
- `qt_configurations` — сохранённые конфигурации
- `qt_pricing_alerts` — алерты для администраторов
## Поддержка
По вопросам работы приложения обращайтесь:
- Email: mike@mchus.pro
- Internal: @mchus
## Лицензия
Данное программное обеспечение является собственностью компании и предназначено исключительно для внутреннего использования. Распространение, копирование или модификация без письменного разрешения запрещены.
См. файл [LICENSE](LICENSE) для подробностей.

1
bible Submodule

Submodule bible added at 52444350c1

View File

@@ -0,0 +1,70 @@
# 01 - Overview
## Product
QuoteForge is a local-first tool for server configuration, quotation, and project tracking.
Core user flows:
- create and edit configurations locally;
- calculate prices from synced pricelists;
- group configurations into projects and variants;
- import vendor workspaces and map vendor PNs to internal LOTs;
- review revision history and roll back safely.
## Runtime model
QuoteForge is a single-user thick client.
Rules:
- runtime HTTP binds to loopback only;
- browser requests are treated as part of the same local user session;
- MariaDB is not a live dependency for normal CRUD;
- if non-loopback deployment is ever introduced, auth/RBAC must be added first.
## Product scope
In scope:
- configurator and quote calculation;
- projects, variants, and configuration ordering;
- local revision history;
- read-only pricelist browsing from SQLite cache;
- background sync with MariaDB;
- rotating local backups.
Out of scope and intentionally removed:
- admin pricing UI/API;
- alerts and notification workflows;
- stock import tooling;
- cron jobs and importer utilities.
## Tech stack
| Layer | Stack |
| --- | --- |
| Backend | Go, Gin, GORM |
| Frontend | HTML templates, htmx, Tailwind CSS |
| Local storage | SQLite |
| Sync transport | MariaDB |
| Export | CSV and XLSX generation |
## Repository map
```text
cmd/
qfs/ main HTTP runtime
migrate/ server migration tool
migrate_ops_projects/ OPS project migration helper
internal/
appstate/ backup and runtime state
config/ runtime config parsing
handlers/ HTTP handlers
localdb/ SQLite models and migrations
repository/ repositories
services/ business logic and sync
web/
templates/ HTML templates
static/ static assets
bible/ shared engineering rules
bible-local/ project-specific architecture
releases/ release artifacts and notes
```

View File

@@ -0,0 +1,127 @@
# 02 - Architecture
## Local-first rule
SQLite is the runtime source of truth.
MariaDB is sync transport plus setup and migration tooling.
```text
browser -> Gin handlers -> SQLite
-> pending_changes
background sync <------> MariaDB
```
Rules:
- user CRUD must continue when MariaDB is offline;
- runtime handlers and pages must read and write SQLite only;
- MariaDB access in runtime code is allowed only inside sync and setup flows;
- no live MariaDB fallback for reads that already exist in local cache.
## Sync contract
Bidirectional:
- projects;
- configurations;
- `vendor_spec`;
- pending change metadata.
Pull-only:
- components;
- pricelists and pricelist items;
- partnumber books and partnumber book items.
Readiness guard:
- every sync push/pull runs a preflight check;
- blocked sync returns `423 Locked` with a machine-readable reason;
- local work continues even when sync is blocked.
- sync metadata updates must preserve project `updated_at`; sync time belongs in `synced_at`, not in the user-facing last-modified timestamp.
- pricelist pull must persist a new local snapshot atomically: header and items appear together, and `last_pricelist_sync` advances only after item download succeeds.
- UI sync status must distinguish "last sync failed" from "up to date"; if the app can prove newer server pricelist data exists, the indicator must say local cache is incomplete.
## Pricing contract
Prices come only from `local_pricelist_items`.
Rules:
- `local_components` is metadata-only;
- quote calculation must not read prices from components;
- latest pricelist selection ignores snapshots without items;
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
## Pricing tab layout
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
Column order (both tables):
```
PN вендора | Описание | LOT | Кол-во | Estimate | Склад | Конкуренты | Ручная цена
```
Per-LOT row expansion rules:
- each `lot_mappings` entry in a BOM row becomes its own table row with its own quantity and prices;
- `baseLot` (resolved LOT without an explicit mapping) is treated as the first sub-row with `quantity_per_pn` from `_getRowLotQtyPerPN`;
- when one vendor PN expands into N LOT sub-rows, PN вендора and Описание cells use `rowspan="N"` and appear only on the first sub-row;
- a visual top border (`border-t border-gray-200`) separates each vendor PN group.
Vendor price attachment:
- `vendorOrig` and `vendorOrigUnit` (BOM unit/total price) are attached to the first LOT sub-row only;
- subsequent sub-rows carry empty `data-vendor-orig` so `setPricingCustomPriceFromVendor` counts each vendor PN exactly once.
Controls terminology:
- custom price input is labeled **Ручная цена** (not "Своя цена");
- the button that fills custom price from BOM totals is labeled **BOM Цена** (not "Проставить цены BOM").
CSV export reads PN вендора, Описание, and LOT from `data-vendor-pn`, `data-desc`, `data-lot` row attributes to bypass the rowspan cell offset problem.
## Configuration versioning
Configuration revisions are append-only snapshots stored in `local_configuration_versions`.
Rules:
- the editable working configuration is always the implicit head named `main`; UI must not switch the user to a numbered revision after save;
- create a new revision when spec, BOM, or pricing content changes;
- revision history is retrospective: the revisions page shows past snapshots, not the current `main` state;
- rollback creates a new head revision from an old snapshot;
- rename, reorder, project move, and similar operational edits do not create a new revision snapshot;
- revision deduplication includes `items`, `server_count`, `total_price`, `custom_price`, `vendor_spec`, pricelist selectors, `disable_price_refresh`, and `only_in_stock`;
- BOM updates must use version-aware save flow, not a direct SQL field update;
- current revision pointer must be recoverable if legacy or damaged rows are found locally.
## Sync UX
UI-facing sync status must never block on live MariaDB calls.
Rules:
- navbar sync indicator and sync info modal read only local cached state from SQLite/app settings;
- background/manual sync may talk to MariaDB, but polling endpoints must stay fast even on slow or broken connections;
- any MariaDB timeout/invalid-connection during sync must invalidate the cached remote handle immediately so UI stops treating the connection as healthy.
## Naming collisions
UI-driven rename and copy flows use one suffix convention for conflicts.
Rules:
- configuration and variant names must auto-resolve collisions with `_копия`, then `_копия2`, `_копия3`, and so on;
- copy checkboxes and copy modals must prefill `_копия`, not ` (копия)`;
- the literal variant name `main` is reserved and must not be allowed for non-main variants.
## Configuration types
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.
Rules:
- PN to LOT resolution uses the active local partnumber book;
- canonical persisted mapping is `lot_mappings[]`;
- QuoteForge does not use legacy BOM tables such as `qt_bom`, `qt_lot_bundles`, or `qt_lot_bundle_items`.

403
bible-local/03-database.md Normal file
View File

@@ -0,0 +1,403 @@
# 03 - Database
## SQLite
SQLite is the local runtime database.
Main tables:
| Table | Purpose |
| --- | --- |
| `local_components` | synced component metadata |
| `local_pricelists` | local pricelist headers |
| `local_pricelist_items` | local pricelist rows, the only runtime price source |
| `local_projects` | user projects |
| `local_configurations` | user configurations |
| `local_configuration_versions` | immutable revision snapshots |
| `local_partnumber_books` | partnumber book headers |
| `local_partnumber_book_items` | PN -> LOT catalog payload |
| `pending_changes` | sync queue |
| `connection_settings` | encrypted MariaDB connection settings |
| `app_settings` | local app state |
| `local_schema_migrations` | applied local migration markers |
Rules:
- cache tables may be rebuilt if local migration recovery requires it;
- user-authored tables must not be dropped as a recovery shortcut;
- `local_pricelist_items` is the only valid runtime source of prices;
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows.
## MariaDB
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
### QuoteForge tables (qt_*)
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
- `qt_partnumber_books` — partnumber book headers
- `qt_partnumber_book_items` — PN→LOT catalog payload
Runtime read/write:
- `qt_projects` — projects
- `qt_configurations` — configurations
- `qt_client_schema_state` — per-client sync status and version tracking
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
Insert-only tracking:
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
Server-side only (not queried by client runtime):
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
- `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime)
- `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`)
- `qt_scheduler_runs` — server background job tracking (no Go code references it in this repo)
### Competitor subsystem (server-side only, not used by QuoteForge Go code)
- `qt_competitors` — competitor registry
- `partnumber_log_competitors` — competitor price log (FK → qt_competitors)
These tables exist in the schema and are maintained by another tool or workflow.
QuoteForge references competitor pricelists only via `qt_pricelists` (source='competitor').
### Legacy RFQ tables (pre-QuoteForge, no Go code references)
- `lot` — original component registry (data preserved; superseded by `qt_lot_metadata`)
- `lot_log` — original supplier price log
- `supplier` — supplier registry (FK target for lot_log and machine_log)
- `machine` — device model registry
- `machine_log` — device price/quote log
- `parts_log` — supplier partnumber log used by server-side import/pricing workflows, not by QuoteForge runtime
These tables are retained for historical data. QuoteForge does not read or write them at runtime.
Rules:
- QuoteForge runtime must not depend on any legacy RFQ tables;
- QuoteForge sync reads prices and categories from `qt_pricelists` / `qt_pricelist_items` only;
- QuoteForge does not enrich local pricelist rows from `parts_log` or any other raw supplier log table;
- normal UI requests must not query MariaDB tables directly;
- `qt_client_local_migrations` exists in the 2026-04-15 schema dump, but runtime sync does not depend on it.
## MariaDB Table Structures
Full column reference as of 2026-03-21 (`RFQ_LOG` final schema).
### qt_categories
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| code | varchar(20) UNIQUE NOT NULL | |
| name | varchar(100) NOT NULL | |
| name_ru | varchar(100) | |
| display_order | bigint DEFAULT 0 | |
| is_required | tinyint(1) DEFAULT 0 | |
### qt_client_schema_state
PK: (username, hostname)
| Column | Type | Notes |
|--------|------|-------|
| username | varchar(100) | |
| hostname | varchar(255) DEFAULT '' | |
| last_applied_migration_id | varchar(128) | |
| app_version | varchar(64) | |
| last_sync_at | datetime | |
| last_sync_status | varchar(32) | |
| pending_changes_count | int DEFAULT 0 | |
| pending_errors_count | int DEFAULT 0 | |
| configurations_count | int DEFAULT 0 | |
| projects_count | int DEFAULT 0 | |
| estimate_pricelist_version | varchar(128) | |
| warehouse_pricelist_version | varchar(128) | |
| competitor_pricelist_version | varchar(128) | |
| last_sync_error_code | varchar(128) | |
| last_sync_error_text | text | |
| last_checked_at | datetime NOT NULL | |
| updated_at | datetime NOT NULL | |
### qt_component_usage_stats
PK: lot_name
| Column | Type | Notes |
|--------|------|-------|
| lot_name | varchar(255) | |
| quotes_total | bigint DEFAULT 0 | |
| quotes_last30d | bigint DEFAULT 0 | |
| quotes_last7d | bigint DEFAULT 0 | |
| total_quantity | bigint DEFAULT 0 | |
| total_revenue | decimal(14,2) DEFAULT 0 | |
| trend_direction | enum('up','stable','down') DEFAULT 'stable' | |
| trend_percent | decimal(5,2) DEFAULT 0 | |
| last_used_at | datetime(3) | |
### qt_competitors
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| name | varchar(255) NOT NULL | |
| code | varchar(100) UNIQUE NOT NULL | |
| delivery_basis | varchar(50) DEFAULT 'DDP' | |
| currency | varchar(10) DEFAULT 'USD' | |
| column_mapping | longtext JSON | |
| is_active | tinyint(1) DEFAULT 1 | |
| created_at | timestamp | |
| updated_at | timestamp ON UPDATE | |
| price_uplift | decimal(8,4) DEFAULT 1.3 | effective_price = price / price_uplift |
### qt_configurations
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| uuid | varchar(36) UNIQUE NOT NULL | |
| user_id | bigint UNSIGNED | |
| owner_username | varchar(100) NOT NULL | |
| app_version | varchar(64) | |
| project_uuid | char(36) | FK → qt_projects.uuid ON DELETE SET NULL |
| name | varchar(200) NOT NULL | |
| items | longtext JSON NOT NULL | component list |
| total_price | decimal(12,2) | |
| notes | text | |
| is_template | tinyint(1) DEFAULT 0 | |
| created_at | datetime(3) | |
| custom_price | decimal(12,2) | |
| server_count | bigint DEFAULT 1 | |
| server_model | varchar(100) | |
| support_code | varchar(20) | |
| article | varchar(80) | |
| pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
| warehouse_pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
| competitor_pricelist_id | bigint UNSIGNED | FK → qt_pricelists.id |
| disable_price_refresh | tinyint(1) DEFAULT 0 | |
| only_in_stock | tinyint(1) DEFAULT 0 | |
| line_no | int | position within project |
| price_updated_at | timestamp | |
| vendor_spec | longtext JSON | |
### qt_lot_metadata
PK: lot_name
| Column | Type | Notes |
|--------|------|-------|
| lot_name | varchar(255) | |
| category_id | bigint UNSIGNED | FK → qt_categories.id |
| vendor | varchar(50) | |
| model | varchar(100) | |
| specs | longtext JSON | |
| current_price | decimal(12,2) | cached computed price |
| price_method | enum('manual','median','average','weighted_median') DEFAULT 'median' | |
| price_period_days | bigint DEFAULT 90 | |
| price_updated_at | datetime(3) | |
| request_count | bigint DEFAULT 0 | |
| last_request_date | date | |
| popularity_score | decimal(10,4) DEFAULT 0 | |
| price_coefficient | decimal(5,2) DEFAULT 0 | markup % |
| manual_price | decimal(12,2) | |
| meta_prices | varchar(1000) | raw price samples JSON |
| meta_method | varchar(20) | method used for last compute |
| meta_period_days | bigint DEFAULT 90 | |
| is_hidden | tinyint(1) DEFAULT 0 | |
### qt_partnumber_books
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| version | varchar(30) UNIQUE NOT NULL | |
| created_at | timestamp | |
| created_by | varchar(100) | |
| is_active | tinyint(1) DEFAULT 0 | only one active at a time |
| partnumbers_json | longtext DEFAULT '[]' | flat list of partnumbers |
### qt_partnumber_book_items
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| partnumber | varchar(255) UNIQUE NOT NULL | |
| lots_json | longtext NOT NULL | JSON array of lot_names |
| description | varchar(10000) | |
### qt_pricelists
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| source | varchar(20) DEFAULT 'estimate' | 'estimate' / 'warehouse' / 'competitor' |
| version | varchar(20) NOT NULL | UNIQUE with source |
| created_at | datetime(3) | |
| created_by | varchar(100) | |
| is_active | tinyint(1) DEFAULT 1 | |
| usage_count | bigint DEFAULT 0 | |
| expires_at | datetime(3) | |
| notification | varchar(500) | shown to clients on sync |
### qt_pricelist_items
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| pricelist_id | bigint UNSIGNED NOT NULL | FK → qt_pricelists.id |
| lot_name | varchar(255) NOT NULL | INDEX with pricelist_id |
| lot_category | varchar(50) | |
| price | decimal(12,2) NOT NULL | |
| price_method | varchar(20) | |
| price_period_days | bigint DEFAULT 90 | |
| price_coefficient | decimal(5,2) DEFAULT 0 | |
| manual_price | decimal(12,2) | |
| meta_prices | varchar(1000) | |
### qt_pricelist_sync_status
PK: username
| Column | Type | Notes |
|--------|------|-------|
| username | varchar(100) | |
| last_sync_at | datetime NOT NULL | |
| updated_at | datetime NOT NULL | |
| app_version | varchar(64) | |
### qt_pricing_alerts
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| lot_name | varchar(255) NOT NULL | |
| alert_type | enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price') | |
| severity | enum('low','medium','high','critical') DEFAULT 'medium' | |
| message | text NOT NULL | |
| details | longtext JSON | |
| status | enum('new','acknowledged','resolved','ignored') DEFAULT 'new' | |
| created_at | datetime(3) | |
### qt_projects
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| uuid | char(36) UNIQUE NOT NULL | |
| owner_username | varchar(100) NOT NULL | |
| code | varchar(100) NOT NULL | UNIQUE with variant |
| variant | varchar(100) DEFAULT '' | UNIQUE with code |
| name | varchar(200) | |
| tracker_url | varchar(500) | |
| is_active | tinyint(1) DEFAULT 1 | |
| is_system | tinyint(1) DEFAULT 0 | |
| created_at | timestamp | |
| updated_at | timestamp ON UPDATE | |
### qt_schema_migrations
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| filename | varchar(255) UNIQUE NOT NULL | |
| applied_at | datetime(3) | |
### qt_scheduler_runs
PK: job_name
| Column | Type | Notes |
|--------|------|-------|
| job_name | varchar(100) | |
| last_started_at | datetime | |
| last_finished_at | datetime | |
| last_status | varchar(20) DEFAULT 'idle' | |
| last_error | text | |
| updated_at | timestamp ON UPDATE | |
### qt_vendor_partnumber_seen
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| source_type | varchar(32) NOT NULL | |
| vendor | varchar(255) DEFAULT '' | |
| partnumber | varchar(255) UNIQUE NOT NULL | |
| description | varchar(10000) | |
| last_seen_at | datetime(3) NOT NULL | |
| is_ignored | tinyint(1) DEFAULT 0 | |
| is_pattern | tinyint(1) DEFAULT 0 | |
| ignored_at | datetime(3) | |
| ignored_by | varchar(100) | |
| created_at | datetime(3) | |
| updated_at | datetime(3) | |
| lot_suggestion | longtext (JSON) | nullable; set when user manually maps PN → LOT in vendor-spec UI; same format as `qt_partnumber_book_items.lots_json`; see [11-lot-suggestions.md](11-lot-suggestions.md) |
### stock_ignore_rules
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| target | varchar(20) NOT NULL | UNIQUE with match_type+pattern |
| match_type | varchar(20) NOT NULL | |
| pattern | varchar(500) NOT NULL | |
| created_at | timestamp | |
### stock_log
| Column | Type | Notes |
|--------|------|-------|
| stock_log_id | bigint UNSIGNED PK AUTO_INCREMENT | |
| partnumber | varchar(255) NOT NULL | INDEX with date |
| supplier | varchar(255) | |
| date | date NOT NULL | |
| price | decimal(12,2) NOT NULL | |
| quality | varchar(255) | |
| comments | text | |
| vendor | varchar(255) | INDEX |
| qty | decimal(14,3) | |
### partnumber_log_competitors
| Column | Type | Notes |
|--------|------|-------|
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
| competitor_id | bigint UNSIGNED NOT NULL | FK → qt_competitors.id |
| partnumber | varchar(255) NOT NULL | |
| description | varchar(500) | |
| vendor | varchar(255) | |
| price | decimal(12,2) NOT NULL | |
| price_loccur | decimal(12,2) | local currency price |
| currency | varchar(10) | |
| qty | decimal(12,4) DEFAULT 1 | |
| date | date NOT NULL | |
| created_at | timestamp | |
### Legacy tables (lot / lot_log / machine / machine_log / supplier)
Retained for historical data only. Not queried by QuoteForge.
**lot**: lot_name (PK, char 255), lot_category, lot_description
**lot_log**: lot_log_id AUTO_INCREMENT, lot (FK→lot), supplier (FK→supplier), date, price double, quality, comments
**supplier**: supplier_name (PK, char 255), supplier_comment
**machine**: machine_name (PK, char 255), machine_description
**machine_log**: machine_log_id AUTO_INCREMENT, date, supplier (FK→supplier), country, opty, type, machine (FK→machine), customer_requirement, variant, price_gpl, price_estimate, qty, quality, carepack, lead_time_weeks, prepayment_percent, price_got, Comment
## MariaDB User Permissions
The application user needs read-only access to reference tables and read/write access to runtime tables.
```sql
-- Read-only: reference and pricing data
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.qt_partnumber_books TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'qfs_user'@'%';
GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
-- Read/write: runtime sync and user data
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_vendor_partnumber_seen TO 'qfs_user'@'%';
FLUSH PRIVILEGES;
```
Rules:
- `qt_client_schema_state` requires INSERT + UPDATE for sync status tracking (uses `ON DUPLICATE KEY UPDATE`);
- `qt_vendor_partnumber_seen` requires INSERT + UPDATE (vendor PN discovery during sync);
- no DELETE is needed on sync/tracking tables — rows are never removed by the client;
- `lot` SELECT is required for the connection validation probe in `/setup`;
- the setup page shows `can_write: true` only when `qt_client_schema_state` INSERT succeeds.
## Migrations
SQLite:
- schema creation and additive changes go through GORM `AutoMigrate`;
- data fixes, index repair, and one-off rewrites go through `runLocalMigrations`;
- local migration state is tracked in `local_schema_migrations`.
MariaDB:
- SQL files live in `migrations/`;
- they are applied by `go run ./cmd/qfs -migrate`.

125
bible-local/04-api.md Normal file
View File

@@ -0,0 +1,125 @@
# 04 - API
## Public web routes
| Route | Purpose |
| --- | --- |
| `/` | configurator |
| `/configs` | configuration list |
| `/configs/:uuid/revisions` | revision history page |
| `/projects` | project list |
| `/projects/:uuid` | project detail |
| `/pricelists` | pricelist list |
| `/pricelists/:id` | pricelist detail |
| `/partnumber-books` | partnumber book page |
| `/setup` | DB setup page |
## Setup and health
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/health` | process health |
| `GET` | `/setup` | setup page |
| `POST` | `/setup` | save tested DB settings |
| `POST` | `/setup/test` | test DB connection |
| `GET` | `/setup/status` | setup status |
| `GET` | `/api/db-status` | current DB/sync status |
| `GET` | `/api/current-user` | local user identity |
| `GET` | `/api/ping` | lightweight API ping |
`POST /api/restart` exists only in `debug` mode.
## Reference data
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/components` | list component metadata |
| `GET` | `/api/components/:lot_name` | one component |
| `GET` | `/api/categories` | list categories |
| `GET` | `/api/pricelists` | list local pricelists |
| `GET` | `/api/pricelists/latest` | latest pricelist by source |
| `GET` | `/api/pricelists/:id` | pricelist header |
| `GET` | `/api/pricelists/:id/items` | pricelist rows |
| `GET` | `/api/pricelists/:id/lots` | lot names in a pricelist |
| `GET` | `/api/partnumber-books` | local partnumber books |
| `GET` | `/api/partnumber-books/:id` | book items by `server_id` |
## Quote and export
| Method | Path | Purpose |
| --- | --- | --- |
| `POST` | `/api/quote/validate` | validate config items |
| `POST` | `/api/quote/calculate` | calculate quote totals |
| `POST` | `/api/quote/price-levels` | resolve estimate/warehouse/competitor prices |
| `POST` | `/api/export/csv` | export a single configuration |
| `GET` | `/api/configs/:uuid/export` | export a stored configuration |
| `GET` | `/api/projects/:uuid/export` | legacy project BOM export |
| `POST` | `/api/projects/:uuid/export` | pricing-tab project export |
## Configurations
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/configs` | list configurations |
| `POST` | `/api/configs/import` | import configurations from server |
| `POST` | `/api/configs` | create configuration |
| `POST` | `/api/configs/preview-article` | preview article |
| `GET` | `/api/configs/:uuid` | get configuration |
| `PUT` | `/api/configs/:uuid` | update configuration |
| `DELETE` | `/api/configs/:uuid` | archive configuration |
| `POST` | `/api/configs/:uuid/reactivate` | reactivate configuration |
| `PATCH` | `/api/configs/:uuid/rename` | rename configuration |
| `POST` | `/api/configs/:uuid/clone` | clone configuration |
| `POST` | `/api/configs/:uuid/refresh-prices` | refresh prices |
| `PATCH` | `/api/configs/:uuid/project` | move configuration to project |
| `GET` | `/api/configs/:uuid/versions` | list revisions |
| `GET` | `/api/configs/:uuid/versions/:version` | get one revision |
| `POST` | `/api/configs/:uuid/rollback` | rollback by creating a new head revision |
| `PATCH` | `/api/configs/:uuid/server-count` | update server count |
| `GET` | `/api/configs/:uuid/vendor-spec` | read vendor BOM |
| `PUT` | `/api/configs/:uuid/vendor-spec` | replace vendor BOM |
| `POST` | `/api/configs/:uuid/vendor-spec/resolve` | resolve PN -> LOT |
| `POST` | `/api/configs/:uuid/vendor-spec/apply` | apply BOM to cart |
## Projects
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/projects` | paginated project list |
| `GET` | `/api/projects/all` | lightweight list for dropdowns |
| `POST` | `/api/projects` | create project |
| `GET` | `/api/projects/:uuid` | get project |
| `PUT` | `/api/projects/:uuid` | update project |
| `POST` | `/api/projects/:uuid/archive` | archive project |
| `POST` | `/api/projects/:uuid/reactivate` | reactivate project |
| `DELETE` | `/api/projects/:uuid` | delete project variant only |
| `GET` | `/api/projects/:uuid/configs` | list project configurations |
| `PATCH` | `/api/projects/:uuid/configs/reorder` | persist line order |
| `POST` | `/api/projects/:uuid/configs` | create configuration inside project |
| `POST` | `/api/projects/:uuid/configs/:config_uuid/clone` | clone config into project |
| `POST` | `/api/projects/:uuid/vendor-import` | import CFXML workspace into project |
Vendor import contract:
- multipart field name is `file`;
- file limit is `1 GiB`;
- oversized payloads are rejected before XML parsing.
## Sync
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/sync/status` | sync status |
| `GET` | `/api/sync/readiness` | sync readiness |
| `GET` | `/api/sync/info` | sync modal data |
| `GET` | `/api/sync/users-status` | remote user status |
| `GET` | `/api/sync/pending/count` | pending queue count |
| `GET` | `/api/sync/pending` | pending queue rows |
| `POST` | `/api/sync/components` | pull components |
| `POST` | `/api/sync/pricelists` | pull pricelists |
| `POST` | `/api/sync/partnumber-books` | pull partnumber books |
| `POST` | `/api/sync/partnumber-seen` | report unresolved vendor PN |
| `POST` | `/api/sync/all` | push and pull full sync |
| `POST` | `/api/sync/push` | push pending changes |
| `POST` | `/api/sync/repair` | repair broken pending rows |
When readiness is blocked, sync write endpoints return `423 Locked`.

74
bible-local/05-config.md Normal file
View File

@@ -0,0 +1,74 @@
# 05 - Config
## Runtime files
| Artifact | Default location |
| --- | --- |
| `qfs.db` | OS-specific user state directory |
| `config.yaml` | same state directory as `qfs.db` |
| `local_encryption.key` | same state directory as `qfs.db` |
| `backups/` | next to `qfs.db` unless overridden |
The runtime state directory can be overridden with `QFS_STATE_DIR`.
Direct paths can be overridden with `QFS_DB_PATH` and `QFS_CONFIG_PATH`.
## Runtime config shape
Runtime keeps `config.yaml` intentionally small:
```yaml
server:
host: "127.0.0.1"
port: 8080
mode: "release"
read_timeout: 30s
write_timeout: 30s
backup:
time: "00:00"
logging:
level: "info"
format: "json"
output: "stdout"
```
Rules:
- QuoteForge creates this file automatically if it does not exist;
- startup rewrites legacy config files into this minimal runtime shape;
- startup normalizes any `server.host` value to `127.0.0.1` before saving the runtime config;
- `server.host` must stay on loopback.
Saved MariaDB credentials do not live in `config.yaml`.
They are stored in SQLite and encrypted with `local_encryption.key` unless `QUOTEFORGE_ENCRYPTION_KEY` overrides the key material.
## Environment variables
| Variable | Purpose |
| --- | --- |
| `QFS_STATE_DIR` | override runtime state directory |
| `QFS_DB_PATH` | explicit SQLite path |
| `QFS_CONFIG_PATH` | explicit config path |
| `QFS_BACKUP_DIR` | explicit backup root |
| `QFS_BACKUP_DISABLE` | disable rotating backups |
| `QUOTEFORGE_ENCRYPTION_KEY` | override encryption key |
| `QF_SERVER_PORT` | override HTTP port |
`QFS_BACKUP_DISABLE` accepts `1`, `true`, or `yes`.
## CLI flags
| Flag | Purpose |
| --- | --- |
| `-config <path>` | config file path |
| `-localdb <path>` | SQLite path |
| `-reset-localdb` | destructive local DB reset |
| `-migrate` | apply server migrations and exit |
| `-version` | print app version and exit |
## First run
1. runtime ensures `config.yaml` exists;
2. runtime opens the local SQLite database;
3. if no stored MariaDB credentials exist, `/setup` is served;
4. after setup, runtime works locally and sync uses saved DB settings in the background.

55
bible-local/06-backup.md Normal file
View File

@@ -0,0 +1,55 @@
# 06 - Backup
## Scope
QuoteForge creates rotating local ZIP backups of:
- a consistent SQLite snapshot saved as `qfs.db`;
- `config.yaml` when present.
The backup intentionally does not include `local_encryption.key`.
## Location and naming
Default root:
- `<db dir>/backups`
Subdirectories:
- `daily/`
- `weekly/`
- `monthly/`
- `yearly/`
Archive name:
- `qfs-backp-YYYY-MM-DD.zip`
## Retention
| Period | Keep |
| --- | --- |
| Daily | 7 |
| Weekly | 4 |
| Monthly | 12 |
| Yearly | 10 |
## Behavior
- on startup, QuoteForge creates a backup if the current period has none yet;
- a daily scheduler creates the next backup at `backup.time`;
- duplicate snapshots inside the same period are prevented by a period marker file;
- old archives are pruned automatically.
## Safety rules
- backup root must be outside the git worktree;
- backup creation is blocked if the resolved backup root sits inside the repository;
- SQLite snapshot must be created from a consistent database copy, not by copying live WAL files directly;
- restore to another machine requires re-entering DB credentials unless the encryption key is migrated separately.
## Restore
1. stop QuoteForge;
2. unpack the chosen archive outside the repository;
3. replace `qfs.db`;
4. replace `config.yaml` if needed;
5. restart the app;
6. re-enter MariaDB credentials if the original encryption key is unavailable.

35
bible-local/07-dev.md Normal file
View File

@@ -0,0 +1,35 @@
# 07 - Development
## Common commands
```bash
go run ./cmd/qfs
go run ./cmd/qfs -migrate
go run ./cmd/migrate_project_updated_at
go test ./...
go vet ./...
make build-release
make install-hooks
```
## Guardrails
- run `gofmt` before commit;
- use `slog` for server logging;
- keep runtime business logic SQLite-only;
- limit MariaDB access to sync, setup, and migration tooling;
- keep `config.yaml` out of git and use `config.example.yaml` only as a template;
- update `bible-local/` in the same commit as architecture changes.
## Removed features that must not return
- admin pricing UI/API;
- alerts and notification workflows;
- stock import tooling;
- cron jobs;
- standalone importer utility.
## Release notes
Release history belongs under `releases/<version>/RELEASE_NOTES.md`.
Do not keep temporary change summaries in the repository root.

View File

@@ -0,0 +1,125 @@
# 09 - Vendor BOM
## Storage contract
Vendor BOM is stored in `local_configurations.vendor_spec` and synced with `qt_configurations.vendor_spec`.
Each row uses this canonical shape:
```json
{
"sort_order": 10,
"vendor_partnumber": "ABC-123",
"quantity": 2,
"description": "row description",
"unit_price": 4500.0,
"total_price": 9000.0,
"lot_mappings": [
{ "lot_name": "LOT_A", "quantity_per_pn": 1 }
]
}
```
Rules:
- `lot_mappings[]` is the only persisted PN -> LOT mapping contract;
- QuoteForge does not use legacy BOM tables;
- apply flow rebuilds cart rows from `lot_mappings[]`.
## Partnumber books
Partnumber books are pull-only snapshots from PriceForge.
Local tables:
- `local_partnumber_books`
- `local_partnumber_book_items`
Server tables:
- `qt_partnumber_books`
- `qt_partnumber_book_items`
Resolution flow:
1. load the active local book;
2. find `vendor_partnumber`;
3. copy `lots_json` into `lot_mappings[]`;
4. keep unresolved rows editable in the UI.
## CFXML import
`POST /api/projects/:uuid/vendor-import` imports one vendor workspace into an existing project.
Rules:
- accepted file field is `file`;
- maximum file size is `1 GiB`;
- one `ProprietaryGroupIdentifier` becomes one QuoteForge configuration;
- software rows stay inside their hardware group and never become standalone configurations;
- primary group row is selected structurally, without vendor-specific SKU hardcoding;
- imported configuration order follows workspace order.
Imported configuration fields:
- `name` from primary row `ProductName`
- `server_count` from primary row `Quantity`
- `server_model` from primary row `ProductDescription`
- `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.
## Text BOM import
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a human-readable Russian text BOM.
Format: an optional header line ending with `, в составе:` followed by one component per line as
`<description> - <quantity> шт.`. The separator may be a hyphen, en-dash, or em-dash; the space before
`шт` is optional; a trailing `.` after `шт` is optional. Quantities are anchored to the end of the line,
so hyphens, commas, and digits inside the description (e.g. `8-GPU-2304GB`, `RAID0,1,10`) are preserved.
Example:
```
Вычислительный GPU сервер G5500V7, в составе:
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
NVIDIA twin port transceiver, 800Gbps, OSFP - 8шт.
```
Rules:
- the entire file becomes a single configuration (`server_count = 1`);
- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the
last whitespace-separated token before the comma (so both `Сервер X3` and `Вычислительный GPU сервер X3`
resolve to `X3`);
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
- each line is trimmed, so leading/trailing whitespace never enters `vendor_partnumber`;
- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and
`description`, so rows resolve through the active partnumber book when matched and otherwise stay
unresolved and editable in the UI;
- lines that do not match `<description> - <quantity> шт.` are skipped;
- no price data is present in the format; `unit_price` and `total_price` are left nil.
## Pasted BOM text parsing
`POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM
(Inspur and Russian text BOM) into rows. Request body: `{"text": "..."}`. Response:
`{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`.
This shares the exact detectors and parsers used by the file-import path
(`ParsePastedBOMText``IsInspurBOM`/`parseInspurBOM`, `IsTextBOM`/`parseTextBOM`), so paste and upload
behave identically — there is no second parser in the frontend. The configurator's BOM paste box calls
this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real spreadsheet table)
falls back to the manual column-mapping grid.

View File

@@ -0,0 +1,563 @@
# 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

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

32
bible-local/README.md Normal file
View File

@@ -0,0 +1,32 @@
# QuoteForge Bible
Project-specific architecture and operational contracts.
## Files
| File | Scope |
| --- | --- |
| [01-overview.md](01-overview.md) | Product scope, runtime model, repository map |
| [02-architecture.md](02-architecture.md) | Local-first rules, sync, pricing, versioning |
| [03-database.md](03-database.md) | SQLite and MariaDB data model, permissions, migrations |
| [04-api.md](04-api.md) | HTTP routes and API contract |
| [05-config.md](05-config.md) | Runtime config, paths, env vars, startup behavior |
| [06-backup.md](06-backup.md) | Backup contract and restore workflow |
| [07-dev.md](07-dev.md) | Development commands and guardrails |
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
| [10-agent-api-guide.md](10-agent-api-guide.md) | End-to-end API guide for agents pricing servers from a TZ |
| [11-lot-suggestions.md](11-lot-suggestions.md) | lot_suggestion column in qt_vendor_partnumber_seen — write/read contract for manual UI mappings |
## Rules
- `bible-local/` is the source of truth for QuoteForge-specific behavior.
- Keep these files in English.
- Update the matching file in the same commit as any architectural change.
- Remove stale documentation instead of preserving history in place.
## Quick reference
- Local DB path: see [05-config.md](05-config.md)
- Runtime bind: loopback only
- Local backups: see [06-backup.md](06-backup.md)
- Release notes: `releases/<version>/RELEASE_NOTES.md`

View File

@@ -0,0 +1,31 @@
# 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

@@ -0,0 +1,86 @@
# 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)
fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs")
slog.Info("Done! You can now run the server with: go run ./cmd/qfs")
}
func derefUint(v *uint) uint {

View File

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

View File

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

View File

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

View File

@@ -6,9 +6,11 @@ import (
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log/slog"
"math"
"net"
"net/http"
"os"
"os/exec"
@@ -31,7 +33,6 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
@@ -43,11 +44,19 @@ import (
// Version is set via ldflags during build
var Version = "dev"
var errVendorImportTooLarge = errors.New("vendor workspace file exceeds 1 GiB limit")
const backgroundSyncInterval = 5 * time.Minute
const onDemandPullCooldown = 30 * time.Second
const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать"
var vendorImportMaxBytes int64 = 1 << 30
const vendorImportMultipartOverheadBytes int64 = 8 << 20
func main() {
showStartupConsoleWarning()
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
@@ -139,6 +148,15 @@ func main() {
}
}
setConfigDefaults(cfg)
normalizedHost, changed, err := normalizeLoopbackServerHost(cfg.Server.Host)
if err != nil {
slog.Error("invalid server host", "host", cfg.Server.Host, "error", err)
os.Exit(1)
}
if changed {
slog.Warn("corrected server host to loopback", "from", cfg.Server.Host, "to", normalizedHost)
}
cfg.Server.Host = normalizedHost
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
os.Exit(1)
@@ -147,29 +165,25 @@ func main() {
setupLogger(cfg.Logging)
// Create connection manager and try to connect immediately if settings exist
// Create connection manager. Runtime stays local-first; MariaDB is used on demand by sync/setup only.
connMgr := db.NewConnectionManager(local)
dbUser := local.GetDBUser()
// Try to connect to MariaDB on startup
mariaDB, err := connMgr.GetDB()
if err != nil {
slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err)
mariaDB = nil
} else {
slog.Info("successfully connected to MariaDB on startup")
}
slog.Info("starting QuoteForge server",
"version", Version,
"host", cfg.Server.Host,
"port", cfg.Server.Port,
"db_user", dbUser,
"online", mariaDB != nil,
"online", false,
)
if *migrate {
mariaDB, err := connMgr.GetDB()
if err != nil {
slog.Error("cannot run migrations: database not available", "error", err)
os.Exit(1)
}
if mariaDB == nil {
slog.Error("cannot run migrations: database not available")
os.Exit(1)
@@ -186,39 +200,10 @@ func main() {
slog.Info("migrations completed")
}
// Always apply SQL migrations on startup when database is available.
// This keeps schema in sync for long-running installations without manual steps.
// If current DB user does not have enough privileges, continue startup in normal mode.
if mariaDB != nil {
sqlMigrationsPath := filepath.Join("migrations")
needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath)
if err != nil {
if models.IsMigrationPermissionError(err) {
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
} else {
slog.Error("startup SQL migrations check failed", "path", sqlMigrationsPath, "error", err)
os.Exit(1)
}
} else if needsMigrations {
if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil {
if models.IsMigrationPermissionError(err) {
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
} else {
slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err)
os.Exit(1)
}
} else {
slog.Info("startup SQL migrations applied", "path", sqlMigrationsPath)
}
} else {
slog.Debug("startup SQL migrations not needed", "path", sqlMigrationsPath)
}
}
gin.SetMode(cfg.Server.Mode)
restartSig := make(chan struct{}, 1)
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser, restartSig)
router, syncService, err := setupRouter(cfg, local, connMgr, dbUser, restartSig)
if err != nil {
slog.Error("failed to setup router", "error", err)
os.Exit(1)
@@ -303,6 +288,12 @@ func main() {
}
}
func showStartupConsoleWarning() {
slog.Warn(startupConsoleWarning)
// Keep the warning always visible in the console window title when supported.
fmt.Printf("\033]0;%s\007", startupConsoleWarning)
}
func shouldResetLocalDB(flagValue bool) bool {
if flagValue {
return true
@@ -326,8 +317,6 @@ func derefString(value *string) string {
return *value
}
func setConfigDefaults(cfg *config.Config) {
if cfg.Server.Host == "" {
cfg.Server.Host = "127.0.0.1"
@@ -342,31 +331,51 @@ func setConfigDefaults(cfg *config.Config) {
cfg.Server.ReadTimeout = 30 * time.Second
}
if cfg.Server.WriteTimeout == 0 {
cfg.Server.WriteTimeout = 30 * time.Second
}
if cfg.Pricing.DefaultMethod == "" {
cfg.Pricing.DefaultMethod = "weighted_median"
}
if cfg.Pricing.DefaultPeriodDays == 0 {
cfg.Pricing.DefaultPeriodDays = 90
}
if cfg.Pricing.FreshnessGreenDays == 0 {
cfg.Pricing.FreshnessGreenDays = 30
}
if cfg.Pricing.FreshnessYellowDays == 0 {
cfg.Pricing.FreshnessYellowDays = 60
}
if cfg.Pricing.FreshnessRedDays == 0 {
cfg.Pricing.FreshnessRedDays = 90
}
if cfg.Pricing.MinQuotesForMedian == 0 {
cfg.Pricing.MinQuotesForMedian = 3
// 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
}
if cfg.Backup.Time == "" {
cfg.Backup.Time = "00:00"
}
}
func normalizeLoopbackServerHost(host string) (string, bool, error) {
trimmed := strings.TrimSpace(host)
if trimmed == "" {
return "", false, fmt.Errorf("server.host must not be empty")
}
const loopbackHost = "127.0.0.1"
if trimmed == loopbackHost {
return loopbackHost, false, nil
}
if strings.EqualFold(trimmed, "localhost") {
return loopbackHost, true, nil
}
ip := net.ParseIP(strings.Trim(trimmed, "[]"))
if ip != nil {
if ip.IsLoopback() || ip.IsUnspecified() {
return loopbackHost, trimmed != loopbackHost, nil
}
return loopbackHost, true, nil
}
return loopbackHost, true, nil
}
func vendorImportBodyLimit() int64 {
return vendorImportMaxBytes + vendorImportMultipartOverheadBytes
}
func isVendorImportTooLarge(fileSize int64, err error) bool {
if fileSize > vendorImportMaxBytes {
return true
}
var maxBytesErr *http.MaxBytesError
return errors.As(err, &maxBytesErr)
}
func ensureDefaultConfigFile(configPath string) error {
if strings.TrimSpace(configPath) == "" {
return fmt.Errorf("config path is empty")
@@ -386,7 +395,7 @@ func ensureDefaultConfigFile(configPath string) error {
port: 8080
mode: "release"
read_timeout: 30s
write_timeout: 30s
write_timeout: 10m
backup:
time: "00:00"
@@ -547,11 +556,11 @@ func runSetupMode(local *localdb.LocalDB) {
router := gin.New()
router.Use(gin.Recovery())
staticPath := filepath.Join("web", "static")
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
router.Static("/static", staticPath)
} else if staticFS, err := qfassets.StaticFS(); err == nil {
if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS))
} else {
slog.Error("failed to load embedded static assets", "error", err)
os.Exit(1)
}
// Setup routes only
@@ -663,46 +672,14 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
return db, nil
}
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string, restartSig chan struct{}) (*gin.Engine, *sync.Service, error) {
// mariaDB may be nil if we're in offline mode
// Repositories
var componentRepo *repository.ComponentRepository
var categoryRepo *repository.CategoryRepository
var statsRepo *repository.StatsRepository
var pricelistRepo *repository.PricelistRepository
// Only initialize repositories if we have a database connection
if mariaDB != nil {
componentRepo = repository.NewComponentRepository(mariaDB)
categoryRepo = repository.NewCategoryRepository(mariaDB)
statsRepo = repository.NewStatsRepository(mariaDB)
pricelistRepo = repository.NewPricelistRepository(mariaDB)
} else {
// In offline mode, we'll use nil repositories or handle them differently
// This is handled in the sync service and other components
}
// Services
var componentService *services.ComponentService
var quoteService *services.QuoteService
var exportService *services.ExportService
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, dbUsername string, restartSig chan struct{}) (*gin.Engine, *sync.Service, error) {
var syncService *sync.Service
var projectService *services.ProjectService
// Sync service always uses ConnectionManager (works offline and online)
syncService = sync.NewService(connMgr, local)
if mariaDB != nil {
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
exportService = services.NewExportService(cfg.Export, categoryRepo, local)
} else {
// In offline mode, we still need to create services that don't require DB.
componentService = services.NewComponentService(nil, nil, nil)
quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
exportService = services.NewExportService(cfg.Export, nil, local)
}
componentService := services.NewComponentService(nil, nil)
quoteService := services.NewQuoteService(nil, nil, local, nil)
exportService := services.NewExportService(cfg.Export, local)
// isOnline function for local-first architecture
isOnline := func() bool {
@@ -723,16 +700,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := local.BackfillConfigurationProjects(dbUsername); err != nil {
slog.Warn("failed to backfill local configuration projects", "error", err)
}
if mariaDB != nil {
serverProjectRepo := repository.NewProjectRepository(mariaDB)
if removed, err := serverProjectRepo.PurgeEmptyNamelessProjects(); err == nil && removed > 0 {
slog.Info("purged empty nameless server projects", "removed", removed)
}
if err := serverProjectRepo.EnsureSystemProjectsAndBackfillConfigurations(); err != nil {
slog.Warn("failed to backfill server configuration projects", "error", err)
}
}
type pullState struct {
mu syncpkg.Mutex
running bool
@@ -810,13 +777,18 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Handlers
componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, projectService)
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
pricelistHandler := handlers.NewPricelistHandler(local)
vendorSpecHandler := handlers.NewVendorSpecHandler(local, syncService)
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
respondError := handlers.RespondError
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil {
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 {
@@ -824,24 +796,24 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
// Web handler (templates)
webHandler, err := handlers.NewWebHandler(templatesPath, componentService)
webHandler, err := handlers.NewWebHandler(templatesPath, local)
if err != nil {
return nil, nil, err
}
// Router
router := gin.New()
router.MaxMultipartMemory = vendorImportBodyLimit()
router.Use(gin.Recovery())
router.Use(requestLogger())
router.Use(middleware.CORS())
router.Use(middleware.OfflineDetector(connMgr, local))
// Static files (use filepath.Join for Windows compatibility)
staticPath := filepath.Join("web", "static")
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
router.Static("/static", staticPath)
} else if staticFS, err := qfassets.StaticFS(); err == nil {
if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS))
} else {
return nil, nil, fmt.Errorf("load embedded static assets: %w", err)
}
// Health check
@@ -852,17 +824,17 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
})
})
// Restart endpoint (for development purposes)
router.POST("/api/restart", func(c *gin.Context) {
// This will cause the server to restart by exiting
// The restartProcess function will be called to restart the process
slog.Info("Restart requested via API")
go func() {
time.Sleep(100 * time.Millisecond)
restartProcess()
}()
c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
})
// Restart endpoint is intentionally debug-only.
if cfg.Server.Mode == "debug" {
router.POST("/api/restart", func(c *gin.Context) {
slog.Info("Restart requested via API")
go func() {
time.Sleep(100 * time.Millisecond)
restartProcess()
}()
c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
})
}
// DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) {
@@ -881,20 +853,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
}
// Optional diagnostics mode with server table counts.
if includeCounts && status.IsConnected {
if db, err := connMgr.GetDB(); err == nil && db != nil {
_ = db.Table("lot").Count(&lotCount)
_ = db.Table("lot_log").Count(&lotLogCount)
_ = db.Table("qt_lot_metadata").Count(&metadataCount)
} else if err != nil {
dbOK = false
dbError = err.Error()
} else {
dbOK = false
dbError = "Database not connected (offline mode)"
}
} else {
// Runtime diagnostics stay local-only. Server table counts are intentionally unavailable here.
if !includeCounts || !status.IsConnected {
lotCount = 0
lotLogCount = 0
metadataCount = 0
@@ -910,11 +870,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
})
})
// Current user info (DB user, not app user)
// Current user info (local DB username)
router.GET("/api/current-user", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"username": local.GetDBUser(),
"role": "db_user",
})
})
@@ -933,6 +892,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.GET("/configs/:uuid/revisions", webHandler.ConfigRevisions)
router.GET("/pricelists", webHandler.Pricelists)
router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/partnumber-books", webHandler.PartnumberBooks)
// htmx partials
partials := router.Group("/partials")
@@ -948,6 +908,8 @@ 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")
{
@@ -982,6 +944,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricelists.GET("/:id/lots", pricelistHandler.GetLotNames)
}
// Partnumber books (read-only)
pnBooks := api.Group("/partnumber-books")
{
pnBooks.GET("", partnumberBooksHandler.List)
pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
}
// Stateless BOM text parsing shared by paste and file-import paths.
api.POST("/vendor-spec/parse-text", vendorSpecHandler.ParseText)
// Configurations (public - RBAC disabled)
configs := api.Group("/configs")
{
@@ -999,7 +971,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
@@ -1020,7 +992,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
c.JSON(http.StatusOK, result)
@@ -1029,13 +1001,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.POST("", func(c *gin.Context) {
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
config, err := configService.Create(dbUsername, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
@@ -1045,12 +1017,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.POST("/preview-article", func(c *gin.Context) {
var req services.ArticlePreviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
result, err := configService.BuildArticlePreview(&req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -1073,7 +1045,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
uuid := c.Param("uuid")
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
@@ -1081,13 +1053,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil {
switch {
case errors.Is(err, services.ErrConfigNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
respondError(c, http.StatusForbidden, "access denied", err)
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return
}
@@ -1098,7 +1070,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.DELETE("/:uuid", func(c *gin.Context) {
uuid := c.Param("uuid")
if err := configService.DeleteNoAuth(uuid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "archived"})
@@ -1108,7 +1080,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
uuid := c.Param("uuid")
config, err := configService.ReactivateNoAuth(uuid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -1123,13 +1095,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
config, err := configService.RenameNoAuth(uuid, req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
@@ -1143,7 +1115,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
FromVersion int `json:"from_version"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
@@ -1153,7 +1125,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
@@ -1162,9 +1134,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
uuid := c.Param("uuid")
config, err := configService.RefreshPricesNoAuth(uuid)
var req struct {
PricelistID *uint `json:"pricelist_id"`
}
// Ignore bind error — pricelist_id is optional
_ = c.ShouldBindJSON(&req)
config, err := configService.RefreshPricesNoAuth(uuid, req.PricelistID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
c.JSON(http.StatusOK, config)
@@ -1176,20 +1153,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
ProjectUUID string `json:"project_uuid"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID)
if err != nil {
switch {
case errors.Is(err, services.ErrConfigNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
respondError(c, http.StatusForbidden, "access denied", err)
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return
}
@@ -1218,7 +1195,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
case errors.Is(err, services.ErrInvalidVersionNumber):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return
}
@@ -1246,7 +1223,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
case errors.Is(err, services.ErrConfigVersionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return
}
@@ -1261,7 +1238,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
Note string `json:"note"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
if req.TargetVersion <= 0 {
@@ -1279,7 +1256,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
case errors.Is(err, services.ErrVersionConflict):
c.JSON(http.StatusConflict, gin.H{"error": "version conflict"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return
}
@@ -1301,6 +1278,13 @@ 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)
configs.PUT("/:uuid/vendor-spec", vendorSpecHandler.PutVendorSpec)
configs.POST("/:uuid/vendor-spec/resolve", vendorSpecHandler.ResolveVendorSpec)
configs.POST("/:uuid/vendor-spec/apply", vendorSpecHandler.ApplyVendorSpec)
configs.PATCH("/:uuid/server-count", func(c *gin.Context) {
uuid := c.Param("uuid")
@@ -1308,12 +1292,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
ServerCount int `json:"server_count" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
config, err := configService.UpdateServerCount(uuid, req.ServerCount)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
c.JSON(http.StatusOK, config)
@@ -1358,7 +1342,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
@@ -1492,7 +1476,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.GET("/all", func(c *gin.Context) {
allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
@@ -1522,7 +1506,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.POST("", func(c *gin.Context) {
var req services.CreateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
if strings.TrimSpace(req.Code) == "" {
@@ -1532,10 +1516,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
project, err := projectService.Create(dbUsername, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrReservedMainVariant):
respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectCodeExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
respondError(c, http.StatusConflict, "conflict detected", err)
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return
}
@@ -1547,11 +1533,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
respondError(c, http.StatusForbidden, "access denied", err)
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return
}
@@ -1561,20 +1547,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.PUT("/:uuid", func(c *gin.Context) {
var req services.UpdateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrReservedMainVariant),
errors.Is(err, services.ErrCannotRenameMainVariant):
respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectCodeExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
respondError(c, http.StatusConflict, "conflict detected", err)
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
respondError(c, http.StatusForbidden, "access denied", err)
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return
}
@@ -1585,11 +1574,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
respondError(c, http.StatusForbidden, "access denied", err)
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return
}
@@ -1600,11 +1589,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
respondError(c, http.StatusForbidden, "access denied", err)
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return
}
@@ -1615,13 +1604,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
switch {
case errors.Is(err, services.ErrCannotDeleteMainVariant):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
respondError(c, http.StatusForbidden, "access denied", err)
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return
}
@@ -1641,11 +1630,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
respondError(c, http.StatusForbidden, "access denied", err)
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
}
return
}
@@ -1653,10 +1642,47 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusOK, result)
})
projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) {
var req struct {
OrderedUUIDs []string `json:"ordered_uuids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
if len(req.OrderedUUIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ordered_uuids is required"})
return
}
configs, err := configService.ReorderProjectConfigurationsNoAuth(c.Param("uuid"), req.OrderedUUIDs)
if err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err)
default:
respondError(c, http.StatusBadRequest, "invalid request", err)
}
return
}
total := 0.0
for i := range configs {
if configs[i].TotalPrice != nil {
total += *configs[i].TotalPrice
}
}
c.JSON(http.StatusOK, gin.H{
"project_uuid": c.Param("uuid"),
"configurations": configs,
"total": total,
})
})
projects.POST("/:uuid/configs", func(c *gin.Context) {
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
projectUUID := c.Param("uuid")
@@ -1664,27 +1690,79 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
config, err := configService.Create(dbUsername, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
c.JSON(http.StatusCreated, config)
})
projects.POST("/:uuid/vendor-import", func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, vendorImportBodyLimit())
fileHeader, err := c.FormFile("file")
if err != nil {
if isVendorImportTooLarge(0, err) {
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return
}
respondError(c, http.StatusBadRequest, "file is required", err)
return
}
if isVendorImportTooLarge(fileHeader.Size, nil) {
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return
}
file, err := fileHeader.Open()
if err != nil {
respondError(c, http.StatusBadRequest, "failed to open uploaded file", err)
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, vendorImportMaxBytes+1))
if err != nil {
respondError(c, http.StatusBadRequest, "failed to read uploaded file", err)
return
}
if int64(len(data)) > vendorImportMaxBytes {
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return
}
if !services.IsCFXMLWorkspace(data) && !services.IsQuoteForgeCSV(data) && !services.IsInspurBOM(data) && !services.IsTextBOM(data) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
return
}
result, err := configService.ImportVendorWorkspaceToProject(c.Param("uuid"), fileHeader.Filename, data, dbUsername)
if err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err)
default:
respondError(c, http.StatusBadRequest, "invalid request", err)
}
return
}
c.JSON(http.StatusCreated, result)
})
projects.GET("/:uuid/export", exportHandler.ExportProjectCSV)
projects.POST("/:uuid/export", exportHandler.ExportProjectPricingCSV)
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondError(c, http.StatusBadRequest, "invalid request", err)
return
}
projectUUID := c.Param("uuid")
config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
respondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
c.JSON(http.StatusCreated, config)
@@ -1700,6 +1778,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks)
syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen)
syncAPI.POST("/all", syncHandler.SyncAll)
syncAPI.POST("/push", syncHandler.PushPendingChanges)
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
@@ -1756,22 +1836,12 @@ func requestLogger() gin.HandlerFunc {
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
blw := &captureResponseWriter{
ResponseWriter: c.Writer,
body: bytes.NewBuffer(nil),
}
c.Writer = blw
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
if status >= http.StatusBadRequest {
responseBody := strings.TrimSpace(blw.body.String())
if len(responseBody) > 2048 {
responseBody = responseBody[:2048] + "...(truncated)"
}
errText := strings.TrimSpace(c.Errors.String())
slog.Error("request failed",
@@ -1782,7 +1852,6 @@ func requestLogger() gin.HandlerFunc {
"latency", latency,
"ip", c.ClientIP(),
"errors", errText,
"response", responseBody,
)
return
}
@@ -1797,22 +1866,3 @@ func requestLogger() gin.HandlerFunc {
)
}
}
type captureResponseWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w *captureResponseWriter) Write(b []byte) (int, error) {
if len(b) > 0 {
_, _ = w.body.Write(b)
}
return w.ResponseWriter.Write(b)
}
func (w *captureResponseWriter) WriteString(s string) (int, error) {
if s != "" {
_, _ = w.body.WriteString(s)
}
return w.ResponseWriter.WriteString(s)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,213 @@
# Руководство по составлению каталога лотов СХД
## Что такое 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
```

5
go.mod
View File

@@ -5,9 +5,8 @@ go 1.24.0
require (
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/go-sql-driver/mysql v1.7.1
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.43.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.2
gorm.io/gorm v1.25.7
@@ -23,7 +22,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -39,6 +37,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect

2
go.sum
View File

@@ -32,8 +32,6 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=

View File

@@ -10,6 +10,10 @@ import (
"sort"
"strings"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type backupPeriod struct {
@@ -88,6 +92,9 @@ func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
}
root := resolveBackupRoot(dbPath)
if err := validateBackupRoot(root); err != nil {
return nil, err
}
now := backupNow()
created := make([]string, 0)
@@ -111,6 +118,40 @@ func resolveBackupRoot(dbPath string) string {
return filepath.Join(filepath.Dir(dbPath), "backups")
}
func validateBackupRoot(root string) error {
absRoot, err := filepath.Abs(root)
if err != nil {
return fmt.Errorf("resolve backup root: %w", err)
}
if gitRoot, ok := findGitWorktreeRoot(absRoot); ok {
return fmt.Errorf("backup root must stay outside git worktree: %s is inside %s", absRoot, gitRoot)
}
return nil
}
func findGitWorktreeRoot(path string) (string, bool) {
current := filepath.Clean(path)
info, err := os.Stat(current)
if err == nil && !info.IsDir() {
current = filepath.Dir(current)
}
for {
gitPath := filepath.Join(current, ".git")
if _, err := os.Stat(gitPath); err == nil {
return current, true
}
parent := filepath.Dir(current)
if parent == current {
return "", false
}
current = parent
}
}
func isBackupDisabled() bool {
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
return val == "1" || val == "true" || val == "yes"
@@ -213,6 +254,12 @@ func pruneOldBackups(periodDir string, keep int) error {
}
func createBackupArchive(destPath, dbPath, configPath string) error {
snapshotPath, cleanup, err := createSQLiteSnapshot(dbPath)
if err != nil {
return err
}
defer cleanup()
file, err := os.Create(destPath)
if err != nil {
return err
@@ -220,12 +267,10 @@ func createBackupArchive(destPath, dbPath, configPath string) error {
defer file.Close()
zipWriter := zip.NewWriter(file)
if err := addZipFile(zipWriter, dbPath); err != nil {
if err := addZipFileAs(zipWriter, snapshotPath, filepath.Base(dbPath)); err != nil {
_ = zipWriter.Close()
return err
}
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
if strings.TrimSpace(configPath) != "" {
_ = addZipOptionalFile(zipWriter, configPath)
@@ -237,6 +282,77 @@ func createBackupArchive(destPath, dbPath, configPath string) error {
return file.Sync()
}
func createSQLiteSnapshot(dbPath string) (string, func(), error) {
tempFile, err := os.CreateTemp("", "qfs-backup-*.db")
if err != nil {
return "", func() {}, err
}
tempPath := tempFile.Name()
if err := tempFile.Close(); err != nil {
_ = os.Remove(tempPath)
return "", func() {}, err
}
if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) {
return "", func() {}, err
}
cleanup := func() {
_ = os.Remove(tempPath)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
cleanup()
return "", func() {}, err
}
sqlDB, err := db.DB()
if err != nil {
cleanup()
return "", func() {}, err
}
defer sqlDB.Close()
if err := db.Exec("PRAGMA busy_timeout = 5000").Error; err != nil {
cleanup()
return "", func() {}, fmt.Errorf("configure sqlite busy_timeout: %w", err)
}
literalPath := strings.ReplaceAll(tempPath, "'", "''")
if err := vacuumIntoWithRetry(db, literalPath); err != nil {
cleanup()
return "", func() {}, err
}
return tempPath, cleanup, nil
}
func vacuumIntoWithRetry(db *gorm.DB, literalPath string) error {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if err := db.Exec("VACUUM INTO '" + literalPath + "'").Error; err != nil {
lastErr = err
if !isSQLiteBusyError(err) {
return fmt.Errorf("create sqlite snapshot: %w", err)
}
time.Sleep(time.Duration(attempt+1) * 250 * time.Millisecond)
continue
}
return nil
}
return fmt.Errorf("create sqlite snapshot after retries: %w", lastErr)
}
func isSQLiteBusyError(err error) bool {
if err == nil {
return false
}
lower := strings.ToLower(err.Error())
return strings.Contains(lower, "database is locked") || strings.Contains(lower, "database is busy")
}
func addZipOptionalFile(writer *zip.Writer, path string) error {
if _, err := os.Stat(path); err != nil {
return nil
@@ -245,6 +361,10 @@ func addZipOptionalFile(writer *zip.Writer, path string) error {
}
func addZipFile(writer *zip.Writer, path string) error {
return addZipFileAs(writer, path, filepath.Base(path))
}
func addZipFileAs(writer *zip.Writer, path string, archiveName string) error {
in, err := os.Open(path)
if err != nil {
return err
@@ -260,7 +380,7 @@ func addZipFile(writer *zip.Writer, path string) error {
if err != nil {
return err
}
header.Name = filepath.Base(path)
header.Name = archiveName
header.Method = zip.Deflate
out, err := writer.CreateHeader(header)

View File

@@ -1,10 +1,15 @@
package appstate
import (
"archive/zip"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
@@ -12,8 +17,8 @@ func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatalf("write db: %v", err)
if err := writeTestSQLiteDB(dbPath); err != nil {
t.Fatalf("write sqlite db: %v", err)
}
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err)
@@ -35,6 +40,7 @@ func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
if _, err := os.Stat(dailyArchive); err != nil {
t.Fatalf("daily archive missing: %v", err)
}
assertZipContains(t, dailyArchive, "qfs.db", "config.yaml")
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
@@ -56,8 +62,8 @@ func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil {
t.Fatalf("write db: %v", err)
if err := writeTestSQLiteDB(dbPath); err != nil {
t.Fatalf("write sqlite db: %v", err)
}
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err)
@@ -69,7 +75,7 @@ func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
t.Fatalf("backup with env: %v", err)
}
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
if _, err := os.Stat(filepath.Join(backupRoot, "daily", ".period.json")); err != nil {
t.Fatalf("expected backup in custom dir: %v", err)
}
@@ -77,7 +83,75 @@ func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
t.Fatalf("backup disabled: %v", err)
}
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil {
if _, err := os.Stat(filepath.Join(backupRoot, "daily", ".period.json")); err != nil {
t.Fatalf("backup should remain from previous run: %v", err)
}
}
func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) {
temp := t.TempDir()
repoRoot := filepath.Join(temp, "repo")
if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0755); err != nil {
t.Fatalf("mkdir git dir: %v", err)
}
dbPath := filepath.Join(repoRoot, "data", "qfs.db")
cfgPath := filepath.Join(repoRoot, "data", "config.yaml")
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
t.Fatalf("mkdir data dir: %v", err)
}
if err := writeTestSQLiteDB(dbPath); err != nil {
t.Fatalf("write sqlite db: %v", err)
}
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write cfg: %v", err)
}
_, err := EnsureRotatingLocalBackup(dbPath, cfgPath)
if err == nil {
t.Fatal("expected git worktree backup root to be rejected")
}
if !strings.Contains(err.Error(), "outside git worktree") {
t.Fatalf("unexpected error: %v", err)
}
}
func writeTestSQLiteDB(path string) error {
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
defer sqlDB.Close()
return db.Exec(`
CREATE TABLE sample_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
INSERT INTO sample_items(name) VALUES ('backup');
`).Error
}
func assertZipContains(t *testing.T, archivePath string, expected ...string) {
t.Helper()
reader, err := zip.OpenReader(archivePath)
if err != nil {
t.Fatalf("open archive: %v", err)
}
defer reader.Close()
found := make(map[string]bool, len(reader.File))
for _, file := range reader.File {
found[file.Name] = true
}
for _, name := range expected {
if !found[name] {
t.Fatalf("archive %s missing %s", archivePath, name)
}
}
}

View File

@@ -22,24 +22,29 @@ 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) {
segments := make([]string, 0, 8)
segs := make([]namedSeg, 0, 8)
warnings := make([]string, 0)
model := NormalizeServerModel(opts.ServerModel)
if model == "" {
return BuildResult{}, fmt.Errorf("server_model required")
}
segments = append(segments, model)
segs = append(segs, namedSeg{"MODEL", model})
lotNames := make([]string, 0, len(items))
for _, it := range items {
@@ -55,41 +60,39 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
return BuildResult{}, err
}
cpuSeg := buildCPUSegment(items, cats)
if cpuSeg != "" {
segments = append(segments, cpuSeg)
if cpuSeg := buildCPUSegment(items, cats); cpuSeg != "" {
segs = append(segs, namedSeg{"CPU", cpuSeg})
}
memSeg, memWarn := buildMemSegment(items, cats)
if memWarn != "" {
warnings = append(warnings, memWarn)
}
if memSeg != "" {
segments = append(segments, memSeg)
segs = append(segs, namedSeg{"MEM", memSeg})
}
gpuSeg := buildGPUSegment(items, cats)
if gpuSeg != "" {
segments = append(segments, gpuSeg)
if gpuSeg := buildGPUSegment(items, cats); gpuSeg != "" {
segs = append(segs, namedSeg{"GPU", gpuSeg})
}
diskSeg, diskWarn := buildDiskSegment(items, cats)
if diskWarn != "" {
warnings = append(warnings, diskWarn)
}
if diskSeg != "" {
segments = append(segments, diskSeg)
segs = append(segs, namedSeg{"DISK", diskSeg})
}
netSeg, netWarn := buildNetSegment(items, cats)
if netWarn != "" {
warnings = append(warnings, netWarn)
}
if netSeg != "" {
segments = append(segments, netSeg)
segs = append(segs, namedSeg{"NET", netSeg})
}
psuSeg, psuWarn := buildPSUSegment(items, cats)
if psuWarn != "" {
warnings = append(warnings, psuWarn)
}
if psuSeg != "" {
segments = append(segments, psuSeg)
segs = append(segs, namedSeg{"PSU", psuSeg})
}
if strings.TrimSpace(opts.SupportCode) != "" {
@@ -97,12 +100,12 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
if !isSupportCodeValid(code) {
return BuildResult{}, fmt.Errorf("invalid_support_code")
}
segments = append(segments, code)
segs = append(segs, namedSeg{"SUPPORT", code})
}
article := strings.Join(segments, "-")
article := strings.Join(namedSegsValues(segs), "-")
if len([]rune(article)) > 80 {
article = compressArticle(segments)
article = compressArticle(segs)
warnings = append(warnings, "compressed")
}
if len([]rune(article)) > 80 {
@@ -112,6 +115,23 @@ 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
@@ -195,6 +215,9 @@ func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
if !ok || group != GroupGPU {
continue
}
if strings.HasPrefix(strings.ToUpper(it.LotName), "MB_") {
continue
}
model := parseGPUModel(it.LotName)
if model == "" {
model = "UNK"
@@ -326,33 +349,60 @@ 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", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
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]
}
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
}
}
}
if model != "" && mem != "" {
return model + "_" + mem
full := model
if numSuffix != "" {
full = model + numSuffix
}
if model != "" {
return model
if full != "" && mem != "" {
return full + "_" + mem
}
if full != "" {
return full
}
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
@@ -454,60 +504,50 @@ func atoi(v string) int {
return n
}
func compressArticle(segments []string) string {
if len(segments) == 0 {
func compressArticle(segs []namedSeg) string {
if len(segs) == 0 {
return ""
}
normalized := make([]string, 0, len(segments))
for _, s := range segments {
normalized = append(normalized, strings.ReplaceAll(s, "GbE", "G"))
for i, s := range segs {
segs[i].value = strings.ReplaceAll(s.value, "GbE", "G")
}
segments = normalized
article := strings.Join(segments, "-")
article := strings.Join(namedSegsValues(segs), "-")
if len([]rune(article)) <= 80 {
return article
}
// segment order: model, cpu, mem, gpu, disk, net, psu, support
index := func(i int) (int, bool) {
if i >= 0 && i < len(segments) {
return i, true
}
return -1, false
}
// 1) remove PSU
if i, ok := index(6); ok {
segments = append(segments[:i], segments[i+1:]...)
article = strings.Join(segments, "-")
if i := findSegGroup(segs, "PSU"); i >= 0 {
segs = append(segs[:i], segs[i+1:]...)
article = strings.Join(namedSegsValues(segs), "-")
if len([]rune(article)) <= 80 {
return article
}
}
// 2) compress NET/HBA/HCA
if i, ok := index(5); ok {
segments[i] = compressNetSegment(segments[i])
article = strings.Join(segments, "-")
if i := findSegGroup(segs, "NET"); i >= 0 {
segs[i].value = compressNetSegment(segs[i].value)
article = strings.Join(namedSegsValues(segs), "-")
if len([]rune(article)) <= 80 {
return article
}
}
// 3) compress DISK
if i, ok := index(4); ok {
segments[i] = compressDiskSegment(segments[i])
article = strings.Join(segments, "-")
if i := findSegGroup(segs, "DISK"); i >= 0 {
segs[i].value = compressDiskSegment(segs[i].value)
article = strings.Join(namedSegsValues(segs), "-")
if len([]rune(article)) <= 80 {
return article
}
}
// 4) compress GPU to vendor only (GPU_NV)
if i, ok := index(3); ok {
segments[i] = compressGPUSegment(segments[i])
if i := findSegGroup(segs, "GPU"); i >= 0 {
segs[i].value = compressGPUSegment(segs[i].value)
}
return strings.Join(segments, "-")
return strings.Join(namedSegsValues(segs), "-")
}
func compressNetSegment(seg string) string {

View File

@@ -61,6 +61,79 @@ 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

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

View File

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

60
internal/db/validate.go Normal file
View File

@@ -0,0 +1,60 @@
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

@@ -1,113 +0,0 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
)
type AuthHandler struct {
authService *services.AuthService
userRepo *repository.UserRepository
}
func NewAuthHandler(authService *services.AuthService, userRepo *repository.UserRepository) *AuthHandler {
return &AuthHandler{
authService: authService,
userRepo: userRepo,
}
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
User UserResponse `json:"user"`
}
type UserResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
}
func (h *AuthHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, user, err := h.authService.Login(req.Username, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, LoginResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
ExpiresAt: tokens.ExpiresAt,
User: UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: string(user.Role),
},
})
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
func (h *AuthHandler) Refresh(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, err := h.authService.RefreshTokens(req.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *AuthHandler) Me(c *gin.Context) {
claims := middleware.GetClaims(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
user, err := h.userRepo.GetByID(claims.UserID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: string(user.Role),
})
}
func (h *AuthHandler) Logout(c *gin.Context) {
// JWT is stateless, logout is handled on client by discarding tokens
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}

View File

@@ -49,7 +49,7 @@ func (h *ComponentHandler) List(c *gin.Context) {
offset := (page - 1) * perPage
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
@@ -64,11 +64,16 @@ 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{
Components: components,
Total: total,
Items: components,
TotalCount: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
})
}
@@ -90,6 +95,12 @@ 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))
@@ -98,7 +109,15 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
if trimmed == "" {
continue
}
categories = append(categories, models.Category{Code: trimmed, Name: trimmed})
order := orderMap[strings.ToUpper(trimmed)]
if order == 0 {
order = models.MaxKnownDisplayOrder + 1
}
categories = append(categories, models.Category{
Code: trimmed,
Name: trimmed,
DisplayOrder: order,
})
}
c.JSON(http.StatusOK, categories)
return

View File

@@ -1,239 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
type ConfigurationHandler struct {
configService *services.ConfigurationService
exportService *services.ExportService
}
func NewConfigurationHandler(
configService *services.ConfigurationService,
exportService *services.ExportService,
) *ConfigurationHandler {
return &ConfigurationHandler{
configService: configService,
exportService: exportService,
}
}
func (h *ConfigurationHandler) List(c *gin.Context) {
username := middleware.GetUsername(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
configs, total, err := h.configService.ListByUser(username, page, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"configurations": configs,
"total": total,
"page": page,
"per_page": perPage,
})
}
func (h *ConfigurationHandler) Create(c *gin.Context) {
username := middleware.GetUsername(c)
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := h.configService.Create(username, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, config)
}
func (h *ConfigurationHandler) Get(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
config, err := h.configService.GetByUUID(uuid, username)
if err != nil {
status := http.StatusNotFound
if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
}
func (h *ConfigurationHandler) Update(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := h.configService.Update(uuid, username, &req)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
status = http.StatusNotFound
} else if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
}
func (h *ConfigurationHandler) Delete(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
err := h.configService.Delete(uuid, username)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
status = http.StatusNotFound
} else if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
type RenameConfigRequest struct {
Name string `json:"name" binding:"required"`
}
func (h *ConfigurationHandler) Rename(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
var req RenameConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := h.configService.Rename(uuid, username, req.Name)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
status = http.StatusNotFound
} else if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
}
type CloneConfigRequest struct {
Name string `json:"name" binding:"required"`
}
func (h *ConfigurationHandler) Clone(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
var req CloneConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := h.configService.Clone(uuid, username, req.Name)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
status = http.StatusNotFound
} else if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, config)
}
func (h *ConfigurationHandler) RefreshPrices(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
config, err := h.configService.RefreshPrices(uuid, username)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
status = http.StatusNotFound
} else if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
}
// func (h *ConfigurationHandler) ExportJSON(c *gin.Context) {
// userID := middleware.GetUserID(c)
// uuid := c.Param("uuid")
//
// config, err := h.configService.GetByUUID(uuid, userID)
// if err != nil {
// c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
// return
// }
//
// data, err := h.configService.ExportJSON(uuid, userID)
// if err != nil {
// c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
// return
// }
//
// filename := fmt.Sprintf("%s %s SPEC.json", config.CreatedAt.Format("2006-01-02"), config.Name)
// c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
// c.Data(http.StatusOK, "application/json", data)
// }
// func (h *ConfigurationHandler) ImportJSON(c *gin.Context) {
// userID := middleware.GetUserID(c)
//
// data, err := io.ReadAll(c.Request.Body)
// if err != nil {
// c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
// return
// }
//
// config, err := h.configService.ImportJSON(userID, data)
// if err != nil {
// c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
// return
// }
//
// c.JSON(http.StatusCreated, config)
// }

View File

@@ -6,7 +6,6 @@ import (
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
@@ -16,17 +15,20 @@ type ExportHandler struct {
exportService *services.ExportService
configService services.ConfigurationGetter
projectService *services.ProjectService
dbUsername string
}
func NewExportHandler(
exportService *services.ExportService,
configService services.ConfigurationGetter,
projectService *services.ProjectService,
dbUsername string,
) *ExportHandler {
return &ExportHandler{
exportService: exportService,
configService: configService,
projectService: projectService,
dbUsername: dbUsername,
}
}
@@ -45,10 +47,20 @@ type ExportRequest struct {
Notes string `json:"notes"`
}
type ProjectExportOptionsRequest struct {
IncludeLOT bool `json:"include_lot"`
IncludeBOM bool `json:"include_bom"`
IncludeEstimate bool `json:"include_estimate"`
IncludeStock bool `json:"include_stock"`
IncludeCompetitor bool `json:"include_competitor"`
Basis string `json:"basis"` // "fob" or "ddp"
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
}
func (h *ExportHandler) ExportCSV(c *gin.Context) {
var req ExportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}
@@ -56,15 +68,14 @@ 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.StatusBadRequest, gin.H{"error": "no items to export"})
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no items to export"})
return
}
// Get project code for filename
projectCode := req.ProjectName // legacy field: may contain code from frontend
if projectCode == "" && req.ProjectUUID != "" {
username := middleware.GetUsername(c)
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
if project, err := h.projectService.GetByUUID(req.ProjectUUID, h.dbUsername); err == nil && project != nil {
projectCode = project.Code
}
}
@@ -136,13 +147,12 @@ func sanitizeFilenameSegment(value string) string {
}
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
// Get config before streaming (can return JSON error)
config, err := h.configService.GetByUUID(uuid, username)
config, err := h.configService.GetByUUIDNoAuth(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
RespondError(c, http.StatusNotFound, "resource not found", err)
return
}
@@ -150,14 +160,14 @@ 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.StatusBadRequest, gin.H{"error": "no items to export"})
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no items to export"})
return
}
// Get project code for filename
projectCode := config.Name // fallback: use config name if no project
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, h.dbUsername); err == nil && project != nil {
projectCode = project.Code
}
}
@@ -181,23 +191,22 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
// ExportProjectCSV exports all active configurations of a project as a single CSV file.
func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
username := middleware.GetUsername(c)
projectUUID := c.Param("uuid")
project, err := h.projectService.GetByUUID(projectUUID, username)
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
RespondError(c, http.StatusNotFound, "resource not found", err)
return
}
result, err := h.projectService.ListConfigurations(projectUUID, username, "active")
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
if len(result.Configs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"})
return
}
@@ -213,3 +222,119 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
return
}
}
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)
return
}
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
if err != nil {
RespondError(c, http.StatusNotFound, "resource not found", err)
return
}
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
if len(result.Configs) == 0 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"})
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.ProjectToPricingExportData(result.Configs, 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"
}
variantLabel := strings.TrimSpace(project.Variant)
if variantLabel == "" {
variantLabel = "main"
}
filename := fmt.Sprintf("%s (%s) %s %s.csv", time.Now().Format("2006-01-02"), project.Code, basisLabel, variantLabel)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
if err := h.exportService.ToPricingCSV(c.Writer, data, opts); err != nil {
c.Error(err)
return
}
}

View File

@@ -26,16 +26,20 @@ 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, nil)
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{},
nil,
"testuser",
)
// Create JSON request body
@@ -105,11 +109,12 @@ func TestExportCSV_Success(t *testing.T) {
func TestExportCSV_InvalidRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{},
nil,
"testuser",
)
// Create invalid request (missing required field)
@@ -123,8 +128,8 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
handler.ExportCSV(c)
// Should return 400 Bad Request
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
if w.Code != http.StatusUnprocessableEntity {
t.Errorf("Expected status 422, got %d", w.Code)
}
// Should return JSON error
@@ -138,11 +143,12 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
func TestExportCSV_EmptyItems(t *testing.T) {
gin.SetMode(gin.TestMode)
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{},
nil,
"testuser",
)
// Create request with empty items array - should fail binding validation
@@ -156,8 +162,8 @@ func TestExportCSV_EmptyItems(t *testing.T) {
handler.ExportCSV(c)
// Should return 400 Bad Request (validation error from gin binding)
if w.Code != http.StatusBadRequest {
t.Logf("Status code: %d (expected 400 for empty items)", w.Code)
if w.Code != http.StatusUnprocessableEntity {
t.Logf("Status code: %d (expected 422 for empty items)", w.Code)
}
}
@@ -179,11 +185,12 @@ func TestExportConfigCSV_Success(t *testing.T) {
CreatedAt: time.Now(),
}
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{config: mockConfig},
nil,
"testuser",
)
// Create HTTP request
@@ -196,9 +203,6 @@ func TestExportConfigCSV_Success(t *testing.T) {
{Key: "uuid", Value: "test-uuid"},
}
// Mock middleware.GetUsername
c.Set("username", "testuser")
handler.ExportConfigCSV(c)
// Check status code
@@ -228,11 +232,12 @@ func TestExportConfigCSV_Success(t *testing.T) {
func TestExportConfigCSV_NotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{err: errors.New("config not found")},
nil,
"testuser",
)
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
@@ -243,8 +248,6 @@ func TestExportConfigCSV_NotFound(t *testing.T) {
c.Params = gin.Params{
{Key: "uuid", Value: "nonexistent-uuid"},
}
c.Set("username", "testuser")
handler.ExportConfigCSV(c)
// Should return 404 Not Found
@@ -272,11 +275,12 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
CreatedAt: time.Now(),
}
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{config: mockConfig},
nil,
"testuser",
)
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
@@ -287,13 +291,11 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
c.Params = gin.Params{
{Key: "uuid", Value: "test-uuid"},
}
c.Set("username", "testuser")
handler.ExportConfigCSV(c)
// Should return 400 Bad Request
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
if w.Code != http.StatusUnprocessableEntity {
t.Errorf("Expected status 422, got %d", w.Code)
}
// Should return JSON error

View File

@@ -0,0 +1,113 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/gin-gonic/gin"
)
// PartnumberBooksHandler provides read-only access to local partnumber book snapshots.
type PartnumberBooksHandler struct {
localDB *localdb.LocalDB
}
func NewPartnumberBooksHandler(localDB *localdb.LocalDB) *PartnumberBooksHandler {
return &PartnumberBooksHandler{localDB: localDB}
}
// List returns all local partnumber book snapshots.
// GET /api/partnumber-books
func (h *PartnumberBooksHandler) List(c *gin.Context) {
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
books, err := bookRepo.ListBooks()
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
type bookSummary struct {
ID uint `json:"id"`
ServerID int `json:"server_id"`
Version string `json:"version"`
CreatedAt string `json:"created_at"`
IsActive bool `json:"is_active"`
ItemCount int64 `json:"item_count"`
}
summaries := make([]bookSummary, 0, len(books))
for _, b := range books {
summaries = append(summaries, bookSummary{
ID: b.ID,
ServerID: b.ServerID,
Version: b.Version,
CreatedAt: b.CreatedAt.Format("2006-01-02"),
IsActive: b.IsActive,
ItemCount: bookRepo.CountBookItems(b.ID),
})
}
c.JSON(http.StatusOK, gin.H{
"items": summaries,
"total_count": len(summaries),
"page": 1,
"per_page": len(summaries),
"total_pages": 1,
})
}
// GetItems returns items for a partnumber book by server ID.
// GET /api/partnumber-books/:id
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"})
return
}
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "100"))
search := strings.TrimSpace(c.Query("search"))
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 500 {
perPage = 100
}
book, err := h.localDB.GetLocalPartnumberBookByServerID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
return
}
items, total, err := bookRepo.GetBookItemsPage(book.ID, search, page, perPage)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
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,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
"search": search,
"book_total": bookRepo.CountBookItems(book.ID),
"lot_count": bookRepo.CountDistinctLots(book.ID),
})
}

View File

@@ -34,7 +34,7 @@ func (h *PricelistHandler) List(c *gin.Context) {
localPLs, err := h.localDB.GetLocalPricelists()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
if source != "" {
@@ -46,8 +46,30 @@ func (h *PricelistHandler) List(c *gin.Context) {
}
localPLs = filtered
}
if activeOnly {
// Local cache stores only active snapshots for normal operations.
type pricelistWithCount struct {
pricelist localdb.LocalPricelist
itemCount int64
usageCount int
}
withCounts := make([]pricelistWithCount, 0, len(localPLs))
for _, lpl := range localPLs {
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
if activeOnly && itemCount == 0 {
continue
}
usageCount := 0
if lpl.IsUsed {
usageCount = 1
}
withCounts = append(withCounts, pricelistWithCount{
pricelist: lpl,
itemCount: itemCount,
usageCount: usageCount,
})
}
localPLs = localPLs[:0]
for _, row := range withCounts {
localPLs = append(localPLs, row.pricelist)
}
sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) })
total := len(localPLs)
@@ -62,10 +84,14 @@ func (h *PricelistHandler) List(c *gin.Context) {
pageSlice := localPLs[start:end]
summaries := make([]map[string]interface{}, 0, len(pageSlice))
for _, lpl := range pageSlice {
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
itemCount := int64(0)
usageCount := 0
if lpl.IsUsed {
usageCount = 1
for _, row := range withCounts {
if row.pricelist.ID == lpl.ID {
itemCount = row.itemCount
usageCount = row.usageCount
break
}
}
summaries = append(summaries, map[string]interface{}{
"id": lpl.ServerID,
@@ -80,11 +106,16 @@ func (h *PricelistHandler) List(c *gin.Context) {
})
}
totalPages := (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
c.JSON(http.StatusOK, gin.H{
"pricelists": summaries,
"total": total,
"page": page,
"per_page": perPage,
"items": summaries,
"total_count": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
})
}
@@ -93,7 +124,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.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
return
}
@@ -120,7 +151,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.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
return
}
@@ -139,40 +170,47 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
if perPage < 1 {
perPage = 50
}
var items []localdb.LocalPricelistItem
dbq := h.localDB.DB().Model(&localdb.LocalPricelistItem{}).Where("pricelist_id = ?", localPL.ID)
if strings.TrimSpace(search) != "" {
dbq = dbq.Where("lot_name LIKE ?", "%"+strings.TrimSpace(search)+"%")
}
var total int64
if err := dbq.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
offset := (page - 1) * perPage
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
items, total, err := h.localDB.GetLocalPricelistItemsPage(localPL.ID, strings.TrimSpace(search), page, perPage)
if 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
}
resultItems := make([]gin.H, 0, len(items))
for _, item := range items {
resultItems = append(resultItems, gin.H{
"id": item.ID,
"lot_name": item.LotName,
"price": item.Price,
"category": item.LotCategory,
"available_qty": item.AvailableQty,
"partnumbers": []string(item.Partnumbers),
"id": item.ID,
"lot_name": item.LotName,
"lot_description": descMap[item.LotName],
"price": item.Price,
"category": item.LotCategory,
"available_qty": item.AvailableQty,
"partnumbers": []string(item.Partnumbers),
"partnumber_qtys": map[string]interface{}{},
"competitor_names": []string{},
"price_spread_pct": nil,
})
}
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
c.JSON(http.StatusOK, gin.H{
"source": localPL.Source,
"items": resultItems,
"total": total,
"page": page,
"per_page": perPage,
"source": localPL.Source,
"items": resultItems,
"total_count": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
})
}
@@ -180,7 +218,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.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"})
return
}
@@ -191,7 +229,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
}
items, err := h.localDB.GetLocalPricelistItems(localPL.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
lotNames := make([]string, 0, len(items))

View File

@@ -82,3 +82,80 @@ func TestPricelistGetItems_ReturnsLotCategoryFromLocalPricelistItems(t *testing.
}
}
func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
gin.SetMode(gin.TestMode)
local, err := localdb.New(filepath.Join(t.TempDir(), "local_active_only.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 10,
Source: "estimate",
Version: "E-1",
Name: "with-items",
CreatedAt: time.Now().Add(-time.Minute),
SyncedAt: time.Now().Add(-time.Minute),
}); err != nil {
t.Fatalf("save with-items pricelist: %v", err)
}
withItems, err := local.GetLocalPricelistByServerID(10)
if err != nil {
t.Fatalf("load with-items pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{
PricelistID: withItems.ID,
LotName: "CPU_X",
LotCategory: "CPU",
Price: 100,
},
}); err != nil {
t.Fatalf("save with-items pricelist items: %v", err)
}
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 11,
Source: "estimate",
Version: "E-2",
Name: "without-items",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save without-items pricelist: %v", err)
}
h := NewPricelistHandler(local)
req, _ := http.NewRequest("GET", "/api/pricelists?source=estimate&active_only=true", nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
h.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Items []struct {
ID uint `json:"id"`
} `json:"items"`
TotalCount int `json:"total_count"`
}
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 len(resp.Items) != 1 {
t.Fatalf("expected 1 pricelist, got %d", len(resp.Items))
}
if resp.Items[0].ID != 10 {
t.Fatalf("expected pricelist id=10, got %d", resp.Items[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 {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}
result, err := h.quoteService.ValidateAndCalculate(&req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
RespondError(c, http.StatusUnprocessableEntity, "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 {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}
result, err := h.quoteService.ValidateAndCalculate(&req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
RespondError(c, http.StatusUnprocessableEntity, "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 {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}
result, err := h.quoteService.CalculatePriceLevels(&req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}

View File

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

View File

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

View File

@@ -6,19 +6,14 @@ import (
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin"
gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
mysqlDriver "github.com/go-sql-driver/mysql"
)
type SetupHandler struct {
@@ -28,7 +23,7 @@ type SetupHandler struct {
restartSig chan struct{}
}
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
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 },
"add": func(a, b int) int { return a + b },
@@ -37,14 +32,9 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te
templates := make(map[string]*template.Template)
// Load setup template (standalone, no base needed)
setupPath := filepath.Join(templatesPath, "setup.html")
var tmpl *template.Template
var err error
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
}
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
if err != nil {
return nil, fmt.Errorf("parsing setup template: %w", err)
}
@@ -71,7 +61,8 @@ func (h *SetupHandler) ShowSetup(c *gin.Context) {
tmpl := h.templates["setup.html"]
if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil {
c.String(http.StatusInternalServerError, "Template error: %v", err)
_ = c.Error(err)
c.String(http.StatusInternalServerError, "Template error")
}
}
@@ -96,49 +87,16 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
}
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
lotCount, canWrite, err := db.ValidateMariaDBConnection(dsn)
if err != nil {
_ = c.Error(err)
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Connection failed: %v", err),
"error": "Connection check failed",
})
return
}
sqlDB, err := db.DB()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Failed to get database handle: %v", err),
})
return
}
defer sqlDB.Close()
if err := sqlDB.Ping(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Ping failed: %v", err),
})
return
}
// Check for required tables
var lotCount int64
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Table 'lot' not found or inaccessible: %v", err),
})
return
}
// Check write permission
canWrite := testWritePermission(db)
c.JSON(http.StatusOK, gin.H{
"success": true,
"lot_count": lotCount,
@@ -171,26 +129,21 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
// Test connection first
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
if _, _, err := db.ValidateMariaDBConnection(dsn); err != nil {
_ = c.Error(err)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": fmt.Sprintf("Connection failed: %v", err),
"error": "Connection check failed",
})
return
}
sqlDB, _ := db.DB()
sqlDB.Close()
// Save settings
if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil {
_ = c.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": fmt.Sprintf("Failed to save settings: %v", err),
"error": "Failed to save settings",
})
return
}
@@ -239,22 +192,6 @@ func (h *SetupHandler) GetStatus(c *gin.Context) {
})
}
func testWritePermission(db *gorm.DB) bool {
// Simple check: try to create a temporary table and drop it
testTable := fmt.Sprintf("qt_write_test_%d", time.Now().UnixNano())
// Try to create a test table
err := db.Exec(fmt.Sprintf("CREATE TABLE %s (id INT)", testTable)).Error
if err != nil {
return false
}
// Drop it immediately
db.Exec(fmt.Sprintf("DROP TABLE %s", testTable))
return true
}
func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string {
cfg := mysqlDriver.NewConfig()
cfg.User = user
@@ -270,3 +207,4 @@ func buildMySQLDSN(host string, port int, database, user, password string, timeo
}
return cfg.FormatDSN()
}

View File

@@ -0,0 +1,212 @@
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

@@ -0,0 +1,20 @@
//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

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

View File

@@ -6,8 +6,7 @@ import (
"html/template"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
stdsync "sync"
"time"
@@ -32,16 +31,9 @@ type SyncHandler struct {
}
// NewSyncHandler creates a new sync handler
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) {
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, _ string, autoSyncInterval time.Duration) (*SyncHandler, error) {
// Load sync_status partial template
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
var tmpl *template.Template
var err error
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
tmpl, err = template.ParseFiles(partialPath)
} else {
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
}
tmpl, err := template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
if err != nil {
return nil, err
}
@@ -58,15 +50,20 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
// SyncStatusResponse represents the sync status
type SyncStatusResponse struct {
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
}
type SyncReadinessResponse struct {
@@ -81,42 +78,34 @@ type SyncReadinessResponse struct {
// GetStatus returns current sync status
// GET /api/sync/status
func (h *SyncHandler) GetStatus(c *gin.Context) {
// Check online status by pinging MariaDB
isOnline := h.checkOnline()
// Get sync times
connStatus := h.connMgr.GetStatus()
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
lastComponentSync := h.localDB.GetComponentSyncTime()
lastPricelistSync := h.localDB.GetLastSyncTime()
// Get counts
componentsCount := h.localDB.CountLocalComponents()
pricelistsCount := h.localDB.CountLocalPricelists()
// Get server pricelist count if online
serverPricelists := 0
needPricelistSync := false
if isOnline {
status, err := h.syncService.GetStatus()
if err == nil {
serverPricelists = status.ServerPricelists
needPricelistSync = status.NeedsSync
}
}
// Check if component sync is needed (older than 24 hours)
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
needComponentSync := h.localDB.NeedComponentSync(24)
readiness := h.getReadinessCached(10 * time.Second)
readiness := h.getReadinessLocal()
c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync,
IsOnline: isOnline,
ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount,
ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync,
Readiness: readiness,
LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync,
LastPricelistAttemptAt: lastPricelistAttemptAt,
LastPricelistSyncStatus: lastPricelistSyncStatus,
LastPricelistSyncError: lastPricelistSyncError,
HasIncompleteServerSync: hasFailedSync,
KnownServerChangesMiss: hasFailedSync,
IsOnline: isOnline,
ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount,
ServerPricelists: 0,
NeedComponentSync: needComponentSync,
NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
Readiness: readiness,
})
}
@@ -125,9 +114,7 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
func (h *SyncHandler) GetReadiness(c *gin.Context) {
readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
if readiness == nil {
@@ -167,8 +154,9 @@ func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": err.Error(),
"error": "internal server error",
})
_ = c.Error(err)
_ = readiness
return false
}
@@ -193,20 +181,27 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database connection failed: " + err.Error(),
"error": "database connection failed",
})
_ = c.Error(err)
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,
"error": err.Error(),
"error": "component sync failed",
})
_ = 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,
@@ -226,13 +221,16 @@ 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,
"error": err.Error(),
"error": "pricelist sync failed",
})
_ = c.Error(err)
return
}
h.localDB.AppendSyncLog("pricelists", "ok", "", synced, startTime, time.Since(startTime).Milliseconds())
c.JSON(http.StatusOK, SyncResultResponse{
Success: true,
@@ -240,7 +238,33 @@ 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.
// POST /api/sync/partnumber-books
func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
if !h.ensureSyncReadiness(c) {
return
}
startTime := time.Now()
pulled, err := h.syncService.PullPartnumberBooks()
if err != nil {
slog.Error("partnumber books pull failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "partnumber books sync failed",
})
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, SyncResultResponse{
Success: true,
Message: "Partnumber books synced successfully",
Synced: pulled,
Duration: time.Since(startTime).String(),
})
}
// SyncAllResponse represents result of full sync
@@ -277,8 +301,9 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("pending push failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Pending changes push failed: " + err.Error(),
"error": "pending changes push failed",
})
_ = c.Error(err)
return
}
@@ -287,45 +312,57 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database connection failed: " + err.Error(),
"error": "database connection failed",
})
_ = c.Error(err)
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,
"error": "Component sync failed: " + err.Error(),
"error": "component sync failed",
})
_ = 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,
"error": "Pricelist sync failed: " + err.Error(),
"error": "pricelist sync failed",
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
})
_ = c.Error(err)
return
}
h.localDB.AppendSyncLog("pricelists", "ok", "", pricelistsSynced, plNow, time.Since(plNow).Milliseconds())
projectsResult, err := h.syncService.ImportProjectsToLocal()
if err != nil {
slog.Error("project import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Project import failed: " + err.Error(),
"error": "project import failed",
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced,
})
_ = c.Error(err)
return
}
@@ -334,7 +371,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("configuration import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Configuration import failed: " + err.Error(),
"error": "configuration import failed",
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced,
@@ -342,6 +379,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
"projects_updated": projectsResult.Updated,
"projects_skipped": projectsResult.Skipped,
})
_ = c.Error(err)
return
}
@@ -359,7 +397,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
ConfigurationsSkipped: configsResult.Skipped,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// checkOnline checks if MariaDB is accessible
@@ -380,8 +417,9 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
slog.Error("push pending changes failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": err.Error(),
"error": "pending changes push failed",
})
_ = c.Error(err)
return
}
@@ -391,7 +429,6 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
Synced: pushed,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// GetPendingCount returns the number of pending changes
@@ -408,9 +445,7 @@ func (h *SyncHandler) GetPendingCount(c *gin.Context) {
func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
changes, err := h.localDB.GetPendingChanges()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
@@ -427,8 +462,9 @@ func (h *SyncHandler) RepairPendingChanges(c *gin.Context) {
slog.Error("repair pending changes failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": err.Error(),
"error": "pending changes repair failed",
})
_ = c.Error(err)
return
}
@@ -447,8 +483,13 @@ type SyncInfoResponse struct {
DBName string `json:"db_name"`
// Status
IsOnline bool `json:"is_online"`
LastSyncAt *time.Time `json:"last_sync_at"`
IsOnline bool `json:"is_online"`
LastSyncAt *time.Time `json:"last_sync_at"`
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
// Statistics
LotCount int64 `json:"lot_count"`
@@ -484,8 +525,8 @@ type SyncError struct {
// GetInfo returns sync information for modal
// GET /api/sync/info
func (h *SyncHandler) GetInfo(c *gin.Context) {
// Check online status by pinging MariaDB
isOnline := h.checkOnline()
connStatus := h.connMgr.GetStatus()
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
// Get DB connection info
var dbHost, dbUser, dbName string
@@ -497,19 +538,18 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
// Get sync times
lastPricelistSync := h.localDB.GetLastSyncTime()
// Get MariaDB counts (if online)
var lotCount, lotLogCount int64
if isOnline {
if mariaDB, err := h.connMgr.GetDB(); err == nil {
mariaDB.Table("lot").Count(&lotCount)
mariaDB.Table("lot_log").Count(&lotLogCount)
}
}
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
needPricelistSync := lastPricelistSync == nil || hasFailedSync
hasIncompleteServerSync := hasFailedSync
// Get local counts
configCount := h.localDB.CountConfigurations()
projectCount := h.localDB.CountProjects()
componentCount := h.localDB.CountLocalComponents()
pricelistCount := h.localDB.CountLocalPricelists()
// Get error count (only changes with LastError != "")
errorCount := int(h.localDB.CountErroredChanges())
@@ -536,22 +576,27 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
syncErrors = syncErrors[:10]
}
readiness := h.getReadinessCached(10 * time.Second)
readiness := h.getReadinessLocal()
c.JSON(http.StatusOK, SyncInfoResponse{
DBHost: dbHost,
DBUser: dbUser,
DBName: dbName,
IsOnline: isOnline,
LastSyncAt: lastPricelistSync,
LotCount: lotCount,
LotLogCount: lotLogCount,
ConfigCount: configCount,
ProjectCount: projectCount,
PendingChanges: changes,
ErrorCount: errorCount,
Errors: syncErrors,
Readiness: readiness,
DBHost: dbHost,
DBUser: dbUser,
DBName: dbName,
IsOnline: isOnline,
LastSyncAt: lastPricelistSync,
LastPricelistAttemptAt: lastPricelistAttemptAt,
LastPricelistSyncStatus: lastPricelistSyncStatus,
LastPricelistSyncError: lastPricelistSyncError,
NeedPricelistSync: needPricelistSync,
HasIncompleteServerSync: hasIncompleteServerSync,
LotCount: componentCount,
LotLogCount: pricelistCount,
ConfigCount: configCount,
ProjectCount: projectCount,
PendingChanges: changes,
ErrorCount: errorCount,
Errors: syncErrors,
Readiness: readiness,
})
}
@@ -572,14 +617,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 {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
@@ -608,15 +648,33 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
// Get pending count
pendingCount := h.localDB.GetPendingCount()
readiness := h.getReadinessCached(10 * time.Second)
readiness := h.getReadinessLocal()
isBlocked := readiness != nil && readiness.Blocked
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
hasIncompleteServerSync := hasFailedSync
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
data := gin.H{
"IsOffline": isOffline,
"PendingCount": pendingCount,
"IsBlocked": isBlocked,
"IsOffline": isOffline,
"PendingCount": pendingCount,
"IsBlocked": isBlocked,
"HasFailedSync": hasFailedSync,
"HasIncompleteServerSync": hasIncompleteServerSync,
"SyncIssueTitle": func() string {
if hasIncompleteServerSync {
return "Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые не загружены локально."
}
if hasFailedSync {
if lastPricelistSyncError != "" {
return lastPricelistSyncError
}
return "Последняя синхронизация прайслистов завершилась ошибкой."
}
return ""
}(),
"BlockedReason": func() string {
if readiness == nil {
return ""
@@ -628,27 +686,78 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8")
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
slog.Error("failed to render sync_status template", "error", err)
c.String(http.StatusInternalServerError, "Template error: "+err.Error())
_ = c.Error(err)
c.String(http.StatusInternalServerError, "Template error")
}
}
func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness {
func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness {
h.readinessMu.Lock()
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < 10*time.Second {
cached := *h.readinessCached
h.readinessMu.Unlock()
return &cached
}
h.readinessMu.Unlock()
readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil {
state, err := h.localDB.GetSyncGuardState()
if err != nil || state == nil {
return nil
}
// OFFLINE_UNVERIFIED_SCHEMA is only valid while actually offline.
// Suppress it when the connection manager reports online so the stale
// blocked state from a previous disconnection doesn't linger in the UI.
if state.ReasonCode == "OFFLINE_UNVERIFIED_SCHEMA" && h.checkOnline() {
return nil
}
readiness := &sync.SyncReadiness{
Status: state.Status,
Blocked: state.Status == sync.ReadinessBlocked,
ReasonCode: state.ReasonCode,
ReasonText: state.ReasonText,
RequiredMinAppVersion: state.RequiredMinAppVersion,
LastCheckedAt: state.LastCheckedAt,
}
h.readinessMu.Lock()
h.readinessCached = readiness
h.readinessCachedAt = time.Now()
h.readinessMu.Unlock()
return readiness
}
// ReportPartnumberSeen pushes unresolved vendor partnumbers to qt_vendor_partnumber_seen on MariaDB.
// POST /api/sync/partnumber-seen
func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
var body struct {
Items []struct {
Partnumber string `json:"partnumber"`
Description string `json:"description"`
Ignored bool `json:"ignored"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}
items := make([]sync.SeenPartnumber, 0, len(body.Items))
for _, it := range body.Items {
if it.Partnumber != "" {
items = append(items, sync.SeenPartnumber{
Partnumber: it.Partnumber,
Description: it.Description,
Ignored: it.Ignored,
})
}
}
if err := h.syncService.PushPartnumberSeen(items); err != nil {
RespondError(c, http.StatusServiceUnavailable, "service unavailable", err)
return
}
c.JSON(http.StatusOK, gin.H{"reported": len(items)})
}

View File

@@ -0,0 +1,279 @@
package handlers
import (
"errors"
"log/slog"
"net/http"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
)
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
type VendorSpecHandler struct {
localDB *localdb.LocalDB
configService *services.LocalConfigurationService
syncService *syncsvc.Service // optional; nil = no server push
}
func NewVendorSpecHandler(localDB *localdb.LocalDB, syncService *syncsvc.Service) *VendorSpecHandler {
return &VendorSpecHandler{
localDB: localDB,
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
syncService: syncService,
}
}
// lookupConfig finds an active configuration by UUID using the standard localDB method.
func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfiguration, error) {
cfg, err := h.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, err
}
if !cfg.IsActive {
return nil, errors.New("not active")
}
return cfg, nil
}
// ParseText parses a pasted single-column text BOM (Inspur or Russian text BOM)
// using the same parsers as the vendor file-import path. It is stateless: no
// configuration is required. Returns the parsed rows and the detected format, or
// an empty result when the text is not a recognized single-column format (the
// client then falls back to manual column mapping).
// POST /api/vendor-spec/parse-text
func (h *VendorSpecHandler) ParseText(c *gin.Context) {
var body struct {
Text string `json:"text"`
}
if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}
rows, format := services.ParsePastedBOMText(body.Text)
if rows == nil {
rows = []localdb.VendorSpecItem{}
}
c.JSON(http.StatusOK, gin.H{"rows": rows, "format": format})
}
// GetVendorSpec returns the vendor spec (BOM) for a configuration.
// GET /api/configs/:uuid/vendor-spec
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
cfg, err := h.lookupConfig(c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
spec := cfg.VendorSpec
if spec == nil {
spec = localdb.VendorSpec{}
}
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
}
// PutVendorSpec saves (replaces) the vendor spec for a configuration.
// PUT /api/configs/:uuid/vendor-spec
func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
cfg, err := h.lookupConfig(c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
var body struct {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
}
if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}
for i := range body.VendorSpec {
if body.VendorSpec[i].SortOrder == 0 {
body.VendorSpec[i].SortOrder = (i + 1) * 10
}
// Persist canonical LOT mapping only.
body.VendorSpec[i].LotMappings = normalizeLotMappings(body.VendorSpec[i].LotMappings)
body.VendorSpec[i].ResolvedLotName = ""
body.VendorSpec[i].ResolutionSource = ""
body.VendorSpec[i].ManualLotSuggestion = ""
body.VendorSpec[i].LotQtyPerPN = 0
body.VendorSpec[i].LotAllocations = nil
}
spec := localdb.VendorSpec(body.VendorSpec)
if _, err := h.configService.UpdateVendorSpecNoAuth(cfg.UUID, spec); err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
// Push manual lot mappings as suggestions to the server (best-effort, non-blocking).
h.pushLotSuggestions(body.VendorSpec)
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
}
// pushLotSuggestions sends manual PN→LOT mappings to qt_vendor_partnumber_seen.lot_suggestion.
// Errors are logged and silently dropped — they must not affect the HTTP response.
func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
if h.syncService == nil {
return
}
var items []syncsvc.SeenPartnumber
for _, row := range spec {
if row.VendorPartnumber == "" || len(row.LotMappings) == 0 {
continue
}
suggestion := make([]syncsvc.LotSuggestionEntry, 0, len(row.LotMappings))
for _, m := range row.LotMappings {
if m.LotName == "" {
continue
}
qty := m.QuantityPerPN
if qty < 1 {
qty = 1
}
suggestion = append(suggestion, syncsvc.LotSuggestionEntry{
LotName: m.LotName,
Qty: qty,
})
}
if len(suggestion) == 0 {
continue
}
items = append(items, syncsvc.SeenPartnumber{
Partnumber: row.VendorPartnumber,
Description: row.Description,
LotSuggestion: suggestion,
})
}
if len(items) == 0 {
return
}
if err := h.syncService.PushPartnumberSeen(items); err != nil {
slog.Warn("vendor_spec: failed to push lot suggestions to server", "error", err)
}
}
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(in) == 0 {
return nil
}
merged := make(map[string]int, len(in))
order := make([]string, 0, len(in))
for _, m := range in {
lot := strings.TrimSpace(m.LotName)
if lot == "" {
continue
}
qty := m.QuantityPerPN
if qty < 1 {
qty = 1
}
if _, exists := merged[lot]; !exists {
order = append(order, lot)
}
merged[lot] += qty
}
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
for _, lot := range order {
out = append(out, localdb.VendorSpecLotMapping{
LotName: lot,
QuantityPerPN: merged[lot],
})
}
if len(out) == 0 {
return nil
}
return out
}
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
// POST /api/configs/:uuid/vendor-spec/resolve
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
if _, err := h.lookupConfig(c.Param("uuid")); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
var body struct {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
}
if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
resolver := services.NewVendorSpecResolver(bookRepo)
resolved, err := resolver.Resolve(body.VendorSpec)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
book, err := bookRepo.GetActiveBook()
if err != nil {
slog.Warn("vendor spec resolve: no active partnumber book", "err", err)
book = nil
}
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
c.JSON(http.StatusOK, gin.H{
"resolved": resolved,
"aggregated": aggregated,
})
}
// ApplyVendorSpec applies the resolved BOM to the cart (Estimate items).
// POST /api/configs/:uuid/vendor-spec/apply
func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
cfg, err := h.lookupConfig(c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
var body struct {
Items []struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil {
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
return
}
newItems := make(localdb.LocalConfigItems, 0, len(body.Items))
for _, it := range body.Items {
newItems = append(newItems, localdb.LocalConfigItem{
LotName: it.LotName,
Quantity: it.Quantity,
UnitPrice: it.UnitPrice,
})
}
if _, err := h.configService.ApplyVendorSpecItemsNoAuth(cfg.UUID, newItems); err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
c.JSON(http.StatusOK, gin.H{"items": newItems})
}

View File

@@ -1,23 +1,24 @@
package handlers
import (
"fmt"
"html/template"
"os"
"path/filepath"
"strconv"
"strings"
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/gin-gonic/gin"
)
type WebHandler struct {
templates map[string]*template.Template
componentService *services.ComponentService
templates map[string]*template.Template
localDB *localdb.LocalDB
}
func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) {
func NewWebHandler(_ string, localDB *localdb.LocalDB) (*WebHandler, error) {
funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b },
@@ -60,27 +61,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
}
templates := make(map[string]*template.Template)
basePath := filepath.Join(templatesPath, "base.html")
useDisk := false
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
useDisk = true
}
// Load each page template with base
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
simplePages := []string{"configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html", "partnumber_books.html"}
for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page)
var tmpl *template.Template
var err error
if useDisk {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/"+page,
)
}
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/"+page,
)
if err != nil {
return nil, err
}
@@ -88,20 +78,14 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
}
// Index page needs components_list.html as well
indexPath := filepath.Join(templatesPath, "index.html")
componentsListPath := filepath.Join(templatesPath, "components_list.html")
var indexTmpl *template.Template
var err error
if useDisk {
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
} else {
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/index.html",
"web/templates/components_list.html",
)
}
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/index.html",
"web/templates/components_list.html",
)
if err != nil {
return nil, err
}
@@ -110,17 +94,12 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
// Load partial templates (no base needed)
partials := []string{"components_list.html"}
for _, partial := range partials {
partialPath := filepath.Join(templatesPath, partial)
var tmpl *template.Template
var err error
if useDisk {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/"+partial,
)
}
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/"+partial,
)
if err != nil {
return nil, err
}
@@ -128,21 +107,24 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
}
return &WebHandler{
templates: templates,
componentService: componentService,
templates: templates,
localDB: localDB,
}, nil
}
func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
data["AppVersion"] = appmeta.Version()
c.Header("Content-Type", "text/html; charset=utf-8")
tmpl, ok := h.templates[name]
if !ok {
c.String(500, "Template not found: %s", name)
_ = c.Error(fmt.Errorf("template %q not found", name))
c.String(500, "Template error")
return
}
// Execute the page template which will use base
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
c.String(500, "Template error: %v", err)
_ = c.Error(err)
c.String(500, "Template error")
}
}
@@ -152,36 +134,28 @@ func (h *WebHandler) Index(c *gin.Context) {
}
func (h *WebHandler) Configurator(c *gin.Context) {
categories, _ := h.componentService.GetCategories()
uuid := c.Query("uuid")
filter := repository.ComponentFilter{}
result, err := h.componentService.List(filter, 1, 20)
categories, _ := h.localCategories()
components, total, err := h.localDB.ListComponents(localdb.ComponentFilter{}, 0, 20)
data := gin.H{
"ActivePage": "configurator",
"Categories": categories,
"Components": []interface{}{},
"Components": []localComponentView{},
"Total": int64(0),
"Page": 1,
"PerPage": 20,
"ConfigUUID": uuid,
}
if err == nil && result != nil {
data["Components"] = result.Components
data["Total"] = result.Total
data["Page"] = result.Page
data["PerPage"] = result.PerPage
if err == nil {
data["Components"] = toLocalComponentViews(components)
data["Total"] = total
}
h.render(c, "index.html", data)
}
func (h *WebHandler) Login(c *gin.Context) {
h.render(c, "login.html", nil)
}
func (h *WebHandler) Configs(c *gin.Context) {
h.render(c, "configs.html", gin.H{"ActivePage": "configs"})
}
@@ -212,29 +186,38 @@ func (h *WebHandler) PricelistDetail(c *gin.Context) {
h.render(c, "pricelist_detail.html", gin.H{"ActivePage": "pricelists"})
}
func (h *WebHandler) PartnumberBooks(c *gin.Context) {
h.render(c, "partnumber_books.html", gin.H{"ActivePage": "partnumber-books"})
}
// Partials for htmx
func (h *WebHandler) ComponentsPartial(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
filter := repository.ComponentFilter{
filter := localdb.ComponentFilter{
Category: c.Query("category"),
Search: c.Query("search"),
}
if c.Query("has_price") == "true" {
filter.HasPrice = true
}
offset := (page - 1) * 20
data := gin.H{
"Components": []interface{}{},
"Components": []localComponentView{},
"Total": int64(0),
"Page": page,
"PerPage": 20,
}
result, err := h.componentService.List(filter, page, 20)
if err == nil && result != nil {
data["Components"] = result.Components
data["Total"] = result.Total
data["Page"] = result.Page
data["PerPage"] = result.PerPage
components, total, err := h.localDB.ListComponents(filter, offset, 20)
if err == nil {
data["Components"] = toLocalComponentViews(components)
data["Total"] = total
}
c.Header("Content-Type", "text/html; charset=utf-8")
@@ -242,3 +225,46 @@ func (h *WebHandler) ComponentsPartial(c *gin.Context) {
tmpl.ExecuteTemplate(c.Writer, "components_list.html", data)
}
}
type localComponentView struct {
LotName string
Description string
Category string
CategoryName string
Model string
CurrentPrice *float64
}
func toLocalComponentViews(items []localdb.LocalComponent) []localComponentView {
result := make([]localComponentView, 0, len(items))
for _, item := range items {
result = append(result, localComponentView{
LotName: item.LotName,
Description: item.LotDescription,
Category: item.Category,
CategoryName: item.Category,
Model: item.Model,
})
}
return result
}
func (h *WebHandler) localCategories() ([]models.Category, error) {
codes, err := h.localDB.GetLocalComponentCategories()
if err != nil || len(codes) == 0 {
return []models.Category{}, err
}
categories := make([]models.Category, 0, len(codes))
for _, code := range codes {
trimmed := strings.TrimSpace(code)
if trimmed == "" {
continue
}
categories = append(categories, models.Category{
Code: trimmed,
Name: trimmed,
})
}
return categories, nil
}

View File

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

View File

@@ -28,8 +28,9 @@ type ComponentSyncResult struct {
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
startTime := time.Now()
// Query to join lot with qt_lot_metadata (metadata only, no pricing)
// Use LEFT JOIN to include lots without metadata
// 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.
type componentRow struct {
LotName string
LotDescription string
@@ -40,15 +41,29 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
var rows []componentRow
err := mariaDB.Raw(`
SELECT
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
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)
LEFT JOIN qt_categories c ON m.category_id = c.id
WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL
ORDER BY l.lot_name
GROUP BY src.lot_name
ORDER BY src.lot_name
`).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
@@ -71,18 +86,25 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
existingMap[c.LotName] = true
}
// Prepare components for batch insert/update
// Prepare components for batch insert/update.
// Source joins may duplicate the same lot_name, so collapse them before insert.
syncTime := time.Now()
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 = *row.Category
category = strings.TrimSpace(*row.Category)
} else {
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
parts := strings.SplitN(row.LotName, "_", 2)
parts := strings.SplitN(lotName, "_", 2)
if len(parts) >= 1 {
category = parts[0]
}
@@ -90,18 +112,34 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
model := ""
if row.Model != nil {
model = *row.Model
model = strings.TrimSpace(*row.Model)
}
comp := LocalComponent{
LotName: row.LotName,
LotDescription: row.LotDescription,
LotName: lotName,
LotDescription: strings.TrimSpace(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[row.LotName] {
if !existingMap[lotName] {
newCount++
}
}

View File

@@ -0,0 +1,154 @@
package localdb
import (
"testing"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func TestConfigurationConvertersPreserveBusinessFields(t *testing.T) {
estimateID := uint(11)
warehouseID := uint(22)
competitorID := uint(33)
cfg := &models.Configuration{
UUID: "cfg-1",
OwnerUsername: "tester",
Name: "Config",
PricelistID: &estimateID,
WarehousePricelistID: &warehouseID,
CompetitorPricelistID: &competitorID,
DisablePriceRefresh: true,
OnlyInStock: true,
}
local := ConfigurationToLocal(cfg)
if local.WarehousePricelistID == nil || *local.WarehousePricelistID != warehouseID {
t.Fatalf("warehouse pricelist lost in ConfigurationToLocal: %+v", local.WarehousePricelistID)
}
if local.CompetitorPricelistID == nil || *local.CompetitorPricelistID != competitorID {
t.Fatalf("competitor pricelist lost in ConfigurationToLocal: %+v", local.CompetitorPricelistID)
}
if !local.DisablePriceRefresh {
t.Fatalf("disable_price_refresh lost in ConfigurationToLocal")
}
back := LocalToConfiguration(local)
if back.WarehousePricelistID == nil || *back.WarehousePricelistID != warehouseID {
t.Fatalf("warehouse pricelist lost in LocalToConfiguration: %+v", back.WarehousePricelistID)
}
if back.CompetitorPricelistID == nil || *back.CompetitorPricelistID != competitorID {
t.Fatalf("competitor pricelist lost in LocalToConfiguration: %+v", back.CompetitorPricelistID)
}
if !back.DisablePriceRefresh {
t.Fatalf("disable_price_refresh lost in LocalToConfiguration")
}
}
func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
estimateID := uint(11)
warehouseID := uint(22)
competitorID := uint(33)
cfg := &LocalConfiguration{
UUID: "cfg-1",
Name: "Config",
PricelistID: &estimateID,
WarehousePricelistID: &warehouseID,
CompetitorPricelistID: &competitorID,
DisablePriceRefresh: true,
OnlyInStock: true,
VendorSpec: VendorSpec{
{
SortOrder: 10,
VendorPartnumber: "PN-1",
Quantity: 1,
LotMappings: []VendorSpecLotMapping{
{LotName: "LOT_A", QuantityPerPN: 2},
},
},
},
}
raw, err := BuildConfigurationSnapshot(cfg)
if err != nil {
t.Fatalf("BuildConfigurationSnapshot: %v", err)
}
decoded, err := DecodeConfigurationSnapshot(raw)
if err != nil {
t.Fatalf("DecodeConfigurationSnapshot: %v", err)
}
if decoded.WarehousePricelistID == nil || *decoded.WarehousePricelistID != warehouseID {
t.Fatalf("warehouse pricelist lost in snapshot: %+v", decoded.WarehousePricelistID)
}
if decoded.CompetitorPricelistID == nil || *decoded.CompetitorPricelistID != competitorID {
t.Fatalf("competitor pricelist lost in snapshot: %+v", decoded.CompetitorPricelistID)
}
if !decoded.DisablePriceRefresh {
t.Fatalf("disable_price_refresh lost in snapshot")
}
if len(decoded.VendorSpec) != 1 || decoded.VendorSpec[0].VendorPartnumber != "PN-1" {
t.Fatalf("vendor_spec lost in snapshot: %+v", decoded.VendorSpec)
}
if len(decoded.VendorSpec[0].LotMappings) != 1 || decoded.VendorSpec[0].LotMappings[0].LotName != "LOT_A" {
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
}
}
func TestConfigurationFingerprintIncludesPricingSelectorsAndVendorSpec(t *testing.T) {
estimateID := uint(11)
warehouseID := uint(22)
competitorID := uint(33)
base := &LocalConfiguration{
UUID: "cfg-1",
Name: "Config",
ServerCount: 1,
Items: LocalConfigItems{{LotName: "LOT_A", Quantity: 1, UnitPrice: 100}},
PricelistID: &estimateID,
WarehousePricelistID: &warehouseID,
CompetitorPricelistID: &competitorID,
DisablePriceRefresh: true,
OnlyInStock: true,
VendorSpec: VendorSpec{
{
SortOrder: 10,
VendorPartnumber: "PN-1",
Quantity: 1,
},
},
}
baseFingerprint, err := BuildConfigurationSpecPriceFingerprint(base)
if err != nil {
t.Fatalf("base fingerprint: %v", err)
}
changedPricelist := *base
newEstimateID := uint(44)
changedPricelist.PricelistID = &newEstimateID
pricelistFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedPricelist)
if err != nil {
t.Fatalf("pricelist fingerprint: %v", err)
}
if pricelistFingerprint == baseFingerprint {
t.Fatalf("expected pricelist selector to affect fingerprint")
}
changedVendorSpec := *base
changedVendorSpec.VendorSpec = VendorSpec{
{
SortOrder: 10,
VendorPartnumber: "PN-2",
Quantity: 1,
},
}
vendorFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedVendorSpec)
if err != nil {
t.Fatalf("vendor fingerprint: %v", err)
}
if vendorFingerprint == baseFingerprint {
t.Fatalf("expected vendor spec to affect fingerprint")
}
}

View File

@@ -18,31 +18,33 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
}
local := &LocalConfiguration{
UUID: cfg.UUID,
ProjectUUID: cfg.ProjectUUID,
IsActive: true,
Name: cfg.Name,
Items: items,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
ServerModel: cfg.ServerModel,
SupportCode: cfg.SupportCode,
Article: cfg.Article,
PricelistID: cfg.PricelistID,
OnlyInStock: cfg.OnlyInStock,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
OriginalUserID: derefUint(cfg.UserID),
OriginalUsername: cfg.OwnerUsername,
}
if local.OriginalUsername == "" && cfg.User != nil {
local.OriginalUsername = cfg.User.Username
UUID: cfg.UUID,
ProjectUUID: cfg.ProjectUUID,
IsActive: true,
Name: cfg.Name,
Items: items,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
ServerModel: cfg.ServerModel,
SupportCode: cfg.SupportCode,
Article: cfg.Article,
PricelistID: cfg.PricelistID,
WarehousePricelistID: cfg.WarehousePricelistID,
CompetitorPricelistID: cfg.CompetitorPricelistID,
ConfigType: cfg.ConfigType,
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
DisablePriceRefresh: cfg.DisablePriceRefresh,
OnlyInStock: cfg.OnlyInStock,
Line: cfg.Line,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
OriginalUserID: derefUint(cfg.UserID),
OriginalUsername: cfg.OwnerUsername,
}
if cfg.ID > 0 {
@@ -65,23 +67,29 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
}
cfg := &models.Configuration{
UUID: local.UUID,
OwnerUsername: local.OriginalUsername,
ProjectUUID: local.ProjectUUID,
Name: local.Name,
Items: items,
TotalPrice: local.TotalPrice,
CustomPrice: local.CustomPrice,
Notes: local.Notes,
IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount,
ServerModel: local.ServerModel,
SupportCode: local.SupportCode,
Article: local.Article,
PricelistID: local.PricelistID,
OnlyInStock: local.OnlyInStock,
PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt,
UUID: local.UUID,
OwnerUsername: local.OriginalUsername,
ProjectUUID: local.ProjectUUID,
Name: local.Name,
Items: items,
TotalPrice: local.TotalPrice,
CustomPrice: local.CustomPrice,
Notes: local.Notes,
IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount,
ServerModel: local.ServerModel,
SupportCode: local.SupportCode,
Article: local.Article,
PricelistID: local.PricelistID,
WarehousePricelistID: local.WarehousePricelistID,
CompetitorPricelistID: local.CompetitorPricelistID,
ConfigType: local.ConfigType,
VendorSpec: localVendorSpecToModel(local.VendorSpec),
DisablePriceRefresh: local.DisablePriceRefresh,
OnlyInStock: local.OnlyInStock,
Line: local.Line,
PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt,
}
if local.ServerID != nil {
@@ -105,6 +113,88 @@ func derefUint(v *uint) uint {
return *v
}
func modelVendorSpecToLocal(spec models.VendorSpec) VendorSpec {
if len(spec) == 0 {
return nil
}
out := make(VendorSpec, 0, len(spec))
for _, item := range spec {
row := VendorSpecItem{
SortOrder: item.SortOrder,
VendorPartnumber: item.VendorPartnumber,
Quantity: item.Quantity,
Description: item.Description,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
ResolvedLotName: item.ResolvedLotName,
ResolutionSource: item.ResolutionSource,
ManualLotSuggestion: item.ManualLotSuggestion,
LotQtyPerPN: item.LotQtyPerPN,
}
if len(item.LotAllocations) > 0 {
row.LotAllocations = make([]VendorSpecLotAllocation, 0, len(item.LotAllocations))
for _, alloc := range item.LotAllocations {
row.LotAllocations = append(row.LotAllocations, VendorSpecLotAllocation{
LotName: alloc.LotName,
Quantity: alloc.Quantity,
})
}
}
if len(item.LotMappings) > 0 {
row.LotMappings = make([]VendorSpecLotMapping, 0, len(item.LotMappings))
for _, mapping := range item.LotMappings {
row.LotMappings = append(row.LotMappings, VendorSpecLotMapping{
LotName: mapping.LotName,
QuantityPerPN: mapping.QuantityPerPN,
})
}
}
out = append(out, row)
}
return out
}
func localVendorSpecToModel(spec VendorSpec) models.VendorSpec {
if len(spec) == 0 {
return nil
}
out := make(models.VendorSpec, 0, len(spec))
for _, item := range spec {
row := models.VendorSpecItem{
SortOrder: item.SortOrder,
VendorPartnumber: item.VendorPartnumber,
Quantity: item.Quantity,
Description: item.Description,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
ResolvedLotName: item.ResolvedLotName,
ResolutionSource: item.ResolutionSource,
ManualLotSuggestion: item.ManualLotSuggestion,
LotQtyPerPN: item.LotQtyPerPN,
}
if len(item.LotAllocations) > 0 {
row.LotAllocations = make([]models.VendorSpecLotAllocation, 0, len(item.LotAllocations))
for _, alloc := range item.LotAllocations {
row.LotAllocations = append(row.LotAllocations, models.VendorSpecLotAllocation{
LotName: alloc.LotName,
Quantity: alloc.Quantity,
})
}
}
if len(item.LotMappings) > 0 {
row.LotMappings = make([]models.VendorSpecLotMapping, 0, len(item.LotMappings))
for _, mapping := range item.LotMappings {
row.LotMappings = append(row.LotMappings, models.VendorSpecLotMapping{
LotName: mapping.LotName,
QuantityPerPN: mapping.QuantityPerPN,
})
}
}
out = append(out, row)
}
return out
}
func ProjectToLocal(project *models.Project) *LocalProject {
local := &LocalProject{
UUID: project.UUID,

View File

@@ -7,19 +7,104 @@ import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
)
// getEncryptionKey derives a 32-byte key from environment variable or machine ID
func getEncryptionKey() []byte {
const encryptionKeyFileName = "local_encryption.key"
// getEncryptionKey resolves the active encryption key.
// Preference order:
// 1. QUOTEFORGE_ENCRYPTION_KEY env var
// 2. application-managed random key file in the user state directory
func getEncryptionKey() ([]byte, error) {
key := os.Getenv("QUOTEFORGE_ENCRYPTION_KEY")
if key == "" {
// Fallback to a machine-based key (hostname + fixed salt)
hostname, _ := os.Hostname()
key = hostname + "quoteforge-salt-2024"
if key != "" {
hash := sha256.Sum256([]byte(key))
return hash[:], nil
}
// Hash to get exactly 32 bytes for AES-256
stateDir, err := resolveEncryptionStateDir()
if err != nil {
return nil, fmt.Errorf("resolve encryption state dir: %w", err)
}
return loadOrCreateEncryptionKey(filepath.Join(stateDir, encryptionKeyFileName))
}
func resolveEncryptionStateDir() (string, error) {
configPath, err := appstate.ResolveConfigPath("")
if err != nil {
return "", err
}
return filepath.Dir(configPath), nil
}
func loadOrCreateEncryptionKey(path string) ([]byte, error) {
if data, err := os.ReadFile(path); err == nil {
return parseEncryptionKeyFile(data)
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read encryption key: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, fmt.Errorf("create encryption key dir: %w", err)
}
raw := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, raw); err != nil {
return nil, fmt.Errorf("generate encryption key: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(raw)
if err := writeKeyFile(path, []byte(encoded+"\n")); err != nil {
if errors.Is(err, os.ErrExist) {
data, readErr := os.ReadFile(path)
if readErr != nil {
return nil, fmt.Errorf("read concurrent encryption key: %w", readErr)
}
return parseEncryptionKeyFile(data)
}
return nil, err
}
return raw, nil
}
func writeKeyFile(path string, data []byte) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
defer file.Close()
if _, err := file.Write(data); err != nil {
return err
}
return file.Sync()
}
func parseEncryptionKeyFile(data []byte) ([]byte, error) {
trimmed := strings.TrimSpace(string(data))
decoded, err := base64.StdEncoding.DecodeString(trimmed)
if err != nil {
return nil, fmt.Errorf("decode encryption key file: %w", err)
}
if len(decoded) != 32 {
return nil, fmt.Errorf("invalid encryption key length: %d", len(decoded))
}
return decoded, nil
}
func getLegacyEncryptionKey() []byte {
hostname, _ := os.Hostname()
key := hostname + "quoteforge-salt-2024"
hash := sha256.Sum256([]byte(key))
return hash[:]
}
@@ -30,7 +115,10 @@ func Encrypt(plaintext string) (string, error) {
return "", nil
}
key := getEncryptionKey()
key, err := getEncryptionKey()
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
@@ -56,12 +144,50 @@ func Decrypt(ciphertext string) (string, error) {
return "", nil
}
key := getEncryptionKey()
data, err := base64.StdEncoding.DecodeString(ciphertext)
key, err := getEncryptionKey()
if err != nil {
return "", err
}
plaintext, legacy, err := decryptWithKeys(ciphertext, key, getLegacyEncryptionKey())
if err != nil {
return "", err
}
_ = legacy
return plaintext, nil
}
func DecryptWithMetadata(ciphertext string) (string, bool, error) {
if ciphertext == "" {
return "", false, nil
}
key, err := getEncryptionKey()
if err != nil {
return "", false, err
}
return decryptWithKeys(ciphertext, key, getLegacyEncryptionKey())
}
func decryptWithKeys(ciphertext string, primaryKey, legacyKey []byte) (string, bool, error) {
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", false, err
}
plaintext, err := decryptWithKey(data, primaryKey)
if err == nil {
return plaintext, false, nil
}
legacyPlaintext, legacyErr := decryptWithKey(data, legacyKey)
if legacyErr == nil {
return legacyPlaintext, true, nil
}
return "", false, err
}
func decryptWithKey(data, key []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err

View File

@@ -0,0 +1,97 @@
package localdb
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"io"
"os"
"path/filepath"
"testing"
)
func TestEncryptCreatesPersistentKeyFile(t *testing.T) {
stateDir := t.TempDir()
t.Setenv("QFS_STATE_DIR", stateDir)
t.Setenv("QUOTEFORGE_ENCRYPTION_KEY", "")
ciphertext, err := Encrypt("secret-password")
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if ciphertext == "" {
t.Fatal("expected ciphertext")
}
keyPath := filepath.Join(stateDir, encryptionKeyFileName)
info, err := os.Stat(keyPath)
if err != nil {
t.Fatalf("stat key file: %v", err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("expected 0600 key file, got %v", info.Mode().Perm())
}
}
func TestDecryptMigratesLegacyCiphertext(t *testing.T) {
stateDir := t.TempDir()
t.Setenv("QFS_STATE_DIR", stateDir)
t.Setenv("QUOTEFORGE_ENCRYPTION_KEY", "")
legacyCiphertext := encryptWithKeyForTest(t, getLegacyEncryptionKey(), "legacy-password")
plaintext, migrated, err := DecryptWithMetadata(legacyCiphertext)
if err != nil {
t.Fatalf("decrypt legacy: %v", err)
}
if plaintext != "legacy-password" {
t.Fatalf("unexpected plaintext: %q", plaintext)
}
if !migrated {
t.Fatal("expected legacy ciphertext to require migration")
}
currentCiphertext, err := Encrypt("legacy-password")
if err != nil {
t.Fatalf("encrypt current: %v", err)
}
plaintext, migrated, err = DecryptWithMetadata(currentCiphertext)
if err != nil {
t.Fatalf("decrypt current: %v", err)
}
if migrated {
t.Fatal("did not expect current ciphertext to require migration")
}
}
func encryptWithKeyForTest(t *testing.T, key []byte, plaintext string) string {
t.Helper()
block, err := aes.NewCipher(key)
if err != nil {
t.Fatalf("new cipher: %v", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatalf("new gcm: %v", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
t.Fatalf("read nonce: %v", err)
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext)
}
func TestLegacyEncryptionKeyRemainsDeterministic(t *testing.T) {
hostname, _ := os.Hostname()
expected := sha256.Sum256([]byte(hostname + "quoteforge-salt-2024"))
actual := getLegacyEncryptionKey()
if string(actual) != string(expected[:]) {
t.Fatal("legacy key derivation changed")
}
}

View File

@@ -5,7 +5,10 @@ import (
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
@@ -253,3 +256,340 @@ func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *te
t.Fatalf("expected current_version_id to point to kept latest version v3")
}
}
func TestRunLocalMigrationsBackfillsConfigurationLineNo(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "line_no_backfill.db")
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
projectUUID := "project-line"
cfg1 := &LocalConfiguration{
UUID: "line-cfg-1",
ProjectUUID: &projectUUID,
Name: "Cfg 1",
Items: LocalConfigItems{},
SyncStatus: "pending",
OriginalUsername: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-2 * time.Hour),
}
cfg2 := &LocalConfiguration{
UUID: "line-cfg-2",
ProjectUUID: &projectUUID,
Name: "Cfg 2",
Items: LocalConfigItems{},
SyncStatus: "pending",
OriginalUsername: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-1 * time.Hour),
}
if err := local.SaveConfiguration(cfg1); err != nil {
t.Fatalf("save cfg1: %v", err)
}
if err := local.SaveConfiguration(cfg2); err != nil {
t.Fatalf("save cfg2: %v", err)
}
if err := local.DB().Model(&LocalConfiguration{}).Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Update("line_no", 0).Error; err != nil {
t.Fatalf("reset line_no: %v", err)
}
if err := local.DB().Where("id = ?", "2026_02_19_local_config_line_no").Delete(&LocalSchemaMigration{}).Error; err != nil {
t.Fatalf("delete migration record: %v", err)
}
if err := runLocalMigrations(local.DB()); err != nil {
t.Fatalf("rerun local migrations: %v", err)
}
var rows []LocalConfiguration
if err := local.DB().Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Order("created_at ASC").Find(&rows).Error; err != nil {
t.Fatalf("load configurations: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 configurations, got %d", len(rows))
}
if rows[0].Line != 10 || rows[1].Line != 20 {
t.Fatalf("expected line_no [10,20], got [%d,%d]", rows[0].Line, rows[1].Line)
}
}
func TestRunLocalMigrationsDeduplicatesCanonicalPartnumberCatalog(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "partnumber_catalog_dedup.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
firstLots := LocalPartnumberBookLots{
{LotName: "LOT-A", Qty: 1},
}
secondLots := LocalPartnumberBookLots{
{LotName: "LOT-B", Qty: 2},
}
if err := db.Exec(`
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL,
lots_json TEXT NOT NULL,
description TEXT
)
`).Error; err != nil {
t.Fatalf("create dirty local_partnumber_book_items: %v", err)
}
if err := db.Create(&LocalPartnumberBookItem{
Partnumber: "PN-001",
LotsJSON: firstLots,
Description: "",
}).Error; err != nil {
t.Fatalf("insert first duplicate row: %v", err)
}
if err := db.Create(&LocalPartnumberBookItem{
Partnumber: "PN-001",
LotsJSON: secondLots,
Description: "Canonical description",
}).Error; err != nil {
t.Fatalf("insert second duplicate row: %v", err)
}
if err := migrateLocalPartnumberBookCatalog(db); err != nil {
t.Fatalf("migrate local partnumber catalog: %v", err)
}
var items []LocalPartnumberBookItem
if err := db.Order("partnumber ASC").Find(&items).Error; err != nil {
t.Fatalf("load migrated partnumber items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 deduplicated item, got %d", len(items))
}
if items[0].Partnumber != "PN-001" {
t.Fatalf("unexpected partnumber: %s", items[0].Partnumber)
}
if items[0].Description != "Canonical description" {
t.Fatalf("expected merged description, got %q", items[0].Description)
}
if len(items[0].LotsJSON) != 2 {
t.Fatalf("expected merged lots from duplicates, got %d", len(items[0].LotsJSON))
}
var duplicateCount int64
if err := db.Model(&LocalPartnumberBookItem{}).
Where("partnumber = ?", "PN-001").
Count(&duplicateCount).Error; err != nil {
t.Fatalf("count deduplicated partnumber: %v", err)
}
if duplicateCount != 1 {
t.Fatalf("expected unique partnumber row after migration, got %d", duplicateCount)
}
}
func TestSanitizeLocalPartnumberBookCatalogRemovesRowsWithoutPartnumber(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "sanitize_partnumber_catalog.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Exec(`
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NULL,
lots_json TEXT NOT NULL,
description TEXT
)
`).Error; err != nil {
t.Fatalf("create local_partnumber_book_items: %v", err)
}
if err := db.Exec(`
INSERT INTO local_partnumber_book_items (partnumber, lots_json, description) VALUES
(NULL, '[]', 'null pn'),
('', '[]', 'empty pn'),
('PN-OK', '[]', 'valid pn')
`).Error; err != nil {
t.Fatalf("seed local_partnumber_book_items: %v", err)
}
if err := sanitizeLocalPartnumberBookCatalog(db); err != nil {
t.Fatalf("sanitize local partnumber catalog: %v", err)
}
var items []LocalPartnumberBookItem
if err := db.Order("id ASC").Find(&items).Error; err != nil {
t.Fatalf("load sanitized items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 valid item after sanitize, got %d", len(items))
}
if items[0].Partnumber != "PN-OK" {
t.Fatalf("expected remaining partnumber PN-OK, got %q", items[0].Partnumber)
}
}
func TestNewMigratesLegacyPartnumberBookCatalogBeforeAutoMigrate(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy_partnumber_catalog.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Exec(`
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL UNIQUE,
lots_json TEXT NOT NULL,
is_primary_pn INTEGER NOT NULL DEFAULT 0,
description TEXT
)
`).Error; err != nil {
t.Fatalf("create legacy local_partnumber_book_items: %v", err)
}
if err := db.Exec(`
INSERT INTO local_partnumber_book_items (partnumber, lots_json, is_primary_pn, description)
VALUES ('PN-001', '[{"lot_name":"CPU_A","qty":1}]', 0, 'Legacy row')
`).Error; err != nil {
t.Fatalf("seed legacy local_partnumber_book_items: %v", err)
}
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb with legacy catalog: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
var columns []struct {
Name string `gorm:"column:name"`
}
if err := local.DB().Raw(`SELECT name FROM pragma_table_info('local_partnumber_book_items')`).Scan(&columns).Error; err != nil {
t.Fatalf("load local_partnumber_book_items columns: %v", err)
}
for _, column := range columns {
if column.Name == "is_primary_pn" {
t.Fatalf("expected legacy is_primary_pn column to be removed before automigrate")
}
}
var items []LocalPartnumberBookItem
if err := local.DB().Find(&items).Error; err != nil {
t.Fatalf("load migrated local_partnumber_book_items: %v", err)
}
if len(items) != 1 || items[0].Partnumber != "PN-001" {
t.Fatalf("unexpected migrated rows: %#v", items)
}
}
func TestNewRecoversBrokenPartnumberBookCatalogCache(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "broken_partnumber_catalog.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Exec(`
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL UNIQUE,
lots_json TEXT NOT NULL,
description TEXT
)
`).Error; err != nil {
t.Fatalf("create broken local_partnumber_book_items: %v", err)
}
if err := db.Exec(`
INSERT INTO local_partnumber_book_items (partnumber, lots_json, description)
VALUES ('PN-001', '{not-json}', 'Broken cache row')
`).Error; err != nil {
t.Fatalf("seed broken local_partnumber_book_items: %v", err)
}
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb with broken catalog cache: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
var count int64
if err := local.DB().Model(&LocalPartnumberBookItem{}).Count(&count).Error; err != nil {
t.Fatalf("count recovered local_partnumber_book_items: %v", err)
}
if count != 0 {
t.Fatalf("expected empty recovered local_partnumber_book_items, got %d rows", count)
}
var quarantineTables []struct {
Name string `gorm:"column:name"`
}
if err := local.DB().Raw(`
SELECT name
FROM sqlite_master
WHERE type = 'table' AND name LIKE 'local_partnumber_book_items_broken_%'
`).Scan(&quarantineTables).Error; err != nil {
t.Fatalf("load quarantine tables: %v", err)
}
if len(quarantineTables) != 1 {
t.Fatalf("expected one quarantined broken catalog table, got %d", len(quarantineTables))
}
}
func TestCleanupStaleReadOnlyCacheTempTablesDropsShadowTempWhenBaseExists(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "stale_cache_temp.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Exec(`
CREATE TABLE local_pricelist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pricelist_id INTEGER NOT NULL,
partnumber TEXT,
brand TEXT NOT NULL DEFAULT '',
lot_name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL DEFAULT 0,
quantity INTEGER NOT NULL DEFAULT 0,
reserve INTEGER NOT NULL DEFAULT 0,
available_qty REAL,
partnumbers TEXT,
lot_category TEXT,
created_at DATETIME,
updated_at DATETIME
)
`).Error; err != nil {
t.Fatalf("create local_pricelist_items: %v", err)
}
if err := db.Exec(`
CREATE TABLE local_pricelist_items__temp (
id INTEGER PRIMARY KEY AUTOINCREMENT,
legacy TEXT
)
`).Error; err != nil {
t.Fatalf("create local_pricelist_items__temp: %v", err)
}
if err := cleanupStaleReadOnlyCacheTempTables(db); err != nil {
t.Fatalf("cleanup stale read-only cache temp tables: %v", err)
}
if db.Migrator().HasTable("local_pricelist_items__temp") {
t.Fatalf("expected stale temp table to be dropped")
}
if !db.Migrator().HasTable("local_pricelist_items") {
t.Fatalf("expected base local_pricelist_items table to remain")
}
}

View File

@@ -1,6 +1,7 @@
package localdb
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -42,6 +43,14 @@ type LocalDB struct {
path string
}
var localReadOnlyCacheTables = []string{
"local_pricelist_items",
"local_pricelists",
"local_components",
"local_partnumber_book_items",
"local_partnumber_books",
}
// ResetData clears local data tables while keeping connection settings.
// It does not drop schema or connection_settings.
func ResetData(dbPath string) error {
@@ -70,7 +79,6 @@ func ResetData(dbPath string) error {
"local_pricelists",
"local_pricelist_items",
"local_components",
"local_remote_migrations_applied",
"local_sync_guard_state",
"pending_changes",
"app_settings",
@@ -108,9 +116,23 @@ func New(dbPath string) (*LocalDB, error) {
return nil, fmt.Errorf("opening sqlite database: %w", err)
}
// Enable WAL mode so background sync writes never block UI reads.
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
slog.Warn("failed to enable WAL mode", "error", err)
}
if err := db.Exec("PRAGMA synchronous=NORMAL").Error; err != nil {
slog.Warn("failed to set synchronous=NORMAL", "error", err)
}
if err := ensureLocalProjectsTable(db); err != nil {
return nil, fmt.Errorf("ensure local_projects table: %w", err)
}
if err := prepareLocalPartnumberBookCatalog(db); err != nil {
return nil, fmt.Errorf("prepare local partnumber book catalog: %w", err)
}
if err := cleanupStaleReadOnlyCacheTempTables(db); err != nil {
return nil, fmt.Errorf("cleanup stale read-only cache temp tables: %w", err)
}
// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
if db.Migrator().HasTable(&LocalProject{}) {
@@ -131,22 +153,28 @@ func New(dbPath string) (*LocalDB, error) {
}
// Auto-migrate all local tables
if err := db.AutoMigrate(
&ConnectionSettings{},
&LocalConfiguration{},
&LocalConfigurationVersion{},
&LocalPricelist{},
&LocalPricelistItem{},
&LocalComponent{},
&AppSetting{},
&LocalRemoteMigrationApplied{},
&LocalSyncGuardState{},
&PendingChange{},
); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err)
if err := autoMigrateLocalSchema(db); err != nil {
if recovered, recoveryErr := recoverFromReadOnlyCacheInitFailure(db, err); recoveryErr != nil {
return nil, fmt.Errorf("migrating sqlite database: %w (recovery failed: %v)", err, recoveryErr)
} else if !recovered {
return nil, fmt.Errorf("migrating sqlite database: %w", err)
}
if err := autoMigrateLocalSchema(db); err != nil {
return nil, fmt.Errorf("migrating sqlite database after cache recovery: %w", err)
}
}
if err := ensureLocalPartnumberBookItemTable(db); err != nil {
return nil, fmt.Errorf("ensure local partnumber book item table: %w", err)
}
if err := runLocalMigrations(db); err != nil {
return nil, fmt.Errorf("running local sqlite migrations: %w", err)
if recovered, recoveryErr := recoverFromReadOnlyCacheInitFailure(db, err); recoveryErr != nil {
return nil, fmt.Errorf("running local sqlite migrations: %w (recovery failed: %v)", err, recoveryErr)
} else if !recovered {
return nil, fmt.Errorf("running local sqlite migrations: %w", err)
}
if err := runLocalMigrations(db); err != nil {
return nil, fmt.Errorf("running local sqlite migrations after cache recovery: %w", err)
}
}
slog.Info("local SQLite database initialized", "path", dbPath)
@@ -189,10 +217,290 @@ CREATE TABLE local_projects (
return nil
}
func autoMigrateLocalSchema(db *gorm.DB) error {
return db.AutoMigrate(
&ConnectionSettings{},
&LocalConfiguration{},
&LocalConfigurationVersion{},
&LocalPricelist{},
&LocalPricelistItem{},
&LocalComponent{},
&AppSetting{},
&LocalSyncGuardState{},
&PendingChange{},
&LocalPartnumberBook{},
&SyncLogEntry{},
)
}
func sanitizeLocalPartnumberBookCatalog(db *gorm.DB) error {
if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return nil
}
// Old local databases may contain partially migrated catalog rows with NULL/empty
// partnumber values. SQLite table rebuild during AutoMigrate fails on such rows once
// the schema enforces NOT NULL, so remove them before AutoMigrate touches the table.
if err := db.Exec(`
DELETE FROM local_partnumber_book_items
WHERE partnumber IS NULL OR TRIM(partnumber) = ''
`).Error; err != nil {
return err
}
return nil
}
func prepareLocalPartnumberBookCatalog(db *gorm.DB) error {
if err := cleanupStaleLocalPartnumberBookCatalogTempTable(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("cleanup stale temp table: %w", err)); recoveryErr != nil {
return recoveryErr
}
return nil
}
if err := sanitizeLocalPartnumberBookCatalog(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("sanitize catalog: %w", err)); recoveryErr != nil {
return recoveryErr
}
return nil
}
if err := migrateLegacyPartnumberBookCatalogBeforeAutoMigrate(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("migrate legacy catalog: %w", err)); recoveryErr != nil {
return recoveryErr
}
return nil
}
if err := ensureLocalPartnumberBookItemTable(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("ensure canonical catalog table: %w", err)); recoveryErr != nil {
return recoveryErr
}
return nil
}
if err := validateLocalPartnumberBookCatalog(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("validate canonical catalog: %w", err)); recoveryErr != nil {
return recoveryErr
}
}
return nil
}
func cleanupStaleReadOnlyCacheTempTables(db *gorm.DB) error {
for _, tableName := range localReadOnlyCacheTables {
tempName := tableName + "__temp"
if !db.Migrator().HasTable(tempName) {
continue
}
if db.Migrator().HasTable(tableName) {
if err := db.Exec(`DROP TABLE ` + tempName).Error; err != nil {
return err
}
continue
}
if err := quarantineSQLiteTable(db, tempName, localReadOnlyCacheQuarantineTableName(tableName, "temp")); err != nil {
return err
}
}
return nil
}
func cleanupStaleLocalPartnumberBookCatalogTempTable(db *gorm.DB) error {
if !db.Migrator().HasTable("local_partnumber_book_items__temp") {
return nil
}
if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return db.Exec(`DROP TABLE local_partnumber_book_items__temp`).Error
}
return quarantineSQLiteTable(db, "local_partnumber_book_items__temp", localPartnumberBookCatalogQuarantineTableName("temp"))
}
func migrateLegacyPartnumberBookCatalogBeforeAutoMigrate(db *gorm.DB) error {
if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return nil
}
// Legacy databases may still have the pre-catalog shape (`book_id`/`lot_name`) or the
// intermediate canonical shape with obsolete columns like `is_primary_pn`. Let the
// explicit rebuild logic normalize this table before GORM AutoMigrate attempts a
// table-copy migration on its own.
return migrateLocalPartnumberBookCatalog(db)
}
func ensureLocalPartnumberBookItemTable(db *gorm.DB) error {
if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return nil
}
if err := db.Exec(`
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL UNIQUE,
lots_json TEXT NOT NULL,
description TEXT
)
`).Error; err != nil {
return err
}
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_partnumber_book_items_partnumber ON local_partnumber_book_items(partnumber)`).Error
}
func validateLocalPartnumberBookCatalog(db *gorm.DB) error {
if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return nil
}
type rawCatalogRow struct {
Partnumber string `gorm:"column:partnumber"`
LotsJSON string `gorm:"column:lots_json"`
Description string `gorm:"column:description"`
}
var rows []rawCatalogRow
if err := db.Raw(`
SELECT partnumber, lots_json, COALESCE(description, '') AS description
FROM local_partnumber_book_items
ORDER BY id ASC
`).Scan(&rows).Error; err != nil {
return fmt.Errorf("load canonical catalog rows: %w", err)
}
seen := make(map[string]struct{}, len(rows))
for _, row := range rows {
partnumber := strings.TrimSpace(row.Partnumber)
if partnumber == "" {
return errors.New("catalog contains empty partnumber")
}
if _, exists := seen[partnumber]; exists {
return fmt.Errorf("catalog contains duplicate partnumber %q", partnumber)
}
seen[partnumber] = struct{}{}
if strings.TrimSpace(row.LotsJSON) == "" {
return fmt.Errorf("catalog row %q has empty lots_json", partnumber)
}
var lots LocalPartnumberBookLots
if err := json.Unmarshal([]byte(row.LotsJSON), &lots); err != nil {
return fmt.Errorf("catalog row %q has invalid lots_json: %w", partnumber, err)
}
}
return nil
}
func recoverLocalPartnumberBookCatalog(db *gorm.DB, cause error) error {
slog.Warn("recovering broken local partnumber book catalog", "error", cause.Error())
if err := ensureLocalPartnumberBooksCatalogColumn(db); err != nil {
return fmt.Errorf("ensure local_partnumber_books.partnumbers_json during recovery: %w", err)
}
if db.Migrator().HasTable("local_partnumber_book_items__temp") {
if err := quarantineSQLiteTable(db, "local_partnumber_book_items__temp", localPartnumberBookCatalogQuarantineTableName("temp")); err != nil {
return fmt.Errorf("quarantine local_partnumber_book_items__temp: %w", err)
}
}
if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
if err := quarantineSQLiteTable(db, "local_partnumber_book_items", localPartnumberBookCatalogQuarantineTableName("broken")); err != nil {
return fmt.Errorf("quarantine local_partnumber_book_items: %w", err)
}
}
if err := ensureLocalPartnumberBookItemTable(db); err != nil {
return fmt.Errorf("recreate local_partnumber_book_items after recovery: %w", err)
}
slog.Warn("local partnumber book catalog reset to empty cache; next sync will rebuild it")
return nil
}
func recoverFromReadOnlyCacheInitFailure(db *gorm.DB, cause error) (bool, error) {
lowerCause := strings.ToLower(cause.Error())
recoveredAny := false
for _, tableName := range affectedReadOnlyCacheTables(lowerCause) {
if err := resetReadOnlyCacheTable(db, tableName); err != nil {
return recoveredAny, err
}
recoveredAny = true
}
if strings.Contains(lowerCause, "local_partnumber_book_items") || strings.Contains(lowerCause, "local_partnumber_books") {
if err := recoverLocalPartnumberBookCatalog(db, cause); err != nil {
return recoveredAny, err
}
recoveredAny = true
}
if recoveredAny {
slog.Warn("recovered read-only local cache tables after startup failure", "error", cause.Error())
}
return recoveredAny, nil
}
func affectedReadOnlyCacheTables(lowerCause string) []string {
affected := make([]string, 0, len(localReadOnlyCacheTables))
for _, tableName := range localReadOnlyCacheTables {
if tableName == "local_partnumber_book_items" || tableName == "local_partnumber_books" {
continue
}
if strings.Contains(lowerCause, tableName) {
affected = append(affected, tableName)
}
}
return affected
}
func resetReadOnlyCacheTable(db *gorm.DB, tableName string) error {
slog.Warn("resetting read-only local cache table", "table", tableName)
tempName := tableName + "__temp"
if db.Migrator().HasTable(tempName) {
if err := quarantineSQLiteTable(db, tempName, localReadOnlyCacheQuarantineTableName(tableName, "temp")); err != nil {
return fmt.Errorf("quarantine temp table %s: %w", tempName, err)
}
}
if db.Migrator().HasTable(tableName) {
if err := quarantineSQLiteTable(db, tableName, localReadOnlyCacheQuarantineTableName(tableName, "broken")); err != nil {
return fmt.Errorf("quarantine table %s: %w", tableName, err)
}
}
return nil
}
func ensureLocalPartnumberBooksCatalogColumn(db *gorm.DB) error {
if !db.Migrator().HasTable(&LocalPartnumberBook{}) {
return nil
}
if db.Migrator().HasColumn(&LocalPartnumberBook{}, "partnumbers_json") {
return nil
}
return db.Exec(`ALTER TABLE local_partnumber_books ADD COLUMN partnumbers_json TEXT NOT NULL DEFAULT '[]'`).Error
}
func quarantineSQLiteTable(db *gorm.DB, tableName string, quarantineName string) error {
if !db.Migrator().HasTable(tableName) {
return nil
}
if tableName == quarantineName {
return nil
}
if db.Migrator().HasTable(quarantineName) {
if err := db.Exec(`DROP TABLE ` + quarantineName).Error; err != nil {
return err
}
}
return db.Exec(`ALTER TABLE ` + tableName + ` RENAME TO ` + quarantineName).Error
}
func localPartnumberBookCatalogQuarantineTableName(kind string) string {
return "local_partnumber_book_items_" + kind + "_" + time.Now().UTC().Format("20060102150405")
}
func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string {
return tableName + "_" + kind + "_" + time.Now().UTC().Format("20060102150405")
}
// HasSettings returns true if connection settings exist
func (l *LocalDB) HasSettings() bool {
var count int64
l.db.Model(&ConnectionSettings{}).Count(&count)
if err := l.db.Model(&ConnectionSettings{}).Count(&count).Error; err != nil {
slog.Error("localdb: HasSettings count failed", "err", err)
return false
}
return count > 0
}
@@ -204,10 +512,23 @@ func (l *LocalDB) GetSettings() (*ConnectionSettings, error) {
}
// Decrypt password
password, err := Decrypt(settings.PasswordEncrypted)
password, migrated, err := DecryptWithMetadata(settings.PasswordEncrypted)
if err != nil {
return nil, fmt.Errorf("decrypting password: %w", err)
}
if migrated {
encrypted, encryptErr := Encrypt(password)
if encryptErr != nil {
return nil, fmt.Errorf("re-encrypting legacy password: %w", encryptErr)
}
if err := l.db.Model(&ConnectionSettings{}).
Where("id = ?", settings.ID).
Update("password_encrypted", encrypted).Error; err != nil {
return nil, fmt.Errorf("upgrading legacy password encryption: %w", err)
}
}
settings.PasswordEncrypted = password // Return decrypted password in this field
return &settings, nil
@@ -302,6 +623,46 @@ func (l *LocalDB) SaveProject(project *LocalProject) error {
return l.db.Save(project).Error
}
// SaveProjectPreservingUpdatedAt stores a project without replacing UpdatedAt
// with the current local sync time.
func (l *LocalDB) SaveProjectPreservingUpdatedAt(project *LocalProject) error {
if project == nil {
return fmt.Errorf("project is nil")
}
if project.ID == 0 && strings.TrimSpace(project.UUID) != "" {
var existing LocalProject
err := l.db.Where("uuid = ?", project.UUID).First(&existing).Error
if err == nil {
project.ID = existing.ID
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if project.ID == 0 {
return l.db.Create(project).Error
}
return l.db.Model(&LocalProject{}).
Where("id = ?", project.ID).
UpdateColumns(map[string]interface{}{
"uuid": project.UUID,
"server_id": project.ServerID,
"owner_username": project.OwnerUsername,
"code": project.Code,
"variant": project.Variant,
"name": project.Name,
"tracker_url": project.TrackerURL,
"is_active": project.IsActive,
"is_system": project.IsSystem,
"created_at": project.CreatedAt,
"updated_at": project.UpdatedAt,
"synced_at": project.SyncedAt,
"sync_status": project.SyncStatus,
}).Error
}
func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) {
var projects []LocalProject
query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername)
@@ -341,7 +702,7 @@ func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, e
func (l *LocalDB) GetProjectConfigurations(projectUUID string) ([]LocalConfiguration, error) {
var configs []LocalConfiguration
err := l.db.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
Order("created_at DESC").
Order(configurationLineOrderClause()).
Find(&configs).Error
return configs, err
}
@@ -514,9 +875,54 @@ func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
// SaveConfiguration saves a configuration to local SQLite
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
if config != nil && config.IsActive && config.Line <= 0 {
line, err := l.NextConfigurationLine(config.ProjectUUID, config.UUID)
if err != nil {
return err
}
config.Line = line
}
return l.db.Save(config).Error
}
func (l *LocalDB) NextConfigurationLine(projectUUID *string, excludeUUID string) (int, error) {
return NextConfigurationLineTx(l.db, projectUUID, excludeUUID)
}
func NextConfigurationLineTx(tx *gorm.DB, projectUUID *string, excludeUUID string) (int, error) {
query := tx.Model(&LocalConfiguration{}).
Where("is_active = ?", true)
trimmedExclude := strings.TrimSpace(excludeUUID)
if trimmedExclude != "" {
query = query.Where("uuid <> ?", trimmedExclude)
}
if projectUUID != nil && strings.TrimSpace(*projectUUID) != "" {
query = query.Where("project_uuid = ?", strings.TrimSpace(*projectUUID))
} else {
query = query.Where("project_uuid IS NULL OR TRIM(project_uuid) = ''")
}
var maxLine int
if err := query.Select("COALESCE(MAX(line_no), 0)").Scan(&maxLine).Error; err != nil {
return 0, fmt.Errorf("read max line_no: %w", err)
}
if maxLine < 0 {
maxLine = 0
}
next := ((maxLine / 10) + 1) * 10
if next < 10 {
next = 10
}
return next, nil
}
func configurationLineOrderClause() string {
return "CASE WHEN COALESCE(local_configurations.line_no, 0) <= 0 THEN 2147483647 ELSE local_configurations.line_no END ASC, local_configurations.created_at DESC, local_configurations.id DESC"
}
// GetConfigurations returns all local configurations
func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) {
var configs []LocalConfiguration
@@ -641,14 +1047,18 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
// CountConfigurations returns the number of local configurations
func (l *LocalDB) CountConfigurations() int64 {
var count int64
l.db.Model(&LocalConfiguration{}).Count(&count)
if err := l.db.Model(&LocalConfiguration{}).Count(&count).Error; err != nil {
slog.Error("localdb: CountConfigurations failed", "err", err)
}
return count
}
// CountProjects returns the number of local projects
func (l *LocalDB) CountProjects() int64 {
var count int64
l.db.Model(&LocalProject{}).Count(&count)
if err := l.db.Model(&LocalProject{}).Count(&count).Error; err != nil {
slog.Error("localdb: CountProjects failed", "err", err)
}
return count
}
@@ -672,6 +1082,26 @@ func (l *LocalDB) GetLastSyncTime() *time.Time {
return &t
}
func (l *LocalDB) getAppSettingValue(key string) (string, bool) {
var setting struct {
Value string
}
if err := l.db.Table("app_settings").
Where("key = ?", key).
First(&setting).Error; err != nil {
return "", false
}
return setting.Value, true
}
func (l *LocalDB) upsertAppSetting(tx *gorm.DB, key, value string, updatedAt time.Time) error {
return tx.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, key, value, updatedAt.Format(time.RFC3339)).Error
}
// SetLastSyncTime sets the last sync timestamp
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
@@ -682,6 +1112,134 @@ func (l *LocalDB) SetLastSyncTime(t time.Time) error {
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
}
func (l *LocalDB) GetLastPricelistSyncAttemptAt() *time.Time {
value, ok := l.getAppSettingValue("last_pricelist_sync_attempt_at")
if !ok {
return nil
}
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil
}
return &t
}
func (l *LocalDB) GetLastPricelistSyncStatus() string {
value, ok := l.getAppSettingValue("last_pricelist_sync_status")
if !ok {
return ""
}
return strings.TrimSpace(value)
}
func (l *LocalDB) GetLastPricelistSyncError() string {
value, ok := l.getAppSettingValue("last_pricelist_sync_error")
if !ok {
return ""
}
return strings.TrimSpace(value)
}
func (l *LocalDB) SetPricelistSyncResult(status, errorText string, attemptedAt time.Time) error {
status = strings.TrimSpace(status)
errorText = strings.TrimSpace(errorText)
if status == "" {
status = "unknown"
}
return l.db.Transaction(func(tx *gorm.DB) error {
if err := l.upsertAppSetting(tx, "last_pricelist_sync_status", status, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_pricelist_sync_error", errorText, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_pricelist_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
return err
}
return nil
})
}
const syncLogMaxPerType = 100
// AppendSyncLog writes a sync result and prunes old entries beyond the per-type cap.
func (l *LocalDB) AppendSyncLog(syncType, status, errorText string, syncedCount int, startedAt time.Time, durationMs int64) {
entry := SyncLogEntry{
SyncType: syncType,
Status: status,
ErrorText: errorText,
SyncedCount: syncedCount,
StartedAt: startedAt,
DurationMs: durationMs,
}
if err := l.db.Create(&entry).Error; err != nil {
return
}
// Prune: keep only the most recent N entries for this sync_type
l.db.Exec(`
DELETE FROM sync_log
WHERE sync_type = ? AND id NOT IN (
SELECT id FROM sync_log WHERE sync_type = ? ORDER BY started_at DESC LIMIT ?
)
`, syncType, syncType, syncLogMaxPerType)
}
// GetSyncLog returns the most recent sync log entries, newest first.
func (l *LocalDB) GetSyncLog(limit int) ([]SyncLogEntry, error) {
var entries []SyncLogEntry
err := l.db.Order("started_at DESC").Limit(limit).Find(&entries).Error
return entries, err
}
func (l *LocalDB) GetLastComponentSyncAttemptAt() *time.Time {
value, ok := l.getAppSettingValue("last_component_sync_attempt_at")
if !ok {
return nil
}
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil
}
return &t
}
func (l *LocalDB) GetLastComponentSyncStatus() string {
value, ok := l.getAppSettingValue("last_component_sync_status")
if !ok {
return ""
}
return strings.TrimSpace(value)
}
func (l *LocalDB) GetLastComponentSyncError() string {
value, ok := l.getAppSettingValue("last_component_sync_error")
if !ok {
return ""
}
return strings.TrimSpace(value)
}
func (l *LocalDB) SetComponentSyncResult(status, errorText string, attemptedAt time.Time) error {
status = strings.TrimSpace(status)
errorText = strings.TrimSpace(errorText)
if status == "" {
status = "unknown"
}
return l.db.Transaction(func(tx *gorm.DB) error {
if err := l.upsertAppSetting(tx, "last_component_sync_status", status, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_component_sync_error", errorText, attemptedAt); err != nil {
return err
}
if err := l.upsertAppSetting(tx, "last_component_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
return err
}
return nil
})
}
// CountLocalPricelists returns the number of local pricelists
func (l *LocalDB) CountLocalPricelists() int64 {
var count int64
@@ -689,10 +1247,37 @@ func (l *LocalDB) CountLocalPricelists() int64 {
return count
}
// CountAllPricelistItems returns total rows across all local_pricelist_items.
func (l *LocalDB) CountAllPricelistItems() int64 {
var count int64
l.db.Model(&LocalPricelistItem{}).Count(&count)
return count
}
// CountComponents returns the number of rows in local_components.
func (l *LocalDB) CountComponents() int64 {
var count int64
l.db.Model(&LocalComponent{}).Count(&count)
return count
}
// DBFileSizeBytes returns the size of the SQLite database file in bytes.
func (l *LocalDB) DBFileSizeBytes() int64 {
info, err := os.Stat(l.path)
if err != nil {
return 0
}
return info.Size()
}
// GetLatestLocalPricelist returns the most recently synced pricelist
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&pricelist).Error; err != nil {
if err := l.db.
Where("source = ?", "estimate").
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
Order("created_at DESC, id DESC").
First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
@@ -701,7 +1286,11 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("source = ?", source).Order("created_at DESC").First(&pricelist).Error; err != nil {
if err := l.db.
Where("source = ?", source).
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
Order("created_at DESC, id DESC").
First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
@@ -785,20 +1374,20 @@ func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (i
return count, nil
}
// SaveLocalPricelistItems saves pricelist items to local SQLite
// SaveLocalPricelistItems saves pricelist items to local SQLite.
// Duplicate (pricelist_id, lot_name) rows are silently ignored.
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
if len(items) == 0 {
return nil
}
// Batch insert
batchSize := 500
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
if err := l.db.CreateInBatches(items[i:end], batchSize).Error; err != nil {
if err := l.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(items[i:end], batchSize).Error; err != nil {
return err
}
}
@@ -848,6 +1437,32 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
return item.Price, nil
}
// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query.
// Uses the composite index (pricelist_id, lot_name). Missing lots are omitted from the result.
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
result := make(map[string]float64, len(lotNames))
if len(lotNames) == 0 {
return result, nil
}
type row struct {
LotName string `gorm:"column:lot_name"`
Price float64 `gorm:"column:price"`
}
var rows []row
if err := l.db.Model(&LocalPricelistItem{}).
Select("lot_name, price").
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
if r.Price > 0 {
result[r.LotName] = r.Price
}
}
return result, nil
}
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
// Missing lots are not included in the map; caller is responsible for strict validation.
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
@@ -1180,42 +1795,6 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
return nil
}
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
var migration LocalRemoteMigrationApplied
if err := l.db.Where("id = ?", id).First(&migration).Error; err != nil {
return nil, err
}
return &migration, nil
}
// UpsertRemoteMigrationApplied writes applied migration metadata.
func (l *LocalDB) UpsertRemoteMigrationApplied(id, checksum, appVersion string, appliedAt time.Time) error {
record := &LocalRemoteMigrationApplied{
ID: id,
Checksum: checksum,
AppVersion: appVersion,
AppliedAt: appliedAt,
}
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"checksum": checksum,
"app_version": appVersion,
"applied_at": appliedAt,
}),
}).Create(record).Error
}
// GetLatestAppliedRemoteMigrationID returns last applied remote migration id.
func (l *LocalDB) GetLatestAppliedRemoteMigrationID() (string, error) {
var record LocalRemoteMigrationApplied
if err := l.db.Order("applied_at DESC").First(&record).Error; err != nil {
return "", err
}
return record.ID, nil
}
// GetSyncGuardState returns the latest readiness guard state.
func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) {
var state LocalSyncGuardState
@@ -1247,3 +1826,62 @@ 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

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"time"
@@ -108,6 +109,29 @@ var localMigrations = []localMigration{
name: "Deduplicate configuration revisions by spec+price",
run: deduplicateConfigurationVersionsBySpecAndPrice,
},
{
id: "2026_02_19_local_config_line_no",
name: "Add line_no to local_configurations and backfill ordering",
run: addLocalConfigurationLineNo,
},
{
id: "2026_03_07_local_partnumber_book_catalog",
name: "Convert local partnumber book cache to book membership + deduplicated PN catalog",
run: migrateLocalPartnumberBookCatalog,
},
{
id: "2026_03_13_pricelist_items_dedup_unique",
name: "Deduplicate local_pricelist_items and add unique index on (pricelist_id, lot_name)",
run: deduplicatePricelistItemsAndAddUniqueIndex,
},
}
type localPartnumberCatalogRow struct {
Partnumber string
LotsJSON LocalPartnumberBookLots
Description string
CreatedAt time.Time
ServerID int
}
func runLocalMigrations(db *gorm.DB) error {
@@ -806,3 +830,293 @@ func addLocalConfigurationSupportCode(tx *gorm.DB) error {
}
return nil
}
func addLocalConfigurationLineNo(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_configurations')
WHERE name IN ('line_no')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(line_no) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN line_no INTEGER
`).Error; err != nil {
return fmt.Errorf("add local_configurations.line_no: %w", err)
}
slog.Info("added line_no to local_configurations")
}
if err := tx.Exec(`
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(TRIM(project_uuid), ''), '__NO_PROJECT__')
ORDER BY created_at ASC, id ASC
) AS rn
FROM local_configurations
WHERE line_no IS NULL OR line_no <= 0
)
UPDATE local_configurations
SET line_no = (
SELECT rn * 10
FROM ranked
WHERE ranked.id = local_configurations.id
)
WHERE id IN (SELECT id FROM ranked)
`).Error; err != nil {
return fmt.Errorf("backfill local_configurations.line_no: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_configurations_project_line_no
ON local_configurations(project_uuid, line_no)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_configurations_project_line_no: %w", err)
}
return nil
}
func migrateLocalPartnumberBookCatalog(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
hasBooksTable := tx.Migrator().HasTable(&LocalPartnumberBook{})
hasItemsTable := tx.Migrator().HasTable(&LocalPartnumberBookItem{})
if !hasItemsTable {
return nil
}
if hasBooksTable {
var bookCols []columnInfo
if err := tx.Raw(`SELECT name FROM pragma_table_info('local_partnumber_books')`).Scan(&bookCols).Error; err != nil {
return fmt.Errorf("load local_partnumber_books columns: %w", err)
}
hasPartnumbersJSON := false
for _, c := range bookCols {
if c.Name == "partnumbers_json" {
hasPartnumbersJSON = true
break
}
}
if !hasPartnumbersJSON {
if err := tx.Exec(`ALTER TABLE local_partnumber_books ADD COLUMN partnumbers_json TEXT NOT NULL DEFAULT '[]'`).Error; err != nil {
return fmt.Errorf("add local_partnumber_books.partnumbers_json: %w", err)
}
}
}
var itemCols []columnInfo
if err := tx.Raw(`SELECT name FROM pragma_table_info('local_partnumber_book_items')`).Scan(&itemCols).Error; err != nil {
return fmt.Errorf("load local_partnumber_book_items columns: %w", err)
}
hasBookID := false
hasLotName := false
hasLotsJSON := false
for _, c := range itemCols {
if c.Name == "book_id" {
hasBookID = true
}
if c.Name == "lot_name" {
hasLotName = true
}
if c.Name == "lots_json" {
hasLotsJSON = true
}
}
if !hasBookID && !hasLotName && !hasLotsJSON {
return nil
}
type legacyRow struct {
BookID uint
Partnumber string
LotName string
Description string
CreatedAt time.Time
ServerID int
}
bookPNs := make(map[uint]map[string]struct{})
catalog := make(map[string]*localPartnumberCatalogRow)
if hasBookID || hasLotName {
var rows []legacyRow
if err := tx.Raw(`
SELECT
i.book_id,
i.partnumber,
i.lot_name,
COALESCE(i.description, '') AS description,
b.created_at,
b.server_id
FROM local_partnumber_book_items i
INNER JOIN local_partnumber_books b ON b.id = i.book_id
ORDER BY b.created_at DESC, b.id DESC, i.partnumber ASC, i.id ASC
`).Scan(&rows).Error; err != nil {
return fmt.Errorf("load legacy local partnumber book items: %w", err)
}
for _, row := range rows {
if _, ok := bookPNs[row.BookID]; !ok {
bookPNs[row.BookID] = make(map[string]struct{})
}
bookPNs[row.BookID][row.Partnumber] = struct{}{}
entry, ok := catalog[row.Partnumber]
if !ok {
entry = &localPartnumberCatalogRow{
Partnumber: row.Partnumber,
Description: row.Description,
CreatedAt: row.CreatedAt,
ServerID: row.ServerID,
}
catalog[row.Partnumber] = entry
}
if row.CreatedAt.After(entry.CreatedAt) || (row.CreatedAt.Equal(entry.CreatedAt) && row.ServerID >= entry.ServerID) {
entry.Description = row.Description
entry.CreatedAt = row.CreatedAt
entry.ServerID = row.ServerID
}
found := false
for i := range entry.LotsJSON {
if entry.LotsJSON[i].LotName == row.LotName {
entry.LotsJSON[i].Qty += 1
found = true
break
}
}
if !found && row.LotName != "" {
entry.LotsJSON = append(entry.LotsJSON, LocalPartnumberBookLot{LotName: row.LotName, Qty: 1})
}
}
var books []LocalPartnumberBook
if err := tx.Find(&books).Error; err != nil {
return fmt.Errorf("load local partnumber books: %w", err)
}
for _, book := range books {
pnSet := bookPNs[book.ID]
partnumbers := make([]string, 0, len(pnSet))
for pn := range pnSet {
partnumbers = append(partnumbers, pn)
}
sort.Strings(partnumbers)
if err := tx.Model(&LocalPartnumberBook{}).
Where("id = ?", book.ID).
Update("partnumbers_json", LocalStringList(partnumbers)).Error; err != nil {
return fmt.Errorf("update partnumbers_json for local book %d: %w", book.ID, err)
}
}
} else {
var items []LocalPartnumberBookItem
if err := tx.Order("id DESC").Find(&items).Error; err != nil {
return fmt.Errorf("load canonical local partnumber book items: %w", err)
}
for _, item := range items {
entry, ok := catalog[item.Partnumber]
if !ok {
copiedLots := append(LocalPartnumberBookLots(nil), item.LotsJSON...)
catalog[item.Partnumber] = &localPartnumberCatalogRow{
Partnumber: item.Partnumber,
LotsJSON: copiedLots,
Description: item.Description,
}
continue
}
if entry.Description == "" && item.Description != "" {
entry.Description = item.Description
}
for _, lot := range item.LotsJSON {
merged := false
for i := range entry.LotsJSON {
if entry.LotsJSON[i].LotName == lot.LotName {
if lot.Qty > entry.LotsJSON[i].Qty {
entry.LotsJSON[i].Qty = lot.Qty
}
merged = true
break
}
}
if !merged {
entry.LotsJSON = append(entry.LotsJSON, lot)
}
}
}
}
return rebuildLocalPartnumberBookCatalog(tx, catalog)
}
func rebuildLocalPartnumberBookCatalog(tx *gorm.DB, catalog map[string]*localPartnumberCatalogRow) error {
if err := tx.Exec(`
CREATE TABLE local_partnumber_book_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL UNIQUE,
lots_json TEXT NOT NULL,
description TEXT
)
`).Error; err != nil {
return fmt.Errorf("create new local_partnumber_book_items table: %w", err)
}
orderedPartnumbers := make([]string, 0, len(catalog))
for pn := range catalog {
orderedPartnumbers = append(orderedPartnumbers, pn)
}
sort.Strings(orderedPartnumbers)
for _, pn := range orderedPartnumbers {
row := catalog[pn]
sort.Slice(row.LotsJSON, func(i, j int) bool {
return row.LotsJSON[i].LotName < row.LotsJSON[j].LotName
})
if err := tx.Table("local_partnumber_book_items_new").Create(&LocalPartnumberBookItem{
Partnumber: row.Partnumber,
LotsJSON: row.LotsJSON,
Description: row.Description,
}).Error; err != nil {
return fmt.Errorf("insert new local_partnumber_book_items row for %s: %w", pn, err)
}
}
if err := tx.Exec(`DROP TABLE local_partnumber_book_items`).Error; err != nil {
return fmt.Errorf("drop legacy local_partnumber_book_items: %w", err)
}
if err := tx.Exec(`ALTER TABLE local_partnumber_book_items_new RENAME TO local_partnumber_book_items`).Error; err != nil {
return fmt.Errorf("rename new local_partnumber_book_items table: %w", err)
}
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_partnumber_book_items_partnumber ON local_partnumber_book_items(partnumber)`).Error; err != nil {
return fmt.Errorf("create local_partnumber_book_items partnumber index: %w", err)
}
return nil
}
func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error {
// Remove duplicate (pricelist_id, lot_name) rows keeping only the row with the lowest id.
if err := tx.Exec(`
DELETE FROM local_pricelist_items
WHERE id NOT IN (
SELECT MIN(id) FROM local_pricelist_items
GROUP BY pricelist_id, lot_name
)
`).Error; err != nil {
return fmt.Errorf("deduplicate local_pricelist_items: %w", err)
}
// Add unique index to prevent future duplicates.
if err := tx.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot_unique
ON local_pricelist_items(pricelist_id, lot_name)
`).Error; err != nil {
return fmt.Errorf("create unique index on local_pricelist_items: %w", err)
}
slog.Info("deduplicated local_pricelist_items and added unique index")
return nil
}

View File

@@ -83,35 +83,39 @@ func (s *LocalStringList) Scan(value interface{}) error {
// LocalConfiguration stores configurations in local SQLite
type LocalConfiguration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
Name string `gorm:"not null" json:"name"`
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
Article string `gorm:"size:80" json:"article,omitempty"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
Name string `gorm:"not null" json:"name"`
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
Article string `gorm:"size:80" json:"article,omitempty"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at"`
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"`
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
Line int `gorm:"column:line_no;index" json:"line"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
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"`
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
}
func (LocalConfiguration) TableName() string {
@@ -200,18 +204,6 @@ func (LocalComponent) TableName() string {
return "local_components"
}
// LocalRemoteMigrationApplied tracks remote SQLite migrations received from server and applied locally.
type LocalRemoteMigrationApplied struct {
ID string `gorm:"primaryKey;size:128" json:"id"`
Checksum string `gorm:"size:128;not null" json:"checksum"`
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
AppliedAt time.Time `gorm:"not null" json:"applied_at"`
}
func (LocalRemoteMigrationApplied) TableName() string {
return "local_remote_migrations_applied"
}
// LocalSyncGuardState stores latest sync readiness decision for UI and preflight checks.
type LocalSyncGuardState struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
@@ -242,3 +234,125 @@ type PendingChange struct {
func (PendingChange) TableName() string {
return "pending_changes"
}
// LocalPartnumberBook stores a version snapshot of the PN→LOT mapping book (pull-only from PriceForge)
type LocalPartnumberBook struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ServerID int `gorm:"uniqueIndex;not null" json:"server_id"`
Version string `gorm:"not null" json:"version"`
CreatedAt time.Time `gorm:"not null" json:"created_at"`
IsActive bool `gorm:"not null;default:true" json:"is_active"`
PartnumbersJSON LocalStringList `gorm:"column:partnumbers_json;type:text" json:"partnumbers_json"`
}
func (LocalPartnumberBook) TableName() string {
return "local_partnumber_books"
}
type LocalPartnumberBookLot struct {
LotName string `json:"lot_name"`
Qty float64 `json:"qty"`
}
type LocalPartnumberBookLots []LocalPartnumberBookLot
func (l LocalPartnumberBookLots) Value() (driver.Value, error) {
return json.Marshal(l)
}
func (l *LocalPartnumberBookLots) Scan(value interface{}) error {
if value == nil {
*l = make(LocalPartnumberBookLots, 0)
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New("type assertion failed for LocalPartnumberBookLots")
}
return json.Unmarshal(bytes, l)
}
// LocalPartnumberBookItem stores the canonical PN composition pulled from PriceForge.
type LocalPartnumberBookItem struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Partnumber string `gorm:"not null" json:"partnumber"`
LotsJSON LocalPartnumberBookLots `gorm:"column:lots_json;type:text" json:"lots_json"`
Description string `json:"description,omitempty"`
}
func (LocalPartnumberBookItem) TableName() string {
return "local_partnumber_book_items"
}
// VendorSpecItem represents a single row in a vendor BOM specification
type VendorSpecItem struct {
SortOrder int `json:"sort_order"`
VendorPartnumber string `json:"vendor_partnumber"`
Quantity int `json:"quantity"`
Description string `json:"description,omitempty"`
UnitPrice *float64 `json:"unit_price,omitempty"`
TotalPrice *float64 `json:"total_price,omitempty"`
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved"
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
}
type VendorSpecLotAllocation struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"` // quantity of LOT per 1 vendor PN
}
// VendorSpecLotMapping is the canonical persisted LOT mapping for a vendor PN row.
// It stores all mapped LOTs (base + bundle) uniformly.
type VendorSpecLotMapping struct {
LotName string `json:"lot_name"`
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
func (v VendorSpec) Value() (driver.Value, error) {
if v == nil {
return nil, nil
}
return json.Marshal(v)
}
func (v *VendorSpec) Scan(value interface{}) error {
if value == nil {
*v = nil
return nil
}
var bytes []byte
switch val := value.(type) {
case []byte:
bytes = val
case string:
bytes = []byte(val)
default:
return errors.New("type assertion failed for VendorSpec")
}
return json.Unmarshal(bytes, v)
}

View File

@@ -0,0 +1,128 @@
package localdb
import (
"path/filepath"
"testing"
"time"
)
func TestGetLatestLocalPricelistBySource_SkipsPricelistWithoutItems(t *testing.T) {
local, err := New(filepath.Join(t.TempDir(), "latest_without_items.db"))
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
base := time.Now().Add(-time.Minute)
withItems := &LocalPricelist{
ServerID: 1001,
Source: "estimate",
Version: "E-1",
Name: "with-items",
CreatedAt: base,
SyncedAt: base,
}
if err := local.SaveLocalPricelist(withItems); err != nil {
t.Fatalf("save pricelist with items: %v", err)
}
storedWithItems, err := local.GetLocalPricelistByServerID(withItems.ServerID)
if err != nil {
t.Fatalf("load pricelist with items: %v", err)
}
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
{
PricelistID: storedWithItems.ID,
LotName: "CPU_A",
Price: 100,
},
}); err != nil {
t.Fatalf("save pricelist items: %v", err)
}
withoutItems := &LocalPricelist{
ServerID: 1002,
Source: "estimate",
Version: "E-2",
Name: "without-items",
CreatedAt: base.Add(2 * time.Second),
SyncedAt: base.Add(2 * time.Second),
}
if err := local.SaveLocalPricelist(withoutItems); err != nil {
t.Fatalf("save pricelist without items: %v", err)
}
got, err := local.GetLatestLocalPricelistBySource("estimate")
if err != nil {
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
}
if got.ServerID != withItems.ServerID {
t.Fatalf("expected server_id=%d, got %d", withItems.ServerID, got.ServerID)
}
}
func TestGetLatestLocalPricelistBySource_TieBreaksByID(t *testing.T) {
local, err := New(filepath.Join(t.TempDir(), "latest_tie_break.db"))
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
base := time.Now().Add(-time.Minute)
first := &LocalPricelist{
ServerID: 2001,
Source: "warehouse",
Version: "S-1",
Name: "first",
CreatedAt: base,
SyncedAt: base,
}
if err := local.SaveLocalPricelist(first); err != nil {
t.Fatalf("save first pricelist: %v", err)
}
storedFirst, err := local.GetLocalPricelistByServerID(first.ServerID)
if err != nil {
t.Fatalf("load first pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
{
PricelistID: storedFirst.ID,
LotName: "CPU_A",
Price: 101,
},
}); err != nil {
t.Fatalf("save first items: %v", err)
}
second := &LocalPricelist{
ServerID: 2002,
Source: "warehouse",
Version: "S-2",
Name: "second",
CreatedAt: base,
SyncedAt: base,
}
if err := local.SaveLocalPricelist(second); err != nil {
t.Fatalf("save second pricelist: %v", err)
}
storedSecond, err := local.GetLocalPricelistByServerID(second.ServerID)
if err != nil {
t.Fatalf("load second pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
{
PricelistID: storedSecond.ID,
LotName: "CPU_A",
Price: 102,
},
}); err != nil {
t.Fatalf("save second items: %v", err)
}
got, err := local.GetLatestLocalPricelistBySource("warehouse")
if err != nil {
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
}
if got.ServerID != second.ServerID {
t.Fatalf("expected server_id=%d, got %d", second.ServerID, got.ServerID)
}
}

View File

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

View File

@@ -10,31 +10,36 @@ import (
// BuildConfigurationSnapshot serializes the full local configuration state.
func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
snapshot := map[string]interface{}{
"id": localCfg.ID,
"uuid": localCfg.UUID,
"server_id": localCfg.ServerID,
"project_uuid": localCfg.ProjectUUID,
"current_version_id": localCfg.CurrentVersionID,
"is_active": localCfg.IsActive,
"name": localCfg.Name,
"items": localCfg.Items,
"total_price": localCfg.TotalPrice,
"custom_price": localCfg.CustomPrice,
"notes": localCfg.Notes,
"is_template": localCfg.IsTemplate,
"server_count": localCfg.ServerCount,
"server_model": localCfg.ServerModel,
"support_code": localCfg.SupportCode,
"article": localCfg.Article,
"pricelist_id": localCfg.PricelistID,
"only_in_stock": localCfg.OnlyInStock,
"price_updated_at": localCfg.PriceUpdatedAt,
"created_at": localCfg.CreatedAt,
"updated_at": localCfg.UpdatedAt,
"synced_at": localCfg.SyncedAt,
"sync_status": localCfg.SyncStatus,
"original_user_id": localCfg.OriginalUserID,
"original_username": localCfg.OriginalUsername,
"id": localCfg.ID,
"uuid": localCfg.UUID,
"server_id": localCfg.ServerID,
"project_uuid": localCfg.ProjectUUID,
"current_version_id": localCfg.CurrentVersionID,
"is_active": localCfg.IsActive,
"name": localCfg.Name,
"items": localCfg.Items,
"total_price": localCfg.TotalPrice,
"custom_price": localCfg.CustomPrice,
"notes": localCfg.Notes,
"is_template": localCfg.IsTemplate,
"server_count": localCfg.ServerCount,
"server_model": localCfg.ServerModel,
"support_code": localCfg.SupportCode,
"article": localCfg.Article,
"pricelist_id": localCfg.PricelistID,
"warehouse_pricelist_id": localCfg.WarehousePricelistID,
"competitor_pricelist_id": localCfg.CompetitorPricelistID,
"disable_price_refresh": localCfg.DisablePriceRefresh,
"only_in_stock": localCfg.OnlyInStock,
"vendor_spec": localCfg.VendorSpec,
"line": localCfg.Line,
"price_updated_at": localCfg.PriceUpdatedAt,
"created_at": localCfg.CreatedAt,
"updated_at": localCfg.UpdatedAt,
"synced_at": localCfg.SyncedAt,
"sync_status": localCfg.SyncStatus,
"original_user_id": localCfg.OriginalUserID,
"original_username": localCfg.OriginalUsername,
}
data, err := json.Marshal(snapshot)
@@ -47,23 +52,28 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
// DecodeConfigurationSnapshot returns editable fields from one saved snapshot.
func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
var snapshot struct {
ProjectUUID *string `json:"project_uuid"`
IsActive *bool `json:"is_active"`
Name string `json:"name"`
Items LocalConfigItems `json:"items"`
TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
ServerModel string `json:"server_model"`
SupportCode string `json:"support_code"`
Article string `json:"article"`
PricelistID *uint `json:"pricelist_id"`
OnlyInStock bool `json:"only_in_stock"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
OriginalUserID uint `json:"original_user_id"`
OriginalUsername string `json:"original_username"`
ProjectUUID *string `json:"project_uuid"`
IsActive *bool `json:"is_active"`
Name string `json:"name"`
Items LocalConfigItems `json:"items"`
TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
ServerModel string `json:"server_model"`
SupportCode string `json:"support_code"`
Article string `json:"article"`
PricelistID *uint `json:"pricelist_id"`
WarehousePricelistID *uint `json:"warehouse_pricelist_id"`
CompetitorPricelistID *uint `json:"competitor_pricelist_id"`
DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"`
VendorSpec VendorSpec `json:"vendor_spec"`
Line int `json:"line"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
OriginalUserID uint `json:"original_user_id"`
OriginalUsername string `json:"original_username"`
}
if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
@@ -76,31 +86,42 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
}
return &LocalConfiguration{
IsActive: isActive,
ProjectUUID: snapshot.ProjectUUID,
Name: snapshot.Name,
Items: snapshot.Items,
TotalPrice: snapshot.TotalPrice,
CustomPrice: snapshot.CustomPrice,
Notes: snapshot.Notes,
IsTemplate: snapshot.IsTemplate,
ServerCount: snapshot.ServerCount,
ServerModel: snapshot.ServerModel,
SupportCode: snapshot.SupportCode,
Article: snapshot.Article,
PricelistID: snapshot.PricelistID,
OnlyInStock: snapshot.OnlyInStock,
PriceUpdatedAt: snapshot.PriceUpdatedAt,
OriginalUserID: snapshot.OriginalUserID,
OriginalUsername: snapshot.OriginalUsername,
IsActive: isActive,
ProjectUUID: snapshot.ProjectUUID,
Name: snapshot.Name,
Items: snapshot.Items,
TotalPrice: snapshot.TotalPrice,
CustomPrice: snapshot.CustomPrice,
Notes: snapshot.Notes,
IsTemplate: snapshot.IsTemplate,
ServerCount: snapshot.ServerCount,
ServerModel: snapshot.ServerModel,
SupportCode: snapshot.SupportCode,
Article: snapshot.Article,
PricelistID: snapshot.PricelistID,
WarehousePricelistID: snapshot.WarehousePricelistID,
CompetitorPricelistID: snapshot.CompetitorPricelistID,
DisablePriceRefresh: snapshot.DisablePriceRefresh,
OnlyInStock: snapshot.OnlyInStock,
VendorSpec: snapshot.VendorSpec,
Line: snapshot.Line,
PriceUpdatedAt: snapshot.PriceUpdatedAt,
OriginalUserID: snapshot.OriginalUserID,
OriginalUsername: snapshot.OriginalUsername,
}, nil
}
type configurationSpecPriceFingerprint struct {
Items []configurationSpecPriceFingerprintItem `json:"items"`
ServerCount int `json:"server_count"`
TotalPrice *float64 `json:"total_price,omitempty"`
CustomPrice *float64 `json:"custom_price,omitempty"`
Items []configurationSpecPriceFingerprintItem `json:"items"`
ServerCount int `json:"server_count"`
TotalPrice *float64 `json:"total_price,omitempty"`
CustomPrice *float64 `json:"custom_price,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"`
VendorSpec VendorSpec `json:"vendor_spec,omitempty"`
}
type configurationSpecPriceFingerprintItem struct {
@@ -131,10 +152,16 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin
})
payload := configurationSpecPriceFingerprint{
Items: items,
ServerCount: localCfg.ServerCount,
TotalPrice: localCfg.TotalPrice,
CustomPrice: localCfg.CustomPrice,
Items: items,
ServerCount: localCfg.ServerCount,
TotalPrice: localCfg.TotalPrice,
CustomPrice: localCfg.CustomPrice,
PricelistID: localCfg.PricelistID,
WarehousePricelistID: localCfg.WarehousePricelistID,
CompetitorPricelistID: localCfg.CompetitorPricelistID,
DisablePriceRefresh: localCfg.DisablePriceRefresh,
OnlyInStock: localCfg.OnlyInStock,
VendorSpec: localCfg.VendorSpec,
}
raw, err := json.Marshal(payload)

View File

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

View File

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

View File

@@ -1,110 +0,0 @@
package middleware
import (
"net/http"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
const (
AuthUserKey = "auth_user"
AuthClaimsKey = "auth_claims"
)
func Auth(authService *services.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "authorization header required",
})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid authorization header format",
})
return
}
claims, err := authService.ValidateToken(parts[1])
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": err.Error(),
})
return
}
c.Set(AuthClaimsKey, claims)
c.Next()
}
}
func RequireRole(roles ...models.UserRole) gin.HandlerFunc {
return func(c *gin.Context) {
claims, exists := c.Get(AuthClaimsKey)
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "authentication required",
})
return
}
authClaims := claims.(*services.Claims)
for _, role := range roles {
if authClaims.Role == role {
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "insufficient permissions",
})
}
}
func RequireEditor() gin.HandlerFunc {
return RequireRole(models.RoleEditor, models.RolePricingAdmin, models.RoleAdmin)
}
func RequirePricingAdmin() gin.HandlerFunc {
return RequireRole(models.RolePricingAdmin, models.RoleAdmin)
}
func RequireAdmin() gin.HandlerFunc {
return RequireRole(models.RoleAdmin)
}
// GetClaims extracts auth claims from context
func GetClaims(c *gin.Context) *services.Claims {
claims, exists := c.Get(AuthClaimsKey)
if !exists {
return nil
}
return claims.(*services.Claims)
}
// GetUserID extracts user ID from context
func GetUserID(c *gin.Context) uint {
claims := GetClaims(c)
if claims == nil {
return 0
}
return claims.UserID
}
// GetUsername extracts username from context
func GetUsername(c *gin.Context) string {
claims := GetClaims(c)
if claims == nil {
return ""
}
return claims.Username
}

View File

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

View File

@@ -13,32 +13,32 @@ func (Category) TableName() string {
return "qt_categories"
}
// DefaultCategories defines the standard categories with display order
// Order: BB, CPU, MEM, GPU, SSD, RAID, HBA, NIC, PSU, RISERS, ACC, and others
// 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.
var DefaultCategories = []Category{
{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},
{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},
}
// MaxKnownDisplayOrder is the highest display order for known categories
// New categories will get display order starting from this + 1
const MaxKnownDisplayOrder = 100
// MaxKnownDisplayOrder is the highest display order for known categories.
// New categories will get display order starting from this + 1.
const MaxKnownDisplayOrder = 200

View File

@@ -39,6 +39,57 @@ func (c ConfigItems) Total() float64 {
return total
}
type VendorSpecLotAllocation struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
}
type VendorSpecLotMapping struct {
LotName string `json:"lot_name"`
QuantityPerPN int `json:"quantity_per_pn"`
}
type VendorSpecItem struct {
SortOrder int `json:"sort_order"`
VendorPartnumber string `json:"vendor_partnumber"`
Quantity int `json:"quantity"`
Description string `json:"description,omitempty"`
UnitPrice *float64 `json:"unit_price,omitempty"`
TotalPrice *float64 `json:"total_price,omitempty"`
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
ResolutionSource string `json:"resolution_source,omitempty"`
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
}
type VendorSpec []VendorSpecItem
func (v VendorSpec) Value() (driver.Value, error) {
if v == nil {
return nil, nil
}
return json.Marshal(v)
}
func (v *VendorSpec) Scan(value interface{}) error {
if value == nil {
*v = nil
return nil
}
var bytes []byte
switch val := value.(type) {
case []byte:
bytes = val
case string:
bytes = []byte(val)
default:
return errors.New("type assertion failed for VendorSpec")
}
return json.Unmarshal(bytes, v)
}
type Configuration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
@@ -59,31 +110,17 @@ type Configuration struct {
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
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"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
func (Configuration) TableName() string {
return "qt_configurations"
}
type PriceOverride struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
LotName string `gorm:"size:255;not null" json:"lot_name"`
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
ValidFrom time.Time `gorm:"type:date;not null" json:"valid_from"`
ValidUntil *time.Time `gorm:"type:date" json:"valid_until"`
Reason string `gorm:"type:text" json:"reason"`
CreatedBy uint `gorm:"not null" json:"created_by"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"`
}
func (PriceOverride) TableName() string {
return "qt_price_overrides"
}

View File

@@ -1,7 +1,5 @@
package models
import "time"
// Lot represents existing lot table
type Lot struct {
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
@@ -12,69 +10,3 @@ 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"
}
// LotPartnumber maps external part numbers to internal lots.
type LotPartnumber struct {
Partnumber string `gorm:"column:partnumber;size:255;primaryKey" json:"partnumber"`
LotName string `gorm:"column:lot_name;size:255;primaryKey" json:"lot_name"`
Description *string `gorm:"column:description;size:10000" json:"description,omitempty"`
}
func (LotPartnumber) TableName() string {
return "lot_partnumbers"
}
// 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

@@ -10,14 +10,10 @@ import (
// AllModels returns all models for auto-migration
func AllModels() []interface{} {
return []interface{}{
&User{},
&Category{},
&LotMetadata{},
&Project{},
&Configuration{},
&PriceOverride{},
&PricingAlert{},
&ComponentUsageStats{},
&Pricelist{},
&PricelistItem{},
}
@@ -32,7 +28,9 @@ 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, "check that it exists") ||
strings.Contains(errStr, "Cannot change column") ||
strings.Contains(errStr, "used in a foreign key constraint") {
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
continue
}
@@ -42,64 +40,19 @@ func Migrate(db *gorm.DB) error {
return nil
}
// SeedCategories inserts default categories if not exist
// SeedCategories upserts default categories, updating display_order on existing rows.
func SeedCategories(db *gorm.DB) error {
for _, cat := range DefaultCategories {
result := db.Where("code = ?", cat.Code).FirstOrCreate(&cat)
if result.Error != nil {
return result.Error
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
}
}
}
return nil
}
// SeedAdminUser creates default admin user if not exists
// Default credentials: admin / admin123
func SeedAdminUser(db *gorm.DB, passwordHash string) error {
var count int64
db.Model(&User{}).Where("username = ?", "admin").Count(&count)
if count > 0 {
return nil
}
admin := &User{
Username: "admin",
Email: "admin@example.com",
PasswordHash: passwordHash,
Role: RoleAdmin,
IsActive: true,
}
return db.Create(admin).Error
}
// EnsureDBUser creates or returns the user corresponding to the database connection username.
// This is used when RBAC is disabled - configurations are owned by the DB user.
// Returns the user ID that should be used for all operations.
func EnsureDBUser(db *gorm.DB, dbUsername string) (uint, error) {
if dbUsername == "" {
return 0, nil
}
var user User
err := db.Where("username = ?", dbUsername).First(&user).Error
if err == nil {
return user.ID, nil
}
// User doesn't exist, create it
user = User{
Username: dbUsername,
Email: dbUsername + "@db.local",
PasswordHash: "-", // No password - this is a DB user, not an app user
Role: RoleAdmin,
IsActive: true,
}
if err := db.Create(&user).Error; err != nil {
slog.Error("failed to create DB user", "username", dbUsername, "error", err)
return 0, err
}
slog.Info("created DB user for configurations", "username", dbUsername, "user_id", user.ID)
return user.ID, nil
}

View File

@@ -1,39 +0,0 @@
package models
import "time"
type UserRole string
const (
RoleViewer UserRole = "viewer"
RoleEditor UserRole = "editor"
RolePricingAdmin UserRole = "pricing_admin"
RoleAdmin UserRole = "admin"
)
type User struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Username string `gorm:"size:100;uniqueIndex;not null" json:"username"`
Email string `gorm:"size:255;uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Role UserRole `gorm:"type:enum('viewer','editor','pricing_admin','admin');default:'viewer'" json:"role"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (User) TableName() string {
return "qt_users"
}
func (u *User) CanEdit() bool {
return u.Role == RoleEditor || u.Role == RolePricingAdmin || u.Role == RoleAdmin
}
func (u *User) CanManagePricing() bool {
return u.Role == RolePricingAdmin || u.Role == RoleAdmin
}
func (u *User) CanManageUsers() bool {
return u.Role == RoleAdmin
}

View File

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

View File

@@ -63,11 +63,6 @@ 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

@@ -1,6 +1,8 @@
package repository
import (
"strings"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
@@ -14,7 +16,13 @@ func NewConfigurationRepository(db *gorm.DB) *ConfigurationRepository {
}
func (r *ConfigurationRepository) Create(config *models.Configuration) error {
return r.db.Create(config).Error
if err := r.db.Create(config).Error; err != nil {
if isUnknownLineNoColumnError(err) {
return r.db.Omit("line_no").Create(config).Error
}
return err
}
return nil
}
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
@@ -36,7 +44,21 @@ func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration,
}
func (r *ConfigurationRepository) Update(config *models.Configuration) error {
return r.db.Save(config).Error
if err := r.db.Save(config).Error; err != nil {
if isUnknownLineNoColumnError(err) {
return r.db.Omit("line_no").Save(config).Error
}
return err
}
return nil
}
func isUnknownLineNoColumnError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unknown column 'line_no'") || strings.Contains(msg, "no column named line_no")
}
func (r *ConfigurationRepository) Delete(id uint) error {

View File

@@ -0,0 +1,174 @@
package repository
import (
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// PartnumberBookRepository provides read-only access to local partnumber book snapshots.
type PartnumberBookRepository struct {
db *gorm.DB
}
func NewPartnumberBookRepository(db *gorm.DB) *PartnumberBookRepository {
return &PartnumberBookRepository{db: db}
}
// GetActiveBook returns the most recently active local partnumber book.
func (r *PartnumberBookRepository) GetActiveBook() (*localdb.LocalPartnumberBook, error) {
var book localdb.LocalPartnumberBook
err := r.db.Where("is_active = 1").Order("created_at DESC, id DESC").First(&book).Error
if err != nil {
return nil, err
}
return &book, nil
}
// GetBookItems returns all items for the given local book ID.
func (r *PartnumberBookRepository) GetBookItems(bookID uint) ([]localdb.LocalPartnumberBookItem, error) {
book, err := r.getBook(bookID)
if err != nil {
return nil, err
}
items, _, err := r.listCatalogItems(book.PartnumbersJSON, "", 0, 0)
return items, err
}
// GetBookItemsPage returns items for the given local book ID with optional search and pagination.
func (r *PartnumberBookRepository) GetBookItemsPage(bookID uint, search string, page, perPage int) ([]localdb.LocalPartnumberBookItem, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 100
}
book, err := r.getBook(bookID)
if err != nil {
return nil, 0, err
}
return r.listCatalogItems(book.PartnumbersJSON, search, page, perPage)
}
// FindLotByPartnumber looks up a partnumber in the active book and returns the matching items.
func (r *PartnumberBookRepository) FindLotByPartnumber(bookID uint, partnumber string) ([]localdb.LocalPartnumberBookItem, error) {
book, err := r.getBook(bookID)
if err != nil {
return nil, err
}
found := false
for _, pn := range book.PartnumbersJSON {
if pn == partnumber {
found = true
break
}
}
if !found {
return nil, nil
}
var items []localdb.LocalPartnumberBookItem
err = r.db.Where("partnumber = ?", partnumber).Find(&items).Error
return items, err
}
// ListBooks returns all local partnumber books ordered newest first.
func (r *PartnumberBookRepository) ListBooks() ([]localdb.LocalPartnumberBook, error) {
var books []localdb.LocalPartnumberBook
err := r.db.Order("created_at DESC, id DESC").Find(&books).Error
return books, err
}
// SaveBook saves a new partnumber book snapshot.
func (r *PartnumberBookRepository) SaveBook(book *localdb.LocalPartnumberBook) error {
return r.db.Save(book).Error
}
// SaveBookItems upserts canonical PN catalog rows.
func (r *PartnumberBookRepository) SaveBookItems(items []localdb.LocalPartnumberBookItem) error {
if len(items) == 0 {
return nil
}
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "partnumber"}},
DoUpdates: clause.AssignmentColumns([]string{
"lots_json",
"description",
}),
}).CreateInBatches(items, 500).Error
}
// CountBookItems returns the number of items for a given local book ID.
func (r *PartnumberBookRepository) CountBookItems(bookID uint) int64 {
book, err := r.getBook(bookID)
if err != nil {
return 0
}
return int64(len(book.PartnumbersJSON))
}
func (r *PartnumberBookRepository) CountDistinctLots(bookID uint) int64 {
items, err := r.GetBookItems(bookID)
if err != nil {
return 0
}
seen := make(map[string]struct{})
for _, item := range items {
for _, lot := range item.LotsJSON {
if lot.LotName == "" {
continue
}
seen[lot.LotName] = struct{}{}
}
}
return int64(len(seen))
}
func (r *PartnumberBookRepository) HasAllBookItems(bookID uint) bool {
book, err := r.getBook(bookID)
if err != nil {
return false
}
if len(book.PartnumbersJSON) == 0 {
return true
}
var count int64
if err := r.db.Model(&localdb.LocalPartnumberBookItem{}).
Where("partnumber IN ?", []string(book.PartnumbersJSON)).
Count(&count).Error; err != nil {
return false
}
return count == int64(len(book.PartnumbersJSON))
}
func (r *PartnumberBookRepository) getBook(bookID uint) (*localdb.LocalPartnumberBook, error) {
var book localdb.LocalPartnumberBook
if err := r.db.First(&book, bookID).Error; err != nil {
return nil, err
}
return &book, nil
}
func (r *PartnumberBookRepository) listCatalogItems(partnumbers localdb.LocalStringList, search string, page, perPage int) ([]localdb.LocalPartnumberBookItem, int64, error) {
if len(partnumbers) == 0 {
return []localdb.LocalPartnumberBookItem{}, 0, nil
}
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers))
if search != "" {
trimmedSearch := "%" + search + "%"
query = query.Where("partnumber LIKE ? OR lots_json LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var items []localdb.LocalPartnumberBookItem
if page > 0 && perPage > 0 {
query = query.Offset((page - 1) * perPage).Limit(perPage)
}
err := query.Order("partnumber ASC, id ASC").Find(&items).Error
return items, total, err
}

View File

@@ -1,124 +0,0 @@
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

@@ -3,12 +3,10 @@ package repository
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
@@ -40,7 +38,7 @@ func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]
}
var pricelists []models.Pricelist
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
}
@@ -67,7 +65,7 @@ func (r *PricelistRepository) ListActiveBySource(source string, offset, limit in
}
var pricelists []models.Pricelist
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
}
@@ -148,7 +146,11 @@ func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
// GetLatestActiveBySource returns the most recent active pricelist by source.
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
var pricelist models.Pricelist
if err := r.db.Where("is_active = ? AND source = ?", true, source).Order("created_at DESC").First(&pricelist).Error; err != nil {
if err := r.db.
Where("is_active = ? AND source = ?", true, source).
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)").
Order("created_at DESC, id DESC").
First(&pricelist).Error; err != nil {
return nil, fmt.Errorf("getting latest pricelist: %w", err)
}
return &pricelist, nil
@@ -241,91 +243,9 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
items[i].Category = strings.TrimSpace(items[i].LotCategory)
}
if err := r.enrichItemsWithStock(items); err != nil {
return nil, 0, fmt.Errorf("enriching pricelist items with stock: %w", err)
}
return items, total, nil
}
func (r *PricelistRepository) enrichItemsWithStock(items []models.PricelistItem) error {
if len(items) == 0 {
return nil
}
resolver, err := lotmatch.NewLotResolverFromDB(r.db)
if err != nil {
return err
}
type stockRow struct {
Partnumber string `gorm:"column:partnumber"`
Qty *float64 `gorm:"column:qty"`
}
rows := make([]stockRow, 0)
if err := r.db.Raw(`
SELECT s.partnumber, s.qty
FROM stock_log s
INNER JOIN (
SELECT partnumber, MAX(date) AS max_date
FROM stock_log
GROUP BY partnumber
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
WHERE s.qty IS NOT NULL
`).Scan(&rows).Error; err != nil {
return err
}
lotTotals := make(map[string]float64, len(items))
lotPartnumbers := make(map[string][]string, len(items))
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
for i := range rows {
row := rows[i]
if strings.TrimSpace(row.Partnumber) == "" {
continue
}
lotName, _, resolveErr := resolver.Resolve(row.Partnumber)
if resolveErr != nil || strings.TrimSpace(lotName) == "" {
continue
}
if row.Qty != nil {
lotTotals[lotName] += *row.Qty
}
pn := strings.TrimSpace(row.Partnumber)
if pn == "" {
continue
}
if _, ok := seenPartnumbers[lotName]; !ok {
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
}
key := strings.ToLower(pn)
if _, exists := seenPartnumbers[lotName][key]; exists {
continue
}
seenPartnumbers[lotName][key] = struct{}{}
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
}
for i := range items {
lotName := items[i].LotName
if qty, ok := lotTotals[lotName]; ok {
qtyCopy := qty
items[i].AvailableQty = &qtyCopy
}
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
sort.Slice(partnumbers, func(a, b int) bool {
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
})
items[i].Partnumbers = partnumbers
}
}
return nil
}
// GetLotNames returns distinct lot names from pricelist items.
func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
var lotNames []string

View File

@@ -75,54 +75,98 @@ func TestGenerateVersion_IsolatedBySource(t *testing.T) {
}
}
func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) {
func TestGetLatestActiveBySource_SkipsPricelistsWithoutItems(t *testing.T) {
repo := newTestPricelistRepository(t)
db := repo.db
ts := time.Now().Add(-time.Minute)
source := "test-estimate-skip-empty"
warehouse := models.Pricelist{
Source: string(models.PricelistSourceWarehouse),
Version: "S-2026-02-07-001",
emptyLatest := models.Pricelist{
Source: source,
Version: "E-empty",
CreatedBy: "test",
IsActive: true,
CreatedAt: ts.Add(2 * time.Second),
}
if err := db.Create(&warehouse).Error; err != nil {
t.Fatalf("create pricelist: %v", err)
if err := db.Create(&emptyLatest).Error; err != nil {
t.Fatalf("create empty pricelist: %v", err)
}
withItems := models.Pricelist{
Source: source,
Version: "E-with-items",
CreatedBy: "test",
IsActive: true,
CreatedAt: ts,
}
if err := db.Create(&withItems).Error; err != nil {
t.Fatalf("create pricelist with items: %v", err)
}
if err := db.Create(&models.PricelistItem{
PricelistID: warehouse.ID,
LotName: "SSD_NVME_03.2T",
PricelistID: withItems.ID,
LotName: "CPU_A",
Price: 100,
}).Error; err != nil {
t.Fatalf("create pricelist item: %v", err)
}
if err := db.Create(&models.Lot{LotName: "SSD_NVME_03.2T"}).Error; err != nil {
t.Fatalf("create lot: %v", err)
got, err := repo.GetLatestActiveBySource(source)
if err != nil {
t.Fatalf("GetLatestActiveBySource: %v", err)
}
qty := 5.0
if err := db.Create(&models.StockLog{
Partnumber: "SSD_NVME_03.2T_GEN3_P4610",
Date: time.Now(),
Price: 200,
Qty: &qty,
if got.ID != withItems.ID {
t.Fatalf("expected pricelist with items id=%d, got id=%d", withItems.ID, got.ID)
}
}
func TestGetLatestActiveBySource_TieBreaksByID(t *testing.T) {
repo := newTestPricelistRepository(t)
db := repo.db
ts := time.Now().Add(-time.Minute)
source := "test-warehouse-tie-break"
first := models.Pricelist{
Source: source,
Version: "S-1",
CreatedBy: "test",
IsActive: true,
CreatedAt: ts,
}
if err := db.Create(&first).Error; err != nil {
t.Fatalf("create first pricelist: %v", err)
}
if err := db.Create(&models.PricelistItem{
PricelistID: first.ID,
LotName: "CPU_A",
Price: 101,
}).Error; err != nil {
t.Fatalf("create stock log: %v", err)
t.Fatalf("create first item: %v", err)
}
items, total, err := repo.GetItems(warehouse.ID, 0, 20, "")
second := models.Pricelist{
Source: source,
Version: "S-2",
CreatedBy: "test",
IsActive: true,
CreatedAt: ts,
}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("create second pricelist: %v", err)
}
if err := db.Create(&models.PricelistItem{
PricelistID: second.ID,
LotName: "CPU_A",
Price: 102,
}).Error; err != nil {
t.Fatalf("create second item: %v", err)
}
got, err := repo.GetLatestActiveBySource(source)
if err != nil {
t.Fatalf("GetItems: %v", err)
t.Fatalf("GetLatestActiveBySource: %v", err)
}
if total != 1 {
t.Fatalf("expected total=1, got %d", total)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].AvailableQty == nil {
t.Fatalf("expected available qty to be set")
}
if *items[0].AvailableQty != 5 {
t.Fatalf("expected available qty=5, got %v", *items[0].AvailableQty)
if got.ID != second.ID {
t.Fatalf("expected later inserted pricelist id=%d, got id=%d", second.ID, got.ID)
}
}
@@ -133,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{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewPricelistRepository(db)

View File

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

View File

@@ -1,62 +0,0 @@
package repository
import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(user *models.User) error {
return r.db.Create(user).Error
}
func (r *UserRepository) GetByID(id uint) (*models.User, error) {
var user models.User
err := r.db.First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetByUsername(username string) (*models.User, error) {
var user models.User
err := r.db.Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
var user models.User
err := r.db.Where("email = ?", email).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) Update(user *models.User) error {
return r.db.Save(user).Error
}
func (r *UserRepository) Delete(id uint) error {
return r.db.Delete(&models.User{}, id).Error
}
func (r *UserRepository) List(offset, limit int) ([]models.User, int64, error) {
var users []models.User
var total int64
r.db.Model(&models.User{}).Count(&total)
err := r.db.Offset(offset).Limit(limit).Find(&users).Error
return users, total, err
}

View File

@@ -1,180 +0,0 @@
package services
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUserNotFound = errors.New("user not found")
ErrUserInactive = errors.New("user account is inactive")
ErrInvalidToken = errors.New("invalid token")
ErrTokenExpired = errors.New("token expired")
)
type AuthService struct {
userRepo *repository.UserRepository
config config.AuthConfig
}
func NewAuthService(userRepo *repository.UserRepository, cfg config.AuthConfig) *AuthService {
return &AuthService{
userRepo: userRepo,
config: cfg,
}
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
}
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role models.UserRole `json:"role"`
jwt.RegisteredClaims
}
func (s *AuthService) Login(username, password string) (*TokenPair, *models.User, error) {
user, err := s.userRepo.GetByUsername(username)
if err != nil {
return nil, nil, ErrInvalidCredentials
}
if !user.IsActive {
return nil, nil, ErrUserInactive
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, nil, ErrInvalidCredentials
}
tokens, err := s.generateTokenPair(user)
if err != nil {
return nil, nil, err
}
return tokens, user, nil
}
func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
claims, err := s.ValidateToken(refreshToken)
if err != nil {
return nil, err
}
user, err := s.userRepo.GetByID(claims.UserID)
if err != nil {
return nil, ErrUserNotFound
}
if !user.IsActive {
return nil, ErrUserInactive
}
return s.generateTokenPair(user)
}
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(s.config.JWTSecret), nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrTokenExpired
}
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
func (s *AuthService) generateTokenPair(user *models.User) (*TokenPair, error) {
now := time.Now()
accessExpiry := now.Add(s.config.TokenExpiry)
refreshExpiry := now.Add(s.config.RefreshExpiry)
accessClaims := &Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(accessExpiry),
IssuedAt: jwt.NewNumericDate(now),
Subject: user.Username,
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString([]byte(s.config.JWTSecret))
if err != nil {
return nil, err
}
refreshClaims := &Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(refreshExpiry),
IssuedAt: jwt.NewNumericDate(now),
Subject: user.Username,
},
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshTokenString, err := refreshToken.SignedString([]byte(s.config.JWTSecret))
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessTokenString,
RefreshToken: refreshTokenString,
ExpiresAt: accessExpiry.Unix(),
}, nil
}
func (s *AuthService) HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func (s *AuthService) CreateUser(username, email, password string, role models.UserRole) (*models.User, error) {
hash, err := s.HashPassword(password)
if err != nil {
return nil, err
}
user := &models.User{
Username: username,
Email: email,
PasswordHash: hash,
Role: role,
IsActive: true,
}
if err := s.userRepo.Create(user); err != nil {
return nil, err
}
return user, nil
}

View File

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

View File

@@ -18,6 +18,7 @@ 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 {
@@ -45,18 +46,22 @@ func NewConfigurationService(
}
type CreateConfigRequest struct {
Name string `json:"name"`
Items models.ConfigItems `json:"items"`
ProjectUUID *string `json:"project_uuid,omitempty"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
ServerModel string `json:"server_model,omitempty"`
SupportCode string `json:"support_code,omitempty"`
Article string `json:"article,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
OnlyInStock bool `json:"only_in_stock"`
Name string `json:"name"`
Items models.ConfigItems `json:"items"`
ProjectUUID *string `json:"project_uuid,omitempty"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
ServerModel string `json:"server_model,omitempty"`
SupportCode string `json:"support_code,omitempty"`
Article string `json:"article,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
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"`
}
type ArticlePreviewRequest struct {
@@ -84,30 +89,34 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
}
config := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock,
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
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
_ = s.quoteService.RecordUsage(req.Items)
return config, nil
}
@@ -163,6 +172,9 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
config.SupportCode = req.SupportCode
config.Article = req.Article
config.PricelistID = pricelistID
config.WarehousePricelistID = req.WarehousePricelistID
config.CompetitorPricelistID = req.CompetitorPricelistID
config.DisablePriceRefresh = req.DisablePriceRefresh
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil {
@@ -230,18 +242,24 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
}
clone := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
WarehousePricelistID: original.WarehousePricelistID,
CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
OnlyInStock: original.OnlyInStock,
}
if err := s.configRepo.Create(clone); err != nil {
@@ -314,7 +332,13 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.ServerModel = req.ServerModel
config.SupportCode = req.SupportCode
config.Article = req.Article
config.PricelistID = pricelistID
config.WarehousePricelistID = req.WarehousePricelistID
config.CompetitorPricelistID = req.CompetitorPricelistID
config.DisablePriceRefresh = req.DisablePriceRefresh
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil {
@@ -587,13 +611,7 @@ func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUserna
if config == nil || ownerUsername == "" {
return false
}
if config.OwnerUsername != "" {
return config.OwnerUsername == ownerUsername
}
if config.User != nil {
return config.User.Username == ownerUsername
}
return false
return config.OwnerUsername == ownerUsername
}
// // Export configuration as JSON

View File

@@ -6,26 +6,24 @@ import (
"fmt"
"io"
"math"
"sort"
"strings"
"time"
"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
categoryRepo *repository.CategoryRepository
localDB *localdb.LocalDB
config config.ExportConfig
localDB *localdb.LocalDB
}
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository, local *localdb.LocalDB) *ExportService {
func NewExportService(cfg config.ExportConfig, local *localdb.LocalDB) *ExportService {
return &ExportService{
config: cfg,
categoryRepo: categoryRepo,
localDB: local,
config: cfg,
localDB: local,
}
}
@@ -42,6 +40,7 @@ type ExportItem struct {
// ConfigExportBlock represents one configuration (server) in the export.
type ConfigExportBlock struct {
Article string
Line int
ServerCount int
UnitPrice float64 // sum of component prices for one server
Items []ExportItem
@@ -53,6 +52,51 @@ type ProjectExportData struct {
CreatedAt time.Time
}
type ProjectPricingExportOptions struct {
IncludeLOT bool `json:"include_lot"`
IncludeBOM bool `json:"include_bom"`
IncludeEstimate bool `json:"include_estimate"`
IncludeStock bool `json:"include_stock"`
IncludeCompetitor bool `json:"include_competitor"`
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
}
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
if o.SaleMarkup > 0 {
return o.SaleMarkup
}
return 1.3
}
func (o ProjectPricingExportOptions) isDDP() bool {
return strings.EqualFold(strings.TrimSpace(o.Basis), "ddp")
}
type ProjectPricingExportData struct {
Configs []ProjectPricingExportConfig
CreatedAt time.Time
}
type ProjectPricingExportConfig struct {
Name string
Article string
Line int
ServerCount int
Rows []ProjectPricingExportRow
}
type ProjectPricingExportRow struct {
LotDisplay string
VendorPN string
Description string
Quantity int
BOMTotal *float64
Estimate *float64
Stock *float64
Competitor *float64
}
// ToCSV writes project export data in the new structured CSV format.
//
// Format:
@@ -80,19 +124,13 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
return fmt.Errorf("failed to write header: %w", err)
}
// 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
}
}
}
categoryOrder := defaultCategoryOrder()
for i, block := range data.Configs {
lineNo := (i + 1) * 10
lineNo := block.Line
if lineNo <= 0 {
lineNo = (i + 1) * 10
}
serverCount := block.ServerCount
if serverCount < 1 {
@@ -104,13 +142,13 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
// Server summary row
serverRow := []string{
fmt.Sprintf("%d", lineNo), // Line
"", // Type
block.Article, // p/n
"", // Description
"", // Qty (1 pcs.)
fmt.Sprintf("%d", serverCount), // Qty (total)
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
formatPriceWithSpace(totalPrice), // Price (total)
"", // Type
block.Article, // p/n
"", // Description
"", // Qty (1 pcs.)
fmt.Sprintf("%d", serverCount), // Qty (total)
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
formatPriceWithSpace(totalPrice), // Price (total)
}
if err := csvWriter.Write(serverRow); err != nil {
return fmt.Errorf("failed to write server row: %w", err)
@@ -124,14 +162,14 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
// Component rows
for _, item := range sortedItems {
componentRow := []string{
"", // Line
item.Category, // Type
item.LotName, // p/n
"", // Description
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
"", // Qty (total)
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
"", // Price (total)
"", // Line
item.Category, // Type
item.LotName, // p/n
"", // Description
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
"", // Qty (total)
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
"", // Price (total)
}
if err := csvWriter.Write(componentRow); err != nil {
return fmt.Errorf("failed to write component row: %w", err)
@@ -163,6 +201,81 @@ 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)
blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs))
for i := range sortedConfigs {
block, err := s.buildPricingExportBlock(&sortedConfigs[i], opts)
if err != nil {
return nil, err
}
blocks = append(blocks, block)
}
return &ProjectPricingExportData{
Configs: blocks,
CreatedAt: time.Now(),
}, nil
}
func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData, opts ProjectPricingExportOptions) error {
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return fmt.Errorf("failed to write BOM: %w", err)
}
csvWriter := csv.NewWriter(w)
csvWriter.Comma = ';'
defer csvWriter.Flush()
headers := pricingCSVHeaders(opts)
if err := csvWriter.Write(headers); err != nil {
return fmt.Errorf("failed to write pricing header: %w", err)
}
writeRows := opts.IncludeLOT || opts.IncludeBOM
for _, cfg := range data.Configs {
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
return fmt.Errorf("failed to write config summary row: %w", err)
}
if writeRows {
for _, row := range cfg.Rows {
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
return fmt.Errorf("failed to write pricing row: %w", err)
}
}
}
}
csvWriter.Flush()
if err := csvWriter.Error(); err != nil {
return fmt.Errorf("csv writer error: %w", err)
}
return nil
}
// ConfigToExportData converts a single configuration into ProjectExportData.
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
block := s.buildExportBlock(cfg)
@@ -174,9 +287,11 @@ 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)
blocks := make([]ConfigExportBlock, 0, len(configs))
for i := range configs {
blocks = append(blocks, s.buildExportBlock(&configs[i]))
for i := range sortedConfigs {
blocks = append(blocks, s.buildExportBlock(&sortedConfigs[i]))
}
return &ProjectExportData{
Configs: blocks,
@@ -184,6 +299,18 @@ 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))
@@ -214,12 +341,129 @@ func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExport
return ConfigExportBlock{
Article: cfg.Article,
Line: cfg.Line,
ServerCount: serverCount,
UnitPrice: unitTotal,
Items: items,
}
}
func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts ProjectPricingExportOptions) (ProjectPricingExportConfig, error) {
block := ProjectPricingExportConfig{
Name: cfg.Name,
Article: cfg.Article,
Line: cfg.Line,
ServerCount: exportPositiveInt(cfg.ServerCount, 1),
Rows: make([]ProjectPricingExportRow, 0),
}
if s.localDB == nil {
for _, item := range cfg.Items {
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName,
VendorPN: "—",
Quantity: item.Quantity,
Estimate: floatPtr(item.UnitPrice * float64(item.Quantity)),
})
}
return block, nil
}
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
if err != nil {
localCfg = nil
}
priceMap := s.resolvePricingTotals(cfg, localCfg, opts)
componentDescriptions := s.resolveLotDescriptions(cfg, localCfg)
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
coveredLots := make(map[string]struct{})
for _, row := range localCfg.VendorSpec {
rowMappings := normalizeLotMappings(row.LotMappings)
for _, mapping := range rowMappings {
coveredLots[mapping.LotName] = struct{}{}
}
description := strings.TrimSpace(row.Description)
if description == "" && len(rowMappings) > 0 {
description = componentDescriptions[rowMappings[0].LotName]
}
pricingRow := ProjectPricingExportRow{
LotDisplay: formatLotDisplay(rowMappings),
VendorPN: row.VendorPartnumber,
Description: description,
Quantity: exportPositiveInt(row.Quantity, 1),
BOMTotal: vendorRowTotal(row),
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
}
block.Rows = append(block.Rows, pricingRow)
}
for _, item := range cfg.Items {
if item.LotName == "" {
continue
}
if _, ok := coveredLots[item.LotName]; ok {
continue
}
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName,
VendorPN: "—",
Description: componentDescriptions[item.LotName],
Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate,
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
})
}
if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
}
return block, nil
}
for _, item := range cfg.Items {
if item.LotName == "" {
continue
}
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName,
VendorPN: "—",
Description: componentDescriptions[item.LotName],
Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate,
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
})
}
if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
}
return block, nil
}
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
for i := range rows {
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
rows[i].Stock = scaleFloatPtr(rows[i].Stock, factor)
rows[i].Competitor = scaleFloatPtr(rows[i].Competitor, factor)
}
}
func scaleFloatPtr(v *float64, factor float64) *float64 {
if v == nil {
return nil
}
result := *v * factor
return &result
}
// resolveCategories returns lot_name → category map.
// Primary source: pricelist items (lot_category). Fallback: local_components table.
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
@@ -260,20 +504,355 @@ 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) {
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]
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
}
return hasI && !hasJ
})
}
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]
type pricingLevels struct {
Estimate *float64
Stock *float64
Competitor *float64
}
func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, opts ProjectPricingExportOptions) map[string]pricingLevels {
result := map[string]pricingLevels{}
lots := collectPricingLots(cfg, localCfg, opts.IncludeBOM)
if len(lots) == 0 || s.localDB == nil {
return result
}
estimateID := cfg.PricelistID
if estimateID == nil || *estimateID == 0 {
if latest, err := s.localDB.GetLatestLocalPricelistBySource("estimate"); err == nil && latest != nil {
estimateID = &latest.ServerID
}
}
var warehouseID *uint
var competitorID *uint
if localCfg != nil {
warehouseID = localCfg.WarehousePricelistID
competitorID = localCfg.CompetitorPricelistID
}
if warehouseID == nil || *warehouseID == 0 {
if latest, err := s.localDB.GetLatestLocalPricelistBySource("warehouse"); err == nil && latest != nil {
warehouseID = &latest.ServerID
}
}
if competitorID == nil || *competitorID == 0 {
if latest, err := s.localDB.GetLatestLocalPricelistBySource("competitor"); err == nil && latest != nil {
competitorID = &latest.ServerID
}
}
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)
}
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 {
return nil
}
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
if err != nil {
return nil
}
prices, err := s.localDB.GetLocalPricesForLots(localPL.ID, lots)
if err != nil {
return nil
}
return prices
}
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{}
}
descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots)
if err != nil {
return map[string]string{}
}
return descriptions
}
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
seen := map[string]struct{}{}
out := make([]string, 0)
if includeBOM && localCfg != nil {
for _, row := range localCfg.VendorSpec {
for _, mapping := range normalizeLotMappings(row.LotMappings) {
if _, ok := seen[mapping.LotName]; ok {
continue
}
seen[mapping.LotName] = struct{}{}
out = append(out, mapping.LotName)
}
}
}
for _, item := range cfg.Items {
lot := strings.TrimSpace(item.LotName)
if lot == "" {
continue
}
if _, ok := seen[lot]; ok {
continue
}
seen[lot] = struct{}{}
out = append(out, lot)
}
return out
}
func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(mappings) == 0 {
return nil
}
out := make([]localdb.VendorSpecLotMapping, 0, len(mappings))
for _, mapping := range mappings {
lot := strings.TrimSpace(mapping.LotName)
if lot == "" {
continue
}
qty := mapping.QuantityPerPN
if qty < 1 {
qty = 1
}
out = append(out, localdb.VendorSpecLotMapping{
LotName: lot,
QuantityPerPN: qty,
})
}
return out
}
func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
if row.TotalPrice != nil {
return floatPtr(*row.TotalPrice)
}
if row.UnitPrice == nil {
return nil
}
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
}
func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 {
if len(mappings) == 0 {
return nil
}
total := 0.0
hasValue := false
qty := exportPositiveInt(pnQty, 1)
for _, mapping := range mappings {
price := selector(priceMap[mapping.LotName])
if price == nil || *price <= 0 {
continue
}
total += *price * float64(qty*mapping.QuantityPerPN)
hasValue = true
}
if !hasValue {
return nil
}
return floatPtr(total)
}
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
if unitPrice == nil || *unitPrice <= 0 {
return nil
}
total := *unitPrice * float64(exportPositiveInt(quantity, 1))
return &total
}
func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quantity int) *float64 {
if estimatePrice != nil && *estimatePrice > 0 {
return totalForUnitPrice(estimatePrice, quantity)
}
if fallbackUnitPrice <= 0 {
return nil
}
total := fallbackUnitPrice * float64(maxInt(quantity, 1))
return &total
}
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
headers := make([]string, 0, 8)
headers = append(headers, "Line Item")
if opts.IncludeLOT {
headers = append(headers, "LOT")
}
headers = append(headers, "PN вендора", "Описание", "Кол-во")
if opts.IncludeBOM {
headers = append(headers, "BOM")
}
if opts.IncludeEstimate {
headers = append(headers, "Estimate")
}
if opts.IncludeStock {
headers = append(headers, "Stock")
}
if opts.IncludeCompetitor {
headers = append(headers, "Конкуренты")
}
return headers
}
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
record := make([]string, 0, 8)
record = append(record, "")
if opts.IncludeLOT {
record = append(record, emptyDash(row.LotDisplay))
}
record = append(record,
emptyDash(row.VendorPN),
emptyDash(row.Description),
fmt.Sprintf("%d", exportPositiveInt(row.Quantity, 1)),
)
if opts.IncludeBOM {
record = append(record, formatMoneyValue(row.BOMTotal))
}
if opts.IncludeEstimate {
record = append(record, formatMoneyValue(row.Estimate))
}
if opts.IncludeStock {
record = append(record, formatMoneyValue(row.Stock))
}
if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(row.Competitor))
}
return record
}
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
record := make([]string, 0, 8)
record = append(record, fmt.Sprintf("%d", cfg.Line))
if opts.IncludeLOT {
record = append(record, "")
}
record = append(record,
emptyDash(cfg.Article),
emptyDash(cfg.Name),
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
)
if opts.IncludeBOM {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.BOMTotal })))
}
if opts.IncludeEstimate {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Estimate })))
}
if opts.IncludeStock {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Stock })))
}
if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
}
return record
}
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
switch len(mappings) {
case 0:
return "н/д"
case 1:
return mappings[0].LotName
default:
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
}
}
func formatMoneyValue(value *float64) string {
if value == nil {
return "—"
}
n := math.Round(*value*100) / 100
sign := ""
if n < 0 {
sign = "-"
n = -n
}
whole := int64(n)
fraction := int(math.Round((n - float64(whole)) * 100))
if fraction == 100 {
whole++
fraction = 0
}
return fmt.Sprintf("%s%s,%02d", sign, formatIntWithSpace(whole), fraction)
}
func emptyDash(value string) string {
if strings.TrimSpace(value) == "" {
return "—"
}
return value
}
func sumPricingColumn(rows []ProjectPricingExportRow, selector func(ProjectPricingExportRow) *float64) *float64 {
total := 0.0
hasValue := false
for _, row := range rows {
value := selector(row)
if value == nil {
continue
}
total += *value
hasValue = true
}
if !hasValue {
return nil
}
return floatPtr(total)
}
func floatPtr(value float64) *float64 {
v := value
return &v
}
func exportPositiveInt(value, fallback int) int {
if value < 1 {
return fallback
}
return value
}
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").

View File

@@ -8,6 +8,7 @@ import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func newTestProjectData(items []ExportItem, article string, serverCount int) *ProjectExportData {
@@ -32,7 +33,7 @@ func newTestProjectData(items []ExportItem, article string, serverCount int) *Pr
}
func TestToCSV_UTF8BOM(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
svc := NewExportService(config.ExportConfig{}, nil)
data := newTestProjectData([]ExportItem{
{
@@ -62,7 +63,7 @@ func TestToCSV_UTF8BOM(t *testing.T) {
}
func TestToCSV_SemicolonDelimiter(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
svc := NewExportService(config.ExportConfig{}, nil)
data := newTestProjectData([]ExportItem{
{
@@ -129,7 +130,7 @@ func TestToCSV_SemicolonDelimiter(t *testing.T) {
}
func TestToCSV_ServerRow(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
svc := NewExportService(config.ExportConfig{}, nil)
data := newTestProjectData([]ExportItem{
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
@@ -174,7 +175,7 @@ func TestToCSV_ServerRow(t *testing.T) {
}
func TestToCSV_CategorySorting(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
svc := NewExportService(config.ExportConfig{}, nil)
data := newTestProjectData([]ExportItem{
{LotName: "LOT-001", Category: "CAT-A", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
@@ -213,7 +214,7 @@ func TestToCSV_CategorySorting(t *testing.T) {
}
func TestToCSV_EmptyData(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
svc := NewExportService(config.ExportConfig{}, nil)
data := &ProjectExportData{
Configs: []ConfigExportBlock{},
@@ -246,7 +247,7 @@ func TestToCSV_EmptyData(t *testing.T) {
}
func TestToCSVBytes_BackwardCompat(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
svc := NewExportService(config.ExportConfig{}, nil)
data := newTestProjectData([]ExportItem{
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
@@ -269,7 +270,7 @@ func TestToCSVBytes_BackwardCompat(t *testing.T) {
}
func TestToCSV_WriterError(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
svc := NewExportService(config.ExportConfig{}, nil)
data := newTestProjectData([]ExportItem{
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
@@ -283,7 +284,7 @@ func TestToCSV_WriterError(t *testing.T) {
}
func TestToCSV_MultipleBlocks(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
svc := NewExportService(config.ExportConfig{}, nil)
data := &ProjectExportData{
Configs: []ConfigExportBlock{
@@ -357,6 +358,51 @@ func TestToCSV_MultipleBlocks(t *testing.T) {
}
}
func TestProjectToExportData_SortsByLine(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
configs := []models.Configuration{
{
UUID: "cfg-1",
Line: 30,
Article: "ART-30",
ServerCount: 1,
Items: models.ConfigItems{{LotName: "LOT-30", Quantity: 1, UnitPrice: 300}},
CreatedAt: time.Now().Add(-1 * time.Hour),
},
{
UUID: "cfg-2",
Line: 10,
Article: "ART-10",
ServerCount: 1,
Items: models.ConfigItems{{LotName: "LOT-10", Quantity: 1, UnitPrice: 100}},
CreatedAt: time.Now().Add(-2 * time.Hour),
},
{
UUID: "cfg-3",
Line: 20,
Article: "ART-20",
ServerCount: 1,
Items: models.ConfigItems{{LotName: "LOT-20", Quantity: 1, UnitPrice: 200}},
CreatedAt: time.Now().Add(-3 * time.Hour),
},
}
data := svc.ProjectToExportData(configs)
if len(data.Configs) != 3 {
t.Fatalf("expected 3 blocks, got %d", len(data.Configs))
}
if data.Configs[0].Article != "ART-10" || data.Configs[0].Line != 10 {
t.Fatalf("first block must be line 10, got article=%s line=%d", data.Configs[0].Article, data.Configs[0].Line)
}
if data.Configs[1].Article != "ART-20" || data.Configs[1].Line != 20 {
t.Fatalf("second block must be line 20, got article=%s line=%d", data.Configs[1].Article, data.Configs[1].Line)
}
if data.Configs[2].Article != "ART-30" || data.Configs[2].Line != 30 {
t.Fatalf("third block must be line 30, got article=%s line=%d", data.Configs[2].Article, data.Configs[2].Line)
}
}
func TestFormatPriceWithSpace(t *testing.T) {
tests := []struct {
input float64
@@ -398,6 +444,117 @@ func TestFormatPriceComma(t *testing.T) {
}
}
func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
data := &ProjectPricingExportData{
Configs: []ProjectPricingExportConfig{
{
Name: "Config A",
Article: "ART-1",
Line: 10,
ServerCount: 2,
Rows: []ProjectPricingExportRow{
{
LotDisplay: "LOT_A +1",
VendorPN: "PN-001",
Description: "Bundle row",
Quantity: 2,
BOMTotal: floatPtr(2400.5),
Estimate: floatPtr(2000),
Stock: floatPtr(1800.25),
},
},
},
},
CreatedAt: time.Now(),
}
opts := ProjectPricingExportOptions{
IncludeLOT: true,
IncludeBOM: true,
IncludeEstimate: true,
IncludeStock: true,
}
var buf bytes.Buffer
if err := svc.ToPricingCSV(&buf, data, opts); err != nil {
t.Fatalf("ToPricingCSV failed: %v", err)
}
reader := csv.NewReader(bytes.NewReader(buf.Bytes()[3:]))
reader.Comma = ';'
reader.FieldsPerRecord = -1
header, err := reader.Read()
if err != nil {
t.Fatalf("read header row: %v", err)
}
expectedHeader := []string{"Line Item", "LOT", "PN вендора", "Описание", "Кол-во", "BOM", "Estimate", "Stock"}
for i, want := range expectedHeader {
if header[i] != want {
t.Fatalf("header[%d]: expected %q, got %q", i, want, header[i])
}
}
summary, err := reader.Read()
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"}
for i, want := range expectedSummary {
if summary[i] != want {
t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i])
}
}
row, err := reader.Read()
if err != nil {
t.Fatalf("read data row: %v", err)
}
expectedRow := []string{"", "LOT_A +1", "PN-001", "Bundle row", "2", "2 400,50", "2 000,00", "1 800,25"}
for i, want := range expectedRow {
if row[i] != want {
t.Fatalf("row[%d]: expected %q, got %q", i, want, row[i])
}
}
}
func TestProjectToPricingExportData_UsesCartRowsWithoutBOM(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil)
configs := []models.Configuration{
{
UUID: "cfg-1",
Name: "Config A",
Article: "ART-1",
ServerCount: 1,
Items: models.ConfigItems{
{LotName: "LOT_A", Quantity: 2, UnitPrice: 300},
},
CreatedAt: time.Now(),
},
}
data, err := svc.ProjectToPricingExportData(configs, ProjectPricingExportOptions{
IncludeLOT: true,
IncludeEstimate: true,
})
if err != nil {
t.Fatalf("ProjectToPricingExportData failed: %v", err)
}
if len(data.Configs) != 1 || len(data.Configs[0].Rows) != 1 {
t.Fatalf("unexpected rows count: %+v", data.Configs)
}
row := data.Configs[0].Rows[0]
if row.LotDisplay != "LOT_A" {
t.Fatalf("expected LOT_A, got %q", row.LotDisplay)
}
if row.VendorPN != "—" {
t.Fatalf("expected vendor dash, got %q", row.VendorPN)
}
if row.Estimate == nil || *row.Estimate != 600 {
t.Fatalf("expected estimate total 600, got %+v", row.Estimate)
}
}
// failingWriter always returns an error
type failingWriter struct{}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
@@ -49,11 +50,13 @@ func NewLocalConfigurationService(
// Create creates a new configuration in local SQLite and queues it for sync
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
// If online, check for new pricelists first
// If online, trigger pricelist sync in the background — do not block config creation
if s.isOnline() {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
// Log but don't fail - we can still use local pricelists
}
go func() {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
// Log but don't fail - we can still use local pricelists
}
}()
}
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
@@ -83,22 +86,29 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
}
cfg := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock,
CreatedAt: time.Now(),
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
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
@@ -107,9 +117,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("create configuration with version: %w", err)
}
// Record usage stats
_ = s.quoteService.RecordUsage(req.Items)
cfg.Line = localCfg.Line
return cfg, nil
}
@@ -195,6 +203,9 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
localCfg.SupportCode = req.SupportCode
localCfg.Article = req.Article
localCfg.PricelistID = pricelistID
localCfg.WarehousePricelistID = req.WarehousePricelistID
localCfg.CompetitorPricelistID = req.CompetitorPricelistID
localCfg.DisablePriceRefresh = req.DisablePriceRefresh
localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
@@ -303,28 +314,32 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
}
clone := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
WarehousePricelistID: original.WarehousePricelistID,
CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
}
localCfg := localdb.ConfigurationToLocal(clone)
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("clone configuration with version: %w", err)
}
clone.Line = localCfg.Line
return clone, nil
}
@@ -388,17 +403,31 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
return nil, ErrConfigForbidden
}
// Refresh local pricelists when online and use latest active/local pricelist for recalculation.
// Refresh local pricelists when online.
if s.isOnline() {
_ = s.syncService.SyncPricelistsIfNeeded()
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
slog.Warn("local configuration: background pricelist sync failed", "err", err)
}
}
// Use the pricelist stored in the config; fall back to latest if unavailable.
var pricelist *localdb.LocalPricelist
if localCfg.PricelistID != nil && *localCfg.PricelistID > 0 {
if pl, err := s.localDB.GetLocalPricelistByServerID(*localCfg.PricelistID); err == nil {
pricelist = pl
}
}
if pricelist == nil {
if pl, err := s.localDB.GetLatestLocalPricelist(); err == nil {
pricelist = pl
}
}
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Update prices for all items from pricelist
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
if latestErr == nil && latestPricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
if pricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName)
if err == nil && price > 0 {
updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
@@ -423,8 +452,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
}
localCfg.TotalPrice = &total
if latestErr == nil && latestPricelist != nil {
localCfg.PricelistID = &latestPricelist.ServerID
if pricelist != nil {
localCfg.PricelistID = &pricelist.ServerID
}
// Set price update timestamp and mark for sync
@@ -519,6 +548,9 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
localCfg.SupportCode = req.SupportCode
localCfg.Article = req.Article
localCfg.PricelistID = pricelistID
localCfg.WarehousePricelistID = req.WarehousePricelistID
localCfg.CompetitorPricelistID = req.CompetitorPricelistID
localCfg.DisablePriceRefresh = req.DisablePriceRefresh
localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
@@ -621,25 +653,32 @@ func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID s
}
clone := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
WarehousePricelistID: original.WarehousePricelistID,
CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
}
localCfg := localdb.ConfigurationToLocal(clone)
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("clone configuration without auth with version: %w", err)
}
clone.Line = localCfg.Line
return clone, nil
}
@@ -741,8 +780,10 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
return templates[start:end], total, nil
}
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check.
// pricelistServerID optionally specifies which pricelist to use; if nil, the config's stored
// pricelist is used; if that is also absent, the latest local pricelist is used as a fallback.
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistServerID *uint) (*models.Configuration, error) {
// Get configuration from local SQLite
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
@@ -750,15 +791,40 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
}
if s.isOnline() {
_ = s.syncService.SyncPricelistsIfNeeded()
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
slog.Warn("local configuration: background pricelist sync failed", "err", err)
}
}
// Resolve which pricelist to use:
// 1. Explicitly requested pricelist (from UI selection)
// 2. Pricelist stored in the configuration
// 3. Latest local pricelist as last-resort fallback
var targetServerID *uint
if pricelistServerID != nil && *pricelistServerID > 0 {
targetServerID = pricelistServerID
} else if localCfg.PricelistID != nil && *localCfg.PricelistID > 0 {
targetServerID = localCfg.PricelistID
}
var pricelist *localdb.LocalPricelist
if targetServerID != nil {
if pl, err := s.localDB.GetLocalPricelistByServerID(*targetServerID); err == nil {
pricelist = pl
}
}
if pricelist == nil {
// Fallback: use latest local pricelist
if pl, err := s.localDB.GetLatestLocalPricelist(); err == nil {
pricelist = pl
}
}
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Update prices for all items from pricelist
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
if latestErr == nil && latestPricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
if pricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(pricelist.ID, item.LotName)
if err == nil && price > 0 {
updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
@@ -783,8 +849,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
}
localCfg.TotalPrice = &total
if latestErr == nil && latestPricelist != nil {
localCfg.PricelistID = &latestPricelist.ServerID
if pricelist != nil {
localCfg.PricelistID = &pricelist.ServerID
}
// Set price update timestamp and mark for sync
@@ -826,21 +892,13 @@ func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverC
return fmt.Errorf("save local configuration: %w", err)
}
// Use existing current version for the pending change
var version localdb.LocalConfigurationVersion
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err != nil {
return fmt.Errorf("load current version: %w", err)
}
} else {
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
Order("version_no DESC").First(&version).Error; err != nil {
return fmt.Errorf("load latest version: %w", err)
}
version, err := s.loadVersionForPendingTx(tx, localCfg)
if err != nil {
return err
}
cfg = localdb.LocalToConfiguration(localCfg)
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", &version, ""); err != nil {
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", version, ""); err != nil {
return fmt.Errorf("enqueue server-count pending change: %w", err)
}
return nil
@@ -852,6 +910,99 @@ func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverC
return cfg, nil
}
func (s *LocalConfigurationService) ReorderProjectConfigurationsNoAuth(projectUUID string, orderedUUIDs []string) ([]models.Configuration, error) {
projectUUID = strings.TrimSpace(projectUUID)
if projectUUID == "" {
return nil, ErrProjectNotFound
}
if _, err := s.localDB.GetProjectByUUID(projectUUID); err != nil {
return nil, ErrProjectNotFound
}
if len(orderedUUIDs) == 0 {
return []models.Configuration{}, nil
}
seen := make(map[string]struct{}, len(orderedUUIDs))
normalized := make([]string, 0, len(orderedUUIDs))
for _, raw := range orderedUUIDs {
u := strings.TrimSpace(raw)
if u == "" {
return nil, fmt.Errorf("ordered_uuids contains empty uuid")
}
if _, exists := seen[u]; exists {
return nil, fmt.Errorf("ordered_uuids contains duplicate uuid: %s", u)
}
seen[u] = struct{}{}
normalized = append(normalized, u)
}
err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
var active []localdb.LocalConfiguration
if err := tx.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
Find(&active).Error; err != nil {
return fmt.Errorf("load project active configurations: %w", err)
}
if len(active) != len(normalized) {
return fmt.Errorf("ordered_uuids count mismatch: expected %d got %d", len(active), len(normalized))
}
byUUID := make(map[string]*localdb.LocalConfiguration, len(active))
for i := range active {
cfg := active[i]
byUUID[cfg.UUID] = &cfg
}
for _, id := range normalized {
if _, ok := byUUID[id]; !ok {
return fmt.Errorf("configuration %s not found in project %s", id, projectUUID)
}
}
now := time.Now()
for idx, id := range normalized {
cfg := byUUID[id]
newLine := (idx + 1) * 10
if cfg.Line == newLine {
continue
}
cfg.Line = newLine
cfg.UpdatedAt = now
cfg.SyncStatus = "pending"
if err := tx.Save(cfg).Error; err != nil {
return fmt.Errorf("save reordered configuration %s: %w", cfg.UUID, err)
}
version, err := s.loadVersionForPendingTx(tx, cfg)
if err != nil {
return err
}
if err := s.enqueueConfigurationPendingChangeTx(tx, cfg, "update", version, ""); err != nil {
return fmt.Errorf("enqueue reorder pending change for %s: %w", cfg.UUID, err)
}
}
return nil
})
if err != nil {
return nil, err
}
var localConfigs []localdb.LocalConfiguration
if err := s.localDB.DB().
Preload("CurrentVersion").
Where("project_uuid = ? AND is_active = ?", projectUUID, true).
Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC").
Find(&localConfigs).Error; err != nil {
return nil, fmt.Errorf("load reordered configurations: %w", err)
}
result := make([]models.Configuration, 0, len(localConfigs))
for i := range localConfigs {
result = append(result, *localdb.LocalToConfiguration(&localConfigs[i]))
}
return result, nil
}
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
return s.syncService.ImportConfigurationsToLocal()
@@ -965,34 +1116,43 @@ func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, own
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
if err := tx.Create(localCfg).Error; err != nil {
return fmt.Errorf("create local configuration: %w", err)
}
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
if err != nil {
return fmt.Errorf("append create version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
localCfg.CurrentVersionID = &version.ID
localCfg.CurrentVersion = version
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
return fmt.Errorf("enqueue create pending change: %w", err)
}
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil
return s.createWithVersionTx(tx, localCfg, createdBy)
})
}
func (s *LocalConfigurationService) createWithVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration, createdBy string) error {
if localCfg.IsActive {
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
return err
}
}
if err := tx.Create(localCfg).Error; err != nil {
return fmt.Errorf("create local configuration: %w", err)
}
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
if err != nil {
return fmt.Errorf("append create version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
localCfg.CurrentVersionID = &version.ID
localCfg.CurrentVersion = version
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
return fmt.Errorf("enqueue create pending change: %w", err)
}
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil
}
func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.LocalConfiguration, operation string, createdBy string) (*models.Configuration, error) {
var cfg *models.Configuration
@@ -1021,12 +1181,31 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
return fmt.Errorf("compare revision content: %w", err)
}
if sameRevisionContent {
cfg = localdb.LocalToConfiguration(&locked)
if !hasNonRevisionConfigurationChanges(&locked, localCfg) {
cfg = localdb.LocalToConfiguration(&locked)
return nil
}
if err := tx.Save(localCfg).Error; err != nil {
return fmt.Errorf("save local configuration (no new revision): %w", err)
}
cfg = localdb.LocalToConfiguration(localCfg)
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, currentVersion, createdBy); err != nil {
return fmt.Errorf("enqueue %s pending change without revision: %w", operation, err)
}
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil
}
}
}
if localCfg.IsActive {
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
return err
}
}
if err := tx.Save(localCfg).Error; err != nil {
return fmt.Errorf("save local configuration: %w", err)
}
@@ -1061,6 +1240,85 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
return cfg, nil
}
func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, next *localdb.LocalConfiguration) bool {
if current == nil || next == nil {
return true
}
if current.Name != next.Name ||
current.Notes != next.Notes ||
current.IsTemplate != next.IsTemplate ||
current.ServerModel != next.ServerModel ||
current.SupportCode != next.SupportCode ||
current.Article != next.Article ||
current.IsActive != next.IsActive ||
current.Line != next.Line {
return true
}
if !equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
return true
}
return false
}
func (s *LocalConfigurationService) UpdateVendorSpecNoAuth(uuid string, spec localdb.VendorSpec) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
localCfg.VendorSpec = spec
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, fmt.Errorf("update vendor spec without auth with version: %w", err)
}
return cfg, nil
}
func (s *LocalConfigurationService) ApplyVendorSpecItemsNoAuth(uuid string, items localdb.LocalConfigItems) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
localCfg.Items = items
total := items.Total()
if localCfg.ServerCount > 1 {
total *= float64(localCfg.ServerCount)
}
localCfg.TotalPrice = &total
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, fmt.Errorf("apply vendor spec items without auth with version: %w", err)
}
return cfg, nil
}
func equalStringPtr(a, b *string) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return strings.TrimSpace(*a) == strings.TrimSpace(*b)
}
func equalUintPtr(a, b *uint) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
var version localdb.LocalConfigurationVersion
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
@@ -1082,6 +1340,76 @@ func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *
return &version, nil
}
func (s *LocalConfigurationService) loadVersionForPendingTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
var current localdb.LocalConfigurationVersion
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&current).Error; err == nil {
return &current, nil
}
}
var latest localdb.LocalConfigurationVersion
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
Order("version_no DESC").
First(&latest).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("load version for pending change: %w", err)
}
// Legacy/imported rows may exist without local version history.
// Bootstrap the first version so pending sync payloads can reference a version.
version, createErr := s.appendVersionTx(tx, localCfg, "bootstrap", "")
if createErr != nil {
return nil, fmt.Errorf("bootstrap version for pending change: %w", createErr)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return nil, fmt.Errorf("set current version id for bootstrapped pending change: %w", err)
}
localCfg.CurrentVersionID = &version.ID
return version, nil
}
return &latest, nil
}
func (s *LocalConfigurationService) ensureConfigurationLineTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) error {
if localCfg == nil || !localCfg.IsActive {
return nil
}
needsAssign := localCfg.Line <= 0
if !needsAssign {
query := tx.Model(&localdb.LocalConfiguration{}).
Where("is_active = ? AND line_no = ?", true, localCfg.Line)
if strings.TrimSpace(localCfg.UUID) != "" {
query = query.Where("uuid <> ?", strings.TrimSpace(localCfg.UUID))
}
if localCfg.ProjectUUID != nil && strings.TrimSpace(*localCfg.ProjectUUID) != "" {
query = query.Where("project_uuid = ?", strings.TrimSpace(*localCfg.ProjectUUID))
} else {
query = query.Where("project_uuid IS NULL OR TRIM(project_uuid) = ''")
}
var conflicts int64
if err := query.Count(&conflicts).Error; err != nil {
return fmt.Errorf("check line_no conflict for configuration %s: %w", localCfg.UUID, err)
}
needsAssign = conflicts > 0
}
if needsAssign {
line, err := localdb.NextConfigurationLineTx(tx, localCfg.ProjectUUID, localCfg.UUID)
if err != nil {
return fmt.Errorf("assign line_no for configuration %s: %w", localCfg.UUID, err)
}
localCfg.Line = line
}
return nil
}
func (s *LocalConfigurationService) hasSameRevisionContent(localCfg *localdb.LocalConfiguration, currentVersion *localdb.LocalConfigurationVersion) (bool, error) {
currentSnapshotCfg, err := s.decodeConfigurationSnapshot(currentVersion.Data)
if err != nil {
@@ -1193,8 +1521,18 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
current.Notes = rollbackData.Notes
current.IsTemplate = rollbackData.IsTemplate
current.ServerCount = rollbackData.ServerCount
current.ServerModel = rollbackData.ServerModel
current.SupportCode = rollbackData.SupportCode
current.Article = rollbackData.Article
current.PricelistID = rollbackData.PricelistID
current.WarehousePricelistID = rollbackData.WarehousePricelistID
current.CompetitorPricelistID = rollbackData.CompetitorPricelistID
current.DisablePriceRefresh = rollbackData.DisablePriceRefresh
current.OnlyInStock = rollbackData.OnlyInStock
current.VendorSpec = rollbackData.VendorSpec
if rollbackData.Line > 0 {
current.Line = rollbackData.Line
}
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
current.UpdatedAt = time.Now()
current.SyncStatus = "pending"

View File

@@ -137,6 +137,149 @@ func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
}
}
func TestUpdateNoAuthCreatesRevisionWhenPricingSettingsChanged(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "pricing",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
Name: "pricing",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
DisablePriceRefresh: true,
OnlyInStock: true,
}); err != nil {
t.Fatalf("update pricing settings: %v", err)
}
versions := loadVersions(t, local, created.UUID)
if len(versions) != 2 {
t.Fatalf("expected 2 versions after pricing settings change, got %d", len(versions))
}
if versions[1].VersionNo != 2 {
t.Fatalf("expected latest version_no=2, got %d", versions[1].VersionNo)
}
}
func TestUpdateVendorSpecNoAuthCreatesRevision(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "bom",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
spec := localdb.VendorSpec{
{
VendorPartnumber: "PN-001",
Quantity: 2,
SortOrder: 10,
LotMappings: []localdb.VendorSpecLotMapping{
{LotName: "CPU_A", QuantityPerPN: 1},
},
},
}
if _, err := service.UpdateVendorSpecNoAuth(created.UUID, spec); err != nil {
t.Fatalf("update vendor spec: %v", err)
}
versions := loadVersions(t, local, created.UUID)
if len(versions) != 2 {
t.Fatalf("expected 2 versions after vendor spec change, got %d", len(versions))
}
cfg, err := local.GetConfigurationByUUID(created.UUID)
if err != nil {
t.Fatalf("load config after vendor spec update: %v", err)
}
if len(cfg.VendorSpec) != 1 || cfg.VendorSpec[0].VendorPartnumber != "PN-001" {
t.Fatalf("expected saved vendor spec, got %+v", cfg.VendorSpec)
}
}
func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
project := &localdb.LocalProject{
UUID: "project-reorder",
OwnerUsername: "tester",
Code: "PRJ-ORDER",
Variant: "",
Name: ptrString("Project Reorder"),
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SyncStatus: "pending",
}
if err := local.SaveProject(project); err != nil {
t.Fatalf("save project: %v", err)
}
first, err := service.Create("tester", &CreateConfigRequest{
Name: "Cfg A",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
ServerCount: 1,
ProjectUUID: &project.UUID,
})
if err != nil {
t.Fatalf("create first config: %v", err)
}
second, err := service.Create("tester", &CreateConfigRequest{
Name: "Cfg B",
Items: models.ConfigItems{{LotName: "CPU_B", Quantity: 1, UnitPrice: 200}},
ServerCount: 1,
ProjectUUID: &project.UUID,
})
if err != nil {
t.Fatalf("create second config: %v", err)
}
beforeFirst := loadVersions(t, local, first.UUID)
beforeSecond := loadVersions(t, local, second.UUID)
reordered, err := service.ReorderProjectConfigurationsNoAuth(project.UUID, []string{second.UUID, first.UUID})
if err != nil {
t.Fatalf("reorder configurations: %v", err)
}
if len(reordered) != 2 {
t.Fatalf("expected 2 reordered configs, got %d", len(reordered))
}
if reordered[0].UUID != second.UUID || reordered[0].Line != 10 {
t.Fatalf("expected second config first with line 10, got uuid=%s line=%d", reordered[0].UUID, reordered[0].Line)
}
if reordered[1].UUID != first.UUID || reordered[1].Line != 20 {
t.Fatalf("expected first config second with line 20, got uuid=%s line=%d", reordered[1].UUID, reordered[1].Line)
}
afterFirst := loadVersions(t, local, first.UUID)
afterSecond := loadVersions(t, local, second.UUID)
if len(afterFirst) != len(beforeFirst) || len(afterSecond) != len(beforeSecond) {
t.Fatalf("reorder must not create new versions")
}
var pendingCount int64
if err := local.DB().
Table("pending_changes").
Where("entity_type = ? AND operation = ? AND entity_uuid IN ?", "configuration", "update", []string{first.UUID, second.UUID}).
Count(&pendingCount).Error; err != nil {
t.Fatalf("count reorder pending changes: %v", err)
}
if pendingCount < 2 {
t.Fatalf("expected at least 2 pending update changes for reorder, got %d", pendingCount)
}
}
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)

View File

@@ -16,10 +16,12 @@ import (
)
var (
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
)
type ProjectService struct {
@@ -31,10 +33,10 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
}
type CreateProjectRequest struct {
Code string `json:"code"`
Variant string `json:"variant,omitempty"`
Code string `json:"code"`
Variant string `json:"variant,omitempty"`
Name *string `json:"name,omitempty"`
TrackerURL string `json:"tracker_url"`
TrackerURL string `json:"tracker_url"`
}
type UpdateProjectRequest struct {
@@ -63,6 +65,9 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
return nil, fmt.Errorf("project code is required")
}
variant := strings.TrimSpace(req.Variant)
if err := validateProjectVariantName(variant); err != nil {
return nil, err
}
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
return nil, err
}
@@ -104,7 +109,15 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
localProject.Code = code
}
if req.Variant != nil {
localProject.Variant = strings.TrimSpace(*req.Variant)
newVariant := strings.TrimSpace(*req.Variant)
// Block renaming of the main variant (empty Variant) — there must always be a main.
if strings.TrimSpace(localProject.Variant) == "" && newVariant != "" {
return nil, ErrCannotRenameMainVariant
}
localProject.Variant = newVariant
if err := validateProjectVariantName(localProject.Variant); err != nil {
return nil, err
}
}
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
return nil, err
@@ -166,6 +179,13 @@ func normalizeProjectVariant(variant string) string {
return strings.ToLower(strings.TrimSpace(variant))
}
func validateProjectVariantName(variant string) error {
if normalizeProjectVariant(variant) == "main" {
return ErrReservedMainVariant
}
return nil
}
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
return s.setProjectActive(projectUUID, ownerUsername, false)
}
@@ -275,8 +295,23 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
}, nil
}
query := s.localDB.DB().
Preload("CurrentVersion").
Where("project_uuid = ?", projectUUID).
Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC")
switch status {
case "active", "":
query = query.Where("is_active = ?", true)
case "archived":
query = query.Where("is_active = ?", false)
case "all":
default:
query = query.Where("is_active = ?", true)
}
var localConfigs []localdb.LocalConfiguration
if err := s.localDB.DB().Preload("CurrentVersion").Order("created_at DESC").Find(&localConfigs).Error; err != nil {
if err := query.Find(&localConfigs).Error; err != nil {
return nil, err
}
@@ -284,25 +319,6 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
total := 0.0
for i := range localConfigs {
localCfg := localConfigs[i]
if localCfg.ProjectUUID == nil || *localCfg.ProjectUUID != projectUUID {
continue
}
switch status {
case "active", "":
if !localCfg.IsActive {
continue
}
case "archived":
if localCfg.IsActive {
continue
}
case "all":
default:
if !localCfg.IsActive {
continue
}
}
cfg := localdb.LocalToConfiguration(&localCfg)
if cfg.TotalPrice != nil {
total += *cfg.TotalPrice

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
package sync
import (
"encoding/json"
"fmt"
"log/slog"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"gorm.io/gorm"
)
// PullPartnumberBooks synchronizes partnumber book snapshots from MariaDB to local SQLite.
// Append-only for headers; re-pulls items if a book header exists but has 0 items.
func (s *Service) PullPartnumberBooks() (int, error) {
slog.Info("starting partnumber book pull")
mariaDB, err := s.getDB()
if err != nil {
return 0, fmt.Errorf("database not available: %w", err)
}
localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
type serverBook struct {
ID int `gorm:"column:id"`
Version string `gorm:"column:version"`
CreatedAt time.Time `gorm:"column:created_at"`
IsActive bool `gorm:"column:is_active"`
PartnumbersJSON string `gorm:"column:partnumbers_json"`
}
var serverBooks []serverBook
if err := mariaDB.Raw("SELECT id, version, created_at, is_active, partnumbers_json FROM qt_partnumber_books ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil {
return 0, fmt.Errorf("querying server partnumber books: %w", err)
}
slog.Info("partnumber books found on server", "count", len(serverBooks))
pulled := 0
for _, sb := range serverBooks {
var existing localdb.LocalPartnumberBook
err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error
partnumbers, errPartnumbers := decodeServerPartnumbers(sb.PartnumbersJSON)
if errPartnumbers != nil {
slog.Error("failed to decode server partnumbers_json", "server_id", sb.ID, "error", errPartnumbers)
continue
}
if err == nil {
existing.Version = sb.Version
existing.CreatedAt = sb.CreatedAt
existing.IsActive = sb.IsActive
existing.PartnumbersJSON = partnumbers
if err := localBookRepo.SaveBook(&existing); err != nil {
slog.Error("failed to update local partnumber book header", "server_id", sb.ID, "error", err)
continue
}
localItemCount := localBookRepo.CountBookItems(existing.ID)
if localItemCount > 0 && localBookRepo.HasAllBookItems(existing.ID) {
slog.Debug("partnumber book already synced, skipping", "server_id", sb.ID, "version", sb.Version, "items", localItemCount)
continue
}
slog.Info("partnumber book header exists but catalog items are missing, re-pulling items", "server_id", sb.ID, "version", sb.Version)
n, err := pullBookItems(mariaDB, localBookRepo, existing.PartnumbersJSON)
if err != nil {
slog.Error("failed to re-pull items for existing book", "server_id", sb.ID, "error", err)
} else {
slog.Info("re-pulled items for existing book", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
pulled++
}
continue
}
slog.Info("pulling new partnumber book", "server_id", sb.ID, "version", sb.Version, "is_active", sb.IsActive)
localBook := &localdb.LocalPartnumberBook{
ServerID: sb.ID,
Version: sb.Version,
CreatedAt: sb.CreatedAt,
IsActive: sb.IsActive,
PartnumbersJSON: partnumbers,
}
if err := localBookRepo.SaveBook(localBook); err != nil {
slog.Error("failed to save local partnumber book", "server_id", sb.ID, "error", err)
continue
}
n, err := pullBookItems(mariaDB, localBookRepo, localBook.PartnumbersJSON)
if err != nil {
slog.Error("failed to pull items for new book", "server_id", sb.ID, "error", err)
continue
}
slog.Info("partnumber book saved locally", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
pulled++
}
slog.Info("partnumber book pull completed", "new_books_pulled", pulled, "total_on_server", len(serverBooks))
return pulled, nil
}
// pullBookItems fetches catalog items for a partnumber list from MariaDB and saves them to SQLite.
// Returns the number of items saved.
func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, partnumbers localdb.LocalStringList) (int, error) {
if len(partnumbers) == 0 {
return 0, nil
}
type serverItem struct {
Partnumber string `gorm:"column:partnumber"`
LotsJSON string `gorm:"column:lots_json"`
Description string `gorm:"column:description"`
}
var serverItems []serverItem
err := mariaDB.Raw("SELECT partnumber, lots_json, description FROM qt_partnumber_book_items WHERE partnumber IN ?", []string(partnumbers)).Scan(&serverItems).Error
if err != nil {
return 0, fmt.Errorf("querying items from server: %w", err)
}
slog.Info("partnumber book items fetched from server", "count", len(serverItems), "requested_partnumbers", len(partnumbers))
if len(serverItems) == 0 {
slog.Warn("server returned 0 partnumber book items")
return 0, nil
}
localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems))
for _, si := range serverItems {
var lots localdb.LocalPartnumberBookLots
if err := json.Unmarshal([]byte(si.LotsJSON), &lots); err != nil {
return 0, fmt.Errorf("decode lots_json for %s: %w", si.Partnumber, err)
}
localItems = append(localItems, localdb.LocalPartnumberBookItem{
Partnumber: si.Partnumber,
LotsJSON: lots,
Description: si.Description,
})
}
if err := repo.SaveBookItems(localItems); err != nil {
return 0, fmt.Errorf("saving items to local db: %w", err)
}
return len(localItems), nil
}
func decodeServerPartnumbers(raw string) (localdb.LocalStringList, error) {
if raw == "" {
return localdb.LocalStringList{}, nil
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil, err
}
return localdb.LocalStringList(items), nil
}

View File

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

View File

@@ -1,13 +1,10 @@
package sync
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strconv"
"os"
"strings"
"time"
@@ -79,48 +76,11 @@ func (s *Service) GetReadiness() (*SyncReadiness, error) {
)
}
migrations, err := listActiveClientMigrations(mariaDB)
if err != nil {
return s.blockedReadiness(
now,
"REMOTE_MIGRATION_REGISTRY_UNAVAILABLE",
"Синхронизация заблокирована: не удалось проверить централизованные миграции локальной БД.",
nil,
)
}
for i := range migrations {
m := migrations[i]
if strings.TrimSpace(m.MinAppVersion) != "" {
if compareVersions(appmeta.Version(), m.MinAppVersion) < 0 {
min := m.MinAppVersion
return s.blockedReadiness(
now,
"MIN_APP_VERSION_REQUIRED",
fmt.Sprintf("Требуется обновление приложения до версии %s для безопасной синхронизации.", m.MinAppVersion),
&min,
)
}
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.applyMissingRemoteMigrations(migrations); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "checksum") {
return s.blockedReadiness(
now,
"REMOTE_MIGRATION_CHECKSUM_MISMATCH",
"Синхронизация заблокирована: контрольная сумма миграции не совпадает.",
nil,
)
}
return s.blockedReadiness(
now,
"LOCAL_MIGRATION_APPLY_FAILED",
"Синхронизация заблокирована: не удалось применить миграции локальной БД.",
nil,
)
}
})
if err := s.reportClientSchemaState(mariaDB, now); err != nil {
slog.Warn("failed to report client schema state", "error", err)
}
@@ -157,76 +117,98 @@ func (s *Service) isOnline() bool {
return s.connMgr.IsOnline()
}
type clientLocalMigration struct {
ID string `gorm:"column:id"`
Name string `gorm:"column:name"`
SQLText string `gorm:"column:sql_text"`
Checksum string `gorm:"column:checksum"`
MinAppVersion string `gorm:"column:min_app_version"`
OrderNo int `gorm:"column:order_no"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func listActiveClientMigrations(db *gorm.DB) ([]clientLocalMigration, error) {
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
return []clientLocalMigration{}, nil
}
if err := ensureClientMigrationRegistryTable(db); err != nil {
return nil, err
}
rows := make([]clientLocalMigration, 0)
if err := db.Raw(`
SELECT id, name, sql_text, checksum, COALESCE(min_app_version, '') AS min_app_version, order_no, created_at
FROM qt_client_local_migrations
WHERE is_active = 1
ORDER BY order_no ASC, created_at ASC, id ASC
`).Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("load client local migrations: %w", err)
}
return rows, nil
}
func ensureClientMigrationRegistryTable(db *gorm.DB) error {
// Check if table exists instead of trying to create (avoids permission issues)
if !tableExists(db, "qt_client_local_migrations") {
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
id VARCHAR(128) NOT NULL,
name VARCHAR(255) NOT NULL,
sql_text LONGTEXT NOT NULL,
checksum VARCHAR(128) NOT NULL,
min_app_version VARCHAR(64) NULL,
order_no INT NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
)
`).Error; err != nil {
return fmt.Errorf("create qt_client_local_migrations table: %w", err)
}
}
func ensureClientSchemaStateTable(db *gorm.DB) error {
if !tableExists(db, "qt_client_schema_state") {
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
username VARCHAR(100) NOT NULL,
last_applied_migration_id VARCHAR(128) NULL,
hostname VARCHAR(255) NOT NULL DEFAULT '',
app_version VARCHAR(64) NULL,
last_sync_at DATETIME NULL,
last_sync_status VARCHAR(32) NULL,
pending_changes_count INT NOT NULL DEFAULT 0,
pending_errors_count INT NOT NULL DEFAULT 0,
configurations_count INT NOT NULL DEFAULT 0,
projects_count INT NOT NULL DEFAULT 0,
estimate_pricelist_version VARCHAR(128) NULL,
warehouse_pricelist_version VARCHAR(128) NULL,
competitor_pricelist_version VARCHAR(128) NULL,
last_sync_error_code VARCHAR(128) NULL,
last_sync_error_text TEXT NULL,
last_checked_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (username),
PRIMARY KEY (username, hostname),
INDEX idx_qt_client_schema_state_checked (last_checked_at)
)
`).Error; err != nil {
return fmt.Errorf("create qt_client_schema_state table: %w", err)
}
}
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)
}
}
type colMigration struct {
column string
stmt string
}
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 {
return fmt.Errorf("expand qt_client_schema_state: %w", err)
}
}
}
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
@@ -239,110 +221,6 @@ func tableExists(db *gorm.DB, tableName string) bool {
return count > 0
}
func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {
for i := range migrations {
m := migrations[i]
computedChecksum := digestSQL(m.SQLText)
checksum := strings.TrimSpace(m.Checksum)
if checksum == "" {
checksum = computedChecksum
} else if !strings.EqualFold(checksum, computedChecksum) {
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
}
applied, err := s.localDB.GetRemoteMigrationApplied(m.ID)
if err == nil {
if strings.TrimSpace(applied.Checksum) != checksum {
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
}
continue
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("check local applied migration %s: %w", m.ID, err)
}
if strings.TrimSpace(m.SQLText) == "" {
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
return fmt.Errorf("mark empty migration %s as applied: %w", m.ID, err)
}
continue
}
statements := splitSQLStatementsLite(m.SQLText)
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
for _, stmt := range statements {
if err := tx.Exec(stmt).Error; err != nil {
return fmt.Errorf("apply migration %s statement %q: %w", m.ID, stmt, err)
}
}
return nil
}); err != nil {
return err
}
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
return fmt.Errorf("record applied migration %s: %w", m.ID, err)
}
}
return nil
}
func splitSQLStatementsLite(script string) []string {
scanner := bufio.NewScanner(strings.NewReader(script))
scanner.Buffer(make([]byte, 1024), 1024*1024)
lines := make([]string, 0, 64)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "--") {
continue
}
lines = append(lines, scanner.Text())
}
combined := strings.Join(lines, "\n")
raw := strings.Split(combined, ";")
stmts := make([]string, 0, len(raw))
for _, stmt := range raw {
trimmed := strings.TrimSpace(stmt)
if trimmed == "" {
continue
}
stmts = append(stmts, trimmed)
}
return stmts
}
func digestSQL(sqlText string) string {
hash := sha256.Sum256([]byte(sqlText))
return hex.EncodeToString(hash[:])
}
func compareVersions(left, right string) int {
leftParts := normalizeVersionParts(left)
rightParts := normalizeVersionParts(right)
maxLen := len(leftParts)
if len(rightParts) > maxLen {
maxLen = len(rightParts)
}
for i := 0; i < maxLen; i++ {
lv := 0
rv := 0
if i < len(leftParts) {
lv = leftParts[i]
}
if i < len(rightParts) {
rv = rightParts[i]
}
if lv < rv {
return -1
}
if lv > rv {
return 1
}
}
return 0
}
func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) error {
if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
return nil
@@ -351,47 +229,113 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
if username == "" {
return nil
}
lastMigrationID := ""
if id, err := s.localDB.GetLatestAppliedRemoteMigrationID(); err == nil {
lastMigrationID = id
hostname, err := os.Hostname()
if err != nil {
hostname = ""
}
hostname = strings.TrimSpace(hostname)
lastSyncAt := s.localDB.GetLastSyncTime()
lastSyncStatus := ReadinessReady
pendingChangesCount := s.localDB.CountPendingChanges()
pendingErrorsCount := s.localDB.CountErroredChanges()
configurationsCount := s.localDB.CountConfigurations()
projectsCount := s.localDB.CountProjects()
estimateVersion := latestPricelistVersion(s.localDB, "estimate")
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
localPricelistCount := s.localDB.CountLocalPricelists()
pricelistItemsCount := s.localDB.CountAllPricelistItems()
componentsCount := s.localDB.CountComponents()
dbSizeBytes := s.localDB.DBFileSizeBytes()
return mariaDB.Exec(`
INSERT INTO qt_client_schema_state (username, last_applied_migration_id, app_version, last_checked_at, updated_at)
VALUES (?, ?, ?, ?, ?)
INSERT INTO qt_client_schema_state (
username, hostname, app_version,
last_sync_at, last_sync_status, pending_changes_count, pending_errors_count,
configurations_count, projects_count,
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
last_sync_error_code, last_sync_error_text,
local_pricelist_count, pricelist_items_count, components_count, db_size_bytes,
last_checked_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_applied_migration_id = VALUES(last_applied_migration_id),
app_version = VALUES(app_version),
last_sync_at = VALUES(last_sync_at),
last_sync_status = VALUES(last_sync_status),
pending_changes_count = VALUES(pending_changes_count),
pending_errors_count = VALUES(pending_errors_count),
configurations_count = VALUES(configurations_count),
projects_count = VALUES(projects_count),
estimate_pricelist_version = VALUES(estimate_pricelist_version),
warehouse_pricelist_version = VALUES(warehouse_pricelist_version),
competitor_pricelist_version = VALUES(competitor_pricelist_version),
last_sync_error_code = VALUES(last_sync_error_code),
last_sync_error_text = VALUES(last_sync_error_text),
local_pricelist_count = VALUES(local_pricelist_count),
pricelist_items_count = VALUES(pricelist_items_count),
components_count = VALUES(components_count),
db_size_bytes = VALUES(db_size_bytes),
last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at)
`, username, lastMigrationID, appmeta.Version(), checkedAt, checkedAt).Error
`, username, hostname, appmeta.Version(),
lastSyncAt, lastSyncStatus, pendingChangesCount, pendingErrorsCount,
configurationsCount, projectsCount,
estimateVersion, warehouseVersion, competitorVersion,
lastSyncErrorCode, lastSyncErrorText,
localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes,
checkedAt, checkedAt).Error
}
func normalizeVersionParts(v string) []int {
trimmed := strings.TrimSpace(v)
trimmed = strings.TrimPrefix(trimmed, "v")
chunks := strings.Split(trimmed, ".")
parts := make([]int, 0, len(chunks))
for _, chunk := range chunks {
clean := strings.TrimSpace(chunk)
if clean == "" {
parts = append(parts, 0)
continue
}
n := 0
for i := 0; i < len(clean); i++ {
if clean[i] < '0' || clean[i] > '9' {
clean = clean[:i]
break
}
}
if clean != "" {
if parsed, err := strconv.Atoi(clean); err == nil {
n = parsed
}
}
parts = append(parts, n)
func isDuplicatePrimaryKeyDefinition(err error) bool {
if err == nil {
return false
}
return parts
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "multiple primary key defined") ||
strings.Contains(msg, "duplicate key name 'primary'") ||
strings.Contains(msg, "duplicate entry")
}
func latestPricelistVersion(local *localdb.LocalDB, source string) *string {
if local == nil {
return nil
}
pl, err := local.GetLatestLocalPricelistBySource(source)
if err != nil || pl == nil {
return nil
}
version := strings.TrimSpace(pl.Version)
if version == "" {
return nil
}
return &version
}
func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
if local == nil {
return nil, nil
}
if guard, err := local.GetSyncGuardState(); err == nil && guard != nil && strings.EqualFold(guard.Status, ReadinessBlocked) {
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
}
var pending localdb.PendingChange
if err := local.DB().
Where("TRIM(COALESCE(last_error, '')) <> ''").
Order("id DESC").
First(&pending).Error; err == nil {
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
}
return nil, nil
}
func optionalString(value string) *string {
if strings.TrimSpace(value) == "" {
return nil
}
v := strings.TrimSpace(value)
return &v
}
func toReadinessFromState(state *localdb.LocalSyncGuardState) *SyncReadiness {

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