Vendor mapping: wildcard ignore patterns, bulk CSV import, multi-lot qty

- Add glob pattern support (* and ?) for ignore rules stored in
  qt_vendor_partnumber_seen (is_pattern flag, migration 041)
- Pattern matching applied in stock/competitor import, partnumber book
  snapshot, and vendor mappings list (Go-side via NormalizeKey)
- BulkUpsertMappings: replace N+1 loop with two batch SQL upserts,
  validating all lots in a single query (~1500 queries → 3-4)
- CSV import: multi-lot per PN via repeated rows, optional qty column
- CSV export: updated column format vendor;partnumber;lot_name;qty;description;ignore;notes
- UI: ignore patterns section with add/delete, import progress feedback
- Update bible-local/vendor-mapping.md with new CSV format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-03-19 09:41:48 +03:00
parent df14da2265
commit f73e3d144d
13 changed files with 653 additions and 51 deletions

View File

@@ -690,7 +690,11 @@ async function importVendorMappingsCsv() {
const fd = new FormData();
fd.append('file', file);
setVendorMappingsImportModalError('');
if (submitBtn) submitBtn.disabled = true;
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = 'Импортирую…';
}
if (typeof showToast === 'function') showToast('Импорт начался…', 'info');
try {
const resp = await fetch('/api/admin/pricing/vendor-mappings/import-csv', {
method: 'POST',
@@ -725,7 +729,10 @@ async function importVendorMappingsCsv() {
} finally {
input.value = '';
renderVendorMappingsImportSelectedFile();
if (submitBtn) submitBtn.disabled = false;
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = 'Импортировать';
}
}
}
@@ -891,4 +898,100 @@ document.addEventListener('DOMContentLoaded', () => {
});
loadPartnumberBooks();
loadVendorMappings(1);
loadIgnorePatterns();
});
// ─── Ignore Patterns ──────────────────────────────────────────────────────────
async function loadIgnorePatterns() {
const container = document.getElementById('ignore-patterns-list');
if (!container) return;
try {
const resp = await fetch('/api/admin/pricing/vendor-mappings/ignore-patterns');
if (!resp.ok) return;
const data = await resp.json();
const patterns = data.patterns || [];
if (patterns.length === 0) {
container.innerHTML = '<p class="text-gray-400 text-xs">Нет активных масок.</p>';
return;
}
container.innerHTML = patterns.map(p => `
<div class="flex items-center justify-between px-3 py-1.5 bg-gray-50 border rounded">
<div class="flex items-center gap-3">
<span class="font-mono text-sm font-medium text-orange-700">${escapeHtml(p.pattern)}</span>
${p.ignored_by ? `<span class="text-xs text-gray-400">by ${escapeHtml(p.ignored_by)}</span>` : ''}
</div>
<button onclick="deleteIgnorePattern(${p.id}, ${JSON.stringify(p.pattern)})"
class="text-xs text-red-500 hover:text-red-700 px-2 py-0.5 border border-red-200 rounded hover:bg-red-50">
Удалить
</button>
</div>`).join('');
} catch (e) {
container.innerHTML = `<p class="text-red-500 text-xs">${e.message}</p>`;
}
}
function openIgnorePatternsAddModal() {
const modal = document.getElementById('ignore-pattern-modal');
const inp = document.getElementById('ip-pattern');
const err = document.getElementById('ignore-pattern-modal-error');
if (inp) inp.value = '';
if (err) { err.classList.add('hidden'); err.textContent = ''; }
if (modal) { modal.classList.remove('hidden'); modal.classList.add('flex'); }
setTimeout(() => inp && inp.focus(), 50);
}
function closeIgnorePatternsAddModal() {
const modal = document.getElementById('ignore-pattern-modal');
if (modal) { modal.classList.add('hidden'); modal.classList.remove('flex'); }
}
async function createIgnorePattern() {
const inp = document.getElementById('ip-pattern');
const err = document.getElementById('ignore-pattern-modal-error');
const pattern = (inp?.value || '').trim();
if (!pattern) { showIgnorePatternError('Введите маску'); return; }
if (!/[*?]/.test(pattern)) { showIgnorePatternError('Маска должна содержать * или ?'); return; }
try {
const resp = await fetch('/api/admin/pricing/vendor-mappings/ignore-patterns', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) { showIgnorePatternError(data.error || 'Ошибка'); return; }
closeIgnorePatternsAddModal();
await loadIgnorePatterns();
if (typeof showToast === 'function') showToast(`Маска «${pattern}» добавлена`, 'success');
} catch (e) {
showIgnorePatternError(e.message || 'Ошибка');
}
}
function showIgnorePatternError(msg) {
const box = document.getElementById('ignore-pattern-modal-error');
if (!box) return;
box.textContent = msg;
box.classList.remove('hidden');
}
async function deleteIgnorePattern(id, pattern) {
if (!confirm(`Удалить маску «${pattern}»?`)) return;
try {
const resp = await fetch('/api/admin/pricing/vendor-mappings/ignore-patterns', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) { if (typeof showToast === 'function') showToast(data.error || 'Ошибка', 'error'); return; }
await loadIgnorePatterns();
if (typeof showToast === 'function') showToast(`Маска «${pattern}» удалена`, 'success');
} catch (e) {
if (typeof showToast === 'function') showToast(e.message || 'Ошибка', 'error');
}
}
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}