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:
co-authored by
Claude Sonnet 5
parent
b087b7eb58
commit
4d7b0e13ef
+63
-155
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user