Ozon Price Calculator

A new extension

Size

21.8 KB

Version

1.1.1

Created

Apr 1, 2026

Updated

14 days ago

1// ==UserScript==
2// @name		Ozon Price Calculator
3// @description		A new extension
4// @version		1.1.1
5// @match		https://*.calculator.ozon.ru/*
6// @icon		https://ir.ozone.ru/s3/fe-favicon/favicon-media.svg
7// ==/UserScript==
8(function () {
9    'use strict';
10
11    // ── helpers ──────────────────────────────────────────────────────────────
12    function parseRub(text) {
13        const m = text.replace(//g, '-').match(/-?([\d\s]+)/);
14        return m ? parseFloat(m[1].replace(/\s/g, '')) : 0;
15    }
16
17    function parsePct(text) {
18        const m = text.match(/([\d.]+)%/);
19        return m ? parseFloat(m[1]) / 100 : 0;
20    }
21
22    // ── read commissions from Ozon table (FBO column) ─────────────────────
23    function readOzonTable() {
24        const t = document.querySelector('table');
25        if (!t) return null;
26        const rows = Array.from(t.rows);
27
28        function findRow(keyword) {
29            return rows.find(r => r.innerText.includes(keyword)) || null;
30        }
31
32        function extractRub(row) {
33            if (!row) return 0;
34            const text = row.innerText.replace(//g, '-');
35            const m = text.match(/-?([\d\s]+)\s*₽/);
36            return m ? parseFloat(m[1].replace(/\s/g, '')) : 0;
37        }
38
39        function extractPct(row) {
40            if (!row) return 0;
41            const m = row.innerText.match(/([\d.]+)%/);
42            return m ? parseFloat(m[1]) / 100 : 0;
43        }
44
45        const priceRow    = findRow('Цена товара');
46        const commRow     = findRow('Вознаграждение');
47        const acqRow      = findRow('Эквайринг');
48        const delivRow    = findRow('Обработка');
49        const taxRow      = findRow('Налог на прибыль');
50
51        const price       = extractRub(priceRow);
52        const commPct     = extractPct(commRow);
53        const acqPct      = extractPct(acqRow);
54        const deliveryRub = extractRub(delivRow);
55        const taxRub      = extractRub(taxRow);
56        const taxPct      = price > 0 ? taxRub / price : 0;
57
58        console.log('[RM Calc] Таблица Ozon:', { price, commPct, acqPct, deliveryRub, taxRub, taxPct });
59
60        return { price, commPct, acqPct, deliveryRub, taxPct, taxRub };
61    }
62
63    // ── formulas ──────────────────────────────────────────────────────────────
64    function calculate(inputs, ozon) {
65        const { cost, buyoutPct, ozonDiscount, advPct, targetValue, calcMode } = inputs;
66        const { commPct, acqPct, deliveryRub, taxPct } = ozon;
67
68        const costPerBuyout = cost / (buyoutPct / 100);
69        const totalCommPct = commPct + acqPct + taxPct + advPct / 100;
70
71        let P, divisor;
72
73        if (calcMode === 'margin') {
74            const targetMargin = targetValue / 100;
75            divisor = 1 - totalCommPct - targetMargin;
76            if (divisor <= 0) return null;
77            P = (deliveryRub + costPerBuyout) / divisor;
78        } else {
79            const targetRom = targetValue / 100;
80            divisor = 1 - totalCommPct;
81            if (divisor <= 0) return null;
82            P = (deliveryRub + costPerBuyout + targetRom * cost) / divisor;
83        }
84
85        const commRub    = P * commPct;
86        const acqRub     = P * acqPct;
87        const taxRub     = P * taxPct;
88        const advRub     = P * (advPct / 100);
89        const salePrice  = P * (1 - ozonDiscount / 100);
90        const profit     = P - commRub - acqRub - deliveryRub - taxRub - advRub - costPerBuyout;
91        const margin     = profit / P * 100;
92        const rom        = profit / cost * 100;
93        const costPct    = costPerBuyout / P * 100;
94
95        return { P, salePrice, commRub, commPct: commPct * 100, acqRub, acqPct: acqPct * 100, deliveryRub, taxRub, taxPct: taxPct * 100, advRub, costPerBuyout, profit, margin, rom, costPct };
96    }
97
98    // ── styles ────────────────────────────────────────────────────────────────
99    function injectStyles() {
100        const style = document.createElement('style');
101        style.textContent = `
102      @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
103      
104      #rm-calc {
105        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
106        border-radius: 24px;
107        padding: 32px;
108        margin: 24px 0;
109        font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
110        max-width: 1000px;
111        box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
112        color: white;
113        position: relative;
114        overflow: hidden;
115      }
116      
117      #rm-calc::before {
118        content: '';
119        position: absolute;
120        top: 0;
121        left: 0;
122        right: 0;
123        bottom: 0;
124        background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
125        opacity: 0.5;
126        pointer-events: none;
127        z-index: 0;
128      }
129      
130      #rm-calc > * {
131        position: relative;
132        z-index: 1;
133      }
134      
135      #rm-calc h2 {
136        font-size: 28px;
137        font-weight: 700;
138        margin: 0 0 8px 0;
139        color: #ffffff;
140      }
141      
142      .rm-subtitle {
143        font-size: 14px;
144        color: rgba(255, 255, 255, 0.7);
145        margin-bottom: 24px;
146        font-weight: 500;
147      }
148
149      .rm-mode-switch {
150        display: flex;
151        background: rgba(255, 255, 255, 0.15);
152        border-radius: 12px;
153        padding: 4px;
154        margin-bottom: 24px;
155        width: fit-content;
156        backdrop-filter: blur(10px);
157      }
158
159      .rm-mode-btn {
160        border: none;
161        background: transparent;
162        color: rgba(255, 255, 255, 0.8);
163        padding: 10px 20px;
164        border-radius: 10px;
165        cursor: pointer;
166        font-size: 14px;
167        font-weight: 600;
168        transition: all 0.3s ease;
169        font-family: inherit;
170      }
171
172      .rm-mode-btn.active {
173        background: rgba(255, 255, 255, 0.95);
174        color: #764ba2;
175        box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
176      }
177
178      .rm-mode-btn:hover:not(.active) {
179        color: white;
180        background: rgba(255, 255, 255, 0.1);
181      }
182      
183      .rm-grid {
184        display: grid;
185        grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
186        gap: 16px;
187        margin-bottom: 24px;
188      }
189      
190      .rm-field {
191        background: rgba(255, 255, 255, 0.1);
192        border-radius: 16px;
193        padding: 16px;
194        backdrop-filter: blur(10px);
195        border: 1px solid rgba(255, 255, 255, 0.2);
196        transition: all 0.3s ease;
197      }
198      
199      .rm-field:hover {
200        background: rgba(255, 255, 255, 0.15);
201        transform: translateY(-2px);
202        box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
203      }
204      
205      .rm-field label {
206        display: block;
207        font-size: 12px;
208        color: rgba(255, 255, 255, 0.8);
209        margin-bottom: 8px;
210        font-weight: 600;
211        text-transform: uppercase;
212        letter-spacing: 0.5px;
213      }
214      
215      .rm-field input {
216        width: 100%;
217        box-sizing: border-box;
218        border: 2px solid rgba(255, 255, 255, 0.3);
219        border-radius: 12px;
220        padding: 12px 16px;
221        font-size: 18px;
222        color: white;
223        outline: none;
224        transition: all 0.3s ease;
225        background: rgba(0, 0, 0, 0.2);
226        font-weight: 600;
227        font-family: inherit;
228      }
229      
230      .rm-field input::placeholder {
231        color: rgba(255, 255, 255, 0.4);
232      }
233      
234      .rm-field input:focus {
235        border-color: rgba(255, 255, 255, 0.8);
236        background: rgba(0, 0, 0, 0.3);
237        box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1);
238      }
239      
240      .rm-field input[type="number"]::-webkit-inner-spin-button,
241      .rm-field input[type="number"]::-webkit-outer-spin-button {
242        -webkit-appearance: none;
243        margin: 0;
244      }
245      
246      .rm-btn {
247        background: linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%);
248        color: #764ba2;
249        border: none;
250        border-radius: 16px;
251        padding: 16px 40px;
252        font-size: 16px;
253        font-weight: 700;
254        cursor: pointer;
255        transition: all 0.3s ease;
256        box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
257        font-family: inherit;
258        text-transform: uppercase;
259        letter-spacing: 0.5px;
260      }
261      
262      .rm-btn:hover { 
263        transform: translateY(-3px);
264        box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
265        background: linear-gradient(135deg, #ffffff 0%, #e8e8e8 100%);
266      }
267      
268      .rm-btn:active {
269        transform: translateY(-1px);
270      }
271      
272      .rm-result {
273        margin-top: 32px;
274        background: rgba(255, 255, 255, 0.98);
275        border-radius: 24px;
276        padding: 32px;
277        display: none;
278        box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
279        animation: slideUp 0.5s ease;
280        position: relative;
281        z-index: 2;
282      }
283      
284      @keyframes slideUp {
285        from {
286          opacity: 0;
287          transform: translateY(20px);
288        }
289        to {
290          opacity: 1;
291          transform: translateY(0);
292        }
293      }
294      
295      .rm-result.visible { display: block; }
296      
297      .rm-result-main {
298        font-size: 32px;
299        font-weight: 800;
300        color: #764ba2;
301        margin-bottom: 24px;
302        text-align: center;
303        line-height: 1.4;
304      }
305      
306      .rm-result-main .rm-price-value {
307        display: inline-block;
308        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
309        -webkit-background-clip: text;
310        -webkit-text-fill-color: transparent;
311        background-clip: text;
312        font-size: 36px;
313      }
314      
315      .rm-result-main .rm-price-label {
316        display: block;
317        font-size: 16px;
318        font-weight: 500;
319        color: #6b7280;
320        margin-top: 8px;
321        -webkit-text-fill-color: #6b7280;
322      }
323      
324      .rm-result-grid {
325        display: grid;
326        grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
327        gap: 16px;
328      }
329      
330      .rm-result-item {
331        background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
332        border-radius: 16px;
333        padding: 20px;
334        border: 1px solid #e2e8f0;
335        transition: all 0.3s ease;
336      }
337      
338      .rm-result-item:hover {
339        transform: translateY(-4px);
340        box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
341        border-color: #cbd5e1;
342      }
343      
344      .rm-result-item .rm-ri-label {
345        font-size: 11px;
346        color: #64748b;
347        font-weight: 700;
348        text-transform: uppercase;
349        letter-spacing: 0.8px;
350        margin-bottom: 8px;
351      }
352      
353      .rm-result-item .rm-ri-value {
354        font-size: 20px;
355        font-weight: 800;
356        color: #1e293b;
357      }
358      
359      .rm-result-item .rm-ri-value.green { 
360        color: #059669;
361      }
362      
363      .rm-result-item .rm-ri-value.red { 
364        color: #dc2626;
365      }
366      
367      .rm-error {
368        color: #fecaca;
369        font-size: 14px;
370        margin-top: 16px;
371        padding: 12px 16px;
372        background: rgba(220, 38, 38, 0.3);
373        border-radius: 12px;
374        border: 1px solid rgba(248, 113, 113, 0.3);
375        display: none;
376        font-weight: 500;
377        backdrop-filter: blur(10px);
378      }
379      
380      .rm-error.visible { display: block; }
381      
382      .rm-ozon-note {
383        font-size: 13px;
384        color: #64748b;
385        margin-top: 24px;
386        padding-top: 20px;
387        border-top: 1px solid #e2e8f0;
388        font-weight: 500;
389      }
390      
391      .rm-highlight {
392        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
393        color: white;
394        padding: 2px 8px;
395        border-radius: 6px;
396        font-weight: 600;
397      }
398    `;
399        document.head.appendChild(style);
400    }
401
402    // ── build UI ──────────────────────────────────────────────────────────────
403    function buildCalc() {
404        const wrap = document.createElement('div');
405        wrap.id = 'rm-calc';
406        wrap.innerHTML = `
407      <h2>Калькулятор цены поручения</h2>
408      <div class="rm-subtitle">Рассчитайте оптимальную цену с учётом всех комиссий Ozon</div>
409      
410      <div class="rm-mode-switch">
411        <button class="rm-mode-btn active" data-mode="margin">По маржинальности</button>
412        <button class="rm-mode-btn" data-mode="rom">По ROM</button>
413      </div>
414      
415      <div class="rm-grid">
416        <div class="rm-field">
417          <label>Себестоимость, ₽</label>
418          <input id="rm-cost" type="number" min="0" placeholder="100" value="">
419        </div>
420        <div class="rm-field">
421          <label>% выкупа</label>
422          <input id="rm-buyout" type="number" min="1" max="100" placeholder="80" value="">
423        </div>
424        <div class="rm-field">
425          <label>Скидка от Озона, %</label>
426          <input id="rm-ozon-discount" type="number" min="0" max="100" placeholder="0" value="">
427        </div>
428        <div class="rm-field">
429          <label>% рекламных расходов</label>
430          <input id="rm-adv" type="number" min="0" placeholder="5" value="">
431        </div>
432        <div class="rm-field">
433          <label id="rm-target-label">Желаемая маржинальность, %</label>
434          <input id="rm-target" type="number" min="0" placeholder="20" value="">
435        </div>
436      </div>
437      
438      <button class="rm-btn" id="rm-calc-btn">Рассчитать цену</button>
439      
440      <div class="rm-error" id="rm-error"></div>
441      
442      <div class="rm-result" id="rm-result">
443        <div class="rm-result-main" id="rm-price-out"></div>
444        <div class="rm-result-grid" id="rm-result-grid"></div>
445        <div class="rm-ozon-note" id="rm-ozon-note"></div>
446      </div>
447    `;
448        return wrap;
449    }
450
451    function fmt(n, decimals = 0) {
452        return n.toLocaleString('ru-RU', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
453    }
454
455    function renderResult(res, ozon, calcMode) {
456        const resultDiv = document.getElementById('rm-result');
457        const priceOut  = document.getElementById('rm-price-out');
458        const grid      = document.getElementById('rm-result-grid');
459        const note      = document.getElementById('rm-ozon-note');
460
461        const modeText = calcMode === 'margin' ? `маржинальности ${fmt(res.margin, 1)}%` : `ROM ${fmt(res.rom, 1)}%`;
462        
463        priceOut.innerHTML = `
464          <span class="rm-price-value">${fmt(res.P, 2)}</span>
465          <span class="rm-price-label">Цена поручения при ${modeText}</span>
466        `;
467
468        const items = [
469            { label: 'Цена реализации',          value: `${fmt(res.salePrice, 2)}`,        cls: '' },
470            { label: 'Прибыль за шт',            value: `${fmt(res.profit, 2)}`,            cls: res.profit >= 0 ? 'green' : 'red' },
471            { label: 'Маржинальность',           value: `${fmt(res.margin, 1)} %`,            cls: res.margin >= 0 ? 'green' : 'red' },
472            { label: 'ROM (прибыль/себест.)',     value: `${fmt(res.rom, 1)} %`,               cls: res.rom >= 0 ? 'green' : 'red' },
473            { label: '% себестоимости в цене',   value: `${fmt(res.costPct, 1)} %`,           cls: '' },
474            { label: 'Вознаграждение Ozon',      value: `${fmt(res.commRub, 2)} ₽ (${fmt(res.commPct, 1)}%)`, cls: 'red' },
475            { label: 'Эквайринг',               value: `${fmt(res.acqRub, 2)} ₽ (${fmt(res.acqPct, 1)}%)`,  cls: 'red' },
476            { label: 'Доставка (FBO)',           value: `${fmt(res.deliveryRub, 2)}`,       cls: 'red' },
477            { label: 'Налог',                    value: `${fmt(res.taxRub, 2)} ₽ (${fmt(res.taxPct, 1)}%)`,  cls: 'red' },
478            { label: 'Реклама',                  value: `${fmt(res.advRub, 2)}`,            cls: 'red' },
479            { label: 'Себест. на 1 выкуп',       value: `${fmt(res.costPerBuyout, 2)}`,     cls: '' },
480        ];
481
482        grid.innerHTML = items.map(it => `
483      <div class="rm-result-item">
484        <div class="rm-ri-label">${it.label}</div>
485        <div class="rm-ri-value ${it.cls}">${it.value}</div>
486      </div>
487    `).join('');
488
489        note.innerHTML = `Данные комиссий из таблицы Ozon: <span class="rm-highlight">вознаграждение ${fmt(ozon.commPct * 100, 1)}%</span>, <span class="rm-highlight">эквайринг ${fmt(ozon.acqPct * 100, 1)}%</span>, <span class="rm-highlight">доставка ${fmt(ozon.deliveryRub, 0)}</span>, <span class="rm-highlight">налог ${fmt(ozon.taxPct * 100, 2)}%</span>`;
490
491        resultDiv.classList.add('visible');
492    }
493
494    // ── init ──────────────────────────────────────────────────────────────────
495    function init() {
496        injectStyles();
497
498        const tableContainer = document.querySelector('._tableContainer_3e708_1');
499        if (!tableContainer) {
500            console.error('[RM Calc] Не найден контейнер таблицы ._tableContainer_3e708_1');
501            return;
502        }
503
504        const calcEl = buildCalc();
505        tableContainer.parentNode.insertBefore(calcEl, tableContainer.nextSibling);
506        console.log('[RM Calc] Калькулятор добавлен под таблицей');
507
508        let currentMode = 'margin';
509        const modeBtns = calcEl.querySelectorAll('.rm-mode-btn');
510        const targetLabel = document.getElementById('rm-target-label');
511        const targetInput = document.getElementById('rm-target');
512
513        modeBtns.forEach(btn => {
514            btn.addEventListener('click', () => {
515                modeBtns.forEach(b => b.classList.remove('active'));
516                btn.classList.add('active');
517                currentMode = btn.dataset.mode;
518                
519                if (currentMode === 'margin') {
520                    targetLabel.textContent = 'Желаемая маржинальность, %';
521                    targetInput.placeholder = '20';
522                } else {
523                    targetLabel.textContent = 'Желаемый ROM, %';
524                    targetInput.placeholder = '50';
525                }
526            });
527        });
528
529        document.getElementById('rm-calc-btn').addEventListener('click', function () {
530            const cost         = parseFloat(document.getElementById('rm-cost').value);
531            const buyoutPct    = parseFloat(document.getElementById('rm-buyout').value);
532            const ozonDiscount = parseFloat(document.getElementById('rm-ozon-discount').value) || 0;
533            const advPct       = parseFloat(document.getElementById('rm-adv').value) || 0;
534            const targetValue  = parseFloat(document.getElementById('rm-target').value);
535
536            const errorDiv  = document.getElementById('rm-error');
537            const resultDiv = document.getElementById('rm-result');
538
539            errorDiv.classList.remove('visible');
540            resultDiv.classList.remove('visible');
541
542            if (isNaN(cost) || isNaN(buyoutPct) || isNaN(targetValue)) {
543                errorDiv.textContent = 'Заполните обязательные поля: себестоимость, % выкупа, и целевое значение.';
544                errorDiv.classList.add('visible');
545                return;
546            }
547
548            const ozon = readOzonTable();
549            if (!ozon) {
550                errorDiv.textContent = 'Не удалось прочитать данные из таблицы Ozon. Убедитесь, что таблица загружена.';
551                errorDiv.classList.add('visible');
552                return;
553            }
554
555            console.log('[RM Calc] Данные Ozon:', ozon);
556            console.log('[RM Calc] Входные данные:', { cost, buyoutPct, ozonDiscount, advPct, targetValue, calcMode: currentMode });
557
558            const res = calculate({ cost, buyoutPct, ozonDiscount, advPct, targetValue, calcMode: currentMode }, ozon);
559
560            if (!res) {
561                errorDiv.textContent = 'Невозможно рассчитать: сумма комиссий и целевого показателя превышает 100%. Уменьшите желаемую маржинальность/ROM или % рекламы.';
562                errorDiv.classList.add('visible');
563                return;
564            }
565
566            console.log('[RM Calc] Результат:', res);
567            renderResult(res, ozon, currentMode);
568        });
569    }
570
571    function waitForTable() {
572        const t = document.querySelector('table.ozi__table__table__HAe8A');
573        const container = document.querySelector('._tableContainer_3e708_1');
574        
575        if (t && container && !document.getElementById('rm-calc')) {
576            init();
577        } else if (!document.getElementById('rm-calc')) {
578            setTimeout(waitForTable, 1000);
579        }
580    }
581
582    if (document.readyState === 'loading') {
583        document.addEventListener('DOMContentLoaded', waitForTable);
584    } else {
585        waitForTable();
586    }
587
588})();