// ============================================= // Recycle Object — B2C продажи // Сборка наборов и расчёт цены продажи (1 шт) // ============================================= const Marketplaces = { allSets: [], editingSetId: null, _plasticBlanks: [], // enriched molds _allWarehouseHw: [], // warehouse items (all except packaging) _allWarehousePkg: [], // warehouse items (packaging only) _hwCatalog: [], // hw_blanks catalog _pkgCatalog: [], // pkg_blanks catalog _colors: [], // colors directory // Current set items being edited _plasticItems: [], _hwItems: [], _pkgItems: [], _productionSelection: [], _pendingPhoto: '', _colorVariants: [], _inlineSaveTimers: {}, _inlineSaveSeq: {}, DEFAULT_PACKAGING_COST: 80, DEFAULT_PACKAGING_SPEED_PER_HOUR: 60, B2C_PRICING_VERSION: 3, async load() { try { const [sets, plasticBlanks, hwCatalog, pkgCatalog, warehouseItems, colors] = await Promise.all([ loadMarketplaceSets(), loadMolds(), loadHwBlanks(), loadPkgBlanks(), loadWarehouseItems(), loadColors(), ]); this.allSets = (sets || []).map(s => this._normalizeMarketplaceSet(s)); this._plasticBlanks = plasticBlanks.filter(m => m.status === 'active'); this._hwCatalog = hwCatalog; this._pkgCatalog = pkgCatalog; this._colors = colors || []; // Split warehouse by category this._allWarehouseHw = (warehouseItems || []).filter(i => i.category !== 'packaging'); this._allWarehousePkg = (warehouseItems || []).filter(i => i.category === 'packaging'); this._enrichPlasticBlanks(); const migratedSets = this._migrateLegacySets(this.allSets); this.allSets = migratedSets.allSets; if (migratedSets.changedSets.length) { await Promise.allSettled(migratedSets.changedSets.map(set => saveMarketplaceSet(set))); } this.renderStats(); this.renderSets(); } catch(e) { console.error('Marketplaces.load error:', e); } }, renderStats() { const total = this.allSets.length; const costs = this.allSets .map(s => this._safeNumber(s.total_cost, this._calcSetBreakdown(s).totalCost)) .filter(c => c > 0); const prices = this.allSets .map(s => this._safeNumber( s.mp_actual_price || s.selling_price || s.mp_suggested_price, this._getSuggestedChannelPrice(this._calcSetBreakdown(s).totalCost, s, 'marketplace') )) .filter(p => p > 0); const shopPrices = this.allSets .map(s => this._safeNumber( s.shop_actual_price || s.shop_suggested_price, this._getSuggestedChannelPrice(this._calcSetBreakdown(s).totalCost, s, 'shop') )) .filter(p => p > 0); document.getElementById('mp-total-sets').textContent = total; document.getElementById('mp-avg-cost').textContent = costs.length ? formatRub(round2(costs.reduce((a,b) => a+b, 0) / costs.length)) : '0 ₽'; document.getElementById('mp-avg-price').textContent = prices.length ? formatRub(round2(prices.reduce((a,b) => a+b, 0) / prices.length)) : '0 ₽'; const shopEl = document.getElementById('mp-avg-shop-price'); if (shopEl) shopEl.textContent = shopPrices.length ? formatRub(round2(shopPrices.reduce((a,b) => a+b, 0) / shopPrices.length)) : '0 ₽'; }, _safeNumber(value, fallback = 0) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : fallback; }, _roundSuggestedPrice(value) { const numeric = this._safeNumber(value, 0); if (numeric <= 0) return 0; return Math.ceil(numeric / 10) * 10; }, _parseInlinePrice(value, fallback = 0) { const raw = String(value ?? '').trim().replace(',', '.'); if (!raw) return fallback; const numeric = Number(raw); return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback; }, _marginTone(marginPct) { if (marginPct >= 40) return 'var(--green)'; if (marginPct >= 20) return 'var(--yellow)'; return 'var(--red)'; }, _findSetIndex(setId) { const id = String(setId); return this.allSets.findIndex(set => String(set.id) === id); }, _normalizeChannelRates(source = {}) { const commissionPct = this._safeNumber(source.commission, 46); const vatPct = this._safeNumber(source.vat, 5); const taxPct = this._safeNumber(source.osn, 12); const charityPct = this._safeNumber(source.charity, 1); const commercialPct = this._safeNumber(source.commercial, 6.5); const acquiringPct = this._safeNumber(source.acquiring, 5); const targetMarginPct = this._safeNumber(source.target_margin, 40); return { commissionPct, vatPct, taxPct, charityPct, commercialPct, acquiringPct, targetMarginPct, }; }, _calcChannelResult(totalCost, price, rates, channel) { const cleanPrice = this._safeNumber(price, 0); const cleanCost = this._safeNumber(totalCost, 0); const { commissionPct, vatPct, taxPct, charityPct, commercialPct, acquiringPct, } = this._normalizeChannelRates(rates); const deductionPcts = channel === 'marketplace' ? [commissionPct, vatPct, taxPct, charityPct, commercialPct] : [vatPct, taxPct, charityPct, commercialPct, acquiringPct]; const keepFactor = deductionPcts.reduce((acc, pct) => acc * (1 - Math.max(0, pct) / 100), 1); const cleanInflow = round2(cleanPrice * Math.max(keepFactor, 0)); const cleanProfit = round2(cleanInflow - cleanCost); const cleanMarginPct = cleanInflow > 0 ? round2(cleanProfit * 100 / cleanInflow) : 0; return { keepFactor, cleanInflow, cleanProfit, cleanMarginPct, }; }, _getSuggestedChannelPrice(totalCost, rates, channel) { const { targetMarginPct, } = this._normalizeChannelRates(rates); const baseResult = this._calcChannelResult(totalCost, 1, rates, channel); const keepFactor = baseResult.keepFactor * (1 - Math.max(0, targetMarginPct) / 100); if (!(keepFactor > 0) || !(totalCost > 0)) return 0; return this._roundSuggestedPrice(totalCost / keepFactor); }, _getCurrentPricingDefaults() { return { commission: 46, vat: 5, osn: 12, charity: 1, commercial: 6.5, acquiring: 5, target_margin: 40, default_packaging_enabled: true, marketplace_pricing_version: this.B2C_PRICING_VERSION, }; }, _ratesEqual(left, right) { return Math.abs(this._safeNumber(left, 0) - this._safeNumber(right, 0)) < 0.0001; }, _needsLegacySetMigration(set) { if (!set || typeof set !== 'object') return false; const defaults = this._getCurrentPricingDefaults(); const version = this._safeNumber(set.marketplace_pricing_version, 0); const missingPrices = !( this._safeNumber(set.mp_actual_price, 0) > 0 && this._safeNumber(set.shop_actual_price, 0) > 0 && this._safeNumber(set.mp_suggested_price, 0) > 0 && this._safeNumber(set.shop_suggested_price, 0) > 0 ); const missingCost = !(this._safeNumber(set.total_cost, 0) > 0); const staleRates = !this._ratesEqual(set.commission, defaults.commission) || !this._ratesEqual(set.vat, defaults.vat) || !this._ratesEqual(set.osn, defaults.osn) || !this._ratesEqual(set.charity, defaults.charity) || !this._ratesEqual(set.commercial, defaults.commercial) || !this._ratesEqual(set.acquiring, defaults.acquiring) || !this._ratesEqual(set.target_margin, defaults.target_margin); const packagingMismatch = this._isDefaultPackagingEnabled(set) !== true; return version < this.B2C_PRICING_VERSION || missingPrices || missingCost || staleRates || packagingMismatch; }, _buildAutoPricedSet(set) { const defaults = this._getCurrentPricingDefaults(); const normalizedSet = this._normalizeMarketplaceSet({ ...set, ...defaults, default_packaging_enabled: true, }); const breakdown = this._calcSetBreakdown(normalizedSet); const totalCost = breakdown.totalCost; const suggestedMpPrice = this._getSuggestedChannelPrice(totalCost, normalizedSet, 'marketplace'); const suggestedShopPrice = this._getSuggestedChannelPrice(totalCost, normalizedSet, 'shop'); const mpSummary = this._calcChannelResult(totalCost, suggestedMpPrice, normalizedSet, 'marketplace'); const shopSummary = this._calcChannelResult(totalCost, suggestedShopPrice, normalizedSet, 'shop'); return { ...normalizedSet, ...defaults, default_packaging_enabled: true, total_cost: totalCost, selling_price: suggestedMpPrice, mp_suggested_price: suggestedMpPrice, mp_actual_price: suggestedMpPrice, shop_suggested_price: suggestedShopPrice, shop_actual_price: suggestedShopPrice, mp_margin_actual: mpSummary.cleanMarginPct, shop_margin_actual: shopSummary.cleanMarginPct, actual_margin: mpSummary.cleanMarginPct, }; }, _migrateLegacySets(sets) { const changedSets = []; const allSets = (sets || []).map(set => { if (!this._needsLegacySetMigration(set)) return set; const migrated = this._buildAutoPricedSet(set); changedSets.push(migrated); return migrated; }); return { allSets, changedSets }; }, _isDefaultPackagingEnabled(source = null) { if (!source || typeof source !== 'object') return false; if (Object.prototype.hasOwnProperty.call(source, 'default_packaging_enabled')) { return !!source.default_packaging_enabled; } return true; }, _getDefaultPackagingConfig(source = null) { const isFormSource = !source || source === 'form'; const enabled = isFormSource ? !!document.getElementById('mp-set-default-packaging-enabled')?.checked : this._isDefaultPackagingEnabled(source); return { enabled, costPerUnit: this.DEFAULT_PACKAGING_COST, assemblySpeed: this.DEFAULT_PACKAGING_SPEED_PER_HOUR, name: 'Дефолтная упаковка B2C', }; }, _hasCustomPackagingItems(source = null) { if (!source || typeof source !== 'object') return false; return Array.isArray(source.pkg_items) && source.pkg_items.some(item => { const qty = this._safeNumber(item?.qty, 0); return qty > 0; }); }, _shouldApplyDefaultPackaging(source = null) { const defaultPackaging = this._getDefaultPackagingConfig(source); if (!defaultPackaging.enabled) return false; return !this._hasCustomPackagingItems(source); }, // ========================================== // TABLE VIEW — photo, name, cost, MP price // ========================================== renderSets() { const container = document.getElementById('mp-sets-container'); if (!this.allSets.length) { container.innerHTML = '

Нет наборов. Нажмите «+ Новый набор» чтобы создать.

'; return; } let html = ''; this.allSets.forEach(s => { const bd = this._calcSetBreakdown(s); const cost = bd.totalCost; const suggestedMpPrice = this._getSuggestedChannelPrice(cost, s, 'marketplace'); const suggestedShopPrice = this._getSuggestedChannelPrice(cost, s, 'shop'); const mpPrice = this._safeNumber(s.mp_actual_price || s.selling_price || s.mp_suggested_price, suggestedMpPrice); const shopPrice = this._safeNumber(s.shop_actual_price || s.shop_suggested_price, suggestedShopPrice); const mpMargin = this._calcChannelResult(cost, mpPrice, s, 'marketplace').cleanMarginPct; const shopMargin = this._calcChannelResult(cost, shopPrice, s, 'shop').cleanMarginPct; const statusId = `mp-inline-status-${s.id}`; const mpInputId = `mp-inline-price-${s.id}`; const shopInputId = `mp-inline-shop-price-${s.id}`; const mpMarginId = `mp-inline-margin-${s.id}`; const shopMarginId = `mp-inline-shop-margin-${s.id}`; const mpHintId = `mp-inline-hint-${s.id}`; const shopHintId = `mp-inline-shop-hint-${s.id}`; // Composition const parts = []; (s.plastic_items || []).forEach(i => parts.push(i.name || 'Пластик')); (s.hw_items || []).forEach(i => parts.push(i.name || 'Фурнитура')); (s.pkg_items || []).forEach(i => parts.push(i.name || 'Упаковка')); if (this._shouldApplyDefaultPackaging(s)) parts.push('Дефолтная упаковка'); const photo = s.photo_url ? `` : `${(s.name||'?')[0].toUpperCase()}`; // Cost breakdown lines const breakdownParts = []; if (bd.castingCost > 0) breakdownParts.push('Выливание (материал+молд) ' + formatRub(bd.castingCost)); if (bd.hwMaterialCost > 0) breakdownParts.push('Фурнитура (материалы) ' + formatRub(bd.hwMaterialCost)); if (bd.pkgMaterialCost > 0) breakdownParts.push('Упаковка (материалы) ' + formatRub(bd.pkgMaterialCost)); if (bd.fotCost > 0) breakdownParts.push('ФОТ выливания ' + formatRub(bd.fotCost)); if (bd.assemblyCost > 0) breakdownParts.push('ФОТ сборки ' + formatRub(bd.assemblyCost)); if (bd.indirectCastingCost > 0) breakdownParts.push('Косвенные выливания ' + formatRub(bd.indirectCastingCost)); if (bd.indirectAssemblyCost > 0) breakdownParts.push('Косвенные сборки ' + formatRub(bd.indirectAssemblyCost)); html += `
${photo}
${this._esc(s.name || 'Набор')}
${parts.join(' + ')}
Себестоимость: ${formatRub(cost)}
${Array.isArray(s.color_variants) && s.color_variants.length > 0 ? `
Цвета: ${s.color_variants.map(v => { const vPhoto = v.photo_url ? `` : ''; const vPlaceholder = `${(v.name||'?')[0]}`; const vName = v.name || (v.assignments || []).map(a => a.color_name).filter(Boolean).join('+') || '?'; return `
${vPhoto}${vPlaceholder} ${this._esc(vName)}
`; }).join('')}
` : ''} ${breakdownParts.length > 0 ? `
${breakdownParts.join(' · ')}
` : ''}
Маркетплейс
чистая маржа ${round2(mpMargin)}%
авто ${formatRub(suggestedMpPrice)}
Интернет-магазин
чистая маржа ${round2(shopMargin)}%
авто ${formatRub(suggestedShopPrice)}
`; }); container.innerHTML = html; }, _setInlinePriceStatus(setId, text = '', color = 'var(--text-muted)') { const statusEl = document.getElementById(`mp-inline-status-${setId}`); if (!statusEl) return; statusEl.textContent = text; statusEl.style.color = color; }, _updateInlinePriceCard(setId) { const idx = this._findSetIndex(setId); if (idx < 0) return null; const set = this.allSets[idx]; const totalCost = this._calcSetBreakdown(set).totalCost; const suggestedMpPrice = this._getSuggestedChannelPrice(totalCost, set, 'marketplace'); const suggestedShopPrice = this._getSuggestedChannelPrice(totalCost, set, 'shop'); const mpInput = document.getElementById(`mp-inline-price-${setId}`); const shopInput = document.getElementById(`mp-inline-shop-price-${setId}`); const mpPrice = this._parseInlinePrice( mpInput?.value, this._safeNumber(set.mp_actual_price || set.selling_price || set.mp_suggested_price, suggestedMpPrice) ); const shopPrice = this._parseInlinePrice( shopInput?.value, this._safeNumber(set.shop_actual_price || set.shop_suggested_price, suggestedShopPrice) ); const mpSummary = this._calcChannelResult(totalCost, mpPrice, set, 'marketplace'); const shopSummary = this._calcChannelResult(totalCost, shopPrice, set, 'shop'); const mpMarginEl = document.getElementById(`mp-inline-margin-${setId}`); if (mpMarginEl) { mpMarginEl.textContent = `чистая маржа ${round2(mpSummary.cleanMarginPct)}%`; mpMarginEl.style.color = this._marginTone(mpSummary.cleanMarginPct); } const shopMarginEl = document.getElementById(`mp-inline-shop-margin-${setId}`); if (shopMarginEl) { shopMarginEl.textContent = `чистая маржа ${round2(shopSummary.cleanMarginPct)}%`; shopMarginEl.style.color = this._marginTone(shopSummary.cleanMarginPct); } const mpHintEl = document.getElementById(`mp-inline-hint-${setId}`); if (mpHintEl) mpHintEl.textContent = `авто ${formatRub(suggestedMpPrice)}`; const shopHintEl = document.getElementById(`mp-inline-shop-hint-${setId}`); if (shopHintEl) shopHintEl.textContent = `авто ${formatRub(suggestedShopPrice)}`; this.allSets[idx] = { ...set, total_cost: totalCost, mp_suggested_price: suggestedMpPrice, shop_suggested_price: suggestedShopPrice, mp_actual_price: mpPrice, shop_actual_price: shopPrice, selling_price: mpPrice, mp_margin_actual: mpSummary.cleanMarginPct, shop_margin_actual: shopSummary.cleanMarginPct, actual_margin: mpSummary.cleanMarginPct, marketplace_pricing_version: this.B2C_PRICING_VERSION, }; this.renderStats(); return this.allSets[idx]; }, onInlinePriceInput(setId) { this._updateInlinePriceCard(setId); this._setInlinePriceStatus(setId, 'сохраняем…'); clearTimeout(this._inlineSaveTimers[setId]); this._inlineSaveTimers[setId] = setTimeout(() => { this.saveInlinePrices(setId); }, 500); }, handleInlinePriceKeydown(event, setId) { if (event.key === 'Enter') { event.preventDefault(); clearTimeout(this._inlineSaveTimers[setId]); this.saveInlinePrices(setId); event.target.blur(); } if (event.key === 'Escape') { event.preventDefault(); this.renderSets(); this.renderStats(); } }, async saveInlinePrices(setId) { clearTimeout(this._inlineSaveTimers[setId]); const updatedSet = this._updateInlinePriceCard(setId); if (!updatedSet) return; const saveSeq = (this._inlineSaveSeq[setId] || 0) + 1; this._inlineSaveSeq[setId] = saveSeq; this._setInlinePriceStatus(setId, 'сохраняем…'); await saveMarketplaceSet(updatedSet); if (this._inlineSaveSeq[setId] !== saveSeq) return; this._setInlinePriceStatus(setId, 'сохранено', 'var(--green)'); setTimeout(() => { if (this._inlineSaveSeq[setId] === saveSeq) this._setInlinePriceStatus(setId, ''); }, 1400); }, // ========================================== // SET FORM // ========================================== showSetForm() { this.editingSetId = null; this._pendingPhoto = ''; document.getElementById('mp-form-title').textContent = 'Новый набор'; document.getElementById('mp-set-name').value = ''; document.getElementById('mp-set-commission').value = 46; document.getElementById('mp-set-vat').value = 5; document.getElementById('mp-set-osn').value = 12; document.getElementById('mp-set-charity').value = 1; document.getElementById('mp-set-commercial').value = 6.5; document.getElementById('mp-set-acquiring').value = 5; document.getElementById('mp-set-margin').value = 40; const defaultPackagingEl = document.getElementById('mp-set-default-packaging-enabled'); if (defaultPackagingEl) defaultPackagingEl.checked = true; document.getElementById('mp-set-price-manual').value = ''; document.getElementById('mp-set-shop-price-manual').value = ''; this._plasticItems = []; this._hwItems = []; this._pkgItems = []; this._colorVariants = []; this.hideProductionBuilder(); this._updatePhotoPreview(''); document.getElementById('mp-photo-file').value = ''; document.getElementById('mp-delete-btn').style.display = 'none'; document.getElementById('mp-set-form').style.display = ''; this.renderFormItems(); this.recalcSet(); document.getElementById('mp-set-form').scrollIntoView({ behavior: 'smooth' }); }, openSetEditor(id) { try { this.editSet(id); } catch (error) { console.error('Marketplaces.openSetEditor error:', error); App.toast('Не удалось открыть набор'); } }, editSet(id) { const s = this.allSets.find(x => x.id === id); if (!s) return; const normalizedSet = this._needsLegacySetMigration(s) ? this._buildAutoPricedSet(s) : this._normalizeMarketplaceSet(s); this.editingSetId = id; this._pendingPhoto = normalizedSet.photo_url || ''; document.getElementById('mp-form-title').textContent = 'Редактировать: ' + (normalizedSet.name || ''); document.getElementById('mp-set-name').value = normalizedSet.name || ''; document.getElementById('mp-set-commission').value = normalizedSet.commission || 46; document.getElementById('mp-set-vat').value = normalizedSet.vat || 5; document.getElementById('mp-set-osn').value = normalizedSet.osn || 12; document.getElementById('mp-set-charity').value = Number.isFinite(parseFloat(normalizedSet.charity)) ? normalizedSet.charity : 1; document.getElementById('mp-set-commercial').value = normalizedSet.commercial || 6.5; document.getElementById('mp-set-acquiring').value = normalizedSet.acquiring || 5; document.getElementById('mp-set-margin').value = normalizedSet.target_margin || 40; const defaultPackagingEl = document.getElementById('mp-set-default-packaging-enabled'); if (defaultPackagingEl) defaultPackagingEl.checked = this._isDefaultPackagingEnabled(normalizedSet); document.getElementById('mp-set-price-manual').value = normalizedSet.mp_actual_price || normalizedSet.selling_price || normalizedSet.mp_suggested_price || ''; document.getElementById('mp-set-shop-price-manual').value = normalizedSet.shop_actual_price || normalizedSet.shop_suggested_price || ''; this._plasticItems = (normalizedSet.plastic_items || []).map(i => ({ ...i, colors: Array.isArray(i.colors) ? i.colors : [] })); this._hwItems = (normalizedSet.hw_items || []).map(i => ({ ...i })); this._pkgItems = (normalizedSet.pkg_items || []).map(i => ({ ...i })); this._colorVariants = (normalizedSet.color_variants || []).map(v => ({ ...v, assignments: Array.isArray(v.assignments) ? v.assignments.map(a => ({...a})) : [] })); // Auto-migrate: if no variants but plastic items have old-style colors, create variant from them if (!this._colorVariants.length && this._plasticItems.some(pi => Array.isArray(pi.colors) && pi.colors.length > 0)) { const assignments = this._plasticItems.map(pi => { const c = (pi.colors || [])[0]; return c ? { color_id: c.id, color_number: c.number || '', color_name: c.name || '', color_photo: '' } : { color_id: null, color_number: '', color_name: '', color_photo: '' }; }); this._colorVariants = [{ id: Date.now(), name: this._plasticItems.map(pi => (pi.colors || []).map(c => c.name || '').join('+')).filter(Boolean).join(' / '), photo_url: '', assignments, }]; } this._updatePhotoPreview(normalizedSet.photo_url || ''); document.getElementById('mp-photo-file').value = ''; document.getElementById('mp-delete-btn').style.display = ''; document.getElementById('mp-set-form').style.display = ''; this.renderFormItems(); this.recalcSet(); document.getElementById('mp-set-form').scrollIntoView({ behavior: 'smooth' }); }, hideSetForm() { document.getElementById('mp-set-form').style.display = 'none'; this.editingSetId = null; }, // ========================================== // PHOTO // ========================================== onPhotoFileChange(input) { if (!input.files || !input.files[0]) return; const file = input.files[0]; if (file.size > 2 * 1024 * 1024) { App.toast('Макс 2MB'); return; } const reader = new FileReader(); reader.onload = (e) => { this._resizeImage(e.target.result, 800, (thumb) => { this._pendingPhoto = thumb; this._updatePhotoPreview(thumb); }); }; reader.readAsDataURL(file); }, _resizeImage(dataUrl, maxSize, callback) { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); let w = img.width, h = img.height; if (w > maxSize || h > maxSize) { if (w > h) { h = Math.round(h * maxSize / w); w = maxSize; } else { w = Math.round(w * maxSize / h); h = maxSize; } } canvas.width = w; canvas.height = h; canvas.getContext('2d').drawImage(img, 0, 0, w, h); callback(canvas.toDataURL('image/jpeg', 0.85)); }; img.src = dataUrl; }, _updatePhotoPreview(url) { const el = document.getElementById('mp-photo-preview'); if (!el) return; if (url) { el.innerHTML = ``; } else { el.innerHTML = '📷'; } }, // ========================================== // ITEM MANAGEMENT // ========================================== addPlasticItem() { this._plasticItems.push({ blank_id: null, qty: 1, name: '', cost: 0, color_notes: '', colors: [] }); this.renderFormItems(); }, addHwItem() { this._hwItems.push({ source: 'catalog', blank_id: null, wh_id: null, warehouse_sku: '', photo_thumbnail: '', qty: 1, unit: 'шт', name: '', cost_per_unit: 0, assembly_speed: 0 }); this.renderFormItems(); }, addPkgItem() { this._pkgItems.push({ source: 'catalog', blank_id: null, wh_id: null, warehouse_sku: '', photo_thumbnail: '', qty: 1, unit: 'шт', name: '', cost_per_unit: 0, assembly_speed: 0 }); this.renderFormItems(); }, removePlasticItem(idx) { this._plasticItems.splice(idx, 1); this.renderFormItems(); this.recalcSet(); }, removeHwItem(idx) { this._hwItems.splice(idx, 1); this.renderFormItems(); this.recalcSet(); }, removePkgItem(idx) { this._pkgItems.splice(idx, 1); this.renderFormItems(); this.recalcSet(); }, // ========================================== // SEARCHABLE DROPDOWNS // ========================================== _normalizeSearchText(value) { return String(value || '') .toLowerCase() .replace(/ё/g, 'е') .replace(/[^a-z0-9а-я]+/gi, ' ') .replace(/\s+/g, ' ') .trim(); }, _getPlasticSearchAliases(blank) { const normalizedName = this._normalizeSearchText(blank?.name || ''); const aliases = []; if (/кардхолдер|картхолдер/.test(normalizedName)) { aliases.push('card holder', 'cardholder'); } if (/\bтег\b|тэг/.test(normalizedName)) { aliases.push('tag'); } if (/брелок/.test(normalizedName)) { aliases.push('keychain', 'key holder'); } return aliases; }, _buildSearchText(parts) { return this._normalizeSearchText((parts || []).filter(Boolean).join(' ')); }, _renderSearchableSelect(containerId, items, selectedId, placeholder, onSelectFn) { // items = [{id, name, detail, photo?}] const uid = containerId + '_' + Math.random().toString(36).slice(2, 6); const selected = items.find(i => String(i.id) === String(selectedId)); return `
`; }, _openDropdown(uid) { const dd = document.getElementById(uid + '_dd'); if (dd) { dd.style.display = ''; this._filterDropdown(uid); } // Close on outside click setTimeout(() => { const handler = (e) => { if (!e.target.closest('.mp-searchable')) { this._closeDropdown(uid); document.removeEventListener('click', handler); } }; document.addEventListener('click', handler); }, 50); }, _closeDropdown(uid) { const dd = document.getElementById(uid + '_dd'); if (dd) dd.style.display = 'none'; }, _filterDropdown(uid) { const input = document.getElementById(uid); const dd = document.getElementById(uid + '_dd'); if (!input || !dd) return; const search = this._normalizeSearchText(input.value); dd.querySelectorAll('.mp-dd-item').forEach(el => { const name = this._normalizeSearchText(el.dataset.search || el.dataset.name || ''); el.style.display = (!search || name.includes(search)) ? '' : 'none'; }); }, _buildPickerMetaLine(sku, parts) { const normalizedSku = String(sku || '').trim() || 'без артикула'; return [normalizedSku, ...(parts || []).filter(Boolean)].join(' · '); }, _buildMarketplacePlasticPickerData() { const grouped = {}; (this._plasticBlanks || []).forEach(blank => { const rawCollection = String(blank.collection || '').trim() || 'Без коллекции'; const groupKey = `molds:${rawCollection.toLowerCase().replace(/[^a-z0-9а-я]+/gi, '_')}`; if (!grouped[groupKey]) { grouped[groupKey] = { label: rawCollection, icon: '🧩', color: 'rgba(99, 102, 241, 0.12)', textColor: '#4338ca', items: [], }; } const pph = this._safeNumber(blank.pph_actual || blank.pph_min || blank.pph_max, 0); const metaParts = [ blank.weight_grams ? `${blank.weight_grams} г` : '', pph > 0 ? `${pph} шт/ч` : '', rawCollection, ]; grouped[groupKey].items.push({ id: blank.id, category: 'molds', name: blank.name || '', sku: blank.sku || '', size: blank.weight_grams ? `${blank.weight_grams} г` : '', color: '', photo_thumbnail: blank.photo_url || '', photo_url: blank.photo_url || '', meta_line: this._buildPickerMetaLine(blank.sku || '', metaParts), }); }); return grouped; }, _getPartUnitLabel(item) { if (typeof Warehouse !== 'undefined' && Warehouse && typeof Warehouse.getPickerUnitLabel === 'function') { return Warehouse.getPickerUnitLabel(item); } return String(item?.unit || '').trim() || 'шт'; }, _getPartUnitPrice(item) { if (typeof Warehouse !== 'undefined' && Warehouse && typeof Warehouse.getPickerEffectivePrice === 'function') { return Warehouse.getPickerEffectivePrice(item); } return round2(parseFloat(item?.price_per_unit) || 0); }, _getPartPriceHint(item) { const unitPrice = round2(parseFloat(item?.cost_per_unit) || 0); if (!(unitPrice > 0)) return ''; return `${formatRub(unitPrice)}/${this._getPartUnitLabel(item)}`; }, _buildMarketplacePickerData(type) { const isPackaging = type === 'pkg'; const catalog = isPackaging ? this._pkgCatalog : this._hwCatalog; const warehouseItems = isPackaging ? this._allWarehousePkg : this._allWarehouseHw; const defaultCategory = isPackaging ? 'packaging' : 'other'; const linkedWarehouse = new Map( warehouseItems .filter(item => Number(item.id) > 0) .map(item => [Number(item.id), item]) ); const grouped = { catalog: { label: 'Каталог', icon: '📘', color: 'rgba(59, 130, 246, 0.12)', textColor: '#1d4ed8', items: [], }, warehouse: { label: 'Склад', icon: '📦', color: 'rgba(16, 185, 129, 0.16)', textColor: '#047857', items: [], }, }; catalog.forEach(blank => { const linked = linkedWarehouse.get(Number(blank.warehouse_item_id || 0)) || null; const sellPrice = parseFloat(blank.sell_price) || 0; const cnyRate = typeof ChinaCatalog !== 'undefined' && ChinaCatalog && ChinaCatalog._cnyRate ? ChinaCatalog._cnyRate : 12.5; const baseCost = isPackaging ? (parseFloat(blank.price_per_unit) || 0) + (parseFloat(blank.delivery_per_unit) || 0) : ((parseFloat(blank.price_rub) || 0) > 0 ? (parseFloat(blank.price_rub) || 0) : round2((parseFloat(blank.price_cny) || 0) * cnyRate + (parseFloat(blank.delivery_per_unit) || 0))); const stockQty = linked ? (linked.available_qty ?? linked.qty ?? null) : null; const stockText = stockQty == null ? '' : (stockQty > 0 ? `${stockQty} ${this._getPartUnitLabel(linked || blank)} на складе` : 'нет на складе'); const priceText = sellPrice > 0 ? `прайс ${formatRub(sellPrice)}` : `${formatRub(baseCost)} себес`; grouped.catalog.items.push({ id: `catalog:${blank.id}`, category: linked?.category || defaultCategory, name: blank.name || '', sku: linked?.sku || blank.sku || blank.warehouse_sku || '', size: linked?.size || blank.size || '', color: linked?.color || blank.color || '', qty: linked?.qty || 0, available_qty: stockQty, price_per_unit: baseCost || 0, unit: this._getPartUnitLabel(linked || blank), photo_thumbnail: linked?.photo_thumbnail || linked?.photo_url || blank.photo_url || blank._whPhoto || '', photo_url: linked?.photo_url || blank.photo_url || '', meta_line: this._buildPickerMetaLine(linked?.sku || blank.sku || blank.warehouse_sku || '', [stockText, priceText, 'каталог']), }); }); warehouseItems.forEach(item => { const stockQty = item.available_qty ?? item.qty ?? 0; grouped.warehouse.items.push({ id: `warehouse:${item.id}`, category: item.category || defaultCategory, name: item.name || '', sku: item.sku || '', size: item.size || '', color: item.color || '', qty: item.qty || 0, available_qty: stockQty, price_per_unit: this._getPartUnitPrice(item), unit: this._getPartUnitLabel(item), photo_thumbnail: item.photo_thumbnail || item.photo_url || '', photo_url: item.photo_url || '', meta_line: this._buildPickerMetaLine(item.sku || '', [ stockQty > 0 ? `${stockQty} ${this._getPartUnitLabel(item)}` : 'нет', this._getPartUnitPrice(item) > 0 ? `${formatRub(this._getPartUnitPrice(item))}/${this._getPartUnitLabel(item)}` : '', 'склад', ]), }); }); Object.keys(grouped).forEach(key => { if (!grouped[key].items.length) delete grouped[key]; }); return grouped; }, _getMarketplacePickerSelectedId(item) { const normalized = this._normalizeMarketplacePart(item, 'hw'); if (!normalized) return null; if (normalized.source === 'warehouse' && normalized.wh_id) return `warehouse:${normalized.wh_id}`; if (normalized.wh_id && !normalized.blank_id) return `warehouse:${normalized.wh_id}`; if (normalized.blank_id) return `catalog:${normalized.blank_id}`; return null; }, _normalizeMarketplacePart(rawItem, kind) { if (!rawItem) return null; const isPackaging = kind === 'pkg'; const catalog = isPackaging ? (this._pkgCatalog || []) : (this._hwCatalog || []); const warehouseItems = isPackaging ? (this._allWarehousePkg || []) : (this._allWarehouseHw || []); const item = { source: 'catalog', blank_id: null, wh_id: null, warehouse_sku: '', photo_thumbnail: '', qty: 1, unit: 'шт', name: '', cost_per_unit: 0, assembly_speed: 0, ...rawItem, }; const hasBlankId = Number(item.blank_id || 0) > 0; const hasWhId = Number(item.wh_id || 0) > 0; if (item.source === 'custom') { return item; } if (item.source === 'warehouse' || (!item.source && hasWhId && !hasBlankId) || (hasWhId && !hasBlankId)) { item.source = 'warehouse'; } else if (hasBlankId) { item.source = 'catalog'; } else if (hasWhId) { item.source = 'warehouse'; } else { item.source = 'catalog'; } const blank = hasBlankId ? catalog.find(entry => Number(entry.id) === Number(item.blank_id || 0)) || null : null; const linkedWarehouseId = Number(blank?.warehouse_item_id || 0); const warehouseItem = hasWhId ? warehouseItems.find(entry => Number(entry.id) === Number(item.wh_id || 0)) || null : (linkedWarehouseId ? warehouseItems.find(entry => Number(entry.id) === linkedWarehouseId) || null : null); if (item.source === 'warehouse' && hasWhId) { const normalizedWarehouseCost = this._getPartUnitPrice(warehouseItem || item); item.warehouse_sku = item.warehouse_sku || warehouseItem?.sku || blank?.sku || ''; item.photo_thumbnail = item.photo_thumbnail || warehouseItem?.photo_thumbnail || warehouseItem?.photo_url || blank?.photo_url || blank?._whPhoto || ''; item.unit = this._getPartUnitLabel(warehouseItem || item); item.name = item.name || [ warehouseItem?.name || blank?.name || '', warehouseItem?.size || '', warehouseItem?.color || '', ].filter(Boolean).join(' '); item.cost_per_unit = normalizedWarehouseCost; if (!(parseFloat(item.assembly_speed) > 0)) { item.assembly_speed = parseFloat(blank?.assembly_speed) || 0; } return item; } if (blank) { item.warehouse_sku = item.warehouse_sku || warehouseItem?.sku || blank.sku || ''; item.photo_thumbnail = item.photo_thumbnail || warehouseItem?.photo_thumbnail || warehouseItem?.photo_url || blank.photo_url || blank._whPhoto || ''; item.unit = this._getPartUnitLabel(warehouseItem || item); item.name = item.name || blank.name || ''; if (!(parseFloat(item.assembly_speed) > 0)) { item.assembly_speed = parseFloat(blank.assembly_speed) || 0; } if (isPackaging && !(parseFloat(item.cost_per_unit) > 0)) { item.cost_per_unit = round2((parseFloat(blank.price_per_unit) || 0) + (parseFloat(blank.delivery_per_unit) || 0)); } } return item; }, _normalizeHwItem(item) { return this._normalizeMarketplacePart(item, 'hw'); }, _normalizePkgItem(item) { return this._normalizeMarketplacePart(item, 'pkg'); }, _normalizeMarketplaceSet(set) { if (!set) return set; return { ...set, commission: this._safeNumber(set.commission, 46), vat: this._safeNumber(set.vat, 5), osn: this._safeNumber(set.osn, 12), charity: Number.isFinite(parseFloat(set.charity)) ? parseFloat(set.charity) : 1, commercial: this._safeNumber(set.commercial, 6.5), acquiring: this._safeNumber(set.acquiring, 5), target_margin: this._safeNumber(set.target_margin, 40), default_packaging_enabled: !!set.default_packaging_enabled, hw_items: (set.hw_items || []).map(item => this._normalizeHwItem(item)), pkg_items: (set.pkg_items || []).map(item => this._normalizePkgItem(item)), }; }, // ========================================== // RENDER FORM ITEMS // ========================================== renderFormItems() { this._hwItems = (this._hwItems || []).map(item => this._normalizeHwItem(item)); this._pkgItems = (this._pkgItems || []).map(item => this._normalizePkgItem(item)); // Plastic — use the same robust picker as warehouse/catalog items const plasticPickerData = this._buildMarketplacePlasticPickerData(); document.getElementById('mp-plastic-items').innerHTML = this._plasticItems.map((item, i) => `
${Warehouse.buildImagePicker(`mppl-picker-${i}`, plasticPickerData, item.blank_id, 'Marketplaces._selectPlastic', null, { searchPlaceholder: 'Поиск бланка по названию...' })}
`).join(''); // Hardware — all warehouse hw + catalog + custom option const hwPickerData = this._buildMarketplacePickerData('hw'); document.getElementById('mp-hw-items').innerHTML = this._hwItems.map((item, i) => { const isCustom = item.source === 'custom'; const speedMin = item.assembly_speed > 0 ? round2(item.assembly_speed / 60) : ''; const qtyUnit = this._getPartUnitLabel(item); const priceHint = this._getPartPriceHint(item); return `
${isCustom ? `` : Warehouse.buildImagePicker(`mphw-picker-${i}`, hwPickerData, this._getMarketplacePickerSelectedId(item), 'Marketplaces._selectHw', null, { searchPlaceholder: 'Поиск по названию или артикулу...' }) }
${isCustom ? `
` : ''}
${priceHint ? `${this._esc(priceHint)}` : ''}
`; }).join(''); // Packaging — all warehouse pkg + catalog + custom const pkgPickerData = this._buildMarketplacePickerData('pkg'); document.getElementById('mp-pkg-items').innerHTML = this._pkgItems.map((item, i) => { const isCustom = item.source === 'custom'; const speedMin = item.assembly_speed > 0 ? round2(item.assembly_speed / 60) : ''; const qtyUnit = this._getPartUnitLabel(item); const priceHint = this._getPartPriceHint(item); return `
${isCustom ? `` : Warehouse.buildImagePicker(`mppkg-picker-${i}`, pkgPickerData, this._getMarketplacePickerSelectedId(item), 'Marketplaces._selectPkg', null, { searchPlaceholder: 'Поиск по названию или артикулу...' }) }
${isCustom ? `
` : ''}
${priceHint ? `${this._esc(priceHint)}` : ''}
`; }).join(''); // Render color variants section this.renderColorVariants(); }, // Selection callbacks _selectPlastic(idx, blankId) { const b = this._plasticBlanks.find(m => m.id === Number(blankId)); this._plasticItems[idx].blank_id = Number(blankId); this._plasticItems[idx].name = b ? b.name : ''; if (!Array.isArray(this._plasticItems[idx].colors)) this._plasticItems[idx].colors = []; this.renderFormItems(); this.recalcSet(); }, // Color variant methods moved to COLOR VARIANTS section below _selectHw(idx, listId) { const item = this._hwItems[idx]; const rawId = String(listId || ''); if (rawId.startsWith('warehouse:')) { // Warehouse item const whId = Number(rawId.split(':')[1] || 0); const wh = this._allWarehouseHw.find(w => Number(w.id) === whId); if (wh) { item.source = 'warehouse'; item.wh_id = wh.id; item.blank_id = null; item.warehouse_sku = wh.sku || ''; item.photo_thumbnail = wh.photo_thumbnail || wh.photo_url || ''; item.unit = this._getPartUnitLabel(wh); item.name = wh.name + (wh.size ? ' ' + wh.size : '') + (wh.color ? ' ' + wh.color : ''); item.cost_per_unit = this._getPartUnitPrice(wh); const linkedBlank = this._hwCatalog.find(b => Number(b.warehouse_item_id) === Number(wh.id)); item.assembly_speed = linkedBlank?.assembly_speed || 0; } } else { // Catalog item const blankId = rawId.startsWith('catalog:') ? Number(rawId.split(':')[1] || 0) : Number(rawId); const hw = this._hwCatalog.find(b => Number(b.id) === blankId); if (hw) { item.source = 'catalog'; item.blank_id = hw.id; item.wh_id = null; const linkedWarehouse = this._allWarehouseHw.find(w => Number(w.id) === Number(hw.warehouse_item_id || 0)); item.warehouse_sku = linkedWarehouse?.sku || hw.sku || ''; item.photo_thumbnail = linkedWarehouse?.photo_thumbnail || linkedWarehouse?.photo_url || hw.photo_url || hw._whPhoto || ''; item.unit = this._getPartUnitLabel(linkedWarehouse || item); item.name = hw.name; item.cost_per_unit = 0; // will be calculated item.assembly_speed = hw.assembly_speed || 0; } } this.renderFormItems(); this.recalcSet(); }, _selectPkg(idx, listId) { const item = this._pkgItems[idx]; const rawId = String(listId || ''); if (rawId.startsWith('warehouse:')) { const whId = Number(rawId.split(':')[1] || 0); const wh = this._allWarehousePkg.find(w => Number(w.id) === whId); if (wh) { item.source = 'warehouse'; item.wh_id = wh.id; item.blank_id = null; item.warehouse_sku = wh.sku || ''; item.photo_thumbnail = wh.photo_thumbnail || wh.photo_url || ''; item.unit = this._getPartUnitLabel(wh); item.name = wh.name + (wh.size ? ' ' + wh.size : ''); item.cost_per_unit = this._getPartUnitPrice(wh); const linkedBlank = this._pkgCatalog.find(b => Number(b.warehouse_item_id) === Number(wh.id)); item.assembly_speed = linkedBlank?.assembly_speed || 0; } } else { const blankId = rawId.startsWith('catalog:') ? Number(rawId.split(':')[1] || 0) : Number(rawId); const pkg = this._pkgCatalog.find(b => Number(b.id) === blankId); if (pkg) { item.source = 'catalog'; item.blank_id = pkg.id; item.wh_id = null; const linkedWarehouse = this._allWarehousePkg.find(w => Number(w.id) === Number(pkg.warehouse_item_id || 0)); item.warehouse_sku = linkedWarehouse?.sku || pkg.sku || ''; item.photo_thumbnail = linkedWarehouse?.photo_thumbnail || linkedWarehouse?.photo_url || pkg.photo_url || ''; item.unit = this._getPartUnitLabel(linkedWarehouse || item); item.name = pkg.name; item.cost_per_unit = (pkg.price_per_unit || 0) + (pkg.delivery_per_unit || 0); item.assembly_speed = pkg.assembly_speed || 0; } } this.renderFormItems(); this.recalcSet(); }, _setHwSource(idx, source) { this._hwItems[idx].source = source; if (source === 'custom') { this._hwItems[idx].blank_id = null; this._hwItems[idx].wh_id = null; this._hwItems[idx].warehouse_sku = ''; this._hwItems[idx].photo_thumbnail = ''; this._hwItems[idx].unit = 'шт'; } this.renderFormItems(); }, _setPkgSource(idx, source) { this._pkgItems[idx].source = source; if (source === 'custom') { this._pkgItems[idx].blank_id = null; this._pkgItems[idx].wh_id = null; this._pkgItems[idx].warehouse_sku = ''; this._pkgItems[idx].photo_thumbnail = ''; this._pkgItems[idx].unit = 'шт'; } this.renderFormItems(); }, _onQtyChange(type, idx, val) { const q = Number(val) || 1; if (type === 'plastic') this._plasticItems[idx].qty = q; else if (type === 'hw') this._hwItems[idx].qty = q; else if (type === 'pkg') this._pkgItems[idx].qty = q; this.recalcSet(); }, _assemblyCostPerUnit(assemblySpeed, fotPerHour, indirectPerHour) { const speed = parseFloat(assemblySpeed) || 0; if (speed <= 0) return 0; return round2((fotPerHour + indirectPerHour) / speed); }, _calcHwUnitComponents(item, params) { item = this._normalizeHwItem(item); const cnyRate = params.cnyRate || 12.5; const fotPerHour = params.fotPerHour || 400; const indirectPerHour = params.indirectPerHour || 0; const wasteFactor = params.wasteFactor || 1.1; const indirectModeAll = params.indirectCostMode === 'all'; let materialCost = 0; let assemblySpeed = parseFloat(item.assembly_speed) || 0; if (item.source === 'custom' || (item.source === 'warehouse' && item.wh_id)) { materialCost = parseFloat(item.cost_per_unit) || 0; if (!assemblySpeed && item.wh_id) { const linkedBlank = this._hwCatalog.find(b => Number(b.warehouse_item_id) === Number(item.wh_id)); assemblySpeed = parseFloat(linkedBlank?.assembly_speed) || 0; } } else if (item.blank_id) { const hw = this._hwCatalog.find(b => b.id === item.blank_id); if (!hw) return { materialCost: 0, assemblyFot: 0, assemblyIndirect: 0, total: 0 }; materialCost = (hw.price_rub || 0) > 0 ? (hw.price_rub || 0) : ((hw.price_cny || 0) * cnyRate + (hw.delivery_per_unit || 0)); if (!assemblySpeed) assemblySpeed = parseFloat(hw.assembly_speed) || 0; } let assemblyFot = 0; let assemblyIndirect = 0; if (assemblySpeed > 0) { assemblyFot = fotPerHour / assemblySpeed * wasteFactor; assemblyIndirect = indirectModeAll ? (indirectPerHour / assemblySpeed * wasteFactor) : 0; } const total = round2(materialCost + assemblyFot + assemblyIndirect); return { materialCost: round2(materialCost), assemblyFot: round2(assemblyFot), assemblyIndirect: round2(assemblyIndirect), total, }; }, _calcHwUnitCost(item, params) { return this._calcHwUnitComponents(item, params).total; }, _calcPkgUnitComponents(item, params) { item = this._normalizePkgItem(item); const fotPerHour = params.fotPerHour || 400; const indirectPerHour = params.indirectPerHour || 0; const wasteFactor = params.wasteFactor || 1.1; const indirectModeAll = params.indirectCostMode === 'all'; let materialCost = 0; let assemblySpeed = parseFloat(item.assembly_speed) || 0; if (item.source === 'custom' || (item.source === 'warehouse' && item.wh_id)) { materialCost = parseFloat(item.cost_per_unit) || 0; if (!assemblySpeed && item.wh_id) { const linkedBlank = this._pkgCatalog.find(b => Number(b.warehouse_item_id) === Number(item.wh_id)); assemblySpeed = parseFloat(linkedBlank?.assembly_speed) || 0; } } else if (item.blank_id) { const pkg = this._pkgCatalog.find(b => b.id === item.blank_id); if (!pkg) return { materialCost: 0, assemblyFot: 0, assemblyIndirect: 0, total: 0 }; materialCost = (pkg.price_per_unit || 0) + (pkg.delivery_per_unit || 0); if (!assemblySpeed) assemblySpeed = parseFloat(pkg.assembly_speed) || 0; } let assemblyFot = 0; let assemblyIndirect = 0; if (assemblySpeed > 0) { assemblyFot = fotPerHour / assemblySpeed * wasteFactor; assemblyIndirect = indirectModeAll ? (indirectPerHour / assemblySpeed * wasteFactor) : 0; } const total = round2(materialCost + assemblyFot + assemblyIndirect); return { materialCost: round2(materialCost), assemblyFot: round2(assemblyFot), assemblyIndirect: round2(assemblyIndirect), total, }; }, _calcPkgUnitCost(item, params) { return this._calcPkgUnitComponents(item, params).total; }, // ========================================== // CALCULATION (per 1 unit of set) // ========================================== recalcSet() { const rates = this._normalizeChannelRates({ commission: document.getElementById('mp-set-commission')?.value, vat: document.getElementById('mp-set-vat')?.value, osn: document.getElementById('mp-set-osn')?.value, charity: document.getElementById('mp-set-charity')?.value, commercial: document.getElementById('mp-set-commercial')?.value, acquiring: document.getElementById('mp-set-acquiring')?.value, target_margin: document.getElementById('mp-set-margin')?.value, }); const params = App.params || {}; const breakdown = this._calcSetBreakdown({ plastic_items: this._plasticItems, hw_items: this._hwItems, pkg_items: this._pkgItems, default_packaging_enabled: this._getDefaultPackagingConfig('form').enabled, }); const totalCost = breakdown.totalCost; const suggestedMpPrice = this._getSuggestedChannelPrice(totalCost, rates, 'marketplace'); const suggestedShopPrice = this._getSuggestedChannelPrice(totalCost, rates, 'shop'); // Show result const resultBlock = document.getElementById('mp-result-block'); if (totalCost > 0) { resultBlock.style.display = ''; document.getElementById('mp-calc-cost').textContent = formatRub(totalCost); document.getElementById('mp-calc-price').textContent = formatRub(suggestedMpPrice); document.getElementById('mp-calc-shop-price').textContent = formatRub(suggestedShopPrice); const mpManualEl = document.getElementById('mp-set-price-manual'); const shopManualEl = document.getElementById('mp-set-shop-price-manual'); if (mpManualEl && !mpManualEl.matches(':focus') && !mpManualEl.value) mpManualEl.value = String(suggestedMpPrice); if (shopManualEl && !shopManualEl.matches(':focus') && !shopManualEl.value) shopManualEl.value = String(suggestedShopPrice); const mpActualPrice = parseFloat(mpManualEl?.value) || suggestedMpPrice || 0; const shopActualPrice = parseFloat(shopManualEl?.value) || suggestedShopPrice || 0; const mpSummary = this._calcChannelResult(totalCost, mpActualPrice, rates, 'marketplace'); const shopSummary = this._calcChannelResult(totalCost, shopActualPrice, rates, 'shop'); const mpNet = mpSummary.cleanInflow; const mpProfit = mpSummary.cleanProfit; const mpMargin = mpSummary.cleanMarginPct; const shopNet = shopSummary.cleanInflow; const shopProfit = shopSummary.cleanProfit; const shopMargin = shopSummary.cleanMarginPct; const mpMarginEl = document.getElementById('mp-calc-manual-margin'); const shopMarginEl = document.getElementById('mp-calc-shop-margin'); const marginColor = (m) => m >= 40 ? 'var(--green)' : m >= 20 ? 'var(--yellow)' : 'var(--red)'; if (mpMarginEl) { mpMarginEl.innerHTML = `Чистыми: ${formatRub(mpProfit)} · Маржа ${mpMargin}%`; } if (shopMarginEl) { shopMarginEl.innerHTML = `Чистыми: ${formatRub(shopProfit)} · Маржа ${shopMargin}%`; } const stageParts = []; if (breakdown.castingCost > 0) stageParts.push(`Выливание (пластик + амортизация молда + тех. добавки): ${formatRub(breakdown.castingCost)}`); if (breakdown.fotCost > 0) stageParts.push(`ФОТ выливания/срезки/NFC: ${formatRub(breakdown.fotCost)}`); if (breakdown.indirectCastingCost > 0) stageParts.push(`Косвенные выливания: ${formatRub(breakdown.indirectCastingCost)}`); if (breakdown.hwMaterialCost > 0) stageParts.push(`Фурнитура (материалы, включая встроенную): ${formatRub(breakdown.hwMaterialCost)}`); if (breakdown.pkgMaterialCost > 0) stageParts.push(`Упаковка (материалы): ${formatRub(breakdown.pkgMaterialCost)}`); if (breakdown.assemblyCost > 0) stageParts.push(`Сборка фурнитуры/упаковки (ФОТ): ${formatRub(breakdown.assemblyCost)}`); if (breakdown.indirectAssemblyCost > 0) stageParts.push(`Косвенные сборки фурнитуры/упаковки: ${formatRub(breakdown.indirectAssemblyCost)}`); document.getElementById('mp-calc-details').innerHTML = ` ${stageParts.length ? `
${stageParts.join('
')}
` : ''} МП (факт ${formatRub(mpActualPrice)}): −комиссия ${rates.commissionPct}% · −НДС ${rates.vatPct}% · −налоги ${rates.taxPct}% · −благотв. ${rates.charityPct}% · −коммерч. ${rates.commercialPct}% → чистый вход ${formatRub(mpNet)}
ИМ (факт ${formatRub(shopActualPrice)}): −НДС ${rates.vatPct}% · −налоги ${rates.taxPct}% · −благотв. ${rates.charityPct}% · −коммерч. ${rates.commercialPct}% · −эквайринг ${rates.acquiringPct}% → чистый вход ${formatRub(shopNet)} `; this._lastCalc = { totalCost, suggestedMpPrice, suggestedShopPrice, mpActualPrice, shopActualPrice, mpMargin, shopMargin, actualMargin: mpMargin, }; } else { resultBlock.style.display = 'none'; this._lastCalc = { totalCost: 0, suggestedMpPrice: 0, suggestedShopPrice: 0, mpActualPrice: 0, shopActualPrice: 0, mpMargin: 0, shopMargin: 0, actualMargin: 0 }; } }, // ========================================== // SAVE / DELETE // ========================================== async saveSet() { const name = document.getElementById('mp-set-name').value.trim(); if (!name) { App.toast('Введите название набора'); return; } this.recalcSet(); const normalizedHwItems = this._hwItems .map(item => this._normalizeHwItem(item)) .filter(item => item.blank_id || item.wh_id || (item.source === 'custom' && item.name)); const normalizedPkgItems = this._pkgItems .map(item => this._normalizePkgItem(item)) .filter(item => item.blank_id || item.wh_id || (item.source === 'custom' && item.name)); const defaultPackaging = this._getDefaultPackagingConfig('form'); const mset = { id: this.editingSetId || undefined, name, photo_url: this._pendingPhoto || '', commission: parseFloat(document.getElementById('mp-set-commission').value) || 46, vat: parseFloat(document.getElementById('mp-set-vat').value) || 5, osn: parseFloat(document.getElementById('mp-set-osn').value) || 12, charity: (() => { const value = parseFloat(document.getElementById('mp-set-charity').value); return Number.isFinite(value) ? value : 1; })(), commercial: parseFloat(document.getElementById('mp-set-commercial').value) || 6.5, acquiring: parseFloat(document.getElementById('mp-set-acquiring').value) || 5, target_margin: parseFloat(document.getElementById('mp-set-margin').value) || 40, default_packaging_enabled: defaultPackaging.enabled, marketplace_pricing_version: this.B2C_PRICING_VERSION, plastic_items: this._plasticItems.filter(i => i.blank_id), hw_items: normalizedHwItems, pkg_items: normalizedPkgItems, color_variants: this._colorVariants.filter(v => v.name || (v.assignments && v.assignments.some(a => a.color_id))), total_cost: this._lastCalc?.totalCost || 0, selling_price: this._lastCalc?.mpActualPrice || this._lastCalc?.suggestedMpPrice || 0, mp_suggested_price: this._lastCalc?.suggestedMpPrice || 0, mp_actual_price: this._lastCalc?.mpActualPrice || 0, shop_suggested_price: this._lastCalc?.suggestedShopPrice || 0, shop_actual_price: this._lastCalc?.shopActualPrice || 0, mp_margin_actual: this._lastCalc?.mpMargin || 0, shop_margin_actual: this._lastCalc?.shopMargin || 0, actual_margin: this._lastCalc?.actualMargin || 0, }; await saveMarketplaceSet(mset); App.toast('Набор сохранён'); this.hideSetForm(); await this.load(); }, async deleteSet() { if (!this.editingSetId) return; if (confirm('Удалить этот набор?')) { await deleteMarketplaceSet(this.editingSetId); App.toast('Набор удалён'); this.hideSetForm(); await this.load(); } }, async confirmDelete(id, name) { if (confirm(`Удалить набор "${name}"?`)) { await deleteMarketplaceSet(id); App.toast('Удалён'); await this.load(); } }, showProductionBuilder() { const panel = document.getElementById('mp-production-builder'); const list = document.getElementById('mp-production-list'); if (!panel || !list) return; this.hideSetForm(); if (!this.allSets.length) { App.toast('Нет наборов для производства'); return; } this._productionSelection = this.allSets.map(s => ({ id: s.id, qty: 0, selected: false, variantQtys: {} })); list.innerHTML = this.allSets.map(s => { const hasVariants = Array.isArray(s.color_variants) && s.color_variants.length > 0; const liveCost = this._calcSetBreakdown(s).totalCost; let variantsHtml = ''; if (hasVariants) { variantsHtml = ''; } return '
' + '
' + '' + '' + (hasVariants ? '' : '') + '
' + variantsHtml + '
'; }).join(''); panel.style.display = ''; panel.scrollIntoView({ behavior: 'smooth' }); }, hideProductionBuilder() { const panel = document.getElementById('mp-production-builder'); if (panel) panel.style.display = 'none'; }, _toggleProductionSet(setId, checked) { const row = this._productionSelection.find(r => Number(r.id) === Number(setId)); const qtyEl = document.getElementById(`mp-prod-qty-${setId}`); const varsEl = document.getElementById(`mp-prod-vars-${setId}`); if (!row) return; row.selected = !!checked; if (qtyEl) { qtyEl.disabled = !checked; if (checked && (!row.qty || row.qty < 1)) { row.qty = 50; qtyEl.value = '50'; } if (!checked) qtyEl.value = ''; } if (varsEl) { varsEl.style.display = checked ? '' : 'none'; } }, _setProductionQty(setId, value) { const row = this._productionSelection.find(r => Number(r.id) === Number(setId)); if (!row) return; row.qty = Math.max(1, parseInt(value, 10) || 0); }, _setProductionVarQty(setId, varIdx, value) { const row = this._productionSelection.find(r => Number(r.id) === Number(setId)); if (!row) return; if (!row.variantQtys) row.variantQtys = {}; row.variantQtys[varIdx] = Math.max(0, parseInt(value, 10) || 0); }, async createProductionOrderFromSelection() { const selected = (this._productionSelection || []).filter(r => { if (!r.selected) return false; if (r.qty > 0) return true; if (r.variantQtys && Object.values(r.variantQtys).some(q => q > 0)) return true; return false; }); if (!selected.length) { App.toast('Выберите хотя бы один набор и укажите тираж'); return; } const result = await this._showCreateOrderDialog(); if (!result) return; await this._createProductionOrderFromSets(selected, result.orderName, result.deadlineEnd); }, _showCreateOrderDialog() { return new Promise(resolve => { const existing = document.getElementById('b2c-create-order-dialog'); if (existing) existing.remove(); const defaultName = `B2C партия ${new Date().toLocaleDateString('ru-RU')}`; const overlay = document.createElement('div'); overlay.id = 'b2c-create-order-dialog'; overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.35);z-index:1000;display:flex;align-items:center;justify-content:center;'; overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; overlay.innerHTML = `

Создание заказа

Когда заказ должен быть готов
`; document.body.appendChild(overlay); const nameInput = document.getElementById('b2c-dialog-name'); const deadlineInput = document.getElementById('b2c-dialog-deadline'); nameInput.focus(); nameInput.select(); const submit = () => { const name = (nameInput.value || '').trim(); if (!name) { App.toast('Нужно название заказа'); nameInput.focus(); return; } const deadlineEnd = deadlineInput.value || null; overlay.remove(); resolve({ orderName: name, deadlineEnd }); }; document.getElementById('b2c-dialog-ok').onclick = submit; document.getElementById('b2c-dialog-cancel').onclick = () => { overlay.remove(); resolve(null); }; document.getElementById('b2c-dialog-close').onclick = () => { overlay.remove(); resolve(null); }; nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); }); }, _pphForMold(mold) { const pMin = mold?.pph_min || 0; const pMax = mold?.pph_max || 0; const pAvg = (pMin > 0 && pMax > 0) ? Math.round((pMin + pMax) / 2) : (pMin || pMax || 0); return mold?.pph_actual || pAvg || 1; }, _resolveHwWarehouseLink(hw) { hw = this._normalizeHwItem(hw); if (!hw) return { source: 'custom', whId: null, sku: '' }; const directWhId = Number(hw.wh_id || 0); if (hw.source === 'warehouse' && directWhId > 0) { const warehouseItem = this._allWarehouseHw.find(w => Number(w.id) === directWhId); return { source: 'warehouse', whId: directWhId, sku: warehouseItem?.sku || hw.warehouse_sku || '', }; } const blankId = Number(hw.blank_id || 0); if (blankId > 0) { const blank = this._hwCatalog.find(b => Number(b.id) === blankId); const linkedWhId = Number(blank?.warehouse_item_id || 0); if (linkedWhId > 0) { const warehouseItem = this._allWarehouseHw.find(w => Number(w.id) === linkedWhId); return { source: 'warehouse', whId: linkedWhId, sku: warehouseItem?.sku || hw.warehouse_sku || blank?.sku || '', }; } } return { source: 'custom', whId: null, sku: '' }; }, _resolvePkgWarehouseLink(pkg) { pkg = this._normalizePkgItem(pkg); if (!pkg) return { source: 'custom', whId: null, sku: '' }; const directWhId = Number(pkg.wh_id || 0); if (pkg.source === 'warehouse' && directWhId > 0) { const warehouseItem = this._allWarehousePkg.find(w => Number(w.id) === directWhId); return { source: 'warehouse', whId: directWhId, sku: warehouseItem?.sku || pkg.warehouse_sku || '', }; } const blankId = Number(pkg.blank_id || 0); if (blankId > 0) { const blank = this._pkgCatalog.find(b => Number(b.id) === blankId); const linkedWhId = Number(blank?.warehouse_item_id || 0); if (linkedWhId > 0) { const warehouseItem = this._allWarehousePkg.find(w => Number(w.id) === linkedWhId); return { source: 'warehouse', whId: linkedWhId, sku: warehouseItem?.sku || pkg.warehouse_sku || blank?.sku || '', }; } } return { source: 'custom', whId: null, sku: '' }; }, async _createProductionOrderFromSets(selectedRows, orderName, deadlineEnd) { const selectedSets = selectedRows.map(r => { const set = this.allSets.find(s => Number(s.id) === Number(r.id)); if (!set) return null; const hasVariants = Array.isArray(set.color_variants) && set.color_variants.length > 0; if (hasVariants) { const vq = r.variantQtys || {}; let totalQty = 0; (set.color_variants || []).forEach((v, vi) => { totalQty += (vq[vi] || 0); }); return { set, qty: totalQty, variantQtys: vq, hasVariants: true }; } return { set, qty: r.qty, variantQtys: {}, hasVariants: false }; }).filter(x => x && x.set && x.qty > 0); if (!selectedSets.length) { App.toast('Наборы не найдены'); return; } const nowIso = new Date().toISOString(); const today = nowIso.slice(0, 10); const params = App.params || getProductionParams(App.settings || {}); const currentEmployee = App.getCurrentEmployeeName() || ''; const items = []; let itemNumber = 1; let totalRevenue = 0; let totalCosts = 0; let totalHoursPlastic = 0; let totalHoursHardware = 0; let totalHoursPackaging = 0; selectedSets.forEach(({ set: s, qty: setQty, variantQtys = {}, hasVariants = false }) => { // Build plastic batches: one per variant (with variant-specific qty and colors) // or one single batch for the whole set (backward compat) const plasticBatches = []; if (hasVariants && Array.isArray(s.color_variants)) { s.color_variants.forEach((v, vi) => { const vQty = variantQtys[vi] || 0; if (vQty <= 0) return; plasticBatches.push({ variantName: v.name || ('Вариант ' + (vi + 1)), assignments: v.assignments || [], qty: vQty, }); }); } else { plasticBatches.push({ variantName: '', assignments: [], qty: setQty }); } // Plastic products — per batch (variant) plasticBatches.forEach(batch => { (s.plastic_items || []).forEach((pi, piIdx) => { if (!pi.blank_id) return; const mold = this._plasticBlanks.find(m => Number(m.id) === Number(pi.blank_id)); if (!mold) return; const qty = batch.qty * (parseFloat(pi.qty) || 1); const calcItem = { quantity: qty, pieces_per_hour: this._pphForMold(mold), weight_grams: mold.weight_grams || 0, extra_molds: 0, complex_design: false, is_blank_mold: true, is_nfc: mold.category === 'nfc', nfc_programming: mold.category === 'nfc', delivery_included: false, printings: [], }; const r = calculateItemCost(calcItem, params); // Colors from variant assignment or from old per-item colors const assignment = batch.assignments[piIdx] || {}; let colors, colorLabel; if (assignment.color_id) { colors = [{ id: assignment.color_id, number: assignment.color_number || '', name: assignment.color_name || '' }]; colorLabel = colors.map(c => `${c.number || ''} ${c.name || ''}`.trim()).filter(Boolean).join(' + '); } else { colors = Array.isArray(pi.colors) ? pi.colors : []; colorLabel = colors.length ? colors.map(c => `${c.number || ''} ${c.name || ''}`.trim()).filter(Boolean).join(' + ') : String(pi.color_notes || '').trim(); } let productName = mold.name; if (batch.variantName) productName += ` [${batch.variantName}]`; if (colorLabel) productName += ` [цвет: ${colorLabel}]`; items.push({ item_number: itemNumber++, item_type: 'product', product_name: productName, quantity: qty, pieces_per_hour: calcItem.pieces_per_hour, weight_grams: calcItem.weight_grams, extra_molds: 0, complex_design: false, is_blank_mold: true, is_nfc: calcItem.is_nfc, nfc_programming: calcItem.nfc_programming, delivery_included: false, printings: JSON.stringify([]), cost_fot: r.costFot, cost_indirect: r.costIndirect, cost_plastic: r.costPlastic, cost_mold_amortization: r.costMoldAmortization, cost_design: 0, cost_cutting: r.costCutting, cost_cutting_indirect: r.costCuttingIndirect, cost_nfc_tag: r.costNfcTag, cost_nfc_programming: r.costNfcProgramming, cost_nfc_indirect: r.costNfcIndirect, cost_printing: 0, cost_delivery: 0, cost_total: r.costTotal, sell_price_item: 0, sell_price_printing: 0, target_price_item: 0, hours_plastic: r.hoursPlastic, hours_cutting: r.hoursCutting, hours_nfc: r.hoursNfc, template_id: mold.id, color_id: colors[0]?.id || null, color_name: colors[0]?.name || colorLabel || '', colors: JSON.stringify(colors), color_solution_attachment: null, marketplace_set_name: s.set_name || s.name || '', }); totalCosts += r.costTotal * qty; totalHoursPlastic += (r.hoursPlastic || 0) + (r.hoursCutting || 0) + (r.hoursNfc || 0); }); }); // Hardware (s.hw_items || []).forEach((hw, i) => { const qty = setQty * (parseFloat(hw.qty) || 1); if (qty <= 0) return; const base = this._calcHwUnitComponents(hw, params); const hwWarehouseLink = this._resolveHwWarehouseLink(hw); const hwItem = { name: hw.name || `Фурнитура ${i + 1}`, qty, assembly_speed: parseFloat(hw.assembly_speed) || 0, price: base.materialCost, delivery_price: 0, delivery_total: 0, sell_price: 0, }; const res = calculateHardwareCost(hwItem, params); items.push({ item_number: itemNumber++, item_type: 'hardware', product_name: hwItem.name, quantity: qty, hardware_assembly_speed: hwItem.assembly_speed, hardware_price_per_unit: hwItem.price, hardware_delivery_per_unit: hwItem.delivery_price, hardware_delivery_total: hwItem.delivery_total, sell_price_hardware: 0, target_price_hardware: 0, cost_total: res.costPerUnit, hours_hardware: res.hoursHardware, hardware_source: hwWarehouseLink.source, custom_country: hw.custom_country || 'china', hardware_warehouse_item_id: hwWarehouseLink.whId, hardware_warehouse_sku: hwWarehouseLink.sku, hardware_parent_item_index: null, hardware_from_template: false, marketplace_set_name: s.set_name || s.name || '', }); totalCosts += res.costPerUnit * qty; totalHoursHardware += res.hoursHardware || 0; }); // Packaging (s.pkg_items || []).forEach((pkg, i) => { const qty = setQty * (parseFloat(pkg.qty) || 1); if (qty <= 0) return; const base = this._calcPkgUnitComponents(pkg, params); const pkgWarehouseLink = this._resolvePkgWarehouseLink(pkg); const pkgItem = { name: pkg.name || `Упаковка ${i + 1}`, qty, assembly_speed: parseFloat(pkg.assembly_speed) || 0, price: base.materialCost, delivery_price: 0, delivery_total: 0, sell_price: 0, }; const res = calculatePackagingCost(pkgItem, params); items.push({ item_number: itemNumber++, item_type: 'packaging', product_name: pkgItem.name, quantity: qty, packaging_assembly_speed: pkgItem.assembly_speed, packaging_price_per_unit: pkgItem.price, packaging_delivery_per_unit: pkgItem.delivery_price, packaging_delivery_total: pkgItem.delivery_total, sell_price_packaging: 0, target_price_packaging: 0, cost_total: res.costPerUnit, hours_packaging: res.hoursPackaging, packaging_source: pkgWarehouseLink.source, custom_country: pkg.custom_country || 'china', packaging_warehouse_item_id: pkgWarehouseLink.whId, packaging_warehouse_sku: pkgWarehouseLink.sku, packaging_parent_item_index: null, marketplace_set_name: s.set_name || s.name || '', }); totalCosts += res.costPerUnit * qty; totalHoursPackaging += res.hoursPackaging || 0; }); const defaultPackaging = this._getDefaultPackagingConfig(s); if (this._shouldApplyDefaultPackaging(s)) { const qty = setQty; const pkgItem = { name: defaultPackaging.name, qty, assembly_speed: defaultPackaging.assemblySpeed, price: defaultPackaging.costPerUnit, delivery_price: 0, delivery_total: 0, sell_price: 0, }; const res = calculatePackagingCost(pkgItem, params); items.push({ item_number: itemNumber++, item_type: 'packaging', product_name: pkgItem.name, quantity: qty, packaging_assembly_speed: pkgItem.assembly_speed, packaging_price_per_unit: pkgItem.price, packaging_delivery_per_unit: pkgItem.delivery_price, packaging_delivery_total: pkgItem.delivery_total, sell_price_packaging: 0, target_price_packaging: 0, cost_total: res.costPerUnit, hours_packaging: res.hoursPackaging, packaging_source: 'default', custom_country: 'russia', packaging_warehouse_item_id: null, packaging_warehouse_sku: '', packaging_parent_item_index: null, marketplace_set_name: s.set_name || s.name || '', }); totalCosts += res.costPerUnit * qty; totalHoursPackaging += res.hoursPackaging || 0; } const liveMpPrice = this._safeNumber( s.mp_actual_price || s.selling_price || s.mp_suggested_price, this._getSuggestedChannelPrice(this._calcSetBreakdown(s).totalCost, s, 'marketplace') ); totalRevenue += round2(liveMpPrice * setQty); }); totalCosts = round2(totalCosts); totalRevenue = round2(totalRevenue); const totalMargin = round2(totalRevenue - totalCosts); const marginPercent = totalRevenue > 0 ? round2(totalMargin * 100 / totalRevenue) : 0; const notesParts = selectedSets.map(({ set: s, qty }) => `${s.name} × ${qty}`); const order = { id: undefined, order_name: orderName, client_name: 'B2C', manager_name: currentEmployee, owner_name: currentEmployee, status: 'production_casting', deadline: deadlineEnd || today, deadline_start: today, deadline_end: deadlineEnd || null, notes: `Автосоздано из B2C: ${notesParts.join('; ')}`, total_hours_plan: round2(totalHoursPlastic + totalHoursHardware + totalHoursPackaging), production_hours_plastic: round2(totalHoursPlastic), production_hours_packaging: round2(totalHoursPackaging), production_hours_hardware: round2(totalHoursHardware), total_revenue_plan: totalRevenue, total_cost_plan: totalCosts, total_margin_plan: totalMargin, margin_percent_plan: marginPercent, payment_status: 'not_paid', items_snapshot: JSON.stringify({ source: 'b2c', sets: selectedSets.map(({ set: s, qty }) => ({ set_id: s.id || null, set_name: s.name, set_qty: qty })), }), hardware_snapshot: JSON.stringify([]), packaging_snapshot: JSON.stringify([]), }; const savedOrderId = await saveOrder(order, items); if (!savedOrderId) { App.toast('Не удалось создать заказ'); return; } try { if (typeof Orders !== 'undefined' && Orders && typeof Orders._syncWarehouseByStatus === 'function') { await Orders._syncWarehouseByStatus(savedOrderId, 'draft', 'production_casting', orderName, currentEmployee || 'B2C'); } } catch (e) { console.warn('B2C order stock sync warning:', e); } this.hideProductionBuilder(); App.toast('Заказ в производство создан'); App.navigate('order-detail', true, savedOrderId); }, // ========================================== // HELPERS // ========================================== // ========================================== // COLOR VARIANTS // ========================================== addColorVariant() { const assignments = this._plasticItems.map(() => ({ color_id: null, color_number: '', color_name: '', color_photo: '' })); this._colorVariants.push({ id: Date.now() + Math.floor(Math.random() * 1000), name: '', photo_url: '', assignments, }); this.renderColorVariants(); }, removeColorVariant(idx) { this._colorVariants.splice(idx, 1); this.renderColorVariants(); }, _onVariantNameChange(idx, name) { if (this._colorVariants[idx]) this._colorVariants[idx].name = name; }, onVariantPhotoFile(idx, input) { if (!input.files || !input.files[0]) return; const file = input.files[0]; if (file.size > 2 * 1024 * 1024) { App.toast('Макс 2MB'); return; } const reader = new FileReader(); reader.onload = (e) => { this._resizeImage(e.target.result, 800, (thumb) => { if (this._colorVariants[idx]) { this._colorVariants[idx].photo_url = thumb; this.renderColorVariants(); } }); }; reader.readAsDataURL(file); }, _selectVariantColor(varIdx, plasticIdx, colorId) { if (!this._colorVariants[varIdx]) return; const c = (this._colors || []).find(x => Number(x.id) === Number(colorId)); // Ensure assignments array is long enough while (this._colorVariants[varIdx].assignments.length <= plasticIdx) { this._colorVariants[varIdx].assignments.push({ color_id: null, color_number: '', color_name: '', color_photo: '' }); } this._colorVariants[varIdx].assignments[plasticIdx] = { color_id: c ? c.id : null, color_number: c ? (c.number || '') : '', color_name: c ? (c.name || '') : '', color_photo: c ? (c.photo_url || '') : '', }; this.renderColorVariants(); }, renderColorVariants() { const container = document.getElementById('mp-color-variants'); if (!container) return; if (!this._colorVariants.length) { container.innerHTML = '
Нет вариантов. Нажмите «+ Цветовой вариант» чтобы добавить.
'; return; } const plasticNames = this._plasticItems.map((pi, i) => pi.name || ('Пластик ' + (i + 1))); let html = ''; this._colorVariants.forEach((v, varIdx) => { const photoHtml = v.photo_url ? '' : ''; const photoPlaceholder = '🎨'; // Color assignment rows let assignHtml = ''; if (this._plasticItems.length === 0) { assignHtml = '
Сначала добавьте пластиковые элементы выше
'; } else { this._plasticItems.forEach((pi, plasticIdx) => { const assignment = (v.assignments && v.assignments[plasticIdx]) || {}; const selectedColor = assignment.color_id ? (this._colors || []).find(c => Number(c.id) === Number(assignment.color_id)) : null; const uid = 'mp-vc-' + varIdx + '-' + plasticIdx + '-' + Math.random().toString(36).slice(2, 6); const selectedLabel = selectedColor ? (selectedColor.number || '') + ' ' + (selectedColor.name || '') : 'Выберите цвет'; const selectedPhoto = selectedColor && selectedColor.photo_url ? '' : '?'; const colorOptions = (this._colors || []).map(c => { const cPhoto = c.photo_url ? '' : '' + (c.name || '?')[0] + ''; const label = ((c.number || '') + ' ' + (c.name || '')).trim(); return '
' + cPhoto + '' + this._esc(label) + '' + '
'; }).join(''); assignHtml += '' + '
' + '' + this._esc(plasticNames[plasticIdx]) + ':' + '
' + '
' + selectedPhoto + '' + this._esc(selectedLabel.trim()) + '' + '' + '
' + '' + '
' + '
'; }); } html += '' + '
' + '
' + '
' + photoHtml + photoPlaceholder + '
' + '' + '' + '' + '
' + assignHtml + '
'; }); container.innerHTML = html; }, _enrichPlasticBlanks() { const params = App.params || {}; const fotPerHour = params.fotPerHour || 400; this._plasticBlanks.forEach(m => { const pMin = m.pph_min || 0; const pMax = m.pph_max || 0; const pAvg = (pMin > 0 && pMax > 0) ? Math.round((pMin + pMax) / 2) : (pMin || pMax || 0); const pph = m.pph_actual || pAvg || 1; const weight = m.weight_grams || 0; const moldCount = m.mold_count || 1; const singleMoldCost = (m.cost_cny ?? 800) * (m.cny_rate ?? 12.5) + (m.delivery_cost ?? 3000); const moldAmortPerUnit = (singleMoldCost * moldCount) / MOLD_MAX_LIFETIME; m.tiers = {}; MOLD_TIERS.forEach(qty => { const item = { quantity: qty, pieces_per_hour: pph, weight_grams: weight, extra_molds: 0, complex_design: false, is_nfc: m.category === 'nfc', nfc_programming: m.category === 'nfc', hardware_qty: 0, packaging_qty: 0, printing_qty: 0, delivery_included: false, }; const result = calculateItemCost(item, params); let adjustedCost = result.costTotal - result.costMoldAmortization + moldAmortPerUnit; if (m.hw_name && m.hw_price_per_unit > 0) { let hwCostPerUnit = m.hw_price_per_unit + (m.hw_delivery_total ? m.hw_delivery_total / qty : 0); if (m.hw_speed > 0) { hwCostPerUnit += (qty / m.hw_speed * (params.wasteFactor || 1.1)) * fotPerHour / qty; } adjustedCost += hwCostPerUnit; } m.tiers[qty] = { cost: round2(this._safeNumber(adjustedCost, 0)) }; }); }); }, _calcSetBreakdown(s) { const params = App.params || {}; let plasticCost = 0, hwCost = 0, pkgCost = 0; let castingCost = 0; let hwMaterialCost = 0; let pkgMaterialCost = 0; let fotCost = 0; let assemblyCost = 0; let indirectCost = 0; let indirectCastingCost = 0; let indirectAssemblyCost = 0; (s.plastic_items || []).forEach(item => { if (!item.blank_id) return; const mold = this._plasticBlanks.find(m => m.id === item.blank_id); if (!mold || !mold.tiers) return; const tier = mold.tiers[500] || mold.tiers[300] || mold.tiers[1000]; const qtyMult = item.qty || 1; if (tier) plasticCost += tier.cost * qtyMult; // Detailed split for plastic part (as in _enrichPlasticBlanks) const pMin = mold.pph_min || 0; const pMax = mold.pph_max || 0; const pAvg = (pMin > 0 && pMax > 0) ? Math.round((pMin + pMax) / 2) : (pMin || pMax || 0); const pph = mold.pph_actual || pAvg || 1; const weight = mold.weight_grams || 0; const moldCount = mold.mold_count || 1; const singleMoldCost = (mold.cost_cny ?? 800) * (mold.cny_rate ?? 12.5) + (mold.delivery_cost ?? 3000); const moldAmortPerUnit = (singleMoldCost * moldCount) / MOLD_MAX_LIFETIME; const baseItem = { quantity: 500, pieces_per_hour: pph, weight_grams: weight, extra_molds: 0, complex_design: false, is_nfc: mold.category === 'nfc', nfc_programming: mold.category === 'nfc', hardware_qty: 0, packaging_qty: 0, printing_qty: 0, delivery_included: false, }; const res = calculateItemCost(baseItem, params); const castingPerUnit = round2( (res.costPlastic || 0) + moldAmortPerUnit + (res.costDesign || 0) + (res.costNfcTag || 0) + (res.costPrinting || 0) + (res.costDelivery || 0) ); const fotPerUnit = round2((res.costFot || 0) + (res.costCutting || 0) + (res.costNfcProgramming || 0)); const indirectPerUnit = round2((res.costIndirect || 0) + (res.costCuttingIndirect || 0) + (res.costNfcIndirect || 0)); castingCost += castingPerUnit * qtyMult; fotCost += fotPerUnit * qtyMult; indirectCost += indirectPerUnit * qtyMult; indirectCastingCost += indirectPerUnit * qtyMult; // Built-in hardware of plastic blank (if exists) if (mold.hw_name && mold.hw_price_per_unit > 0) { const hwMaterialPerUnit = round2((mold.hw_price_per_unit || 0) + ((mold.hw_delivery_total || 0) / 500)); let hwAssemblyFotPerUnit = 0; if (mold.hw_speed > 0) { hwAssemblyFotPerUnit = round2((params.fotPerHour || 400) / mold.hw_speed * (params.wasteFactor || 1.1)); } hwMaterialCost += hwMaterialPerUnit * qtyMult; assemblyCost += hwAssemblyFotPerUnit * qtyMult; } }); (s.hw_items || []).forEach(item => { const qtyMult = item.qty || 1; const c = this._calcHwUnitComponents(item, params); hwCost += c.total * qtyMult; hwMaterialCost += c.materialCost * qtyMult; assemblyCost += c.assemblyFot * qtyMult; indirectCost += c.assemblyIndirect * qtyMult; indirectAssemblyCost += c.assemblyIndirect * qtyMult; }); (s.pkg_items || []).forEach(item => { const qtyMult = item.qty || 1; const c = this._calcPkgUnitComponents(item, params); pkgCost += c.total * qtyMult; pkgMaterialCost += c.materialCost * qtyMult; assemblyCost += c.assemblyFot * qtyMult; indirectCost += c.assemblyIndirect * qtyMult; indirectAssemblyCost += c.assemblyIndirect * qtyMult; }); const defaultPackaging = this._getDefaultPackagingConfig(s); if (this._shouldApplyDefaultPackaging(s)) { const assemblyFot = round2((params.fotPerHour || 400) / defaultPackaging.assemblySpeed * (params.wasteFactor || 1.1)); const assemblyIndirect = params.indirectCostMode === 'all' ? round2((params.indirectPerHour || 0) / defaultPackaging.assemblySpeed * (params.wasteFactor || 1.1)) : 0; pkgCost += defaultPackaging.costPerUnit + assemblyFot + assemblyIndirect; pkgMaterialCost += defaultPackaging.costPerUnit; assemblyCost += assemblyFot; indirectCost += assemblyIndirect; indirectAssemblyCost += assemblyIndirect; } // fallback: if detailed split couldn't be reconstructed, keep at least basic categories if (castingCost === 0 && plasticCost > 0) castingCost = plasticCost; if (hwMaterialCost === 0 && hwCost > 0) hwMaterialCost = hwCost; if (pkgMaterialCost === 0 && pkgCost > 0) pkgMaterialCost = pkgCost; return { plasticCost: round2(plasticCost), hwCost: round2(hwCost), pkgCost: round2(pkgCost), castingCost: round2(castingCost), hwMaterialCost: round2(hwMaterialCost), pkgMaterialCost: round2(pkgMaterialCost), fotCost: round2(fotCost), assemblyCost: round2(assemblyCost), indirectCastingCost: round2(indirectCastingCost), indirectAssemblyCost: round2(indirectAssemblyCost), indirectCost: round2(indirectCost), totalCost: round2(castingCost + hwMaterialCost + pkgMaterialCost + fotCost + assemblyCost + indirectCastingCost + indirectAssemblyCost) }; }, _esc(str) { if (!str) return ''; return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); }, };