// ============================================= // Recycle Object — Pendant Configurator // Letter pendant wizard + card rendering // ============================================= const Pendant = { _wizardOpen: false, _editingIndex: null, // null = new, number = editing existing // ========================================== // EMPTY PENDANT // ========================================== getEmpty() { const cords = [this._createEmptyAttachment('cord')]; const carabiners = [this._createEmptyAttachment('carabiner')]; return { item_type: 'pendant', pendant_id: 'pnd_' + Date.now(), name: '', quantity: 0, elements: [], cords, carabiners, cord: { ...cords[0] }, cord_length_cm: 0, carabiner: { ...carabiners[0] }, packaging: null, element_price_per_unit: null, sell_price_override: null, result: null, }; }, _getAttachmentCollectionKey(type) { return type === 'cord' ? 'cords' : 'carabiners'; }, _getAttachmentLegacyKey(type) { return type === 'cord' ? 'cord' : 'carabiner'; }, _createEmptyAttachment(type) { return { source: 'warehouse', warehouse_item_id: null, warehouse_sku: '', photo_thumbnail: '', name: '', price_per_unit: 0, delivery_price: 0, sell_price: 0, assembly_speed: type === 'cord' ? 20 : 0, unit: 'шт', allocated_qty: 0, qty_per_pendant: 1, length_cm: 0, }; }, _normalizeAttachment(type, data, fallbackLengthCm = 0, totalQty = 0) { const sourceData = data || {}; const normalized = { ...this._createEmptyAttachment(type), ...sourceData, }; const qtyPerPendant = parseFloat(normalized.qty_per_pendant); const hasExplicitAllocatedQty = sourceData.allocated_qty !== undefined && sourceData.allocated_qty !== null && sourceData.allocated_qty !== ''; const allocatedQty = hasExplicitAllocatedQty ? parseFloat(sourceData.allocated_qty) : NaN; normalized.qty_per_pendant = qtyPerPendant > 0 ? qtyPerPendant : 1; normalized.allocated_qty = Number.isFinite(allocatedQty) ? Math.max(0, Math.round(allocatedQty)) : (this._hasAttachmentData(sourceData) && !hasExplicitAllocatedQty && totalQty > 0 ? totalQty : 0); const lengthCm = parseFloat(normalized.length_cm); normalized.length_cm = Number.isFinite(lengthCm) ? lengthCm : (type === 'cord' ? (parseFloat(fallbackLengthCm) || 0) : 0); normalized.unit = normalized.unit || 'шт'; return normalized; }, _hasAttachmentData(entry) { return !!(entry && ( entry.name || entry.warehouse_item_id || entry.warehouse_sku || (parseFloat(entry.price_per_unit) || 0) > 0 || (parseFloat(entry.delivery_price) || 0) > 0 || (parseFloat(entry.sell_price) || 0) > 0 || entry.source === 'custom' )); }, _ensureAttachmentCollections(pnd = this._wizardData, options = {}) { if (!pnd) return pnd; const preserveEmpty = !!options.preserveEmpty; ['cord', 'carabiner'].forEach(type => { const collectionKey = this._getAttachmentCollectionKey(type); const legacyKey = this._getAttachmentLegacyKey(type); const fallbackLengthCm = type === 'cord' ? (parseFloat(pnd.cord_length_cm) || 0) : 0; const totalQty = parseInt(pnd.quantity, 10) || 0; let entries = Array.isArray(pnd[collectionKey]) ? pnd[collectionKey].filter(Boolean) : []; if (!entries.length && this._hasAttachmentData(pnd[legacyKey])) { entries = [pnd[legacyKey]]; } const normalized = entries .map((entry, index) => this._normalizeAttachment(type, entry, index === 0 ? fallbackLengthCm : 0, totalQty)); const prepared = preserveEmpty ? normalized : normalized.filter(entry => this._hasAttachmentData(entry)); pnd[collectionKey] = prepared.length > 0 ? prepared : [this._normalizeAttachment(type, null, fallbackLengthCm)]; }); this._syncLegacyAttachments(pnd); return pnd; }, _syncLegacyAttachments(pnd = this._wizardData) { if (!pnd) return; const cords = Array.isArray(pnd.cords) && pnd.cords.length ? pnd.cords : [this._createEmptyAttachment('cord')]; const carabiners = Array.isArray(pnd.carabiners) && pnd.carabiners.length ? pnd.carabiners : [this._createEmptyAttachment('carabiner')]; pnd.cord = { ...cords[0] }; pnd.cord_length_cm = parseFloat(cords[0]?.length_cm) || 0; pnd.carabiner = { ...carabiners[0] }; }, _getAttachments(pnd, type, options = {}) { this._ensureAttachmentCollections(pnd, { preserveEmpty: !!options.includeEmpty }); const collectionKey = this._getAttachmentCollectionKey(type); const entries = Array.isArray(pnd?.[collectionKey]) ? pnd[collectionKey] : []; if (options.includeEmpty) return entries; return entries.filter(entry => this._hasAttachmentData(entry)); }, _getAttachmentAllocatedQty(entry, pnd = this._wizardData) { if (typeof getPendantAttachmentAllocatedQty === 'function') { return getPendantAttachmentAllocatedQty(pnd, entry); } const totalQty = parseFloat(pnd?.quantity) || 0; if (!entry) return 0; const allocatedQty = parseFloat(entry.allocated_qty); if (Number.isFinite(allocatedQty) && allocatedQty >= 0) return allocatedQty; return this._hasAttachmentData(entry) && totalQty > 0 ? totalQty : 0; }, _getAttachmentAllocatedTotal(type, pnd = this._wizardData, options = {}) { const entries = Array.isArray(options.entries) ? options.entries : this._getAttachments(pnd, type, { includeEmpty: !!options.includeEmpty }); return round2(entries.reduce((sum, entry, index) => { if (index === options.excludeIndex) return sum; return sum + this._getAttachmentAllocatedQty(entry, pnd); }, 0)); }, _getAttachmentRemainingQty(type, pnd = this._wizardData, options = {}) { const totalQty = parseFloat(pnd?.quantity) || 0; return Math.max(0, round2(totalQty - this._getAttachmentAllocatedTotal(type, pnd, options))); }, _describeAttachmentList(entries, emptyLabel) { const names = (entries || []).map(entry => entry?.name).filter(Boolean); if (names.length === 0) return emptyLabel; if (names.length === 1) return names[0]; if (names.length === 2) return names.join(', '); return `${names[0]}, ${names[1]} и ещё ${names.length - 2}`; }, // ========================================== // CARD RENDERING (in calculator) // ========================================== renderCard(idx) { const pnd = Calculator.pendants[idx]; if (!pnd) return; this._ensureAttachmentCollections(pnd); const container = document.getElementById('calc-pendants-container'); if (!container) return; const displayName = this._normalizeName(pnd.name || '') || '...'; const cords = this._getAttachments(pnd, 'cord'); const carabiners = this._getAttachments(pnd, 'carabiner'); let card = document.getElementById('pendant-card-' + idx); if (!card) { card = document.createElement('div'); card.id = 'pendant-card-' + idx; card.className = 'card pendant-card'; container.appendChild(card); } const elements = this._countableElements(pnd.elements); const elemCount = elements.length; const printCount = elements.filter(e => e.has_print).length; const r = pnd.result || {}; const costStr = r.costPerUnit ? formatRub(r.costPerUnit) : '—'; const totalStr = r.totalRevenue ? formatRub(r.totalRevenue) : '—'; const sellStr = r.sellPerUnit ? formatRub(r.sellPerUnit) : '—'; const marginStr = r.margin && r.margin.percent !== null ? formatPercent(r.margin.percent) : '—'; card.innerHTML = `

🔤 Подвес "${App.escHtml(displayName)}" × ${pnd.quantity || 0} шт

${App.escHtml(this._describeAttachmentList(cords, 'Шнур не выбран'))} + ${App.escHtml(this._describeAttachmentList(carabiners, 'Карабин не выбран'))} · ${elemCount} элем.${printCount > 0 ? ', ' + printCount + ' с печатью' : ''}
Себест.
${costStr}
Цена
${sellStr}
Маржа
${marginStr}
Итого
${totalStr}
`; }, renderAllCards() { const container = document.getElementById('calc-pendants-container'); if (container) container.innerHTML = ''; Calculator.pendants.forEach((_, i) => this.renderCard(i)); }, remove(idx) { if (!confirm('Удалить подвес "' + (Calculator.pendants[idx]?.name || '') + '"?')) return; Calculator.pendants.splice(idx, 1); this.renderAllCards(); Calculator.recalculate(); Calculator.scheduleAutosave(); }, // ========================================== // WIZARD MODAL // ========================================== openWizard(editIdx) { this._editingIndex = editIdx !== undefined ? editIdx : null; const pnd = this._editingIndex !== null ? JSON.parse(JSON.stringify(Calculator.pendants[this._editingIndex])) : this.getEmpty(); this._ensureAttachmentCollections(pnd); this._wizardData = pnd; this._commitName(this._wizardData.name); this._wizardStep = 1; this._selectedBeads = new Set(); this._showWizardModal(); }, _showWizardModal() { if (!(this._selectedBeads instanceof Set)) this._selectedBeads = new Set(); // Remove existing modal if any let modal = document.getElementById('pendant-wizard-modal'); if (modal) modal.remove(); modal = document.createElement('div'); modal.id = 'pendant-wizard-modal'; modal.className = 'modal-overlay pendant-wizard-overlay'; modal.innerHTML = `

${this._editingIndex !== null ? 'Редактировать подвес' : 'Новый подвес из букв'}

${[1,2,3,4,5].map(n => ``).join('')}
`; document.body.appendChild(modal); this._renderStep(); }, _wizardClassName() { return `pendant-wizard pendant-wizard-step-${this._wizardStep}`; }, _stepLabel(n) { return ['', '1. Надпись', '2. Цвета', '3. Печать', '4. Шнур', '5. Итого'][n]; }, _closeWizard() { const modal = document.getElementById('pendant-wizard-modal'); if (modal) modal.remove(); this._wizardOpen = false; }, _goToStep(n) { this._readCurrentStep(); if (n === 1 || this._wizardData.name) { this._wizardStep = n; this._showWizardModal(); } }, _prevStep() { if (this._wizardStep > 1) { this._wizardStep--; this._showWizardModal(); } }, _nextStep() { this._readCurrentStep(); if (this._wizardStep === 1 && !this._wizardData.name) { App.toast('Введите надпись'); return; } if (this._wizardStep === 4 && !this._validateAttachmentDistributions()) { return; } if (this._wizardStep < 5) { this._wizardStep++; this._showWizardModal(); } }, // ========================================== // STEP RENDERERS // ========================================== async _renderStep() { const body = document.getElementById('pendant-wizard-body'); if (!body) return; // Ensure warehouse data is loaded for step 4 if (this._wizardStep === 4) { body.innerHTML = '
Загрузка склада...
'; await Calculator._ensureWhPickerData(); } switch (this._wizardStep) { case 1: body.innerHTML = this._renderStep1(); this._bindStep1(); break; case 2: body.innerHTML = this._renderStep2(); this._bindStep2(); break; case 3: body.innerHTML = this._renderStep3(); this._bindStep3(); break; case 4: body.innerHTML = this._renderStep4(); this._bindStep4(); break; case 5: body.innerHTML = this._renderStep5(); break; } }, // --- STEP 1: Inscription + quantity --- _renderStep1() { const pnd = this._wizardData; const normalizedName = this._normalizeName(pnd.name); const elementCount = this._nameChars(normalizedName).length; return `
${elementCount > 20 ? '
⚠️ Больше 20 элементов — проверьте, поместятся ли на шнур
' : ''}
${this._renderBeads(normalizedName, pnd.elements)}
`; }, _bindStep1() { const nameInput = document.getElementById('pw-name'); if (nameInput) { nameInput.addEventListener('input', () => { const rawValue = nameInput.value; const caretPos = nameInput.selectionStart ?? rawValue.length; const normalized = this._commitName(rawValue); if (rawValue !== normalized) { const normalizedCaret = this._normalizeName(rawValue.slice(0, caretPos)).length; nameInput.value = normalized; if (typeof nameInput.setSelectionRange === 'function') { nameInput.setSelectionRange(normalizedCaret, normalizedCaret); } } const preview = document.getElementById('pw-beads-preview'); if (preview) preview.innerHTML = this._renderBeads(normalized, this._wizardData.elements); this._updateStepAvailability(); }); } }, _insertSpecial(char) { const input = document.getElementById('pw-name'); if (!input) return; const pos = input.selectionStart || input.value.length; input.value = input.value.slice(0, pos) + char + input.value.slice(pos); input.focus(); input.dispatchEvent(new Event('input')); }, _renderBeads(text, elements) { const chars = this._nameChars(text); if (chars.length === 0) return 'Введите надпись выше'; return chars.map((ch, i) => { const el = elements && elements[i]; const color = el ? el.color : null; const bgStyle = color ? `background:var(--accent-light);border-color:var(--accent);` : ''; return `
${App.escHtml(ch)} ${color ? `${App.escHtml(color)}` : ''}
`; }).join(''); }, _normalizeName(name) { return String(name || '') .toUpperCase() .replace(/\s+/gu, ''); }, _commitName(name) { const normalized = this._normalizeName(name); this._wizardData.name = normalized; this._syncElements(this._nameChars(normalized)); return normalized; }, _updateStepAvailability() { const buttons = document.querySelectorAll('.pendant-step-btn'); if (!buttons || !buttons.length) return; buttons.forEach((btn, idx) => { if (idx > 0) btn.disabled = !this._wizardData.name; }); }, _stripTechnicalCharParts(char) { return String(char || '').replace(/[\u200D\uFE00-\uFE0F\u{E0100}-\u{E01EF}\p{Mark}\u{1F3FB}-\u{1F3FF}]/gu, ''); }, _isGraphemeExtender(char) { return /[\u200D\uFE00-\uFE0F\u{E0100}-\u{E01EF}\p{Mark}\u{1F3FB}-\u{1F3FF}]/u.test(String(char || '')); }, _isCountableChar(char) { const raw = String(char || ''); if (!raw || !/\S/u.test(raw)) return false; return this._stripTechnicalCharParts(raw).length > 0; }, _charsEquivalent(a, b) { const left = String(a || ''); const right = String(b || ''); return left === right || this._stripTechnicalCharParts(left) === this._stripTechnicalCharParts(right); }, _splitGraphemes(name) { const parts = Array.from(String(name || '')); if (parts.length === 0) return []; const graphemes = []; parts.forEach(ch => { if (graphemes.length === 0) { graphemes.push(ch); return; } const prev = graphemes[graphemes.length - 1]; if (ch === '\u200D' || this._isGraphemeExtender(ch) || prev.endsWith('\u200D')) { graphemes[graphemes.length - 1] += ch; return; } graphemes.push(ch); }); return graphemes.filter(ch => this._isCountableChar(ch)); }, _nameChars(name) { const normalized = this._normalizeName(name); if (!normalized) return []; if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') { return Array.from( new Intl.Segmenter('ru', { granularity: 'grapheme' }).segment(normalized), part => part.segment ).filter(ch => this._isCountableChar(ch)); } return this._splitGraphemes(normalized); }, _countableElements(elements) { return (elements || []).filter(el => this._isCountableChar(el?.char)); }, // --- STEP 2: Colors --- _renderStep2() { const pnd = this._wizardData; this._commitName(pnd.name); const elements = pnd.elements; return `

Выделите буквы (клик/shift+клик) и введите цвет. Нажмите «Назначить» чтобы применить.

${elements.map((el, i) => `
${App.escHtml(el.char)} ${el.color ? `${App.escHtml(el.color)}` : ''}
`).join('')}
`; }, _bindStep2() {}, _syncElements(chars) { const pnd = this._wizardData; const old = (pnd.elements || []).filter(el => this._isCountableChar(el?.char)); pnd.elements = chars.map((ch, i) => { const oldEl = old[i]; if (oldEl && this._charsEquivalent(oldEl.char, ch)) { return { ...oldEl, char: ch }; } const hasLegacySellPrice = oldEl && Object.prototype.hasOwnProperty.call(oldEl, 'sell_price'); return { char: ch, color: oldEl?.color || '', has_print: oldEl?.has_print || false, print_price: oldEl?.print_price || 0, sell_price: hasLegacySellPrice ? oldEl.sell_price : null, sell_price_auto: oldEl?.sell_price_auto ?? (!(parseFloat(oldEl?.sell_price) > 0)), sell_print: oldEl?.sell_print || 0, }; }); }, _toggleBead(idx, event) { if (event && event.shiftKey && this._lastClickedBead !== undefined) { const from = Math.min(this._lastClickedBead, idx); const to = Math.max(this._lastClickedBead, idx); for (let i = from; i <= to; i++) this._selectedBeads.add(i); } else { if (this._selectedBeads.has(idx)) this._selectedBeads.delete(idx); else this._selectedBeads.add(idx); } this._lastClickedBead = idx; // Re-render beads only const container = document.getElementById('pw-color-beads'); if (container) { container.querySelectorAll('.pendant-bead').forEach((el, i) => { el.classList.toggle('selected', this._selectedBeads.has(i)); }); } }, _selectAll() { const count = this._wizardData.elements.length; if (this._selectedBeads.size === count) { this._selectedBeads.clear(); } else { for (let i = 0; i < count; i++) this._selectedBeads.add(i); } const container = document.getElementById('pw-color-beads'); if (container) { container.querySelectorAll('.pendant-bead').forEach((el, i) => { el.classList.toggle('selected', this._selectedBeads.has(i)); }); } }, _assignColor() { const input = document.getElementById('pw-color-input'); let color = (input?.value || '').trim(); if (color === '__custom__') { color = prompt('Введите название цвета:'); if (!color) return; color = color.trim(); } if (!color) { App.toast('Выберите цвет'); return; } if (this._selectedBeads.size === 0) { App.toast('Выделите буквы'); return; } this._selectedBeads.forEach(i => { if (this._wizardData.elements[i]) this._wizardData.elements[i].color = color; }); this._selectedBeads.clear(); if (input) input.value = ''; this._renderStep(); }, // --- STEP 3: Print --- _renderStep3() { const elements = this._wizardData.elements || []; return `

Отметьте элементы с печатью и укажите стоимость печати за штуку.

${elements.map((el, i) => `
${App.escHtml(el.char)} ${el.color ? `${App.escHtml(el.color)}` : ''} ${el.has_print ? `` : ''}
`).join('')}
`; }, _bindStep3() {}, _togglePrint(idx, checked) { if (this._wizardData.elements[idx]) { this._wizardData.elements[idx].has_print = checked; if (!checked) this._wizardData.elements[idx].print_price = 0; this._renderStep(); } }, _setPrintPrice(idx, val) { if (this._wizardData.elements[idx]) { this._wizardData.elements[idx].print_price = parseFloat(val) || 0; } }, // --- STEP 4: Cord + Carabiner --- _renderStep4() { const pnd = this._wizardData; this._ensureAttachmentCollections(pnd, { preserveEmpty: true }); const whData = Calculator._whPickerData || {}; const qty = pnd.quantity || 0; return `
${this._renderAttachmentSection('cord', this._getAttachments(pnd, 'cord', { includeEmpty: true }), whData, qty)} ${this._renderAttachmentSection('carabiner', this._getAttachments(pnd, 'carabiner', { includeEmpty: true }), whData, qty)}
`; }, _renderAttachmentSection(type, entries, whData, qty) { const isCord = type === 'cord'; const title = isCord ? '🧵 Шнур' : '🔗 Фурнитура'; const addLabel = isCord ? '+ Добавить шнур' : '+ Добавить фурнитуру'; const allocatedQty = this._getAttachmentAllocatedTotal(type, this._wizardData, { entries, includeEmpty: true }); const remainingQty = Math.max(0, round2((qty || 0) - allocatedQty)); const overflowQty = Math.max(0, round2(allocatedQty - (qty || 0))); const allocationColor = overflowQty > 0 ? 'var(--red)' : remainingQty > 0 ? 'var(--orange)' : 'var(--green)'; const allocationText = qty > 0 ? `Распределено ${allocatedQty} из ${qty} шт` + (overflowQty > 0 ? ` · лишних ${overflowQty} шт` : ` · осталось ${remainingQty} шт`) : 'Сначала укажите количество подвесов'; return `

${title}

${allocationText}
${entries.map((entry, index) => this._renderAttachmentRow(type, entry, whData, qty, index, entries.length)).join('')}
`; }, _renderAttachmentRow(type, data, whData, qty, index, totalEntries) { const isCord = type === 'cord'; const isMetric = isCord && (data?.unit === 'м' || data?.unit === 'см'); const selectedStock = this._getSelectedStock(type, data, whData, index); const allocatedQty = this._getAttachmentAllocatedQty(data, this._wizardData); const qtyPerPendant = parseFloat(data?.qty_per_pendant) || 1; const lengthCm = parseFloat(data?.length_cm) || 0; const unitLabel = isMetric ? '/' + (data?.unit || 'м') : '/шт'; let helperText = ''; let warnText = ''; let costPerPendant = 0; if (isMetric) { const needMeters = round2(lengthCm * allocatedQty / 100); const stockMeters = data?.unit === 'см' && selectedStock !== null ? round2(selectedStock / 100) : selectedStock; costPerPendant = data?.price_per_unit ? round2((data.price_per_unit * this._getMetricAttachmentRateFactor(data)) + (data.delivery_price || 0)) : 0; if (lengthCm > 0 && allocatedQty > 0) { helperText = `Нужно: ${needMeters} м · Подвесов: ${allocatedQty} шт${costPerPendant > 0 ? ` · Цена за подвес: ${formatRub(costPerPendant)}` : ''}`; } if (stockMeters !== null && needMeters > stockMeters) { warnText = `⚠️ Нужно ${needMeters} м, на складе ${stockMeters} м!`; } } else { const totalNeed = allocatedQty * qtyPerPendant; costPerPendant = round2(((data?.price_per_unit || 0) + (data?.delivery_price || 0)) * qtyPerPendant); if ((data?.price_per_unit || 0) > 0 || allocatedQty > 0) { helperText = `Нужно: ${totalNeed} шт${allocatedQty > 0 ? ` · Подвесов: ${allocatedQty} шт` : ''}${qtyPerPendant > 1 ? ` · На подвес: ${qtyPerPendant} шт` : ''}${costPerPendant > 0 ? ` · Цена за подвес: ${formatRub(costPerPendant)}` : ''}`; } if (selectedStock !== null && totalNeed > selectedStock) { warnText = `⚠️ Нужно ${totalNeed} шт, на складе ${selectedStock} шт!`; } } const summaryText = this._hasAttachmentData(data) ? `${App.escHtml(data.name || (isCord ? 'Шнур' : 'Фурнитура'))} · ${formatRub(data.price_per_unit || 0)}${unitLabel}${allocatedQty > 0 ? ` · ${allocatedQty} подв.` : ''}${!isMetric && qtyPerPendant > 1 ? ` · ${qtyPerPendant} шт/подвес` : ''}` : ''; return `
${isCord ? 'Шнур' : 'Фурнитура'} ${index + 1}
${totalEntries > 1 ? `` : ''}
${this._renderWhDropdown(type, data, whData, index)} ${summaryText ? `
${summaryText}
` : ''}
${isMetric ? `
` : `
`} ${helperText ? `
${helperText}
` : ''} ${warnText ? `
${warnText}
` : ''}
`; }, _getWarehouseCategoryMeta(catKey) { if (typeof WAREHOUSE_CATEGORIES !== 'undefined' && Array.isArray(WAREHOUSE_CATEGORIES)) { return WAREHOUSE_CATEGORIES.find(cat => cat.key === catKey) || null; } return null; }, _getWarehouseAttachmentCategoryKeys(type) { return type === 'cord' ? ['cords'] : ['carabiners', 'rings']; }, _getWarehouseAttachmentGroups(type, whData) { return this._getWarehouseAttachmentCategoryKeys(type) .map(catKey => { const group = whData?.[catKey]; const items = Array.isArray(group?.items) ? group.items : []; if (!items.length) return null; const meta = this._getWarehouseCategoryMeta(catKey); return { key: catKey, label: group?.label || meta?.label || catKey, icon: group?.icon || meta?.icon || '📦', color: meta?.color || 'var(--accent-light)', textColor: meta?.textColor || 'var(--text)', items, }; }) .filter(Boolean); }, _findWarehouseAttachmentItem(type, whData, value) { if (!value) return null; const needle = String(value); for (const group of this._getWarehouseAttachmentGroups(type, whData)) { const item = group.items.find(entry => String(entry.id) === needle); if (item) { return { ...item, __categoryKey: group.key }; } } return null; }, _getCurrentOrderDraftDemand(itemId, exclude = null) { const targetId = Number(itemId) || 0; if (!targetId) return 0; let demand = 0; const shouldSkipWizardRow = (row, useWizardState) => { if (!useWizardState || !exclude) return false; return String(row?.attachment_type || '') === String(exclude.type || '') && Number(row?.attachment_index) === Number(exclude.index); }; const addPendantDemand = (pendant, useWizardState = false) => { if (!pendant || typeof getPendantWarehouseDemandRows !== 'function') return; getPendantWarehouseDemandRows(pendant).forEach(row => { if (Number(row?.warehouse_item_id) !== targetId) return; if (shouldSkipWizardRow(row, useWizardState)) return; demand += parseFloat(row?.qty) || 0; }); }; (Calculator.hardwareItems || []).forEach(hw => { if (hw?.source !== 'warehouse') return; if (Number(hw?.warehouse_item_id) !== targetId) return; demand += parseFloat(hw?.qty) || 0; }); (Calculator.packagingItems || []).forEach(pkg => { if (pkg?.source !== 'warehouse') return; if (Number(pkg?.warehouse_item_id) !== targetId) return; demand += parseFloat(pkg?.qty) || 0; }); const hasWizardPendant = !!this._wizardData; const editingIndex = Number.isInteger(this._editingIndex) ? this._editingIndex : null; let wizardMergedIntoOrder = false; (Calculator.pendants || []).forEach((pnd, idx) => { if (editingIndex !== null && idx === editingIndex && hasWizardPendant) { addPendantDemand(this._wizardData, true); wizardMergedIntoOrder = true; return; } addPendantDemand(pnd, false); }); if ((editingIndex === null || !wizardMergedIntoOrder) && hasWizardPendant) { addPendantDemand(this._wizardData, true); } return demand; }, _getEffectiveWarehouseAvailableQty(itemId, exclude = null, fallbackAvailable = 0) { const targetId = Number(itemId) || 0; if (!targetId) return 0; const whItem = typeof Calculator._findWhItem === 'function' ? Calculator._findWhItem(targetId) : null; const baseAvailable = parseFloat(whItem?.available_qty); const ownReserved = typeof Calculator._getCurrentOrderReservedQty === 'function' ? (parseFloat(Calculator._getCurrentOrderReservedQty(targetId)) || 0) : 0; const otherDraftDemand = this._getCurrentOrderDraftDemand(targetId, exclude); const normalizedBase = Number.isFinite(baseAvailable) ? baseAvailable : (parseFloat(fallbackAvailable) || 0); const effective = normalizedBase + ownReserved - otherDraftDemand; return typeof round2 === 'function' ? Math.max(0, round2(effective)) : Math.max(0, effective); }, _getEffectiveWarehouseAttachmentItem(type, index, itemId, whData) { const item = this._findWarehouseAttachmentItem(type, whData, itemId); if (!item) return null; return { ...item, available_qty: this._getEffectiveWarehouseAvailableQty( item.id, { type, index }, item.available_qty ), }; }, _renderWhDropdown(type, data, whData, index = 0) { const groups = this._getWarehouseAttachmentGroups(type, whData); const catItems = groups.flatMap(group => group.items.map(item => ({ ...item, __categoryKey: group.key }))); if (data?.source === 'custom' || catItems.length === 0) { // Fallback to manual input if no warehouse data return this._renderManualPicker(type, data, index, catItems.length > 0); } const selectedId = data?.warehouse_item_id || null; const selectedItem = this._getEffectiveWarehouseAttachmentItem(type, index, selectedId, whData); const selectedPreview = selectedItem || (data?.warehouse_item_id ? { id: data.warehouse_item_id, name: data.name || '', sku: data.warehouse_sku || '', photo_thumbnail: data.photo_thumbnail || '', available_qty: this._getSelectedStock(type, data, whData, index) || 0, unit: data.unit || 'шт', price_per_unit: data.price_per_unit || 0, size: '', color: '', category: type === 'cord' ? 'cords' : 'carabiners', } : null); const uiDefault = type === 'cord' ? { icon: '🧵', color: '#fce7f3', textColor: '#9d174d', label: 'шнур' } : { icon: '🔗', color: '#dbeafe', textColor: '#1d4ed8', label: 'фурнитуру' }; const selectedMeta = selectedPreview ? (this._getWarehouseCategoryMeta(selectedPreview.category || selectedPreview.__categoryKey) || uiDefault) : uiDefault; const selectedHtml = selectedPreview ? (() => { const parts = [selectedPreview.name]; if (selectedPreview.size) parts.push(selectedPreview.size); if (selectedPreview.color) parts.push(selectedPreview.color); const title = parts.filter(Boolean).join(' · ') || 'Позиция со склада'; const priceStr = selectedPreview.price_per_unit > 0 ? (' · ' + formatRub(selectedPreview.price_per_unit)) : ''; const photoHtml = selectedPreview.photo_thumbnail ? `` : `${selectedMeta.icon}`; return `${photoHtml}${App.escHtml(title)}${App.escHtml(selectedPreview.sku || '')}${selectedPreview.sku ? ' · ' : ''}${selectedPreview.available_qty} ${App.escHtml(selectedPreview.unit || 'шт')}${priceStr}`; })() : `${uiDefault.icon}— Выберите ${uiDefault.label} —`; return `
${selectedHtml}
`; }, _renderManualPicker(type, data, index = 0, canSwitchToWarehouse = false) { return ` ${canSwitchToWarehouse ? `
` : ''}
`; }, _addAttachment(type) { const items = this._getAttachments(this._wizardData, type, { includeEmpty: true }); const newItem = this._createEmptyAttachment(type); newItem.allocated_qty = this._getAttachmentRemainingQty(type, this._wizardData, { entries: items, includeEmpty: true }); items.push(newItem); this._syncLegacyAttachments(this._wizardData); this._renderStep(); }, _removeAttachment(type, index) { const collectionKey = this._getAttachmentCollectionKey(type); const items = this._getAttachments(this._wizardData, type, { includeEmpty: true }); items.splice(index, 1); this._wizardData[collectionKey] = items.length > 0 ? items : [this._createEmptyAttachment(type)]; this._syncLegacyAttachments(this._wizardData); this._renderStep(); }, _setAttachmentSource(type, index, source) { const items = this._getAttachments(this._wizardData, type, { includeEmpty: true }); const previous = items[index] || this._createEmptyAttachment(type); items[index] = { ...this._createEmptyAttachment(type), source, allocated_qty: this._getAttachmentAllocatedQty(previous, this._wizardData), qty_per_pendant: parseFloat(previous.qty_per_pendant) > 0 ? parseFloat(previous.qty_per_pendant) : 1, length_cm: parseFloat(previous.length_cm) || 0, }; this._syncLegacyAttachments(this._wizardData); this._renderStep(); }, _updateAttachmentField(type, index, field, value) { const items = this._getAttachments(this._wizardData, type, { includeEmpty: true }); if (!items[index]) items[index] = this._createEmptyAttachment(type); items[index][field] = value; if (field === 'qty_per_pendant') { const qtyPerPendant = parseFloat(items[index][field]); items[index][field] = qtyPerPendant > 0 ? qtyPerPendant : 1; } if (field === 'allocated_qty') { items[index][field] = Math.max(0, Math.round(parseFloat(items[index][field]) || 0)); } if (field === 'length_cm') { items[index][field] = parseFloat(items[index][field]) || 0; } this._syncLegacyAttachments(this._wizardData); this._renderStep(); }, _onWhSelect(type, index, value) { if (value === '__custom__') { this._setAttachmentSource(type, index, 'custom'); return; } if (!value) return; const whData = Calculator._whPickerData || {}; const item = this._findWarehouseAttachmentItem(type, whData, value); if (!item) return; const items = this._getAttachments(this._wizardData, type, { includeEmpty: true }); const data = items[index] || this._createEmptyAttachment(type); data.source = 'warehouse'; data.warehouse_item_id = item.id; data.warehouse_sku = item.sku || ''; data.photo_thumbnail = item.photo_thumbnail || ''; data.name = [item.name, item.color, item.size].filter(Boolean).join(' '); data.price_per_unit = item.price_per_unit || 0; data.delivery_price = 0; data.unit = item.unit || 'шт'; // 'шт', 'м', 'см' // Look up approved sell price from hw blanks catalog data.sell_price = 0; const linkedBlank = Calculator._findHwBlankByWarehouseItemId?.(item.id); if (linkedBlank) { const fixedSellPrice = parseFloat(linkedBlank.sell_price) || 0; if (fixedSellPrice > 0) data.sell_price = fixedSellPrice; } // Fallback: 40% net margin if (!data.sell_price && data.price_per_unit > 0 && typeof calcSellByNetMargin40 === 'function') { data.sell_price = calcSellByNetMargin40(data.price_per_unit, App.params); } items[index] = data; if (!(parseFloat(items[index].allocated_qty) > 0)) { items[index].allocated_qty = this._getAttachmentRemainingQty(type, this._wizardData, { entries: items, includeEmpty: true, excludeIndex: index, }); } this._syncLegacyAttachments(this._wizardData); this._renderStep(); }, _getSelectedStock(type, data, whData, index = 0) { if (!data?.warehouse_item_id) return null; const item = this._getEffectiveWarehouseAttachmentItem(type, index, data.warehouse_item_id, whData); return item ? (item.available_qty || 0) : null; }, _isMetricAttachment(type, entry) { return type === 'cord' && (entry?.unit === 'м' || entry?.unit === 'см'); }, _getMetricAttachmentRateFactor(entry) { if (typeof getPendantMetricRateFactor === 'function') { return getPendantMetricRateFactor(entry); } const lengthCm = parseFloat(entry?.length_cm) || 0; if (!(lengthCm > 0)) return 0; return entry?.unit === 'см' ? lengthCm : (lengthCm / 100); }, _getAttachmentCostPerPendant(type, entry) { if (!entry) return 0; const params = App.params || {}; if (typeof getPendantAttachmentCostPerUnit === 'function') { return getPendantAttachmentCostPerUnit(type, entry, params); } if (this._isMetricAttachment(type, entry)) { return round2(((entry.price_per_unit || 0) * this._getMetricAttachmentRateFactor(entry)) + (entry.delivery_price || 0)); } const qtyPerPendant = parseFloat(entry.qty_per_pendant) || 1; return round2(((entry.price_per_unit || 0) + (entry.delivery_price || 0)) * qtyPerPendant); }, _getAttachmentSellPerPendant(type, entry) { if (!entry) return 0; if (this._isMetricAttachment(type, entry)) { return round2((entry.sell_price || 0) * this._getMetricAttachmentRateFactor(entry)); } const qtyPerPendant = parseFloat(entry.qty_per_pendant) || 1; return round2((entry.sell_price || 0) * qtyPerPendant); }, _ensureAttachmentSellPrice(type, entry) { if (!entry || (parseFloat(entry.sell_price) || 0) > 0 || typeof calcSellByNetMargin40 !== 'function') return; const rowCost = this._getAttachmentCostPerPendant(type, entry); if (!(rowCost > 0)) return; const recommendedRowSell = Math.round(calcSellByNetMargin40(rowCost, App.params)); if (this._isMetricAttachment(type, entry)) { const factor = this._getMetricAttachmentRateFactor(entry); entry.sell_price = factor > 0 ? round2(recommendedRowSell / factor) : recommendedRowSell; } else { const qtyPerPendant = parseFloat(entry.qty_per_pendant) || 1; entry.sell_price = qtyPerPendant > 0 ? round2(recommendedRowSell / qtyPerPendant) : recommendedRowSell; } }, _setAttachmentSellPrice(type, index, rowSellPrice) { const items = this._getAttachments(this._wizardData, type, { includeEmpty: true }); const entry = items[index]; if (!entry) return; const rowSell = round2(parseFloat(rowSellPrice) || 0); if (this._isMetricAttachment(type, entry)) { const factor = this._getMetricAttachmentRateFactor(entry); entry.sell_price = factor > 0 ? round2(rowSell / factor) : rowSell; } else { const qtyPerPendant = parseFloat(entry.qty_per_pendant) || 1; entry.sell_price = qtyPerPendant > 0 ? round2(rowSell / qtyPerPendant) : rowSell; } this._syncLegacyAttachments(this._wizardData); this._renderStep(); }, _renderSourcePicker(type, data) { // Used only for packaging in step 5 return this._renderManualPicker(type, data); }, _bindStep4() {}, _setSource(type, source) { // Initialize packaging object if null if (type === 'packaging' && !this._wizardData.packaging) { this._wizardData.packaging = { source: 'warehouse', name: '', price_per_unit: 0, delivery_price: 0, assembly_speed: 0, warehouse_item_id: null }; } this._wizardData[type].source = source; this._renderStep(); }, _updateField(type, field, value) { // Initialize packaging object if null if (type === 'packaging' && !this._wizardData.packaging) { this._wizardData.packaging = { source: 'warehouse', name: '', price_per_unit: 0, delivery_price: 0, assembly_speed: 0, warehouse_item_id: null }; } this._wizardData[type][field] = value; }, // --- STEP 5: Summary --- // Find letter blank pricing from enriched Molds or App.templates _getLetterBlankTier(totalElements) { if (typeof getPendantLetterBlankMetrics === 'function') { const metrics = getPendantLetterBlankMetrics(totalElements, App.params, this._wizardData); if (metrics) { return { cost: metrics.cost || 0, sellPrice: metrics.sellPrice || 0, margin: metrics.margin || 0, tierQty: metrics.tierQty, }; } } if (!totalElements || totalElements <= 0) return null; const LETTER_BLANK_IDS = [30, 31]; const TIERS = [10, 50, 100, 300, 500, 1000, 3000]; const resolveDefaultBlankMargin = (qty) => { if (typeof getBlankMargin === 'function') return getBlankMargin(qty); if (qty <= 10) return 0.65; if (qty <= 50) return 0.60; if (qty <= 100) return 0.55; if (qty <= 300) return 0.50; if (qty <= 500) return 0.45; if (qty <= 1000) return 0.40; return 0.35; }; const roundPriceTo5 = (value) => { if (typeof roundTo5 === 'function') return roundTo5(value); return Math.round(value / 5) * 5; }; // Find closest tier (round up to next tier) let tierQty = TIERS[TIERS.length - 1]; for (const t of TIERS) { if (totalElements <= t) { tierQty = t; break; } } // Try enriched Molds first (has full cost calculation) if (typeof Molds !== 'undefined' && Molds.allMolds?.length) { const mold = Molds.allMolds.find(m => LETTER_BLANK_IDS.includes(Number(m.id))); if (mold?.tiers?.[tierQty]) { const tier = mold.tiers[tierQty]; return { cost: tier.cost || 0, sellPrice: tier.sellPrice || 0, margin: tier.margin || 0, tierQty }; } } // Fallback: use App.templates (always available after login) const tpl = (App.templates || []).find(t => LETTER_BLANK_IDS.includes(Number(t.id))); if (!tpl) return null; const customPrice = Number(tpl.custom_prices?.[tierQty]); // Compute cost like enrichMolds: calculateItemCost + mold amortization + hw const params = App.params; if (!params) { return Number.isFinite(customPrice) && customPrice > 0 ? { cost: 0, sellPrice: customPrice, margin: 0, tierQty } : null; } const pph = tpl.pieces_per_hour_avg || tpl.pieces_per_hour_min || 100; const weight = tpl.weight_grams || 5; const moldCount = tpl.mold_count || 1; const singleMoldCost = (tpl.cost_cny ?? 800) * (tpl.cny_rate ?? 12.5) + (tpl.delivery_cost ?? 3000); const moldTotalCost = singleMoldCost * moldCount; const MOLD_MAX_LIFETIME = 4500; const moldAmortPerUnit = moldTotalCost / MOLD_MAX_LIFETIME; // Simplified item cost calc (plastic + labor + indirect + mold amort) const baseQtyForCost = 50; const item = { quantity: baseQtyForCost, pieces_per_hour: pph, weight_grams: weight, extra_molds: 0, complex_design: false, is_nfc: false, nfc_programming: false, hardware_qty: 0, packaging_qty: 0, printing_qty: 0, delivery_included: false, builtin_assembly_name: tpl.builtin_assembly_name || '', builtin_assembly_speed: Number(tpl.builtin_assembly_speed || 0), }; let cost = 0; if (typeof calculateItemCost === 'function') { const result = calculateItemCost(item, params); cost = result.costTotal - result.costMoldAmortization + moldAmortPerUnit; } // Add built-in hw cost (assembly) if (tpl.hw_name && (tpl.hw_price_per_unit > 0 || tpl.hw_speed > 0)) { let hwCost = tpl.hw_price_per_unit + (tpl.hw_delivery_total ? tpl.hw_delivery_total / baseQtyForCost : 0); if (tpl.hw_speed > 0) { const hwHours = baseQtyForCost / tpl.hw_speed * (params.wasteFactor || 1.1); hwCost += hwHours * params.fotPerHour / baseQtyForCost; if (params.indirectCostMode === 'all') { hwCost += params.indirectPerHour * hwHours / baseQtyForCost; } } cost += hwCost; } if (Number(tpl.builtin_assembly_speed || 0) > 0) { const assemblyHours = baseQtyForCost / Number(tpl.builtin_assembly_speed || 0) * (params.wasteFactor || 1.1); let assemblyCost = assemblyHours * params.fotPerHour / baseQtyForCost; if (params.indirectCostMode === 'all') { assemblyCost += params.indirectPerHour * assemblyHours / baseQtyForCost; } cost += assemblyCost; } cost = round2(cost); let sellPrice = Number.isFinite(customPrice) && customPrice > 0 ? customPrice : 0; let targetMargin = 0; if (sellPrice <= 0 && cost > 0) { const customMargin = Number(tpl.custom_margins?.[tierQty]); targetMargin = Number.isFinite(customMargin) ? customMargin : resolveDefaultBlankMargin(tierQty); const keepRateForTarget = typeof getKeepRateForTargetMargin === 'function' ? getKeepRateForTargetMargin(params, targetMargin) : (() => { const taxRate = Number.isFinite(params.taxRate) ? params.taxRate : 0.07; const charityRate = Number.isFinite(params.charityRate) ? params.charityRate : 0.01; const retention = 1 - taxRate - 0.065 - charityRate; return retention - targetMargin; })(); if (keepRateForTarget > 0 && targetMargin < 1) { sellPrice = roundPriceTo5(round2(cost / keepRateForTarget)); } } const keepNetRate = typeof getNetRevenueRetentionRate === 'function' ? getNetRevenueRetentionRate(params) : 1 - (Number.isFinite(params.taxRate) ? params.taxRate : 0.07) - 0.065 - (Number.isFinite(params.charityRate) ? params.charityRate : 0.01); const margin = sellPrice > 0 ? round2(((sellPrice * keepNetRate) - cost) / sellPrice) : targetMargin; return { cost, sellPrice, margin, tierQty }; }, _calcElementCost(pnd) { const totalElements = this._countableElements(pnd.elements).length * (pnd.quantity || 0); const tier = this._getLetterBlankTier(totalElements); return tier ? tier.cost : 3; // fallback ~3₽ }, _calcAutoElementPrice(pnd) { const totalElements = this._countableElements(pnd.elements).length * (pnd.quantity || 0); const tier = this._getLetterBlankTier(totalElements); return tier ? tier.sellPrice : null; }, _renderStep5() { const pnd = this._wizardData; this._readCurrentStep(); this._commitName(pnd.name); const elements = this._countableElements(pnd.elements); const elemCount = elements.length; const qty = pnd.quantity || 0; const totalElements = elemCount * qty; // Auto-calculate element prices from blanks catalog const tier = this._getLetterBlankTier(totalElements); const elemCostPerUnit = tier ? tier.cost : 3; const autoElemSell = tier ? tier.sellPrice : null; // Initialize per-element sell_price if not set elements.forEach(el => { const currentSell = parseFloat(el.sell_price); const shouldAutoFill = el.sell_price_auto !== false && (autoElemSell || 0) > 0; if (shouldAutoFill) { el.sell_price = autoElemSell; el.sell_price_auto = true; } else if (!Number.isFinite(currentSell)) { el.sell_price = 0; } }); // Group elements by color const groups = {}; elements.forEach((el, i) => { const key = el.color || 'без цвета'; if (!groups[key]) groups[key] = { chars: [], indices: [], sell: el.sell_price || 0 }; groups[key].chars.push(el.char); groups[key].indices.push(i); groups[key].sell = el.sell_price || 0; }); const cords = this._getAttachments(pnd, 'cord'); const carabiners = this._getAttachments(pnd, 'carabiner'); cords.forEach(entry => this._ensureAttachmentSellPrice('cord', entry)); carabiners.forEach(entry => this._ensureAttachmentSellPrice('carabiner', entry)); const cordRows = cords.map((entry, index) => { const isMetric = this._isMetricAttachment('cord', entry); const allocatedQty = this._getAttachmentAllocatedQty(entry, pnd); const lengthCm = parseFloat(entry.length_cm) || 0; const qtyPerPendant = parseFloat(entry.qty_per_pendant) || 1; const purchasePer = typeof getPendantAttachmentPurchasePerUnit === 'function' ? getPendantAttachmentPurchasePerUnit('cord', entry) : 0; const deliveryPer = typeof getPendantAttachmentDeliveryPerUnit === 'function' ? getPendantAttachmentDeliveryPerUnit('cord', entry) : 0; const assemblyPer = typeof getPendantAttachmentAssemblyCostPerUnit === 'function' ? getPendantAttachmentAssemblyCostPerUnit('cord', entry, App.params || {}) : 0; const indirectPer = typeof getPendantAttachmentIndirectPerUnit === 'function' ? getPendantAttachmentIndirectPerUnit('cord', entry, App.params || {}) : 0; const costPer = round2(purchasePer + deliveryPer + assemblyPer + indirectPer); const sellPer = this._getAttachmentSellPerPendant('cord', entry); const totalQtyLabel = isMetric ? `${round2(lengthCm * allocatedQty / 100)} м${allocatedQty > 0 ? ` · ${allocatedQty} подв.` : ''}` : `${round2(allocatedQty * qtyPerPendant)} шт${allocatedQty > 0 ? ` · ${allocatedQty} подв.` : ''}`; const titleSuffix = isMetric && lengthCm > 0 ? ` (${lengthCm} см/подвес)` : (!isMetric && qtyPerPendant > 1 ? ` × ${qtyPerPendant}` : ''); return { index, title: `🧵 ${App.escHtml(entry.name || 'Шнур')}${titleSuffix}`, qtyLabel: totalQtyLabel, costPer, sellPer, breakdownHint: [purchasePer > 0 ? `закупка ${formatRub(purchasePer)}` : '', deliveryPer > 0 ? `доставка ${formatRub(deliveryPer)}` : '', assemblyPer > 0 ? `сборка ${formatRub(assemblyPer)}` : '', indirectPer > 0 ? `косвенные ${formatRub(indirectPer)}` : ''].filter(Boolean).join(' · '), totalCostValue: round2(allocatedQty * costPer), totalSellValue: round2(allocatedQty * sellPer), totalSell: formatRub(round2(allocatedQty * sellPer)), }; }); const carabinerRows = carabiners.map((entry, index) => { const allocatedQty = this._getAttachmentAllocatedQty(entry, pnd); const qtyPerPendant = parseFloat(entry.qty_per_pendant) || 1; const purchasePer = typeof getPendantAttachmentPurchasePerUnit === 'function' ? getPendantAttachmentPurchasePerUnit('carabiner', entry) : 0; const deliveryPer = typeof getPendantAttachmentDeliveryPerUnit === 'function' ? getPendantAttachmentDeliveryPerUnit('carabiner', entry) : 0; const assemblyPer = typeof getPendantAttachmentAssemblyCostPerUnit === 'function' ? getPendantAttachmentAssemblyCostPerUnit('carabiner', entry, App.params || {}) : 0; const indirectPer = typeof getPendantAttachmentIndirectPerUnit === 'function' ? getPendantAttachmentIndirectPerUnit('carabiner', entry, App.params || {}) : 0; const costPer = round2(purchasePer + deliveryPer + assemblyPer + indirectPer); const sellPer = this._getAttachmentSellPerPendant('carabiner', entry); const titleSuffix = qtyPerPendant > 1 ? ` × ${qtyPerPendant}` : ''; return { index, title: `🔗 ${App.escHtml(entry.name || 'Фурнитура')}${titleSuffix}`, qtyLabel: `${round2(allocatedQty * qtyPerPendant)} шт${allocatedQty > 0 ? ` · ${allocatedQty} подв.` : ''}`, costPer, sellPer, breakdownHint: [purchasePer > 0 ? `закупка ${formatRub(purchasePer)}` : '', deliveryPer > 0 ? `доставка ${formatRub(deliveryPer)}` : '', assemblyPer > 0 ? `сборка ${formatRub(assemblyPer)}` : '', indirectPer > 0 ? `косвенные ${formatRub(indirectPer)}` : ''].filter(Boolean).join(' · '), totalCostValue: round2(allocatedQty * costPer), totalSellValue: round2(allocatedQty * sellPer), totalSell: formatRub(round2(allocatedQty * sellPer)), }; }); // Print — sell price via 40% net margin, editable per-element let printCostPerUnit = 0; let printSellPerUnit = 0; elements.forEach(el => { if (el.has_print && el.print_price) { printCostPerUnit += el.print_price; // Use stored sell_print if set, otherwise auto-calculate rounded if (!el.sell_print && typeof calcSellByNetMargin40 === 'function') { el.sell_print = Math.round(calcSellByNetMargin40(el.print_price, App.params)); } printSellPerUnit += (el.sell_print || el.print_price); } }); // Totals let totalElemSell = 0; elements.forEach(el => { totalElemSell += (el.sell_price || 0); }); const totalCordCostAll = round2(cordRows.reduce((sum, row) => sum + row.totalCostValue, 0)); const totalCordSellAll = round2(cordRows.reduce((sum, row) => sum + row.totalSellValue, 0)); const totalCarabinerCostAll = round2(carabinerRows.reduce((sum, row) => sum + row.totalCostValue, 0)); const totalCarabinerSellAll = round2(carabinerRows.reduce((sum, row) => sum + row.totalSellValue, 0)); const totalCostAll = round2((qty * elemCount * elemCostPerUnit) + totalCordCostAll + totalCarabinerCostAll + (printCostPerUnit * qty)); const totalSellAll = round2((qty * totalElemSell) + totalCordSellAll + totalCarabinerSellAll + (printSellPerUnit * qty)); const totalCostPerUnit = qty > 0 ? round2(totalCostAll / qty) : 0; const totalSellPerUnit = qty > 0 ? round2(totalSellAll / qty) : 0; const vatRate = Number.isFinite(App?.params?.vatRate) ? App.params.vatRate : 0.05; const vatAmount = round2(totalSellAll * vatRate); const totalSellWithVat = round2(totalSellAll + vatAmount); const _keepNetRate = typeof getNetRevenueRetentionRate === 'function' ? getNetRevenueRetentionRate(App?.params || {}) : 1 - (Number.isFinite(App?.params?.taxRate) ? App.params.taxRate : 0.07) - 0.065 - (Number.isFinite(App?.params?.charityRate) ? App.params.charityRate : 0.01); const finalMargin = typeof calculateActualMargin === 'function' ? calculateActualMargin(totalSellAll, totalCostAll) : { percent: totalSellAll > 0 ? round2(((totalSellAll * _keepNetRate) - totalCostAll) / totalSellAll * 100) : 0, }; const finalMarginPercent = finalMargin.percent ?? 0; const vatLabel = `+${formatPercent(vatRate * 100)} НДС`; // Update pnd for calculator engine pnd.element_price_per_unit = elemCostPerUnit; pnd._elemSellTotal = totalElemSell; pnd._totalSellPerUnit = totalSellPerUnit; pnd.sell_price_override = null; pnd.packaging = null; this._syncLegacyAttachments(pnd); // Helper: margin % between cost and sell const marginPct = (cost, sell) => { if (!sell || sell <= 0) return ''; const m = round2(((sell * _keepNetRate) - cost) / sell * 100); return `
маржа ${m}%
`; }; const groupEntries = Object.entries(groups); const inputStyle = 'width:75px;font-size:12px;padding:3px 6px;'; return `

Подвес "${App.escHtml(pnd.name)}" × ${qty} шт

${groupEntries.map(([color, g], gi) => { const gQty = g.chars.length * qty; const gSellTotal = round2(g.chars.length * qty * g.sell); return ` `; }).join('')} ${cordRows.map(row => ``).join('')} ${carabinerRows.map(row => ``).join('')} ${printCostPerUnit > 0 ? `` : ''}
ПозицияКол-воСебест.ПродажаИтого
🔤 ${App.escHtml(g.chars.join(', '))} (${App.escHtml(color)}) ${gQty} ${formatRub(elemCostPerUnit)}${marginPct(elemCostPerUnit, g.sell)} ${formatRub(gSellTotal)}
${row.title} ${row.qtyLabel} ${formatRub(row.costPer)}${marginPct(row.costPer, row.sellPer)}${row.breakdownHint ? `
${row.breakdownHint}
` : ''}
${row.totalSell}
${row.title} ${row.qtyLabel} ${formatRub(row.costPer)}${marginPct(row.costPer, row.sellPer)}${row.breakdownHint ? `
${row.breakdownHint}
` : ''}
${row.totalSell}
🖨 Печать (${elements.filter(e => e.has_print).map(e => e.char).join(', ')}) ${qty} ${formatRub(printCostPerUnit)}${marginPct(printCostPerUnit, printSellPerUnit)} ${formatRub(qty * printSellPerUnit)}
Итого за подвес ${formatRub(totalCostPerUnit)} ${formatRub(totalSellPerUnit)} ${formatRub(totalSellAll)}
${vatLabel} ${formatRub(vatAmount)}
Итого с НДС ${formatRub(totalCostAll)} ${formatRub(totalSellWithVat)}
Маржа без НДС ${finalMarginPercent}%
`; }, _setGroupSellPrice(groupIndex, price) { const pnd = this._wizardData; const groups = {}; const groupOrder = []; (pnd.elements || []).forEach((el, i) => { const key = el.color || 'без цвета'; if (!groups[key]) { groups[key] = []; groupOrder.push(key); } groups[key].push(i); }); const key = groupOrder[groupIndex]; if (key && groups[key]) { groups[key].forEach(i => { pnd.elements[i].sell_price = price; pnd.elements[i].sell_price_auto = !(price > 0); }); } this._renderStep(); }, _setPrintSellPrice(totalSellPrice) { const pnd = this._wizardData; const printElems = (pnd.elements || []).filter(el => el.has_print); if (printElems.length === 0) return; // Distribute evenly across print elements const perElem = Math.round(totalSellPrice / printElems.length); printElems.forEach(el => { el.sell_print = perElem; }); this._renderStep(); }, // ========================================== // READ + SAVE // ========================================== _validateAttachmentDistributions() { const pnd = this._wizardData; const totalQty = parseInt(pnd?.quantity, 10) || 0; if (!(totalQty > 0)) return true; const checks = [ { type: 'cord', label: 'Шнур' }, { type: 'carabiner', label: 'Фурнитура' }, ]; for (const check of checks) { const entries = this._getAttachments(pnd, check.type); if (entries.length === 0) continue; const allocatedQty = this._getAttachmentAllocatedTotal(check.type, pnd, { entries }); if (allocatedQty > totalQty) { App.toast(`${check.label}: распределено на ${allocatedQty - totalQty} шт больше тиража`); return false; } if (allocatedQty < totalQty) { App.toast(`${check.label}: распределите ещё ${totalQty - allocatedQty} шт`); return false; } } return true; }, _readCurrentStep() { const pnd = this._wizardData; if (this._wizardStep === 1) { pnd.quantity = parseInt(document.getElementById('pw-qty')?.value) || 0; const rawName = document.getElementById('pw-name')?.value || ''; this._commitName(rawName); } }, _savePendant() { this._readCurrentStep(); const pnd = this._wizardData; this._commitName(pnd.name); this._ensureAttachmentCollections(pnd); if (!pnd.name) { App.toast('Введите надпись'); return; } if (!pnd.quantity || pnd.quantity <= 0) { App.toast('Введите количество'); return; } if (!this._validateAttachmentDistributions()) return; // sell_price_override and packaging are no longer used pnd.sell_price_override = null; pnd.packaging = null; pnd.cords = this._getAttachments(pnd, 'cord'); pnd.carabiners = this._getAttachments(pnd, 'carabiner'); this._syncLegacyAttachments(pnd); if (this._editingIndex !== null) { Calculator.pendants[this._editingIndex] = pnd; } else { Calculator.pendants.push(pnd); } this._closeWizard(); this.renderAllCards(); Calculator.recalculate(); Calculator.scheduleAutosave(); }, };