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>
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1306,7 +1312,7 @@ function applyConfigTypeToTabs() {
|
||||
// Rebuild assigned categories index
|
||||
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
.flatMap(t => t.categories)
|
||||
.map(c => c.toUpperCase());
|
||||
.map(c => ciStr(c));
|
||||
}
|
||||
|
||||
function updateTabVisibility() {
|
||||
@@ -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