refactor: устранить дублирование кода (Go-хендлеры, JS-автокомплит)

Найдено статическим анализом (dupl/jscpd) + проверено вручную:

Go:
- appstate/path.go: ResolveDBPath/ResolveConfigPath → общий resolvePath()
- handlers/respond.go: дженерик BindJSON[T] для bind+422-ошибки
- article/generator.go: buildNetSegment/buildPSUSegment → buildProfileSegment()
- cmd/qfs/main.go (+respond.go): 8 повторяющихся switch{case errors.Is(...)}
  → respondByErrCase(c, err, errCase{...}, ...)

Frontend (index.html):
- 5 идентичных обработчиков клавиатурной навигации автокомплита
  → handleAutocompleteKeyGeneric(event, onSelect)
- 4 функции сборки нового элемента корзины из автокомплита
  → buildNewCartItem()/commitCartChange()

Также: PricingMarkup — единая точка правды для аплифт-коэффициента
в JS (render + export), с cross-reference комментариями к Go-константам
в export.go (defaultSaleMarkup/stockCompetitorMarkupFactor).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-10 20:13:27 +03:00
co-authored by Claude Sonnet 5
parent b087b7eb58
commit 4d7b0e13ef
8 changed files with 199 additions and 314 deletions
+63 -155
View File
@@ -609,6 +609,19 @@ let warehouseStockLoadsByPricelist = new Map();
let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via API
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
// ─── Sale (DDP) pricing markup — single source of truth for this file ───
// Mirrors internal/services/export.go (saleMarkupFactor / stockCompetitorMarkupFactor).
// Keep both sides in sync: Estimate scales by the user's uplift, Stock/Competitor by a fixed factor.
const PricingMarkup = {
DEFAULT_SALE_UPLIFT: 1.3,
STOCK_COMPETITOR_FIXED: 1.3,
// Reads the "Аплифт к estimate" input; falls back to DEFAULT_SALE_UPLIFT when empty/invalid.
getSaleUplift() {
const v = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
return v > 0 ? v : this.DEFAULT_SALE_UPLIFT;
},
};
// Autocomplete state
let autocompleteInput = null;
let autocompleteCategory = null;
@@ -1885,7 +1898,10 @@ function renderAutocomplete() {
dropdown.classList.remove('hidden');
}
function handleAutocompleteKey(event, category) {
// Shared ArrowUp/ArrowDown/Enter/Escape navigation for all autocomplete dropdown
// variants (single-select, multi-select, section, edit-item, BOM row); onSelect
// receives the chosen autocompleteFiltered index and applies it to that context.
function handleAutocompleteKeyGeneric(event, onSelect) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
@@ -1897,27 +1913,23 @@ function handleAutocompleteKey(event, category) {
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItem(autocompleteIndex);
onSelect(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItem(index) {
const comp = autocompleteFiltered[index];
if (!comp || !autocompleteCategory) return;
function handleAutocompleteKey(event, category) {
handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteItem(index));
}
// Remove existing item of this category
cart = cart.filter(item =>
ciStr(item.category) !== ciStr(autocompleteCategory)
);
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
const qty = parseInt(qtyInput?.value) || 1;
// Builds a fresh cart entry for a component picked from an autocomplete dropdown.
// Warehouse/competitor prices and their deltas start unknown (null) until the
// next price-levels refresh fills them in.
function buildNewCartItem(comp, qty) {
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
return {
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
@@ -1933,15 +1945,35 @@ function selectAutocompleteItem(index) {
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
};
}
hideAutocomplete();
// Re-renders the tab/cart and kicks off autosave + a price-levels refresh
// after a cart mutation from an autocomplete selection.
function commitCartChange() {
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
function selectAutocompleteItem(index) {
const comp = autocompleteFiltered[index];
if (!comp || !autocompleteCategory) return;
// Remove existing item of this category
cart = cart.filter(item =>
ciStr(item.category) !== ciStr(autocompleteCategory)
);
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
const qty = parseInt(qtyInput?.value) || 1;
cart.push(buildNewCartItem(comp, qty));
hideAutocomplete();
commitCartChange();
}
function hideAutocomplete() {
document.getElementById('autocomplete-dropdown').classList.add('hidden');
autocompleteInput = null;
@@ -1986,22 +2018,7 @@ function filterAutocompleteMulti(search) {
}
function handleAutocompleteKeyMulti(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemMulti(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteItemMulti(index));
}
function selectAutocompleteItemMulti(index) {
@@ -2010,31 +2027,10 @@ function selectAutocompleteItemMulti(index) {
const qtyInput = document.getElementById('new-qty');
const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
cart.push(buildNewCartItem(comp, qty));
hideAutocomplete();
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
commitCartChange();
}
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
@@ -2091,22 +2087,7 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
}
function handleAutocompleteKeySection(event, sectionId) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemSection(autocompleteIndex, sectionId);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteItemSection(index, sectionId));
}
function selectAutocompleteItemSection(index, sectionId) {
@@ -2115,25 +2096,7 @@ function selectAutocompleteItemSection(index, sectionId) {
const qtyInput = document.getElementById('new-qty-' + sectionId);
const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
cart.push(buildNewCartItem(comp, qty));
hideAutocomplete();
@@ -2143,10 +2106,7 @@ function selectAutocompleteItemSection(index, sectionId) {
// Reset quantity to 1
if (qtyInput) qtyInput.value = '1';
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
commitCartChange();
}
// Autocomplete for editing an existing cart item's LOT (multi/section tabs)
@@ -2180,22 +2140,7 @@ function filterAutocompleteEditItem(search) {
}
function handleAutocompleteKeyEditItem(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteEditItem(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteEditItem(index));
}
function selectAutocompleteEditItem(index) {
@@ -2205,29 +2150,10 @@ function selectAutocompleteEditItem(index) {
const oldItem = cart.find(i => i.lot_name === lotName);
const qty = oldItem?.quantity || 1;
cart = cart.filter(i => i.lot_name !== lotName);
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
cart.push(buildNewCartItem(comp, qty));
hideAutocomplete();
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
commitCartChange();
}
// Autocomplete for BOM LOT mapping
@@ -2263,22 +2189,7 @@ function filterAutocompleteBOM(rowIdx, search) {
}
function handleAutocompleteKeyBOM(event, rowIdx) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemBOM(autocompleteIndex, rowIdx);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
handleAutocompleteKeyGeneric(event, (index) => selectAutocompleteItemBOM(index, rowIdx));
}
function selectAutocompleteItemBOM(index, rowIdx) {
@@ -4573,12 +4484,9 @@ async function renderPricingTab() {
} catch(e) { /* silent */ }
}
// Sale uplift applied to estimate (default 1.3)
const saleUplift = (() => {
const v = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
return v > 0 ? v : 1.3;
})();
const SALE_FIXED_MULT = 1.3;
// Sale uplift applied to estimate; Stock/Competitor use the fixed factor. See PricingMarkup.
const saleUplift = PricingMarkup.getSaleUplift();
const SALE_FIXED_MULT = PricingMarkup.STOCK_COMPETITOR_FIXED;
// Helper: returns unit prices from pricelist for a single LOT
const _getUnitPrices = (pl) => ({
@@ -5028,7 +4936,7 @@ async function exportPricingCSV(table) {
const basis = table === 'sale' ? 'ddp' : 'fob';
const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy';
const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || '');
const saleUplift = table === 'sale' ? parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '') : 0;
const saleUplift = table === 'sale' ? PricingMarkup.getSaleUplift() : 0;
try {
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
method: 'POST',