Вопрос:
What is the economic effect of the initiative? / Какой экономический эффект дает инициатива?
Краткое содержание:
Расчет экономического эффекта от цифровой инициативы через модель EOQ.
Анализ текущего состояния (AS-IS) и целевого (TO-BE).
Оценка влияния оптимизации запасов на затраты, EBITDA и оборотный капитал.
Разработка инструмента автоматизации расчета EOQ и управления запасами через Apps Script.
Методики:
EOQ (Economic Order Quantity)
Total Cost of Inventory
Working Capital Analysis
AS-IS vs TO-BE Modeling
Unit Economics (запасы)
Automation via Apps Script
Результат:
✔ Расчет EOQ и точки заказа (ROP)
✔ Модель совокупных затрат на запасы (AS-IS vs TO-BE)
✔ Оценка экономического эффекта (снижение затрат, высвобождение cash)
✔ Финансовое обоснование цифровой инициативы
✔ Прототип автоматизации (Apps Script)
Цифровая трансформация в операционных процессах должна оцениваться не через внедрение технологий, а через изменение экономики бизнеса. Один из наглядных инструментов такой оценки — модель EOQ (Economic Order Quantity), которая позволяет увидеть прямую связь между управлением запасами и финансовым результатом компании.
В основе подхода лежит простая логика: у любой системы снабжения есть две ключевые группы затрат — затраты на хранение запасов и затраты на организацию заказов. Если компания закупает большими партиями, она снижает частоту заказов, но резко увеличивает объем запасов и, как следствие, расходы на хранение и замороженный капитал. Если же заказывать слишком часто — растут операционные издержки на закупку. EOQ определяет баланс между этими двумя крайностями, минимизируя совокупные затраты.
При оценке цифровой инициативы важно учитывать только релевантные затраты — те, которые изменяются в результате внедрения решения. К ним относятся затраты на хранение (включая стоимость капитала, складские расходы и риски), затраты на оформление заказов, а также объем оборотного капитала, замороженного в запасах. При этом закупочная стоимость, как правило, не меняется и не влияет на расчет эффекта.
Цифровая инициатива в управлении запасами (например, внедрение системы автоматического расчета EOQ и точки заказа) создает ценность за счет изменения параметров системы. Снижается средний уровень запасов, уменьшаются расходы на хранение, ускоряется оборачиваемость и высвобождается значительный объем денежных средств. Это напрямую влияет на ликвидность и финансовую устойчивость бизнеса, а также на показатель EBITDA.
Экономический эффект в данном случае рассчитывается как разница между затратами в текущей модели (AS-IS) и затратами после оптимизации (TO-BE), дополненная эффектом от высвобождения оборотного капитала. Таким образом, EOQ выступает не как абстрактная формула, а как инструмент финансовой диагностики и обоснования управленческих решений.
Ключевой вывод: цифровая инициатива имеет смысл только в том случае, если она приводит к измеримому снижению затрат, улучшению денежного потока и повышению управляемости бизнеса. EOQ позволяет эту связь сделать прозрачной и перевести операционные решения в язык финансов.
Задача:
Рассчитать EOQ, оценить экономический эффект оптимизации запасов и обосновать цифровую инициативу.
Рассчитайте EOQ
Используя данные кейса, определите оптимальный размер заказа.
Определите текущие затраты (AS-IS)
Рассчитайте совокупные затраты на запасы:
хранение
организация заказов
Постройте TO-BE модель
Пересчитайте затраты при EOQ:
новый уровень запасов
новые затраты
Оцените экономический эффект
экономия затрат
высвобождение оборотного капитала
Рассчитайте точку заказа (ROP)
С учетом срока поставки и страхового запаса.
Обоснуйте инициативу
Ответьте:
выгодно ли внедрение системы управления запасами
срок окупаемости (инвестиции = 15 млн)
Сделайте автоматизацию (Apps Script)
Реализуйте:
ввод параметров
автоматический расчет EOQ и ROP
✔ Google Sheets с расчетами
✔ Краткое финансовое обоснование (1 слайд / 1 страница)
✔ Рабочий прототип автоматизации (Apps Script)
Промт для создания веб-приложения EOQ (Google Apps Script)
Создай веб-приложение на Google Apps Script (Web App) для расчёта модели управления запасами EOQ (Economic Order Quantity).
Данные для расчётов будут подгружаться из финансовой модели, поэтому их не нужно описывать в коде.
В проекте должно быть только два файла:
Code.gs
index.html
Основные функции приложения
Приложение должно выполнять следующие функции.
1. Расчёт модели EOQ
Функция:
calculateEOQ()
должна рассчитывать:
EOQ (оптимальный размер заказа)
AU (однодневная потребность)
количество поставок N
точку заказа без страхового запаса (ROP₀)
точку заказа с учётом страхового запаса (ROP)
совокупные затраты TC
затраты на организацию и хранение TC₂
2. Визуализация управления запасами
Функция:
drawInventoryChart()
должна строить диаграмму на canvas:
ось X — время (дни)
ось Y — уровень запасов
На диаграмме должны быть линии:
EOQ
точка заказа ROP
страховой запас SS
Подписи выводятся под диаграммой, без наложения на график.
3. Формирование Excel-отчёта
Функция:
generateExcelReport()
должна:
сформировать отчёт Excel
включить результаты расчётов
включить таблицу сценариев
добавить данные для построения графиков
автоматически предложить скачать файл на компьютер пользователя
UI / UX веб-приложения
Интерфейс должен быть простым и учебным.
Структура страницы
Экран делится на две панели.
Левая панель
Панель управления.
Кнопки:
Рассчитать (синего цвета)
Сброс (красного увета)
Скачать Excel-отчёт (зеленого цвета)
Правая панель
Блок аналитики.
Отображается:
1) таблица результатов расчета
2) диаграмма управления запасами
Технические требования
Code.gs
должен содержать функции:
doGet()
calculateEOQ()
generateExcelReport()
index.html
должен содержать:
HTML интерфейс
CSS стили
JavaScript
canvas графики
всё внутри одного файла.
Требования к визуализации
Интерфейс должен выглядеть как аналитический инструмент:
чистая таблица результатов
читаемые графики
аккуратная легенда
адаптивная верстка
Симметрично расположенные поля для ввода данных
function doGet() {
return HtmlService.createHtmlOutputFromFile("index")
.setTitle("EOQ Inventory Model")
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
/**
* calculateEOQ()
* Вход: { D, P, OC, EC, AD, SS, daysInYear? }
* Выход: results + scenarios + chart series
*/
function calculateEOQ(input) {
const x = normalizeInputs_(input);
// EOQ
const EOQ = Math.sqrt((2 * x.D * x.OC) / x.EC);
// Average usage per day
const AU = x.D / x.daysInYear;
// Number of orders
const N = x.D / EOQ;
// Reorder points
const ROP0 = AU * x.AD;
const ROP = ROP0 + x.SS;
// Costs
const ordering = (x.D / EOQ) * x.OC;
const holding = (EOQ / 2) * x.EC;
const TC2 = ordering + holding;
const purchase = x.P * x.D;
const TC = purchase + TC2;
const base = {
EOQ,
AU,
N,
ROP0,
ROP,
TC,
TC2,
ordering,
holding,
purchase,
};
// Scenarios: +50% EOQ, -50% EOQ (for teaching)
const scenarios = [
buildScenario_("EOQ × 1.5", 1.5, x),
buildScenario_("EOQ × 0.5", 0.5, x),
];
// Chart series: 3 cycles
const chart = buildChartSeries_(base, x);
return {
ok: true,
inputs: x,
results: base,
scenarios,
chart,
};
}
/**
* generateExcelReport()
* Возвращает { filename, mimeType, base64 } для скачивания в браузере.
*/
function generateExcelReport(input) {
const payload = calculateEOQ(input);
if (!payload || !payload.ok) throw new Error("Failed to calculate EOQ.");
const x = payload.inputs;
const r = payload.results;
const scenarios = payload.scenarios;
const chart = payload.chart;
const ss = SpreadsheetApp.create(`EOQ_Report_${new Date().toISOString().slice(0, 10)}`);
// Sheet 1: Summary
const sh1 = ss.getSheets()[0];
sh1.setName("Summary");
sh1.getRange(1, 1).setValue("EOQ Inventory Model — Report");
sh1.getRange(1, 1, 1, 6).merge().setFontWeight("bold").setFontSize(14);
const rowsInputs = [
["Inputs", "", "", "", "", ""],
["D (annual demand)", "m/year", x.D, "", "", ""],
["P (price per meter)", "u.e./m", x.P, "", "", ""],
["OC (ordering cost)", "u.e./order", x.OC, "", "", ""],
["EC (holding cost)", "u.e./m/year", x.EC, "", "", ""],
["AD (lead time)", "days", x.AD, "", "", ""],
["SS (safety stock)", "m", x.SS, "", "", ""],
["daysInYear", "days", x.daysInYear, "", "", ""],
];
const rowsResults = [
["Results", "", "", "", "", ""],
["EOQ", "m", r.EOQ, "", "", ""],
["AU (daily demand)", "m/day", r.AU, "", "", ""],
["N (orders per year)", "orders", r.N, "", "", ""],
["ROP0 (no safety stock)", "m", r.ROP0, "", "", ""],
["ROP (with safety stock)", "m", r.ROP, "", "", ""],
["TC2 (ordering + holding)", "u.e./year", r.TC2, "", "", ""],
["TC (incl. purchase)", "u.e./year", r.TC, "", "", ""],
];
sh1.getRange(3, 1, rowsInputs.length, 6).setValues(rowsInputs);
sh1.getRange(3 + rowsInputs.length + 1, 1, rowsResults.length, 6).setValues(rowsResults);
// Style blocks
styleBlock_(sh1, 3, rowsInputs.length);
styleBlock_(sh1, 3 + rowsInputs.length + 1, rowsResults.length);
sh1.autoResizeColumns(1, 6);
// Sheet 2: Scenarios
const sh2 = ss.insertSheet("Scenarios");
sh2.getRange(1, 1).setValue("Scenario Comparison");
sh2.getRange(1, 1, 1, 8).merge().setFontWeight("bold").setFontSize(13);
const scenHeader = [
["Scenario", "Order Qty (m)", "Orders/Year", "Ordering Cost", "Holding Cost", "TC2", "Purchase", "TC"],
];
sh2.getRange(3, 1, 1, scenHeader[0].length).setValues(scenHeader).setFontWeight("bold");
const scenRows = scenarios.map(s => ([
s.name,
s.orderQty,
s.N,
s.ordering,
s.holding,
s.TC2,
s.purchase,
s.TC,
]));
sh2.getRange(4, 1, scenRows.length, scenHeader[0].length).setValues(scenRows);
sh2.autoResizeColumns(1, scenHeader[0].length);
// Sheet 3: ChartData
const sh3 = ss.insertSheet("ChartData");
sh3.getRange(1, 1).setValue("Inventory Chart Data");
sh3.getRange(1, 1, 1, 6).merge().setFontWeight("bold").setFontSize(13);
sh3.getRange(3, 1, 1, 4).setValues([["Day", "InventoryLevel", "ROP", "SS"]]).setFontWeight("bold");
const chartRows = chart.points.map(p => [p.day, p.inv, chart.ROP, chart.SS]);
sh3.getRange(4, 1, chartRows.length, 4).setValues(chartRows);
sh3.autoResizeColumns(1, 4);
// Export to XLSX (then trash spreadsheet to avoid clutter)
const file = DriveApp.getFileById(ss.getId());
const blob = file.getBlob().getAs(MimeType.MICROSOFT_EXCEL);
const filename = `EOQ_Report_${new Date().toISOString().slice(0, 10)}.xlsx`;
// Cleanup
file.setTrashed(true);
return {
ok: true,
filename,
mimeType: blob.getContentType(),
base64: Utilities.base64Encode(blob.getBytes()),
};
}
/* -------------------- helpers -------------------- */
function normalizeInputs_(input) {
const num = v => {
if (v === null || v === undefined || v === "") return NaN;
const s = String(v).trim().replace(/\s+/g, "").replace(",", ".");
return Number(s);
};
const x = {
D: num(input?.D),
P: num(input?.P),
OC: num(input?.OC),
EC: num(input?.EC),
AD: num(input?.AD),
SS: num(input?.SS),
daysInYear: num(input?.daysInYear),
};
if (!isFinite(x.daysInYear) || x.daysInYear <= 0) x.daysInYear = 365;
// Basic validation
const required = ["D", "P", "OC", "EC", "AD", "SS"];
required.forEach(k => {
if (!isFinite(x[k]) || x[k] < 0) {
throw new Error(`Invalid input: ${k}`);
}
});
if (x.EC === 0) throw new Error("EC must be > 0");
if (x.D === 0) throw new Error("D must be > 0");
return x;
}
function buildScenario_(name, factor, x) {
const EOQbase = Math.sqrt((2 * x.D * x.OC) / x.EC);
const orderQty = EOQbase * factor;
const ordering = (x.D / orderQty) * x.OC;
const holding = (orderQty / 2) * x.EC;
const TC2 = ordering + holding;
const purchase = x.P * x.D;
const TC = purchase + TC2;
return {
name,
factor,
orderQty,
N: x.D / orderQty,
ordering,
holding,
TC2,
purchase,
TC,
};
}
function buildChartSeries_(r, x) {
// Show 3 sawtooth cycles
const cycleDays = r.EOQ / r.AU;
const totalDays = Math.max(1, Math.ceil(cycleDays * 3));
const points = [];
for (let d = 0; d <= totalDays; d++) {
const t = d % cycleDays;
const inv = Math.max(0, r.EOQ - r.AU * t); // linear depletion
points.push({ day: d, inv });
}
return {
points,
EOQ: r.EOQ,
ROP: r.ROP,
SS: x.SS,
cycleDays,
totalDays,
};
}
function styleBlock_(sh, startRow, numRows) {
// Simple analytic styling
sh.getRange(startRow, 1, 1, 6)
.setFontWeight("bold")
.setBackground("#f3f4f6");
sh.getRange(startRow + 1, 1, numRows - 1, 1).setFontWeight("bold");
sh.getRange(startRow, 1, numRows, 6).setBorder(true, true, true, true, true, true);
sh.getRange(startRow + 1, 3, numRows - 1, 1).setNumberFormat("#,##0.00");
}
<!doctype html>
<html>
<head>
<base target="_top" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EOQ Inventory Model</title>
<style>
:root{
--bg:#0b1220;
--panel:#0f1b31;
--card:#0f223f;
--muted:#91a4c7;
--text:#e9f1ff;
--border:rgba(255,255,255,.12);
--btn-blue:#2563eb;
--btn-red:#dc2626;
--btn-green:#16a34a;
--shadow: 0 10px 25px rgba(0,0,0,.25);
--radius: 14px;
--pad: 14px;
}
*{ box-sizing:border-box; }
body{
margin:0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Helvetica Neue", sans-serif;
background: radial-gradient(1200px 600px at 10% 10%, rgba(37,99,235,.25), transparent 60%),
radial-gradient(900px 500px at 90% 30%, rgba(22,163,74,.18), transparent 55%),
var(--bg);
color:var(--text);
}
header{
padding: 18px 18px 10px;
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:12px;
}
header h1{
margin:0;
font-size: 18px;
letter-spacing:.2px;
}
header .sub{
margin:0;
color:var(--muted);
font-size:12px;
line-height:1.2;
}
.wrap{
padding: 12px 18px 18px;
display:flex;
gap: 14px;
min-height: calc(100vh - 70px);
}
.left, .right{
background: rgba(255,255,255,.04);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.left{
width: 360px;
min-width: 320px;
}
.right{
flex:1;
min-width: 520px;
}
.panel-title{
padding: 12px 14px;
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
border-bottom:1px solid var(--border);
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
}
.panel-title h2{
margin:0;
font-size: 13px;
letter-spacing:.3px;
text-transform: uppercase;
color: rgba(233,241,255,.9);
}
.badge{
font-size: 11px;
color: var(--muted);
}
.form{
padding: 14px;
display:grid;
grid-template-columns: 1fr 1fr;
gap: 10px 10px;
}
.field{
background: rgba(15,34,63,.55);
border:1px solid var(--border);
border-radius: 12px;
padding: 10px;
}
.field label{
display:block;
font-size: 11px;
color: var(--muted);
margin-bottom:6px;
line-height:1.15;
min-height: 28px; /* makes symmetric blocks */
}
.field input{
width:100%;
padding: 10px 10px;
border-radius:10px;
border:1px solid rgba(255,255,255,.12);
outline:none;
background: rgba(0,0,0,.18);
color: var(--text);
font-size: 13px;
}
.field input:focus{
border-color: rgba(37,99,235,.55);
box-shadow: 0 0 0 3px rgba(37,99,235,.18);
}
.field.full{ grid-column: 1 / -1; }
.actions{
padding: 0 14px 14px;
display:grid;
grid-template-columns: 1fr;
gap: 10px;
}
.btn{
width:100%;
border: none;
border-radius: 12px;
padding: 12px 12px;
font-weight: 700;
cursor:pointer;
color:white;
letter-spacing:.2px;
box-shadow: 0 10px 18px rgba(0,0,0,.18);
transition: transform .05s ease, filter .1s ease, opacity .1s ease;
}
.btn:active{ transform: translateY(1px); }
.btn:disabled{ opacity:.55; cursor:not-allowed; }
.btn.blue{ background: var(--btn-blue); }
.btn.red{ background: var(--btn-red); }
.btn.green{ background: var(--btn-green); }
.right-inner{
padding: 14px;
display:grid;
grid-template-columns: 1fr;
gap: 14px;
}
.card{
background: rgba(15,34,63,.55);
border:1px solid var(--border);
border-radius: 14px;
overflow:hidden;
}
.card .card-h{
padding: 10px 12px;
border-bottom:1px solid var(--border);
display:flex;
align-items:center;
justify-content:space-between;
gap: 10px;
}
.card .card-h .t{
font-size: 12px;
text-transform: uppercase;
letter-spacing:.28px;
color: rgba(233,241,255,.9);
margin:0;
font-weight:700;
}
.card .card-b{
padding: 12px;
}
table{
width:100%;
border-collapse: collapse;
font-size: 13px;
}
th, td{
padding: 10px 10px;
border-bottom: 1px solid rgba(255,255,255,.08);
vertical-align: top;
}
th{
text-align:left;
color: rgba(233,241,255,.9);
font-weight: 800;
font-size: 12px;
text-transform: uppercase;
letter-spacing:.22px;
}
td.k{ color: var(--muted); width: 48%; }
td.v{ text-align:right; font-variant-numeric: tabular-nums; }
.grid2{
display:grid;
grid-template-columns: 1.2fr .8fr;
gap: 14px;
align-items:start;
}
.canvas-wrap{
width:100%;
overflow:auto;
}
canvas{
width:100%;
height: 320px;
background: rgba(0,0,0,.18);
border: 1px solid rgba(255,255,255,.10);
border-radius: 12px;
display:block;
}
.legend{
margin-top: 10px;
display:flex;
flex-wrap: wrap;
gap: 10px 14px;
color: var(--muted);
font-size: 12px;
line-height: 1.2;
}
.dot{
width:10px; height:10px; border-radius:999px; display:inline-block; margin-right:8px;
vertical-align: middle;
background: rgba(233,241,255,.75);
}
.dot.eoq{ background: rgba(37,99,235,.95); }
.dot.rop{ background: rgba(245,158,11,.95); }
.dot.ss{ background: rgba(22,163,74,.95); }
.meta{
color: var(--muted);
font-size: 12px;
}
.toast{
position: fixed;
left: 18px;
bottom: 18px;
background: rgba(0,0,0,.55);
border: 1px solid rgba(255,255,255,.15);
color: var(--text);
padding: 10px 12px;
border-radius: 12px;
box-shadow: var(--shadow);
font-size: 12px;
display:none;
max-width: 520px;
}
@media (max-width: 980px){
.wrap{ flex-direction: column; }
.left{ width:100%; }
.right{ min-width: 0; }
.grid2{ grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<div>
<h1>EOQ Inventory Model</h1>
<p class="sub">Учебный аналитический калькулятор (EOQ / ROP / Safety Stock) + график + Excel-отчёт</p>
</div>
<div class="meta" id="status">Готово</div>
</header>
<div class="wrap">
<!-- LEFT PANEL -->
<section class="left">
<div class="panel-title">
<h2>Панель управления</h2>
<div class="badge">Google Apps Script Web App</div>
</div>
<div class="form">
<div class="field">
<label>D — годовая потребность (м/год)</label>
<input id="D" inputmode="decimal" placeholder="224000000" value="224000000">
</div>
<div class="field">
<label>P — цена материала (у.е./м)</label>
<input id="P" inputmode="decimal" placeholder="0.20" value="0.20">
</div>
<div class="field">
<label>OC — стоимость заказа (у.е.)</label>
<input id="OC" inputmode="decimal" placeholder="3000" value="3000">
</div>
<div class="field">
<label>EC — хранение (у.е./м/год)</label>
<input id="EC" inputmode="decimal" placeholder="0.044" value="0.044">
</div>
<div class="field">
<label>AD — lead time (дни)</label>
<input id="AD" inputmode="decimal" placeholder="5" value="5">
</div>
<div class="field">
<label>SS — страховой запас (м)</label>
<input id="SS" inputmode="decimal" placeholder="26840" value="26840">
</div>
<div class="field full">
<label>daysInYear — дней в году (по умолчанию 365)</label>
<input id="daysInYear" inputmode="decimal" placeholder="365" value="365">
</div>
</div>
<div class="actions">
<button class="btn blue" id="btnCalc">Рассчитать</button>
<button class="btn red" id="btnReset">Сброс</button>
<button class="btn green" id="btnXlsx" disabled>Скачать Excel-отчёт</button>
</div>
</section>
<!-- RIGHT PANEL -->
<section class="right">
<div class="panel-title">
<h2>Блок аналитики</h2>
<div class="badge" id="lastCalc">Нет расчёта</div>
</div>
<div class="right-inner">
<div class="grid2">
<div class="card">
<div class="card-h">
<p class="t">Результаты расчёта</p>
<span class="meta" id="miniMeta"></span>
</div>
<div class="card-b">
<table id="resultsTable">
<thead>
<tr>
<th>Показатель</th>
<th style="text-align:right;">Значение</th>
</tr>
</thead>
<tbody>
<tr><td class="k">—</td><td class="v">—</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-h">
<p class="t">Сценарии</p>
<span class="meta">±50% от EOQ</span>
</div>
<div class="card-b">
<table id="scenTable">
<thead>
<tr>
<th>Сценарий</th>
<th style="text-align:right;">TC (у.е./год)</th>
</tr>
</thead>
<tbody>
<tr><td class="k">—</td><td class="v">—</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="card-h">
<p class="t">Диаграмма управления запасами</p>
<span class="meta" id="chartMeta">—</span>
</div>
<div class="card-b">
<div class="canvas-wrap">
<canvas id="invCanvas" width="1200" height="500"></canvas>
</div>
<div class="legend" id="legend">
<span><span class="dot eoq"></span> EOQ (максимум запаса)</span>
<span><span class="dot rop"></span> ROP (точка заказа)</span>
<span><span class="dot ss"></span> SS (страховой запас)</span>
</div>
<div class="meta" id="legendText" style="margin-top:8px;">Подписи выводятся под диаграммой.</div>
</div>
</div>
</div>
</section>
</div>
<div class="toast" id="toast"></div>
<script>
let lastPayload = null;
const $ = (id) => document.getElementById(id);
function toast(msg){
const t = $("toast");
t.textContent = msg;
t.style.display = "block";
clearTimeout(toast._tm);
toast._tm = setTimeout(() => t.style.display = "none", 3000);
}
function setStatus(msg){
$("status").textContent = msg;
}
function parseNum(v){
if (v === null || v === undefined) return NaN;
const s = String(v).trim().replace(/\s+/g,"").replace(",",".");
return Number(s);
}
function getInputs(){
return {
D: parseNum($("D").value),
P: parseNum($("P").value),
OC: parseNum($("OC").value),
EC: parseNum($("EC").value),
AD: parseNum($("AD").value),
SS: parseNum($("SS").value),
daysInYear: parseNum($("daysInYear").value),
};
}
function fmt(n, digits=0){
if (!isFinite(n)) return "—";
return n.toLocaleString(undefined, { maximumFractionDigits: digits, minimumFractionDigits: digits });
}
function renderResults(payload){
const r = payload.results;
const rows = [
["EOQ (оптимальный размер заказа), м", fmt(r.EOQ, 0)],
["AU (однодневная потребность), м/день", fmt(r.AU, 0)],
["N (количество поставок), раз/год", fmt(r.N, 0)],
["ROP₀ (без страхового запаса), м", fmt(r.ROP0, 0)],
["ROP (с учётом страхового запаса), м", fmt(r.ROP, 0)],
["TC₂ (организация + хранение), у.е./год", fmt(r.TC2, 0)],
["TC (включая закуп), у.е./год", fmt(r.TC, 0)],
];
const tbody = $("resultsTable").querySelector("tbody");
tbody.innerHTML = rows.map(([k,v]) => `<tr><td class="k">${k}</td><td class="v">${v}</td></tr>`).join("");
const st = payload.scenarios || [];
const sbody = $("scenTable").querySelector("tbody");
sbody.innerHTML = st.map(s => `<tr><td class="k">${s.name}</td><td class="v">${fmt(s.TC,0)}</td></tr>`).join("");
$("lastCalc").textContent = new Date().toLocaleString();
$("miniMeta").textContent = `Ordering: ${fmt(r.ordering,0)} | Holding: ${fmt(r.holding,0)} | Purchase: ${fmt(r.purchase,0)}`;
$("btnXlsx").disabled = false;
}
/**
* drawInventoryChart()
* Рисует sawtooth-график + линии EOQ / ROP / SS.
* Подписи — под диаграммой (легенда и текстовые значения).
*/
function drawInventoryChart(payload){
const c = $("invCanvas");
const ctx = c.getContext("2d");
const W = c.width, H = c.height;
const padL = 70, padR = 24, padT = 24, padB = 70;
// Clear
ctx.clearRect(0,0,W,H);
// Data
const pts = payload.chart.points;
const EOQ = payload.chart.EOQ;
const ROP = payload.chart.ROP;
const SS = payload.chart.SS;
const maxY = Math.max(EOQ, ROP, SS) * 1.08;
const maxX = pts[pts.length - 1]?.day || 1;
const xMap = (day) => padL + (day / maxX) * (W - padL - padR);
const yMap = (inv) => padT + (1 - inv / maxY) * (H - padT - padB);
// Grid + Axes
ctx.globalAlpha = 1;
ctx.lineWidth = 1;
// Grid
ctx.strokeStyle = "rgba(255,255,255,0.08)";
const gx = 6, gy = 5;
for(let i=0;i<=gx;i++){
const x = padL + (i/gx)*(W-padL-padR);
ctx.beginPath(); ctx.moveTo(x,padT); ctx.lineTo(x,H-padB); ctx.stroke();
}
for(let i=0;i<=gy;i++){
const y = padT + (i/gy)*(H-padT-padB);
ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(W-padR,y); ctx.stroke();
}
// Axes
ctx.strokeStyle = "rgba(255,255,255,0.30)";
ctx.beginPath();
ctx.moveTo(padL, padT);
ctx.lineTo(padL, H-padB);
ctx.lineTo(W-padR, H-padB);
ctx.stroke();
// Labels
ctx.fillStyle = "rgba(233,241,255,0.85)";
ctx.font = "14px ui-sans-serif, system-ui";
ctx.fillText("Уровень запасов (м)", 16, 18);
ctx.save();
ctx.translate(W-210, H-20);
ctx.fillText("Время (дни)", 0, 0);
ctx.restore();
// Sawtooth line
ctx.strokeStyle = "rgba(233,241,255,0.80)";
ctx.lineWidth = 2;
ctx.beginPath();
pts.forEach((p, i) => {
const x = xMap(p.day);
const y = yMap(p.inv);
if (i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
});
ctx.stroke();
// EOQ line
ctx.strokeStyle = "rgba(37,99,235,0.95)";
ctx.setLineDash([8,6]);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(padL, yMap(EOQ));
ctx.lineTo(W-padR, yMap(EOQ));
ctx.stroke();
// ROP line
ctx.strokeStyle = "rgba(245,158,11,0.95)";
ctx.beginPath();
ctx.moveTo(padL, yMap(ROP));
ctx.lineTo(W-padR, yMap(ROP));
ctx.stroke();
// SS line
ctx.strokeStyle = "rgba(22,163,74,0.95)";
ctx.beginPath();
ctx.moveTo(padL, yMap(SS));
ctx.lineTo(W-padR, yMap(SS));
ctx.stroke();
ctx.setLineDash([]);
// Ticks (X)
ctx.fillStyle = "rgba(233,241,255,0.75)";
ctx.font = "12px ui-sans-serif, system-ui";
for(let i=0;i<=gx;i++){
const d = Math.round((i/gx)*maxX);
const x = xMap(d);
ctx.fillText(String(d), x-6, H-padB+20);
}
// Ticks (Y)
for(let i=0;i<=gy;i++){
const v = Math.round((1 - i/gy) * maxY);
const y = padT + (i/gy)*(H-padT-padB);
ctx.fillText(v.toLocaleString(), 10, y+4);
}
$("chartMeta").textContent = `Период цикла ≈ ${payload.chart.cycleDays.toFixed(1)} дней | показано: ${payload.chart.totalDays} дней`;
$("legendText").textContent =
`EOQ = ${fmt(EOQ,0)} м • ROP = ${fmt(ROP,0)} м • SS = ${fmt(SS,0)} м`;
}
function validateInputs(x){
const keys = ["D","P","OC","EC","AD","SS"];
for(const k of keys){
if(!isFinite(x[k]) || x[k] < 0) return `Некорректное значение: ${k}`;
}
if (x.D <= 0) return "D должно быть > 0";
if (x.EC <= 0) return "EC должно быть > 0";
if (!isFinite(x.daysInYear) || x.daysInYear <= 0) return "daysInYear должно быть > 0";
return null;
}
function runCalc(){
const x = getInputs();
const err = validateInputs(x);
if(err){ toast(err); return; }
setStatus("Считаю…");
$("btnCalc").disabled = true;
$("btnXlsx").disabled = true;
google.script.run
.withSuccessHandler((payload) => {
lastPayload = payload;
renderResults(payload);
drawInventoryChart(payload);
setStatus("Готово");
$("btnCalc").disabled = false;
toast("Расчёт выполнен");
})
.withFailureHandler((e) => {
setStatus("Ошибка");
$("btnCalc").disabled = false;
$("btnXlsx").disabled = true;
toast(e && e.message ? e.message : "Ошибка расчёта");
})
.calculateEOQ(x);
}
function resetAll(){
$("D").value = "224000000";
$("P").value = "0.20";
$("OC").value = "3000";
$("EC").value = "0.044";
$("AD").value = "5";
$("SS").value = "26840";
$("daysInYear").value = "365";
lastPayload = null;
$("btnXlsx").disabled = true;
$("lastCalc").textContent = "Нет расчёта";
$("miniMeta").textContent = "";
$("chartMeta").textContent = "—";
$("legendText").textContent = "Подписи выводятся под диаграммой.";
const tbody = $("resultsTable").querySelector("tbody");
tbody.innerHTML = `<tr><td class="k">—</td><td class="v">—</td></tr>`;
const sbody = $("scenTable").querySelector("tbody");
sbody.innerHTML = `<tr><td class="k">—</td><td class="v">—</td></tr>`;
const c = $("invCanvas");
c.getContext("2d").clearRect(0,0,c.width,c.height);
toast("Сброшено");
}
function downloadExcel(){
if(!lastPayload){
toast("Сначала выполните расчёт");
return;
}
const x = getInputs();
const err = validateInputs(x);
if(err){ toast(err); return; }
setStatus("Готовлю Excel…");
$("btnXlsx").disabled = true;
google.script.run
.withSuccessHandler((res) => {
setStatus("Готово");
$("btnXlsx").disabled = false;
if(!res || !res.ok) { toast("Не удалось сформировать отчёт"); return; }
const byteCharacters = atob(res.base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) byteNumbers[i] = byteCharacters.charCodeAt(i);
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = res.filename || "EOQ_Report.xlsx";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast("Excel-отчёт скачан");
})
.withFailureHandler((e) => {
setStatus("Ошибка");
$("btnXlsx").disabled = false;
toast(e && e.message ? e.message : "Ошибка формирования отчёта");
})
.generateExcelReport(x);
}
$("btnCalc").addEventListener("click", runCalc);
$("btnReset").addEventListener("click", resetAll);
$("btnXlsx").addEventListener("click", downloadExcel);
// Auto-first draw with defaults (optional)
window.addEventListener("load", () => {
// runCalc(); // uncomment if you want auto-calc on open
});
</script>
</body>
</html>
После завершения командной работы каждый участник обязан заполнить форму оценки индивидуального вклада. Оценка закрывается преподавателем после урока.