Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7d26a28f8 | ||
|
|
bb742d2f38 | ||
|
|
f70cc680f7 | ||
| 64c9c4e862 | |||
| cc91ca10fc |
2
bible
2
bible
Submodule bible updated: 1977730d93...52444350c1
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
|
||||
23
releases/v2.22/RELEASE_NOTES.md
Normal file
23
releases/v2.22/RELEASE_NOTES.md
Normal 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.
|
||||
@@ -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); });
|
||||
|
||||
Reference in New Issue
Block a user