/** * Budget Engine — расчётное ядро симулятора «Как собирается бюджет компании». * ТЗ: gerdlezhev.ru, плагин budget-simulator (этап «Прототип: логика расчётов»). * * Модель (раздел «Модель данных и расчёты» ТЗ): * Поступления(мес) = Выручка × ДоляПредоплаты + Выручка(мес − отсрочка) × (1 − ДоляПредоплаты) * Платежи(мес) = Закупки(мес − отсрочка поставщикам) + ФОТ + Аренда + Маркетинг + Налоги + Капвложения * Остаток(мес) = Остаток(мес−1) + Поступления(мес) − Платежи(мес) * Кассовый разрыв = Остаток < 0 (без кредитной линии) * * Реализация: событийная модель с шагом 1 день, горизонт 12 месяцев × 30 дней = 360 дней. * Отсрочки задаются в днях и учитываются точно; агрегация — по месяцам (БДР/БДДС) * и по неделям (платёжный календарь, 52 недели). * * Все расчёты ведутся в RUB (базовая валюта). Конвертация — только на отображении. * Библиотек: 0. Формат: UMD (браузер через window.BudgetEngine, Node через module.exports). */ (function (root, factory) { if (typeof module === 'object' && module.exports) { module.exports = factory(); } else { root.BudgetEngine = factory(); } })(typeof self !== 'undefined' ? self : this, function () { 'use strict'; var DAYS_IN_MONTH = 30; var MONTHS = 12; var HORIZON = DAYS_IN_MONTH * MONTHS; // 360 var WEEKS = Math.ceil(HORIZON / 7); // 52 (последняя неделя — 3 дня) /** Параметры по умолчанию — из ТЗ, раздел «Симулятор». */ var DEFAULTS = { revenue: 10000000, // выручка в месяц, ₽ prepayShare: 0.20, // доля предоплаты от клиентов clientDelay: 30, // отсрочка платежа от клиентов, дней purchaseShare: 0.55, // закупки, доля от выручки supplierDelay: 21, // отсрочка от поставщиков, дней payroll: 2000000, // ФОТ в месяц, ₽ rent: 800000, // аренда и накладные в месяц, ₽ taxRate: 0.20, // налоги, % от операционной прибыли marketing: 500000, // маркетинг в месяц, ₽ capex: 3000000, // капвложения, разово capexMonth: 3, // месяц капвложений (1..12) creditLine: false, // кредитная линия Вкл/Выкл initialBalance: 1500000, // начальный остаток, ₽ minBalance: 500000, // целевой остаток для кредитной линии, ₽ creditRate: 0.18 // ставка по кредитной линии, годовых }; /** Курсы к RUB (редактируются в настройках плагина, ТЗ разд. «Мультивалютность»). */ var RATES = { RUB: 1, USD: 95, EUR: 105, CNY: 13 }; var SYMBOLS = { RUB: '₽', USD: '$', EUR: '€', CNY: '¥' }; /** Пресеты сценариев (ТЗ, блок 7: пессимистичный = выручка −30%, отсрочка +30 дней, маржа падает). */ var SCENARIOS = { base: {}, optimistic: { revenue: 12000000, purchaseShare: 0.50, prepayShare: 0.30, clientDelay: 15, supplierDelay: 30 }, pessimistic: { revenue: 7000000, purchaseShare: 0.62, prepayShare: 0.10, clientDelay: 60, supplierDelay: 14 } }; function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); } function num(v, fallback) { v = parseFloat(v); return isFinite(v) ? v : fallback; } /** Санитизация входных параметров: защита ползунков от некорректных значений. */ function sanitize(src) { var p = {}; for (var k in DEFAULTS) { p[k] = DEFAULTS[k]; } for (var k2 in src) { if (!(k2 in DEFAULTS)) { continue; } if (typeof DEFAULTS[k2] === 'boolean') { p[k2] = !!src[k2]; } else { p[k2] = isFinite(parseFloat(src[k2])) ? parseFloat(src[k2]) : DEFAULTS[k2]; } // NaN → значение по умолчанию } p.revenue = clamp(num(p.revenue, 0), 0, 1e12); p.prepayShare = clamp(num(p.prepayShare, 0), 0, 1); p.purchaseShare = clamp(num(p.purchaseShare, 0), 0, 1); p.taxRate = clamp(num(p.taxRate, 0), 0, 1); p.clientDelay = clamp(Math.round(num(p.clientDelay, 0)), 0, HORIZON); p.supplierDelay = clamp(Math.round(num(p.supplierDelay, 0)), 0, HORIZON); p.payroll = clamp(num(p.payroll, 0), 0, 1e12); p.rent = clamp(num(p.rent, 0), 0, 1e12); p.marketing = clamp(num(p.marketing, 0), 0, 1e12); p.capex = clamp(num(p.capex, 0), 0, 1e12); p.capexMonth = clamp(Math.round(num(p.capexMonth, 3)), 1, MONTHS); p.initialBalance = num(p.initialBalance, 0); p.minBalance = clamp(num(p.minBalance, 0), 0, 1e12); p.creditRate = clamp(num(p.creditRate, 0), 0, 5); p.creditLine = !!p.creditLine; return p; } /** Сборка параметров сценария с возможностью ручных переопределений. */ function scenario(name, overrides) { var preset = SCENARIOS[name] || {}; return sanitize(Object.assign({}, DEFAULTS, preset, overrides || {})); } /** * Основной расчёт. Возвращает месячные (БДР + БДДС) и недельные (платёжный календарь) ряды. * * Допущения модели (упрощения симулятора, оговорены в ТЗ): * - выручка начисляется равномерно по дням месяца; предоплата приходит в день продажи, * остаток — через clientDelay дней; * - закупки (purchaseShare × выручка) начисляются в день продажи, платятся через supplierDelay дней; * - ФОТ — 5-й день месяца, аренда — 1-й, маркетинг — 10-й; * - налог = taxRate × max(0, операционная прибыль месяца), платится 15-го числа следующего месяца; * - капвложения — 1-й день месяца capexMonth; * - кредитная линия — револьвер: доводит остаток до minBalance, погашается из излишка, * проценты creditRate/360 начисляются на долг ежедневно, выплачиваются в конце месяца; * - события за горизонтом 360 дней не учитываются (хвост дебиторки/кредиторки обрезается). */ function simulate(input) { var p = sanitize(input || {}); var dailyRevenue = p.revenue / DAYS_IN_MONTH; var dailyPurchase = dailyRevenue * p.purchaseShare; // Кэш-поток по дням, индекс 1..HORIZON var inflow = new Float64Array(HORIZON + 1); // поступления от клиентов var sup = new Float64Array(HORIZON + 1); // платежи поставщикам var sal = new Float64Array(HORIZON + 1); // ФОТ var rnt = new Float64Array(HORIZON + 1); // аренда var mkt = new Float64Array(HORIZON + 1); // маркетинг var tax = new Float64Array(HORIZON + 1); // налоги var cap = new Float64Array(HORIZON + 1); // капвложения function put(arr, day, amount) { if (day >= 1 && day <= HORIZON) { arr[day] += amount; } } // Начисления (БДР) по месяцам — для налога и отчёта var accrual = []; var m, d, day, base; for (m = 0; m < MONTHS; m++) { base = m * DAYS_IN_MONTH; for (d = 1; d <= DAYS_IN_MONTH; d++) { day = base + d; put(inflow, day, dailyRevenue * p.prepayShare); put(inflow, day + p.clientDelay, dailyRevenue * (1 - p.prepayShare)); put(sup, day + p.supplierDelay, dailyPurchase); } put(sal, base + 5, p.payroll); put(rnt, base + 1, p.rent); put(mkt, base + 10, p.marketing); if (m === p.capexMonth - 1) { put(cap, base + 1, p.capex); } var a = { revenue: p.revenue, cogs: p.revenue * p.purchaseShare, commercial: p.marketing, // коммерческие расходы admin: p.payroll + p.rent // управленческие расходы }; a.gross = a.revenue - a.cogs; a.opProfit = a.gross - a.commercial - a.admin; a.tax = Math.max(0, a.opProfit) * p.taxRate; a.netProfit = a.opProfit - a.tax; // проценты добавятся по факту кредита accrual.push(a); if (m < MONTHS - 1) { put(tax, base + DAYS_IN_MONTH + 15, a.tax); } // налог следующего месяца } // Проход по дням: остатки, кредитная линия, проценты var months = []; for (m = 0; m < MONTHS; m++) { months.push({ index: m + 1, label: 'Мес ' + (m + 1), receipts: 0, paySuppliers: 0, payPayroll: 0, payRent: 0, payMarketing: 0, payTax: 0, payCapex: 0, payInterest: 0, loanDraw: 0, loanRepay: 0, interestAccrued: 0, balanceEnd: 0, loanEnd: 0, rawBalanceEnd: 0, minRaw: Infinity, gap: false }); } var weeks = []; for (var w = 0; w < WEEKS; w++) { weeks.push({ index: w + 1, label: 'Нед ' + (w + 1), receipts: 0, payments: 0, balanceEnd: 0, gap: false, minRaw: Infinity }); } var bal = p.initialBalance; // фактический остаток (с кредитной линией) var raw = p.initialBalance; // остаток «без кредита» — для диагностики разрывов var loan = 0; var interestToPay = 0; // накопленные к выплате проценты (выплата — в конце месяца) var maxLoan = 0; for (day = 1; day <= HORIZON; day++) { m = Math.floor((day - 1) / DAYS_IN_MONTH); var mm = months[m]; var wIdx = Math.floor((day - 1) / 7); var ww = weeks[wIdx]; var outDay = sup[day] + sal[day] + rnt[day] + mkt[day] + tax[day] + cap[day]; bal += inflow[day] - outDay; raw += inflow[day] - outDay; mm.receipts += inflow[day]; mm.paySuppliers += sup[day]; mm.payPayroll += sal[day]; mm.payRent += rnt[day]; mm.payMarketing += mkt[day]; mm.payTax += tax[day]; mm.payCapex += cap[day]; ww.receipts += inflow[day]; ww.payments += outDay; if (p.creditLine) { if (bal < p.minBalance) { var draw = p.minBalance - bal; loan += draw; bal += draw; mm.loanDraw += draw; } else if (bal > p.minBalance && loan > 0) { var repay = Math.min(bal - p.minBalance, loan); bal -= repay; loan -= repay; mm.loanRepay += repay; } } var dayInterest = loan * p.creditRate / 360; interestToPay += dayInterest; mm.interestAccrued += dayInterest; if (day % DAYS_IN_MONTH === 0) { // конец месяца: выплата процентов bal -= interestToPay; mm.payInterest = interestToPay; interestToPay = 0; } mm.balanceEnd = bal; mm.loanEnd = loan; mm.rawBalanceEnd = raw; if (raw < mm.minRaw) { mm.minRaw = raw; } if (raw < 0) { mm.gap = true; } ww.balanceEnd = bal; if (raw < ww.minRaw) { ww.minRaw = raw; } if (raw < 0) { ww.gap = true; } if (loan > maxLoan) { maxLoan = loan; } } // Производные показатели var totals = { revenue: 0, cogs: 0, gross: 0, commercial: 0, admin: 0, opProfit: 0, tax: 0, netProfit: 0, interest: 0, receipts: 0, payments: 0, opFlow: 0, invFlow: 0, finFlow: 0, loanDraw: 0, loanRepay: 0, minRaw: Infinity, maxLoan: maxLoan, gapMonths: [], hasGap: false, balanceEnd: 0 }; for (m = 0; m < MONTHS; m++) { var mo = months[m], ac = accrual[m]; mo.revenue = ac.revenue; mo.cogs = ac.cogs; mo.gross = ac.gross; mo.commercial = ac.commercial; mo.admin = ac.admin; mo.opProfit = ac.opProfit; mo.tax = ac.tax; mo.netProfit = ac.opProfit - ac.tax - mo.interestAccrued; mo.payments = mo.paySuppliers + mo.payPayroll + mo.payRent + mo.payMarketing + mo.payTax + mo.payCapex + mo.payInterest; mo.opFlow = mo.receipts - (mo.paySuppliers + mo.payPayroll + mo.payRent + mo.payMarketing + mo.payTax + mo.payInterest); mo.invFlow = -mo.payCapex; mo.finFlow = mo.loanDraw - mo.loanRepay; mo.check = mo.balanceEnd - mo.rawBalanceEnd - (mo.loanEnd); // контрольная сумма: ≈ 0 totals.revenue += mo.revenue; totals.cogs += mo.cogs; totals.gross += mo.gross; totals.commercial += mo.commercial; totals.admin += mo.admin; totals.opProfit += mo.opProfit; totals.tax += mo.tax; totals.netProfit += mo.netProfit; totals.interest += mo.payInterest; totals.receipts += mo.receipts; totals.payments += mo.payments; totals.opFlow += mo.opFlow; totals.invFlow += mo.invFlow; totals.finFlow += mo.finFlow; totals.loanDraw += mo.loanDraw; totals.loanRepay += mo.loanRepay; if (mo.minRaw < totals.minRaw) { totals.minRaw = mo.minRaw; } if (mo.gap) { totals.gapMonths.push(mo.index); } } totals.hasGap = totals.gapMonths.length > 0; totals.balanceEnd = months[MONTHS - 1].balanceEnd; return { params: p, months: months, weeks: weeks, totals: totals, meta: { horizonDays: HORIZON, months: MONTHS, weeks: WEEKS, daysInMonth: DAYS_IN_MONTH } }; } /** Конвертация из RUB в валюту отображения. */ function convert(valueRUB, currency, rates) { var r = (rates || RATES)[currency] || 1; return valueRUB / r; } /** Форматирование денег: 9 500 000 ₽ / $ 100 000 / ¥ 730 769. */ function formatMoney(valueRUB, currency, opts) { opts = opts || {}; var cur = currency || 'RUB'; var v = convert(valueRUB, cur, opts.rates); var frac = opts.fractionDigits != null ? opts.fractionDigits : 0; // ru-RU группирует неразрывными пробелами — заменяем на обычные для чистых строк/поиска в тестах var s = v.toLocaleString('ru-RU', { minimumFractionDigits: frac, maximumFractionDigits: frac }) .replace(/\u00A0/g, ' '); return cur === 'RUB' ? s + ' ₽' : (SYMBOLS[cur] || cur) + ' ' + s; } return { DEFAULTS: DEFAULTS, RATES: RATES, SYMBOLS: SYMBOLS, SCENARIOS: SCENARIOS, simulate: simulate, scenario: scenario, sanitize: sanitize, convert: convert, formatMoney: formatMoney }; });