Разобрать практический процесс генерации, запуска, тестирования и доработки кода для анализа и прогнозирования кассовых разрывов в Google Colab, а также автоматизации формирования технического задания и отчетности с использованием Google Apps Script и AI.
Во втором milestone осуществляется переход к практической реализации кейса анализа кассовых разрывов. На основе ранее сформированного технического задания и подготовленного AI-prompts выполняется генерация Python-кода для обработки данных по движению денежных средств, расчета ключевых показателей cash flow и выявления периодов кассовых разрывов.
Работа проводится в среде Google Colab, где осуществляется загрузка датасета, запуск кода, проверка корректности расчетов и выявление ошибок. В процессе выполняется итеративная доработка решения через дополнительные AI-запросы: уточнение логики расчетов, улучшение структуры данных, добавление новых метрик и визуализаций.
Параллельно используется Google Apps Script для автоматизации формирования структуры технического задания и отчетных форм (Google Forms / Google Sheets), что позволяет связать этап аналитики и этап документирования решения.
Отдельное внимание уделяется развитию навыков prompt engineering: формированию точных запросов к AI для генерации кода, исправления ошибок и улучшения аналитического результата.
В рамках практики формируются графики динамики денежных потоков, выявляются периоды кассовых разрывов, строится прогноз ликвидности на будущие периоды, а также создается управленческий dashboard и итоговый аналитический отчет.
Сформулировать prompt для генерации структуры технического задания по кейсу анализа и прогнозирования кассовых разрывов. В запросе отразить цель проекта, источник данных, ключевые метрики, требования к аналитике и отчетности.
С использованием AI получить структуру чек-листа, включающую блоки формы: описание проекта, данные, расчеты, визуализация, прогнозирование, отчетность. Для каждого блока определить вопросы и варианты ответов.
Создать Google Form с помощью Google Apps Script, автоматически добавив разделы и вопросы на основе сгенерированной структуры. Настроить форму таким образом, чтобы каждый участник мог заполнить индивидуальное техническое задание.
Сопоставить структуру формы с разработанным решением в Google Colab: убедиться, что все элементы ТЗ (метрики, графики, прогнозы, отчеты) отражены в коде и аналитических результатах.
Обеспечить сохранение результатов заполнения формы в Google Sheets с возможностью последующей выгрузки и использования как полноценного технического задания проекта.
Разработан и протестирован рабочий prototype-код в Google Colab для анализа и прогнозирования кассовых разрывов, сформированы графики, аналитические таблицы, прогнозные модели и управленческий dashboard.
Дополнительно создана автоматизированная форма технического задания с использованием Google Apps Script, обеспечивающая структурированную фиксацию требований и результатов проекта.
Ты — senior fullstack developer, создающий production-ready веб-приложение на Google Apps Script.
Задача: сделать CASH GAP ANALYZER PRO для анализа cash flow компании и прогноза ликвидности.
Требования к приложению:
1. Загрузка данных:
- поддержка Excel (.xlsx)
- структура файла:
Дата | Входящий остаток | Поступления от продаж | Погашение дебиторской задолженности | Прочие поступления | Общие поступления | Оплата поставщикам | ФОТ | Налоги | Аренда | Погашение кредита | Прочие выплаты | Общие выплаты | Исходящий остаток
2. Логика расчета:
- balance = входящий остаток + inflow - outflow
- GAP detection: balance < 0
- KPI: burn rate, runway, liquidity ratio
- Risk scoring: HIGH, MEDIUM, LOW
3. Прогноз:
- 7–14 дней
- сценарии: best (+20% inflow), base, worst (-20% inflow)
4. Рекомендации:
- rule-based (если GAP → совет перенести платежи, если runway < 10 → привлечь финансирование и т.п.)
5. Визуализация:
- Dashboard с KPI (cards)
- График cash flow (Chart.js)
- График прогноза (base/best/worst)
- Список рекомендаций
6. Структура проекта:
- Code.gs → backend функции
* doGet()
* processData()
* forecast()
* generateRecommendations()
- index.html → frontend + JS + Chart.js
- стили встроенные
- frontend парсит Excel через SheetJS (XLSX)
- frontend вызывает backend через google.script.run
7. Требования к коду:
- Полный рабочий файл (Code.gs + index.html)
- production-ready
- чистый, читаемый JS и HTML
- все функции сразу рабочие, без заглушек
- интерактивный интерфейс
- понятный дашборд с KPI, графиками и рекомендациями
- комментариев минимум, только по необходимости
Вывод:
- Дай полный рабочий код **Code.gs** и **index.html** для Google Apps Script Web App.
Google Apps Script — это облачная среда разработки от Google на языке JavaScript, предназначенная для автоматизации и создания мини-приложений внутри экосистемы Google Workspace. С его помощью можно работать с Google Sheets, Forms, Gmail, Drive, Calendar и другими сервисами, а также создавать полноценные Web App-приложения с интерфейсом, кнопками, загрузкой файлов, расчетами и дашбордами.
Проще говоря, это инструмент, который позволяет превратить обычную таблицу в рабочее приложение: например, систему анализа кассовых разрывов, KPI dashboard, форму ввода данных или автоматизированный отчет для руководства.
Обычно проект состоит из двух основных частей: файл Code.gs, где находится бизнес-логика, расчеты и работа с данными, и файл Index.html, где создается интерфейс приложения для пользователя.
Открывается Apps Script через Google Sheets: Расширения → Apps Script.
В нашем проекте он используется для загрузки Excel-файла, автоматического анализа cash flow, расчета рисков, прогноза кассовых разрывов и отображения CFO dashboard в виде веб-приложения.
/* ================= CODE.GS ================= */
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('Анализ кассовых разрывов')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
// Универсальный парсер чисел (обрабатывает "- 0", пробелы, запятые)
function parseNum(v) {
if (typeof v === 'number') return v;
if (!v) return 0;
const s = String(v).replace(/\s/g, '').replace(/,/g, '.');
if (s === '-0' || s === '-0.') return 0;
const n = parseFloat(s);
return isNaN(n) ? 0 : n;
}
// 🔹 Основной движок анализа
function runEngine(rows, config) {
if (!rows || !Array.isArray(rows)) throw new Error('Неверный формат данных');
const TARGET_BALANCE = config?.targetBalance || 7000000;
const FORECAST_DAYS = config?.forecastDays || 30;
const validRows = rows.filter(r => r['Дата'] && String(r['Дата']).trim() !== '');
if (validRows.length === 0) throw new Error('В данных не найдено строк с датами');
const data = validRows.map(r => {
let d = r['Дата'];
if (!(d instanceof Date)) {
if (typeof d === 'string') d = new Date(d.trim());
else if (typeof d === 'number') d = new Date((d - 25569) * 86400 * 1000);
else d = new Date();
}
return {
date: isNaN(d.getTime()) ? null : d.toISOString().split('T')[0],
in_balance: parseNum(r['Входящий остаток, тг']),
total_in: parseNum(r['Общие поступления, тг']),
suppliers: parseNum(r['Оплата поставщикам, тг']),
payroll: parseNum(r['Фонд оплаты труда, тг']),
taxes: parseNum(r['Налоги и обязательные платежи, тг']),
rent: parseNum(r['Арендные платежи, тг']),
loans: parseNum(r['Погашение кредита, тг']),
total_out: parseNum(r['Общие выплаты, тг']),
out_balance: parseNum(r['Исходящий остаток, тг'])
};
}).filter(x => x.date).sort((a,b) => a.date.localeCompare(b.date));
if (!data.length) throw new Error('Не удалось распарсить даты');
const analysis = data.map(d => {
const net_cf = d.total_in - d.total_out;
const coverage = d.total_out > 0 ? d.total_in / d.total_out : Infinity;
const gap_amount = Math.min(0, d.out_balance - TARGET_BALANCE);
const gap_flag = gap_amount < 0;
const gap_size = gap_flag ? Math.abs(gap_amount) : 0;
let status = '✅ Норма';
if (d.out_balance < 0) status = '🔴 Критический дефицит';
else if (d.out_balance < TARGET_BALANCE) status = '🟡 Ниже целевого';
else if (coverage < 0.9) status = '🟠 Риск оттока';
let recommendation = '';
if (gap_flag) {
if (d.rent > 5000000) recommendation = 'Перенести аренду / запросить отсрочку';
else if (d.suppliers > 5000000) recommendation = 'Согласовать график с поставщиками';
else if (d.loans > 5000000) recommendation = 'Рефинансировать / активировать овердрафт';
else if (d.payroll > 2000000) recommendation = 'Оптимизировать ФОТ / сдвинуть выплату';
else recommendation = 'Активировать резерв / ускорить инкассацию';
}
let risk = 'LOW';
if (d.out_balance < 0 || coverage < 0.6) risk = 'CRITICAL';
else if (coverage < 0.85 || d.out_balance < TARGET_BALANCE) risk = 'HIGH';
else if (coverage < 1.05) risk = 'MEDIUM';
return { ...d, net_cf, coverage: coverage.toFixed(3), gap_flag, gap_size, gap_amount, status, recommendation, risk };
});
const last3 = analysis.slice(-3).map(x => x.net_cf);
const ma3 = last3.length >= 3 ? last3.reduce((a,b)=>a+b,0)/3 : (analysis[analysis.length-1]?.net_cf || 0);
const lastDate = new Date(analysis[analysis.length-1].date);
const lastBalance = analysis[analysis.length-1].out_balance;
const forecast = Array.from({length: FORECAST_DAYS}, (_,i) => {
const d = new Date(lastDate); d.setDate(d.getDate() + i + 1);
return { date: d.toISOString().split('T')[0], projected_balance: Math.round(lastBalance + ma3 * (i+1)) };
});
const scenarios = {
base: forecast,
negative: forecast.map((f,i) => ({ date: f.date, projected_balance: Math.round(lastBalance + (ma3*0.85)*(i+1)) })),
positive: forecast.map((f,i) => ({ date: f.date, projected_balance: Math.round(lastBalance + (ma3*1.15)*(i+1)) }))
};
const gaps = analysis.filter(x => x.gap_flag);
const kpi = {
avg_coverage: analysis.length ? (analysis.reduce((s,x)=>s+parseFloat(x.coverage),0)/analysis.length).toFixed(3) : '0.000',
max_gap: gaps.length ? Math.max(...gaps.map(g=>g.gap_size)) : 0,
avg_balance: Math.round(analysis.reduce((s,x)=>s+x.out_balance,0)/analysis.length),
total_net_cf: analysis.reduce((s,x)=>s+x.net_cf,0),
gap_days: gaps.length,
critical_days: analysis.filter(x=>x.out_balance<0).length
};
const maxDriver = ['suppliers','payroll','taxes','rent','loans'].reduce((max,key) => {
const avg = analysis.reduce((s,x)=>s+x[key],0)/analysis.length;
return avg > max.val ? {key,val:avg} : max;
}, {key:'suppliers',val:0}).key;
const driverNames = {suppliers:'Оплата поставщикам',payroll:'ФОТ',taxes:'Налоги',rent:'Аренда',loans:'Кредиты'};
const nextGap = forecast.find(f => f.projected_balance < TARGET_BALANCE)?.date || 'Не обнаружено в горизонте';
const gapTable = analysis.filter(x => x.gap_flag || x.risk!=='LOW').map(x => ({
Дата: x.date, Входящий_остаток: x.in_balance, Поступления: x.total_in, Выплаты: x.total_out,
Исходящий_остаток: x.out_balance, Целевой_остаток: TARGET_BALANCE,
Разрыв: x.gap_amount < 0 ? x.gap_amount : '', Статус: x.status, Рекомендация: x.recommendation || '—'
}));
return {
analysis, forecast, scenarios, gaps, kpi, gapTable,
conclusions: {
gap_causes: gaps.length ? `Обнаружено ${gaps.length} дней с разрывом. Причина: крупные выплаты.` : 'Разрывов нет. Ликвидность в норме.',
key_driver: `Главный расход: ${driverNames[maxDriver]}.`,
next_gap: `Следующий риск (прогноз): ${nextGap}.`,
cfo_rec: gaps.length===0 ? '✅ Сохранять резерв 10-15% от среднемесячных расходов.' : '🔧 1) Отсрочка платежей. 2) Овердрафт. 3) Факторинг дебиторки.'
}
};
}
// 📥 Обёртка для Excel
function processCashFlowData(rows, config) {
return runEngine(rows, config);
}
// 📊 Обёртка для Google Sheets
function fetchFromSheet(sheetInput, config) {
if (!sheetInput) throw new Error('ID или ссылка на таблицу не указаны');
let id = sheetInput.trim();
const match = id.match(/\/d\/([a-zA-Z0-9-_]+)/);
if (match) id = match[1];
try {
const ss = SpreadsheetApp.openById(id);
const ws = ss.getSheets()[0];
const raw = ws.getDataRange().getValues();
if (raw.length < 2) throw new Error('Таблица пуста или не содержит данных');
const headers = raw[0].map(h => String(h).trim());
const rowsData = raw.slice(1).filter(r => r[0] && (r[0] instanceof Date || String(r[0]).match(/^\d{4}-/)));
const jsonData = rowsData.map(r => {
const obj = {};
headers.forEach((h, i) => obj[h] = r[i]);
return obj;
});
return runEngine(jsonData, config);
} catch (e) {
throw new Error('Нет доступа к таблице. Проверьте ID и права доступа: ' + e.message);
}
}
<!-- ================= INDEX.HTML ================= -->
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta charset="utf-8">
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<script src="https://cdn.sheetjs.com/xlsx-0.20.1/package/dist/xlsx.full.min.js"></script>
<style>
:root {
--bg: #0f172a; --bg2: #1e293b; --card: #1e293b; --card-hover: #334155;
--primary: #38bdf8; --primary-h: #0ea5e9; --text: #f1f5f9; --muted: #94a3b8;
--border: #334155; --success: #22d3ee; --warning: #fbbf24; --danger: #f87171;
}
* { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 20px; background: var(--bg); color: var(--text); line-height: 1.5; }
.container { max-width: 1400px; margin: 0 auto; }
h1 { margin: 0 0 20px; font-size: 1.6rem; font-weight: 600; color: var(--primary); }
h3 { margin: 0 0 14px; font-size: 1.05rem; color: var(--muted); font-weight: 500; }
.card { background: var(--card); padding: 18px; border-radius: 12px; border: 1px solid var(--border); box-shadow: 0 4px 12px rgba(0,0,0,0.25); margin-bottom: 16px; transition: border-color 0.2s; }
.card:hover { border-color: var(--primary); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.grid-kpi { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
.grid-set { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; align-items: end; margin-top: 12px; }
.kpi-card { background: linear-gradient(145deg, var(--card), var(--bg2)); padding: 16px 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center; display: flex; flex-direction: column; justify-content: center; min-height: 95px; transition: transform 0.2s; }
.kpi-card:hover { transform: translateY(-2px); border-color: var(--primary); }
.kpi-label { font-size: 0.78rem; color: var(--muted); margin-bottom: 6px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.4px; }
.kpi-value { font-size: 1.35rem; font-weight: 700; color: var(--primary); line-height: 1.2; }
.kpi-value.danger { color: var(--danger); } .kpi-value.success { color: var(--success); } .kpi-value.warning { color: var(--warning); }
.set-grp { display: flex; flex-direction: column; gap: 6px; }
.set-lbl { font-size: 0.82rem; color: var(--muted); font-weight: 500; }
.set-inp { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 0.95rem; width: 100%; transition: border-color 0.2s; }
.set-inp:focus { outline: none; border-color: var(--primary); }
.set-hint { font-size: 0.75rem; color: var(--muted); margin-top: 2px; }
.chart-box { width: 100%; height: 320px; }
.t-scroll { overflow-x: auto; max-height: 420px; border-radius: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 0.83rem; }
th, td { padding: 11px 10px; border-bottom: 1px solid var(--border); text-align: right; }
th { background: var(--bg2); font-weight: 600; color: var(--muted); text-align: center; position: sticky; top: 0; z-index: 2; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.4px; }
td:first-child, th:first-child { text-align: left; }
tbody tr:hover { background: var(--card-hover); }
.badge { padding: 5px 12px; border-radius: 20px; font-size: 0.76rem; font-weight: 600; display: inline-block; white-space: nowrap; }
.b-ok { background: rgba(34,197,94,0.15); color: var(--success); border: 1px solid rgba(34,197,94,0.3); }
.b-warn { background: rgba(251,191,36,0.15); color: var(--warning); border: 1px solid rgba(251,191,36,0.3); }
.b-crit { background: rgba(248,113,113,0.15); color: var(--danger); border: 1px solid rgba(248,113,113,0.3); }
.btn { padding: 11px 20px; background: linear-gradient(135deg, var(--primary), var(--primary-h)); color: #0f172a; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 0.92rem; transition: all 0.2s; box-shadow: 0 2px 8px rgba(56,189,248,0.25); display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(56,189,248,0.4); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn-clear { background: linear-gradient(135deg, #475569, #64748b); color: var(--text); box-shadow: 0 2px 8px rgba(100,116,139,0.2); }
.btn-refresh { background: linear-gradient(135deg, #22d3ee, #06b6d4); color: #0f172a; }
#status { padding: 12px 16px; margin: 12px 0; background: var(--bg2); color: var(--text); border-radius: 8px; border-left: 4px solid var(--primary); display: none; font-size: 0.92rem; }
#status.error { background: rgba(248,113,113,0.1); color: var(--danger); border-left-color: var(--danger); }
#status.success { background: rgba(34,197,94,0.1); color: var(--success); border-left-color: var(--success); }
.toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
.source-selector { display: flex; gap: 8px; }
.src-btn { padding: 10px 16px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); cursor: pointer; font-weight: 500; transition: all 0.2s; }
.src-btn.active { background: rgba(56,189,248,0.15); color: var(--primary); border-color: var(--primary); }
.src-btn:hover { border-color: var(--primary); }
#timerStatus { background: #0f172a; color: var(--muted); border: 1px solid var(--border); font-size: 0.9rem; display: flex; align-items: center; gap: 8px; justify-content: center; min-height: 42px; border-radius: 6px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.dot.active { background: var(--success); animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
#fileName { margin-left: 10px; color: var(--muted); font-size: 0.88rem; }
.source-input { margin-top: 8px; animation: fadeIn 0.3s ease; }
@keyframes fadeIn { from{opacity:0;transform:translateY(-5px)} to{opacity:1;transform:translateY(0)} }
.sheet-label { color: var(--success); font-weight: 500; padding: 8px 0; display: flex; align-items: center; gap: 6px; }
.placeholder { color: var(--muted); font-style: italic; padding: 24px; text-align: center; background: var(--bg2); border-radius: 8px; border: 1px dashed var(--border); }
.conclusion { font-size: 0.9rem; }
.conclusion p { margin: 7px 0; color: var(--muted); }
.rec { background: rgba(34,197,94,0.08); padding: 12px; border-radius: 8px; margin-top: 10px; border-left: 3px solid var(--success); color: var(--text); }
@media (max-width:768px){ .grid-kpi{grid-template-columns:repeat(2,1fr)} .toolbar{flex-direction:column;align-items:stretch} }
</style>
</head>
<body>
<div class="container">
<h1>📊 Анализ кассовых разрывов</h1>
<div class="card">
<div class="toolbar">
<div class="source-selector">
<button id="btnExcel" class="src-btn active" onclick="triggerFileSelect()">📁 Excel (ПК)</button>
<button id="btnSheet" class="src-btn" onclick="setSource('sheet')">📊 Google Таблица</button>
</div>
<button id="refreshBtn" class="btn btn-refresh" onclick="refreshAnalysis()" disabled>🔄 Запустить анализ</button>
<button class="btn btn-clear" onclick="resetApp()">🧹 Очистить</button>
</div>
<input type="file" id="fileInput" accept=".xlsx,.xls" style="display:none" onchange="handleFileSelect(this)">
<div id="fileName"></div>
<div id="inputSheet" class="source-input" style="display:none">
<input type="text" id="sheetIdInput" class="set-inp" placeholder="Вставьте ID или полную ссылку на Google Таблицу">
<div id="sheetSourceLabel" class="sheet-label" style="display:none">📂 Источник данных: Google Drive</div>
<div class="set-hint">ID берётся из URL: docs.google.com/spreadsheets/d/<b>ЭТОТ_ID</b>/edit</div>
</div>
<div class="grid-set">
<div class="set-grp"><label class="set-lbl">🎯 Целевой остаток, тг</label><input type="number" id="targetBalance" class="set-inp" value="7000000" min="0" step="100000"><div class="set-hint">Мин. безопасный остаток</div></div>
<div class="set-grp"><label class="set-lbl">📅 Горизонт прогноза, дней</label><input type="number" id="forecastDays" class="set-inp" value="30" min="7" max="90"><div class="set-hint">Период прогнозирования</div></div>
<div class="set-grp"><label class="set-lbl">⏱️ Интервал авто-обновления, мин</label><input type="number" id="timerMin" class="set-inp" value="3" min="1" max="60" onchange="updateTimerSettings()"><div class="set-hint">Работает только для Google Sheets</div></div>
<div class="set-grp"><label class="set-lbl">🔄 Статус таймера</label><div id="timerStatus" class="set-inp"><div class="dot"></div> ⏸️ Выключен</div></div>
</div>
<div id="status"></div>
</div>
<div id="dash" style="display:none">
<div class="card"><h3>📈 Ключевые показатели</h3>
<div class="grid-kpi">
<div class="kpi-card"><div class="kpi-label">Среднее покрытие</div><div class="kpi-value" id="kpi_cov">—</div></div>
<div class="kpi-card"><div class="kpi-label">Макс. разрыв</div><div class="kpi-value danger" id="kpi_max">—</div></div>
<div class="kpi-card"><div class="kpi-label">Дней с разрывом</div><div class="kpi-value warning" id="kpi_gdays">—</div></div>
<div class="kpi-card"><div class="kpi-label">Средний остаток</div><div class="kpi-value" id="kpi_bal">—</div></div>
<div class="kpi-card"><div class="kpi-label">Чистый поток</div><div class="kpi-value success" id="kpi_net">—</div></div>
<div class="kpi-card"><div class="kpi-label">Критических дней</div><div class="kpi-value danger" id="kpi_crit">—</div></div>
</div>
</div>
<div class="card"><h3>📝 Выводы CFO</h3><div id="conc" class="conclusion"></div></div>
<div class="card"><div id="ch_bal" class="chart-box"></div></div>
<div class="grid"><div class="card"><div id="ch_flow" class="chart-box"></div></div><div class="card"><div id="ch_net" class="chart-box"></div></div></div>
<div class="card"><div id="ch_fc" class="chart-box"></div></div>
<div class="card">
<h3>🔍 Детализация по кассовым разрывам</h3>
<div class="t-scroll">
<table><thead><tr><th>Дата</th><th>Вход. остаток</th><th>Поступления</th><th>Выплаты</th><th>Исх. остаток</th><th>Целевой</th><th>Разрыв</th><th>Статус</th><th>Рекомендация</th></tr></thead><tbody id="tbl_body"></tbody></table>
</div>
<div id="tbl_empty" class="placeholder" style="display:none">✅ Разрывов и рисков не обнаружено</div>
</div>
<div style="text-align:center;margin:24px 0"><button class="btn" onclick="exportExcel()">💾 Экспорт отчёта в Excel</button></div>
</div>
</div>
<script>
let currentSource = 'excel', rawRows = null, autoT = null, countT = null, countSec = 180;
const fmt = n => new Intl.NumberFormat('ru-RU',{maximumFractionDigits:0}).format(n);
const fmtM = n => (n<0?'−':'')+fmt(Math.abs(n))+' тг';
const st = (m,t='info') => { const e=document.getElementById('status'); e.style.display='block'; e.className=t; e.textContent=m; };
function triggerFileSelect() { setSource('excel'); document.getElementById('fileInput').click(); }
function setSource(type) {
currentSource = type;
document.getElementById('btnExcel').className = 'src-btn' + (type==='excel'?' active':'');
document.getElementById('btnSheet').className = 'src-btn' + (type==='sheet'?' active':'');
if (type === 'excel') {
rawRows = null; document.getElementById('fileInput').value = ''; document.getElementById('fileName').textContent = '';
stopAuto(); updateTimerUI('⏸️ Выключен (режим Excel)');
document.getElementById('inputSheet').style.display = 'none';
} else {
document.getElementById('inputSheet').style.display = 'block';
updateTimerUI('▶️ Готов к запуску');
}
checkReady();
}
function showSheetInput() {
document.getElementById('sheetIdInput').style.display = 'block';
document.getElementById('sheetSourceLabel').style.display = 'none';
}
function checkReady() {
const ready = (currentSource==='excel' && rawRows) || (currentSource==='sheet' && document.getElementById('sheetIdInput').value.trim());
document.getElementById('refreshBtn').disabled = !ready;
}
document.getElementById('sheetIdInput').addEventListener('input', checkReady);
function handleFileSelect(input) {
const file = input.files[0]; if (!file) return;
document.getElementById('fileName').textContent = '📄 ' + file.name; st('⏳ Чтение файла...');
const r = new FileReader();
r.onload = ev => {
try {
if (typeof XLSX === 'undefined') throw new Error('SheetJS не загружен');
const wb = XLSX.read(ev.target.result, {type:'array', cellDates:true});
const ws = wb.Sheets[wb.SheetNames[0]];
rawRows = XLSX.utils.sheet_to_json(ws, {defval:0}).filter(row => row['Дата']);
if (!rawRows?.length) throw new Error('Нет данных с датами');
st('✅ Файл загружен. Нажмите "Запустить анализ"');
checkReady();
} catch(err) { st('❌ '+err.message, 'error'); }
};
r.readAsArrayBuffer(file);
}
function refreshAnalysis() {
const conf = getConf();
st('🔄 Загрузка и расчёт...');
if (currentSource === 'excel') {
if (!rawRows) return st('⚠️ Файл не загружен', 'error');
google.script.run.withSuccessHandler(render).withFailureHandler(e => st('❌ '+e.message,'error')).processCashFlowData(rawRows, conf);
} else {
const id = document.getElementById('sheetIdInput').value.trim();
if (!id) return st('⚠️ Укажите ID таблицы', 'error');
document.getElementById('sheetIdInput').style.display = 'none';
document.getElementById('sheetSourceLabel').style.display = 'flex';
google.script.run.withSuccessHandler(d => { render(d); startAuto(); }).withFailureHandler(e => {
st('❌ '+e.message,'error'); showSheetInput();
}).fetchFromSheet(id, conf);
}
}
function getConf() {
return {
targetBalance: parseInt(document.getElementById('targetBalance').value) || 7000000,
forecastDays: parseInt(document.getElementById('forecastDays').value) || 30
};
}
function updateTimerSettings() {
const mins = parseInt(document.getElementById('timerMin').value) || 3;
countSec = mins * 60;
if (currentSource === 'sheet' && autoT) { stopAuto(); startAuto(); }
}
function updateTimerUI(txt) { document.getElementById('timerStatus').innerHTML = `<div class="dot ${txt.includes('Выключен')?'':'active'}"></div> ${txt}`; }
function startCount() { if(countT)clearInterval(countT); countSec = parseInt(document.getElementById('timerMin').value)*60; updateCountdown(); countT=setInterval(()=>{countSec--;updateCountdown();if(countSec<=0)countSec=parseInt(document.getElementById('timerMin').value)*60;},1000); }
function updateCountdown() { if(currentSource!=='sheet')return; const m=Math.floor(countSec/60); const s=countSec%60; updateTimerUI(`▶️ Авто: ${m}:${String(s).padStart(2,'0')}`); }
function startAuto() { stopAuto(); if(currentSource!=='sheet')return; startCount(); autoT=setInterval(refreshAnalysis, parseInt(document.getElementById('timerMin').value)*60000); }
function stopAuto() { if(autoT)clearInterval(autoT); if(countT)clearInterval(countT); autoT=null;countT=null; if(currentSource!=='sheet')updateTimerUI('⏸️ Выключен'); }
function resetApp() {
rawRows=null; stopAuto();
document.getElementById('dash').style.display='none';
document.getElementById('status').style.display='none';
document.getElementById('fileInput').value=''; document.getElementById('fileName').textContent='';
document.getElementById('sheetIdInput').value=''; showSheetInput();
['ch_bal','ch_flow','ch_net','ch_fc'].forEach(id=>{try{Plotly.purge(id)}catch(e){}});
checkReady();
}
function render(d){
st('✅ Анализ завершён. Разрывов: '+d.kpi.gap_days,'success'); document.getElementById('dash').style.display='block';
document.getElementById('kpi_cov').textContent=d.kpi.avg_coverage;
document.getElementById('kpi_max').textContent=fmtM(d.kpi.max_gap);
document.getElementById('kpi_gdays').textContent=d.kpi.gap_days;
document.getElementById('kpi_bal').textContent=fmtM(d.kpi.avg_balance);
document.getElementById('kpi_net').textContent=fmtM(d.kpi.total_net_cf);
document.getElementById('kpi_crit').textContent=d.kpi.critical_days;
document.getElementById('conc').innerHTML=`<p><b style="color:var(--text)">🔍 Причины:</b> ${d.conclusions.gap_causes}</p><p><b style="color:var(--text)">📉 Главный расход:</b> ${d.conclusions.key_driver}</p><p><b style="color:var(--text)">📅 Прогноз:</b> ${d.conclusions.next_gap}</p><div class="rec"><b>💡 Рекомендация:</b> ${d.conclusions.cfo_rec}</div>`;
const L={paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',font:{color:'#94a3b8',size:11},margin:{t:30,b:40,l:50,r:20},height:320,showlegend:true,legend:{font:{size:10,color:'#94a3b8'}},xaxis:{gridcolor:'#334155',linecolor:'#475569'},yaxis:{gridcolor:'#334155',linecolor:'#475569'},hoverlabel:{bgcolor:'#1e293b',font:{color:'#f1f5f9'}}};
const dt=d.analysis.map(x=>x.date);
Plotly.newPlot('ch_bal',[{x:dt,y:d.analysis.map(x=>x.out_balance),type:'scatter',mode:'lines+markers',name:'Остаток',line:{color:'#38bdf8',width:2.5}}],{...L,title:{text:'Динамика остатка',font:{size:13,color:'#f1f5f9'}}});
Plotly.newPlot('ch_flow',[{x:dt,y:d.analysis.map(x=>x.total_in),name:'Поступления',type:'bar',marker:{color:'#22d3ee'}},{x:dt,y:d.analysis.map(x=>x.total_out),name:'Выплаты',type:'bar',marker:{color:'#f87171'}}],{...L,barmode:'group',title:{text:'Поступления и выплаты',font:{size:13,color:'#f1f5f9'}}});
const nv=d.analysis.map(x=>x.net_cf);
Plotly.newPlot('ch_net',[{x:dt,y:nv,type:'bar',marker:{color:nv.map(v=>v>=0?'#22d3ee':'#f87171')},name:'Чистый поток'}],{...L,title:{text:'Чистый денежный поток',font:{size:13,color:'#f1f5f9'}}});
const fd=d.forecast.map(x=>x.date);
Plotly.newPlot('ch_fc',[{x:dt,y:d.analysis.map(x=>x.out_balance),name:'Факт',line:{width:3,color:'#38bdf8'}},{x:fd,y:d.scenarios.base.map(x=>x.projected_balance),name:'Базовый',line:{color:'#94a3b8'}},{x:fd,y:d.scenarios.negative.map(x=>x.projected_balance),name:'Риск (−15%)',line:{dash:'dot',color:'#f87171'}},{x:fd,y:d.scenarios.positive.map(x=>x.projected_balance),name:'Оптимистичный (+15%)',line:{dash:'dot',color:'#22d3ee'}}],{...L,title:{text:'Прогноз остатка',font:{size:13,color:'#f1f5f9'}}});
const tb=document.getElementById('tbl_body'),em=document.getElementById('tbl_empty');
if(!d.gapTable?.length){tb.innerHTML='';em.style.display='block';}
else{em.style.display='none';tb.innerHTML=d.gapTable.map(r=>{
const sc=r.Статус?.includes('Критический')?'b-crit':r.Статус?.includes('Ниже')?'b-warn':'b-ok';
const gv=r.Разрыв?`<span style="color:var(--danger);font-weight:600">${fmtM(r.Разрыв)}</span>`:'—';
const bc=r.Исходящий_остаток<0?'var(--danger)':'var(--text)';
return `<tr><td><b style="color:var(--primary)">${r.Дата}</b></td><td>${fmtM(r.Входящий_остаток)}</td><td style="color:var(--success)">${fmtM(r.Поступления)}</td><td style="color:var(--danger)">${fmtM(r.Выплаты)}</td><td><b style="color:${bc}">${fmtM(r.Исходящий_остаток)}</b></td><td>${fmtM(r.Целевой_остаток)}</td><td>${gv}</td><td><span class="badge ${sc}">${r.Статус||'—'}</span></td><td style="text-align:left;color:var(--muted);max-width:200px">${r.Рекомендация||'—'}</td></tr>`;
}).join('');}
}
function exportExcel(){
if(!document.getElementById('dash').style.display==='block') return alert('Нет данных');
const wb=XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb,XLSX.utils.json_to_sheet(resultData?.gapTable||[]),'Разрывы_и_риски');
XLSX.utils.book_append_sheet(wb,XLSX.utils.json_to_sheet(resultData?.forecast||[]),'Прогноз_30дн');
XLSX.utils.book_append_sheet(wb,XLSX.utils.json_to_sheet([{Показатель:'Среднее покрытие',Значение:document.getElementById('kpi_cov').textContent},{Показатель:'Макс. разрыв',Значение:document.getElementById('kpi_max').textContent}]),'KPI_сводка');
XLSX.writeFile(wb,'cashflow_report_'+new Date().toISOString().slice(0,10)+'.xlsx');
}
window.onload = () => setSource('excel');
</script>
</body>
</html>