Compare commits

..

5 Commits
v2.22 ... v2.23

Author SHA1 Message Date
Mikhail Chusavitin
f7d26a28f8 chore: обновление субмодуля bible
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:30:31 +03:00
Mikhail Chusavitin
bb742d2f38 fix: DKC/CTL/ENC попадали в Other при режиме server
ASSIGNED_CATEGORIES пересобирался из отфильтрованного tab.categories,
поэтому категории скрытые для текущего типа конфигурации переставали
считаться «назначенными» и уходили в Other. Теперь используется
tab._allCategories (полный статический список) — категория принадлежит
вкладке независимо от видимости в текущем режиме.

Также убрал лишний .toUpperCase() в updateTabVisibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:29:32 +03:00
Mikhail Chusavitin
f70cc680f7 fix: конфигуратор зависал на «Загрузка...», infinite retry при sync, UpsertByUUID
1. JS-конфигуратор: при загрузке сохранённой конфигурации item.category
   всегда undefined (в config.items хранится только lot_name/quantity/unit_price).
   Добавлено обогащение cart из allComponents после загрузки, все сравнения
   категорий переведены на ciStr() вместо .toUpperCase(), исправлены все 4
   точки построения ASSIGNED_CATEGORIES — устраняет TypeError и таб «Other»
   показывал компоненты с известными категориями.

2. RepairPendingChanges: repair-функции теперь возвращают (bool, error);
   attempts/last_error сбрасываются только при modified=true — устраняет
   бесконечный retry когда ошибка на стороне сервера, а не локальных данных.

3. UpsertByUUID: сброс project.ID=0 перед INSERT … ON DUPLICATE KEY UPDATE,
   чтобы конфликт шёл по уникальному uuid, а не по PK чужой строки —
   устраняет «record not found» при разрешении изменений проекта.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:27:29 +03:00
64c9c4e862 docs: bible-local — удаление local_components, правило регистра lot_name, категория из прайслиста 2026-06-26 08:56:32 +03:00
cc91ca10fc docs: release notes v2.22 2026-06-26 08:54:23 +03:00
7 changed files with 113 additions and 67 deletions

2
bible

Submodule bible updated: 1977730d93...52444350c1

View File

@@ -40,14 +40,25 @@ Readiness guard:
## Pricing contract
Prices come only from `local_pricelist_items`.
`local_pricelist_items` is the single source of truth for both prices and component catalog (lot_name + lot_category). There is no separate component catalog table.
Rules:
- `local_components` is metadata-only;
- quote calculation must not read prices from components;
- `local_components` table has been removed; do not recreate it;
- component list for the configurator autocomplete comes from `local_pricelist_items` via `ListComponents`;
- quote calculation reads prices from `local_pricelist_items` only;
- latest pricelist selection ignores snapshots without items;
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
## lot_name case handling
lot_names in `local_pricelist_items` may be stored in mixed case in databases synced before normalization was enforced. `NormalizeLotName` (uppercase + trim) is applied at sync time via `PricelistItemToLocal`, but existing rows are not retroactively updated.
Rules:
- all SQLite queries that filter by `lot_name` must use `UPPER(lot_name) IN ?` or `UPPER(lot_name) = ?` with an uppercased input — never a bare `=` or `IN` on a string that may have been sourced from user input or a legacy row;
- result map keys must preserve the original case passed by the caller (build a `uppercase → original` index before the query);
- `GetLocalPricesForLots` is the canonical pattern: it uppercases the input list, queries with `UPPER(lot_name) IN ?`, and returns keys that match the input lot_names;
- frontend JS must never infer a component category from the lot_name prefix; `lot_category` from `local_pricelist_items` is the only valid source; items without a category fall into the "Other" tab.
## Pricing tab layout
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
@@ -133,7 +144,7 @@ full contract and JSON schemas.
| `required_categories` | Per-config-type badge on tabs with unfilled required categories |
Rules:
- sync runs after `SyncComponents`; failure is non-fatal (Warn log only);
- sync runs as part of the pricelist pull; failure is non-fatal (Warn log only);
- `local_qt_settings` is a read-only cache — never written by user actions;
- absent or unparseable settings: QF uses hardcoded fallbacks for that key only;
- `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere;

View File

@@ -8,9 +8,8 @@ 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_pricelist_items` | pricelist rows; the only runtime source of prices and component catalog |
| `local_projects` | user projects |
| `local_configurations` | user configurations |
| `local_configuration_versions` | immutable revision snapshots |
@@ -25,8 +24,9 @@ Main tables:
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.
- `local_pricelist_items` is the only valid runtime source of prices and component catalog; do not add a separate component cache table;
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows;
- `local_components` table has been removed; any reference to it is dead code.
## MariaDB

View File

@@ -1720,12 +1720,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
var remainingErrors []string
for _, change := range erroredChanges {
var modified bool
var repairErr error
switch change.EntityType {
case "project":
repairErr = l.repairProjectChange(&change)
modified, repairErr = l.repairProjectChange(&change)
case "configuration":
repairErr = l.repairConfigurationChange(&change)
modified, repairErr = l.repairConfigurationChange(&change)
default:
repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
}
@@ -1736,7 +1737,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
continue
}
// Clear error and reset attempts
// Only reset attempts when the repair actually changed local data.
// If nothing was modified, the error is server-side; leaving attempts
// intact lets maxPendingChangeAttempts eventually abandon the change.
if !modified {
continue
}
if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{
"last_error": "",
"attempts": 0,
@@ -1752,12 +1759,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
}
// repairProjectChange validates and fixes project data.
// Returns (modified, err): modified=true only when local data was actually changed.
// Note: This only validates local data. Server-side conflicts (like duplicate code+variant)
// are handled by sync service layer with deduplication logic.
func (l *LocalDB) repairProjectChange(change *PendingChange) error {
func (l *LocalDB) repairProjectChange(change *PendingChange) (bool, error) {
project, err := l.GetProjectByUUID(change.EntityUUID)
if err != nil {
return fmt.Errorf("project not found locally: %w", err)
return false, fmt.Errorf("project not found locally: %w", err)
}
modified := false
@@ -1783,7 +1791,7 @@ func (l *LocalDB) repairProjectChange(change *PendingChange) error {
if strings.TrimSpace(project.OwnerUsername) == "" {
project.OwnerUsername = l.GetDBUser()
if project.OwnerUsername == "" {
return fmt.Errorf("cannot determine owner username")
return false, fmt.Errorf("cannot determine owner username")
}
modified = true
}
@@ -1804,18 +1812,19 @@ func (l *LocalDB) repairProjectChange(change *PendingChange) error {
if modified {
if err := l.SaveProject(project); err != nil {
return fmt.Errorf("saving repaired project: %w", err)
return false, fmt.Errorf("saving repaired project: %w", err)
}
}
return nil
return modified, nil
}
// repairConfigurationChange validates and fixes configuration data
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
// repairConfigurationChange validates and fixes configuration data.
// Returns (modified, err): modified=true only when local data was actually changed.
func (l *LocalDB) repairConfigurationChange(change *PendingChange) (bool, error) {
config, err := l.GetConfigurationByUUID(change.EntityUUID)
if err != nil {
return fmt.Errorf("configuration not found locally: %w", err)
return false, fmt.Errorf("configuration not found locally: %w", err)
}
modified := false
@@ -1827,7 +1836,7 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
// Project doesn't exist locally - use default system project
systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
if sysErr != nil {
return fmt.Errorf("getting system project: %w", sysErr)
return false, fmt.Errorf("getting system project: %w", sysErr)
}
config.ProjectUUID = &systemProject.UUID
modified = true
@@ -1836,11 +1845,11 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
if modified {
if err := l.SaveConfiguration(config); err != nil {
return fmt.Errorf("saving repaired configuration: %w", err)
return false, fmt.Errorf("saving repaired configuration: %w", err)
}
}
return nil
return modified, nil
}
// GetSyncGuardState returns the latest readiness guard state.

View File

@@ -23,6 +23,11 @@ func (r *ProjectRepository) Update(project *models.Project) error {
}
func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
// Clear the client-side primary key so the upsert is driven purely by the
// uuid unique constraint. Passing a non-zero ID can trigger ON DUPLICATE KEY
// on the primary key of an unrelated row, leaving uuid unchanged and causing
// the follow-up SELECT to return ErrRecordNotFound.
project.ID = 0
if err := r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.AssignmentColumns([]string{

View File

@@ -0,0 +1,23 @@
# QuoteForge v2.22
Дата релиза: 2026-06-26
Тег: `v2.22`
## Что нового
### Исправления
- **MB-автокомплит в конфигураторе теперь работает в offline-режиме.** Корневая причина: прайслист мог быть синхронизирован до введения нормализации имён лотов, из-за чего SQLite хранил их в исходном регистре (`MB_AMD_2.Rome_...`). Запрос на поиск цены отправлял уже нормализованное имя (`MB_AMD_2.ROME_...`), `IN`-сравнение в SQLite регистрозависимо — совпадений не было, цена возвращалась как null, и автокомплит показывал пустой список. Все запросы к `local_pricelist_items` по `lot_name` переведены на `UPPER(lot_name)`.
- **Удалён мёртвый код инференса категории из имени лота.** Функция `getCategoryFromLotName` на фронтенде выводила категорию из префикса лота (`DKC_AFF_A1K``DKC`) как fallback. Категория всегда приходит из прайслиста; функция удалена. Позиции без категории корректно попадают во вкладку «Other».
- **Удалена таблица `local_components` и весь связанный с ней код.** Источник данных для компонентов — только `local_pricelist_items`. Убраны маршрут `POST /api/sync/components`, поля `ComponentsSynced` и `LastComponentSync` в ответах синхронизации.
- **Support bundle расширен диагностическими файлами:** `latest_pricelist_items.json` (все позиции активного estimate-прайслиста), `autocomplete_lots.json` (позиции по категориям с флагом `has_price`), `local.db` (полная копия SQLite-базы).
- **Регистронезависимые сравнения lot_name на фронтенде:** Set-коллекции для склада, добавленных позиций и корзины BOM теперь нормализуют ключи через `.toUpperCase()`.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -417,7 +417,7 @@ let TAB_CONFIG = {
let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
.map(c => ciStr(c));
// State
let configUUID = '{{.ConfigUUID}}';
@@ -760,16 +760,16 @@ async function loadCategoriesFromAPI() {
// Build category order map
categoryOrderMap = {};
cats.forEach(cat => {
categoryOrderMap[cat.code.toUpperCase()] = cat.display_order;
categoryOrderMap[ciStr(cat.code)] = cat.display_order;
});
// Build list of unassigned categories
const knownCodes = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
.map(c => ciStr(c));
const unassignedCategories = cats
.filter(cat => !knownCodes.includes(cat.code.toUpperCase()))
.filter(cat => !knownCodes.includes(ciStr(cat.code)))
.sort((a, b) => a.display_order - b.display_order)
.map(cat => cat.code);
@@ -779,7 +779,7 @@ async function loadCategoriesFromAPI() {
// Rebuild ASSIGNED_CATEGORIES
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
.map(c => ciStr(c));
} catch(e) {
console.error('Failed to load categories, using defaults', e);
// Will use default configuration if API fails
@@ -824,7 +824,7 @@ function applyServerSettings(settings) {
};
});
TAB_CONFIG.other = otherTab || { categories: [], singleSelect: false, label: 'Other' };
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG).flatMap(t => t.categories).map(c => c.toUpperCase());
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG).flatMap(t => t.categories).map(c => ciStr(c));
}
// always_visible_tabs
@@ -953,6 +953,10 @@ document.addEventListener('DOMContentLoaded', async function() {
loadAllComponents(),
categoriesPromise,
]);
cart = cart.map(item => ({
...item,
category: item.category || allComponents.find(c => c.lot_name.toUpperCase() === (item.lot_name || '').toUpperCase())?.category || ''
}));
syncPriceSettingsControls();
renderPricelistSettingsSummary();
updateRefreshPricesButtonState();
@@ -1218,14 +1222,16 @@ function applyPriceSettings() {
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true });
}
function ciStr(s) { return (s || '').toLowerCase(); }
function getComponentCategory(comp) {
return (comp.category || '').toUpperCase();
return comp.category || '';
}
function getTabForCategory(category) {
const cat = category.toUpperCase();
const cat = ciStr(category);
for (const [tabKey, tabConfig] of Object.entries(TAB_CONFIG)) {
if (tabConfig.categories.map(c => c.toUpperCase()).includes(cat)) {
if (tabConfig.categories.some(c => ciStr(c) === cat)) {
return tabKey;
}
}
@@ -1303,10 +1309,11 @@ function applyConfigTypeToTabs() {
}
});
// Rebuild assigned categories index
// Rebuild assigned categories index using the full static list (_allCategories),
// not the filtered one — hidden categories still belong to their tab, not to Other.
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
.flatMap(t => t._allCategories || t.categories)
.map(c => ciStr(c));
}
function updateTabVisibility() {
@@ -1317,8 +1324,7 @@ function updateTabVisibility() {
if (!btn) continue;
const hasComponents = getComponentsForTab(tabId).length > 0;
const hasCartItems = cart.some(item => {
const cat = (item.category || '').toUpperCase();
return getTabForCategory(cat) === tabId;
return getTabForCategory(item.category) === tabId;
});
const visible = hasComponents || hasCartItems;
btn.classList.toggle('hidden', !visible);
@@ -1334,15 +1340,15 @@ function getComponentsForTab(tab) {
return allComponents.filter(comp => {
const category = getComponentCategory(comp);
if (tab === 'other') {
return !ASSIGNED_CATEGORIES.includes(category);
return !ASSIGNED_CATEGORIES.includes(ciStr(category));
}
return config.categories.map(c => c.toUpperCase()).includes(category);
return config.categories.some(c => ciStr(c) === ciStr(category));
});
}
function getComponentsForCategory(category) {
return allComponents.filter(comp => {
return getComponentCategory(comp) === category.toUpperCase();
return ciStr(getComponentCategory(comp)) === ciStr(category);
});
}
@@ -1404,7 +1410,7 @@ function renderSingleSelectTab(categories) {
categories.forEach(cat => {
const catLabel = cat === 'MB' ? 'MB' : cat === 'CPU' ? 'CPU' : cat === 'MEM' ? 'MEM' : cat;
const selectedItem = cart.find(item =>
(item.category).toUpperCase() === cat.toUpperCase()
ciStr(item.category) === ciStr(cat)
);
const comp = selectedItem ? allComponents.find(c => c.lot_name.toUpperCase() === (selectedItem.lot_name || '').toUpperCase()) : null;
@@ -1457,9 +1463,7 @@ function renderSingleSelectTab(categories) {
function renderMultiSelectTab(components) {
// Get cart items for this tab
const tabItems = cart.filter(item => {
const cat = (item.category).toUpperCase();
const tab = getTabForCategory(cat);
return tab === currentTab;
return getTabForCategory(item.category) === currentTab;
});
let html = `
@@ -1546,9 +1550,7 @@ function renderMultiSelectTab(components) {
function renderMultiSelectTabWithSections(sections) {
// Get cart items for this tab
const tabItems = cart.filter(item => {
const cat = (item.category).toUpperCase();
const tab = getTabForCategory(cat);
return tab === currentTab;
return getTabForCategory(item.category) === currentTab;
});
let html = '';
@@ -1556,17 +1558,14 @@ function renderMultiSelectTabWithSections(sections) {
sections.forEach((section, sectionIdx) => {
// Get components for this section's categories
const sectionCategories = section.categories.map(c => c.toUpperCase());
const sectionComponents = allComponents.filter(comp => {
const category = getComponentCategory(comp);
return sectionCategories.includes(category);
return section.categories.some(c => ciStr(c) === ciStr(getComponentCategory(comp)));
});
totalComponents += sectionComponents.length;
// Get cart items for this section
const sectionItems = tabItems.filter(item => {
const cat = (item.category).toUpperCase();
return sectionCategories.includes(cat);
return sectionCategories.some(c => ciStr(c) === ciStr(item.category));
});
// Section header
@@ -1806,7 +1805,7 @@ function selectAutocompleteItem(index) {
// Remove existing item of this category
cart = cart.filter(item =>
(item.category).toUpperCase() !== autocompleteCategory.toUpperCase()
ciStr(item.category) !== ciStr(autocompleteCategory)
);
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
@@ -2189,7 +2188,7 @@ function selectAutocompleteItemBOM(index, rowIdx) {
function clearSingleSelect(category) {
cart = cart.filter(item =>
(item.category).toUpperCase() !== category.toUpperCase()
ciStr(item.category) !== ciStr(category)
);
renderTab();
updateCartUI();
@@ -2199,7 +2198,7 @@ function clearSingleSelect(category) {
function updateSingleQuantity(category, value) {
const qty = parseInt(value) || 1;
const item = cart.find(i =>
(i.category).toUpperCase() === category.toUpperCase()
ciStr(i.category) === ciStr(category)
);
if (item) {
@@ -2258,8 +2257,8 @@ function updateCartUI() {
// Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category).toUpperCase();
const catB = (b.category).toUpperCase();
const catA = ciStr(a.category);
const catB = ciStr(b.category);
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
@@ -2267,8 +2266,7 @@ function updateCartUI() {
const grouped = {};
sortedCart.forEach(item => {
const cat = item.category;
const tab = getTabForCategory(cat);
const tab = getTabForCategory(item.category);
if (!grouped[tab]) grouped[tab] = [];
grouped[tab].push(item);
});
@@ -2276,11 +2274,11 @@ function updateCartUI() {
// Sort tabs by minimum display order of their categories
const sortedTabs = Object.entries(grouped).sort((a, b) => {
const minOrderA = Math.min(...a[1].map(item => {
const cat = (item.category).toUpperCase();
const cat = ciStr(item.category);
return categoryOrderMap[cat] || 9999;
}));
const minOrderB = Math.min(...b[1].map(item => {
const cat = (item.category).toUpperCase();
const cat = ciStr(item.category);
return categoryOrderMap[cat] || 9999;
}));
return minOrderA - minOrderB;
@@ -2731,8 +2729,8 @@ function renderSalePriceTable() {
}
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category).toUpperCase();
const catB = (b.category).toUpperCase();
const catA = ciStr(a.category);
const catB = ciStr(b.category);
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
@@ -2835,8 +2833,8 @@ function calculateCustomPrice() {
// Build adjusted prices table
// Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category).toUpperCase();
const catB = (b.category).toUpperCase();
const catA = ciStr(a.category);
const catB = ciStr(b.category);
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
@@ -4146,8 +4144,8 @@ async function renderPricingTab() {
if (!bomRows.length) {
const sortedByCategory = [...cart].sort((a, b) => {
const catA = (a.category).toUpperCase();
const catB = (b.category).toUpperCase();
const catA = ciStr(a.category);
const catB = ciStr(b.category);
return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999);
});
sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });