diff --git a/.vibe42-run.log b/.vibe42-run.log index cf1d345..6069ff9 100644 --- a/.vibe42-run.log +++ b/.vibe42-run.log @@ -12,3 +12,9 @@ server on port 3000 --- run Sun Sep 27 04:42:10 PM UTC 2026 :: node server.js CHECK: syntax OK, data.json rows: 103, 2025 total: 8 350 018 093 server on port 3000 +--- run Sun Sep 27 04:58:42 PM UTC 2026 :: node server.js +CHECK: syntax OK, data.json rows: 103, 2025 total: 8 350 018 093 +server on port 3000 +--- run Sun Sep 27 05:13:37 PM UTC 2026 :: node server.js +CHECK: syntax OK, data.json rows: 103, 2025 total: 8 350 018 093 +server on port 3000 diff --git a/.vibe42-run.pid b/.vibe42-run.pid index c882b49..a377090 100644 --- a/.vibe42-run.pid +++ b/.vibe42-run.pid @@ -1 +1 @@ -2597 +4826 diff --git a/index.html b/index.html index 4d42c13..f37b911 100644 --- a/index.html +++ b/index.html @@ -107,6 +107,10 @@ Показать презентацию + + + Анализ_расходов_по_сервисным_контрактам.docx + По кнопке «Опубликовать» откроется таблица; презентация живёт на отдельном URL — её можно открыть в другом окне. diff --git a/mkword.py b/mkword.py new file mode 100644 index 0000000..02ad19a --- /dev/null +++ b/mkword.py @@ -0,0 +1,331 @@ +import json, datetime +from docx import Document +from docx.shared import Pt, Cm, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.enum.table import WD_TABLE_ALIGNMENT, WD_ALIGN_VERTICAL +from docx.oxml.ns import qn, nsmap +from docx.oxml import OxmlElement + +DARK = RGBColor(0x0D, 0x1B, 0x2A) +PANEL = RGBColor(0x14, 0x24, 0x33) +ACCENT = RGBColor(0x1B, 0x4F, 0x72) +OK = RGBColor(0x1A, 0x7F, 0x4B) +RISK = RGBColor(0xC0, 0x39, 0x2B) +WARN = RGBColor(0xD4, 0x68, 0x0A) +MUTE = RGBColor(0x88, 0x99, 0xA6) +DIM = RGBColor(0x6C, 0x8F, 0xA8) +FG = RGBColor(0xF0, 0xF4, 0xF8) +TXT = RGBColor(0x0C, 0x0A, 0x09) +TXT2 = RGBColor(0x78, 0x71, 0x6C) + +doc = Document() +# А4, поля +for s in doc.sections: + s.page_height = Cm(29.7); s.page_width = Cm(21.0) + s.top_margin = Cm(1.8); s.bottom_margin = Cm(1.5) + s.left_margin = Cm(1.5); s.right_margin = Cm(1.5) + +d = json.load(open('data.json')) +rows = d['rows'] +t23 = sum(r.get('y2023') or 0 for r in rows) +t24 = sum(r.get('y2024') or 0 for r in rows) +t25 = sum(r.get('y2025') or 0 for r in rows) +q26 = sum(r.get('q1_2026') or 0 for r in rows) +b = lambda v: v / 1e9 +m = lambda v: v / 1e6 + +# ---------- Заголовок ---------- +t = doc.add_paragraph() +t.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = t.add_run('АНАЛИЗ РАСХОДОВ ПО СЕРВИСНЫМ КОНТРАКТАМ') +r.bold = True; r.font.size = Pt(22); r.font.color.rgb = DARK; r.font.name = 'Segoe UI' +sub = doc.add_paragraph() +sub.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = sub.add_run('Детализация 2023 г. – 1-е полугодие 2026 г. · 103 позиции · сумма в тенге (факт)') +r.font.size = Pt(11); r.font.color.rgb = TXT2; r.font.name = 'Segoe UI' +sub.paragraph_format.space_after = Pt(14) + +# ---------- KPI ---------- +def kpi_card(label, value, sub='', color=DARK): + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(2) + r = p.add_run(value) + r.bold = True; r.font.size = Pt(20); r.font.color.rgb = color; r.font.name = 'Segoe UI' + p2 = doc.add_paragraph() + p2.paragraph_format.space_after = Pt(0) + r = p2.add_run(label) + r.font.size = Pt(9); r.font.color.rgb = TXT2; r.font.name = 'Segoe UI' + if sub: + p3 = doc.add_paragraph() + p3.paragraph_format.space_after = Pt(4) + r = p3.add_run(sub) + r.font.size = Pt(8); r.font.italic = True; r.font.color.rgb = MUTE; r.font.name = 'Segoe UI' + +tbl = doc.add_table(rows=1, cols=4) +tbl.style = 'Table Grid' +tbl.alignment = WD_TABLE_ALIGNMENT.CENTER +for i, (lbl, val, sub, col) in enumerate([ + ('2023', f'{b(t23):,.2f} млрд тг', '55 позиций', DARK), + ('2024', f'{b(t24):,.2f} млрд тг', '54 позиции · пик', RISK), + ('2025', f'{b(t25):,.2f} млрд тг', '45 позиций', DARK), + ('1пг 2026', f'{b(q26):,.2f} млрд тг', '31 позиция', OK), +]): + cell = tbl.rows[0].cells[i] + cell.width = Cm(3.8) + # Кнопка-фон: светлый + tcPr = cell._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:fill'), 'F0F4F8'); tcPr.append(shd) + p = cell.paragraphs[0]; p.alignment = WD_ALIGN_PARAGRAPH.CENTER; p.paragraph_format.space_after = Pt(2) + r = p.add_run(val) + r.bold = True; r.font.size = Pt(18); r.font.color.rgb = col; r.font.name = 'Segoe UI' + p2 = cell.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.CENTER; p2.paragraph_format.space_after = Pt(0) + r = p2.add_run(lbl) + r.bold = True; r.font.size = Pt(10); r.font.color.rgb = TXT; r.font.name = 'Segoe UI' + p3 = cell.add_paragraph(); p3.alignment = WD_ALIGN_PARAGRAPH.CENTER; p3.paragraph_format.space_after = Pt(2) + r = p3.add_run(sub) + r.font.size = Pt(8); r.font.color.rgb = MUTE; r.font.name = 'Segoe UI' +doc.add_paragraph() + +# ---------- Сводка ---------- +h = doc.add_paragraph() +h.paragraph_format.space_before = Pt(6); h.paragraph_format.space_after = Pt(6) +r = h.add_run('1. Итоговый свод') +r.bold = True; r.font.size = Pt(14); r.font.color.rgb = DARK; r.font.name = 'Segoe UI' + +def bullet(text, bold_lead=None): + p = doc.add_paragraph(style='List Bullet') + p.paragraph_format.space_after = Pt(6) + if bold_lead: + r = p.add_run(bold_lead + ' ') + r.bold = True; r.font.size = Pt(10.5); r.font.color.rgb = TXT; r.font.name = 'Segoe UI' + r = p.add_run(text) + r.font.size = Pt(10.5); r.font.color.rgb = TXT; r.font.name = 'Segoe UI' + return p + +d24_25 = t25 - t24; p24_25 = (t24 and d24_25 / t24 * 100) +d23_24 = t24 - t23; p23_24 = d23_24 / t23 * 100 +bullet(f'Изменение 2024 → 2025: −{b(abs(d24_25)):,.2f} млрд тг ({p24_25:+.1f} %) — это не экономия, а смена номенклатуры (FLM Expenses → FLM, Ericsson CS/5G part → общий договор, MS EA/COGS/G&A).'.replace('','').replace('',''), + bold_lead='Ключевой вывод') +bullet(f'2023 → 2024: рост на {b(d23_24):,.2f} млрд тг ({p23_24:+.1f} %) — пик расходов.', + bold_lead='Динамика') +bullet(f'Темп 1пг 2026 ({b(q26):,.2f} млрд за полго­да), удвоенный на год ≈ {b(q26*2):,.2f} млрд — на уровне 2024.', + bold_lead='Прогноз 2026') +bullet('Структура 2025: FLM 3 232 млн · Ericsson 790 млн · MS EA 630 млн · MS EA COGS 513 млн · FLM Expenses 3 481 млн (2024) — крупнейшие статьи.', + bold_lead='Топ статей 2025') + +# ---------- Отклонения 2025 vs 2024 ---------- +doc.add_paragraph() +h = doc.add_paragraph() +h.paragraph_format.space_before = Pt(10) +r = h.add_run('2. Отклонения 2025 vs 2024 (существующие статьи, |Δ| ≥ 50 млн тг)') +r.bold = True; r.font.size = Pt(14); r.font.color.rgb = DARK; r.font.name = 'Segoe UI' + +tab = doc.add_table(rows=1, cols=5) +tab.alignment = WD_TABLE_ALIGNMENT.CENTER +hdr = tab.rows[0].cells +labels = ['Позиция', '2024, млн тг', '2025, млн тг', 'Δ, млн тг', 'Δ %'] +for i, l in enumerate(labels): + cell = hdr[i] + tcPr = cell._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:fill'), '142433'); tcPr.append(shd) + p = cell.paragraphs[0]; p.alignment = WD_ALIGN_PARAGRAPH.RIGHT if i > 0 else WD_ALIGN_PARAGRAPH.LEFT + r = p.add_run(l) + r.bold = True; r.font.size = Pt(9); r.font.color.rgb = MUTE; r.font.name = 'Segoe UI' + +cands = [r for r in rows if (r.get('d25') or 0) not in (0, None) and (r.get('y2025') or 0) > 0 and (r.get('y2024') or 0) > 0] +cands.sort(key=lambda r: abs(r.get('d25') or 0), reverse=True) +cands = [c for c in cands if abs(c['d25']) >= 50e6][:15] +ri = 0 +for it in cands: + row = tab.add_row().cells + for i, (v, fmt) in enumerate([ + (it.get('name'), '{:s}'), + (it.get('y2024') / 1e6, '{:,.1f}'), + (it.get('y2025') / 1e6, '{:,.1f}'), + (it.get('d25') / 1e6, '{:+,.1f}'), + (it.get('p25'), '{:+.1f} %'), + ]): + cell = row[i] + if v is None or v == '': + continue + tcPr = cell._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd') + if i == 0: + shd.set(qn('w:fill'), 'F0F4F8' if ri % 2 else 'FFFFFF') + else: + shd.set(qn('w:fill'), 'F0F4F8' if ri % 2 else 'FFFFFF') + tcPr.append(shd) + if i == 0: + p = cell.paragraphs[0]; r = p.add_run(fmt.format(v)); r.font.size = Pt(9); r.font.color.rgb = TXT; r.font.name = 'Segoe UI' + else: + p = cell.paragraphs[0]; p.alignment = WD_ALIGN_PARAGRAPH.RIGHT + r = p.add_run(fmt.format(v)); r.font.size = Pt(9); r.font.name = 'Segoe UI' + if i == 3 or i == 4: + color = RISK if v > 0 else OK + r.bold = True; r.font.color.rgb = color + ri += 1 +cap = doc.add_paragraph() +cap.paragraph_format.space_before = Pt(4) +r = cap.add_run('Красное — рост относительно 2024, зелёное — снижение. Полный список — лист «Детализация» в Excel-отчёте.') +r.font.size = Pt(8); r.font.italic = True; r.font.color.rgb = MUTE; r.font.name = 'Segoe UI' + +# ---------- 1пг 2026 ---------- +doc.add_paragraph() +h = doc.add_paragraph() +h.paragraph_format.space_before = Pt(10) +r = h.add_run('3. 1-е полугодие 2026: существующие статьи (|Δ| ≥ 50 млн тг, к темпу 2025)') +r.bold = True; r.font.size = Pt(14); r.font.color.rgb = DARK; r.font.name = 'Segoe UI' +tab = doc.add_table(rows=1, cols=5) +tab.alignment = WD_TABLE_ALIGNMENT.CENTER +hdr = tab.rows[0].cells +labels = ['Позиция', 'Темп 1пг 2025, млн', '1пг 2026, млн', 'Δ, млн тг', 'Δ %'] +for i, l in enumerate(labels): + cell = hdr[i] + tcPr = cell._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:fill'), '142433'); tcPr.append(shd) + p = cell.paragraphs[0]; p.alignment = WD_ALIGN_PARAGRAPH.RIGHT if i > 0 else WD_ALIGN_PARAGRAPH.LEFT + r = p.add_run(l) + r.bold = True; r.font.size = Pt(9); r.font.color.rgb = MUTE; r.font.name = 'Segoe UI' + +cands26 = [r for r in rows if (r.get('q1_2026') or 0) > 0 and (r.get('y2025') or 0) > 0] +for c in cands26: + c['q1_2025'] = c['y2025'] / 2 + c['d26'] = c['q1_2026'] - c['q1_2025'] + c['p26'] = c['d26'] / c['q1_2025'] * 100 if c['q1_2025'] else 0 +cands26.sort(key=lambda r: abs(r.get('d26') or 0), reverse=True) +cands26 = [c for c in cands26 if abs(c['d26']) >= 50e6][:15] +ri = 0 +for it in cands26: + row = tab.add_row().cells + for i, (v, fmt) in enumerate([ + (it.get('name'), '{:s}'), + (it.get('q1_2025') / 1e6, '{:,.1f}'), + (it.get('q1_2026') / 1e6, '{:,.1f}'), + (it.get('d26') / 1e6, '{:+,.1f}'), + (it.get('p26'), '{:+.1f} %'), + ]): + cell = row[i] + tcPr = cell._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:fill'), 'F0F4F8' if ri % 2 else 'FFFFFF'); tcPr.append(shd) + if i == 0: + p = cell.paragraphs[0]; r = p.add_run(fmt.format(v)); r.font.size = Pt(9); r.font.color.rgb = TXT; r.font.name = 'Segoe UI' + else: + p = cell.paragraphs[0]; p.alignment = WD_ALIGN_PARAGRAPH.RIGHT + r = p.add_run(fmt.format(v)); r.font.size = Pt(9); r.font.name = 'Segoe UI' + if i == 3 or i == 4: + color = RISK if v > 0 else OK + r.bold = True; r.font.color.rgb = color + ri += 1 +cap = doc.add_paragraph() +cap.paragraph_format.space_before = Pt(4) +r = cap.add_run('«Темп 1пг 2025» = факт 2025 / 2 (ориентир при равномерном распределении). Красное — рост, зелёное — снижение.') +r.font.size = Pt(8); r.font.italic = True; r.font.color.rgb = MUTE; r.font.name = 'Segoe UI' + +# ---------- Новые / исчезнувшие ---------- +doc.add_paragraph() +h = doc.add_paragraph() +h.paragraph_format.space_before = Pt(10) +r = h.add_run('4. Смена номенклатуры: что появилось / исчезло в 2025') +r.bold = True; r.font.size = Pt(14); r.font.color.rgb = DARK; r.font.name = 'Segoe UI' + +names24 = set(r['name'] for r in rows if (r.get('y2024') or 0) > 0 and (r.get('y2025') or 0) == 0) +new25 = [r for r in rows if (r.get('y2025') or 0) > 0 and (r.get('y2024') or 0) == 0] +gone25 = [r for r in rows if (r.get('y2024') or 0) > 0 and (r.get('y2025') or 0) == 0] +new25.sort(key=lambda r: r['y2025'], reverse=True) +gone25.sort(key=lambda r: r['y2024'], reverse=True) + +def small_table(rows_, idx, ykey, title): + h2 = doc.add_paragraph() + h2.paragraph_format.space_before = Pt(6); h2.paragraph_format.space_after = Pt(4) + r = h2.add_run(title) + r.bold = True; r.font.size = Pt(11); r.font.color.rgb = ACCENT; r.font.name = 'Segoe UI' + t2 = doc.add_table(rows=1, cols=3) + t2.alignment = WD_TABLE_ALIGNMENT.CENTER + hdr = t2.rows[0].cells + for i, l in enumerate(['Позиция', f'{ykey}, млн тг', 'Коммент']): + cell = hdr[i] + tcPr = cell._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:fill'), 'F0F4F8'); tcPr.append(shd) + p = cell.paragraphs[0]; p.alignment = WD_ALIGN_PARAGRAPH.RIGHT if i == 1 else WD_ALIGN_PARAGRAPH.LEFT + r = p.add_run(l) + r.bold = True; r.font.size = Pt(8); r.font.color.rgb = MUTE; r.font.name = 'Segoe UI' + for it in rows_: + row = t2.add_row().cells + row[0].paragraphs[0].add_run(it['name']).font.size = Pt(8) + p = row[1].paragraphs[0]; p.alignment = WD_ALIGN_PARAGRAPH.RIGHT + r = p.add_run(f"{it[ykey]/1e6:,.1f}"); r.font.size = Pt(8) + row[2].paragraphs[0].add_run('').font.size = Pt(8) + # Итог + tr = t2.add_row().cells + tr[0].paragraphs[0].add_run(f"Итого: {len(rows_)} шт.").bold = True + p = tr[1].paragraphs[0]; p.alignment = WD_ALIGN_PARAGRAPH.RIGHT + r = p.add_run(f"{sum(r_[ykey] for r_ in rows_)/1e6:,.1f}"); r.bold = True; r.font.size = Pt(8) + # Заполнить фон итога + for cell in tr: + tcPr = cell._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:fill'), 'EEF4FB'); tcPr.append(shd) + +small_table(new25[:12], 0, 'y2025', f'Появились в 2025 (нет в 2024): {len(new25)} шт.') +small_table(gone25[:12], 1, 'y2024', f'Исчезли в 2025 (были в 2024): {len(gone25)} шт.') +cap = doc.add_paragraph() +cap.paragraph_format.space_before = Pt(4) +sum_new = sum(r['y2025'] for r in new25) +sum_gone = sum(r['y2024'] for r in gone25) +r = cap.add_run(f'Пришло {b(sum_new):,.2f} млрд тг, ушло {b(sum_gone):,.2f} млрд тг. Чистая смена номенклатуры: {b(sum_new - sum_gone):+.2f} млрд тг — это механика спада 2025.') +r.font.size = Pt(9); r.font.color.rgb = TXT2; r.font.name = 'Segoe UI' + +# ---------- Рекомендации ---------- +doc.add_paragraph() +h = doc.add_paragraph() +h.paragraph_format.space_before = Pt(10) +r = h.add_run('5. Рекомендации') +r.bold = True; r.font.size = Pt(14); r.font.color.rgb = DARK; r.font.name = 'Segoe UI' +recs = [ + ('Приоритет 1 — сверить периметры 1пг 2026.', + 'MS EA (базовая строка 630 млн = как за весь 2025), Oracle HW (376 млн, новая), Ericsson «общий новый договор» (827 млн, объединение CS part + 5G part).'), + ('Приоритет 2 — консолидация Ericsson.', + 'Объединение в один договор уже сделано в 2026. Следующий шаг — переход на гибридную модель (P1 + RMA за вендора, P2/P3 силами КТ).'), + ('Приоритет 3 — проверить FLM.', + '+35,1 % к темпу 2025. 2 184 млн тг за 1пг 2026. Если рост продолжается — пересмотреть условия.'), + ('Приоритет 4 — зафиксировать сокращения.', + 'CORM-Ронекс −26,9 млн, Aitu Passport −21,9 млн, Nexign −68 млн (к темпу 2025). Заложить в план 2027.'), + ('Приоритет 5 — реестр статусов.', + 'К каждой статье: «прямой / посредник / дистрибьютор», «готов к инсорсингу / нужен вендор», P1/RMA за период.'), +] +for i, (b_, s_) in enumerate(recs, 1): + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(4) + r = p.add_run(f' {b_}') + r.bold = True; r.font.size = Pt(10.5); r.font.color.rgb = DARK; r.font.name = 'Segoe UI' + p2 = doc.add_paragraph() + p2.paragraph_format.space_after = Pt(10) + p2.paragraph_format.left_indent = Cm(0.75) + r = p2.add_run(s_) + r.font.size = Pt(9.5); r.font.color.rgb = TXT2; r.font.name = 'Segoe UI' + +# ---------- Итог ---------- +doc.add_paragraph() +h = doc.add_paragraph() +h.paragraph_format.space_before = Pt(10) +r = h.add_run('6. Итог') +r.bold = True; r.font.size = Pt(14); r.font.color.rgb = DARK; r.font.name = 'Segoe UI' +p = doc.add_paragraph() +p.paragraph_format.space_after = Pt(6) +r = p.add_run(f'−{b(abs(t25-t24)):,.2f} млрд тг ({p24_25:+.1f} %) к 2025') +r.bold = True; r.font.size = Pt(20); r.font.color.rgb = ACCENT; r.font.name = 'Segoe UI' +bullets = [ + 'Падение 2025 — не экономия, а смена номенклатуры (FLM Expenses → FLM, Ericsson part → общий договор, MS EA COGS/G&A).', + '1пг 2026 показывает рост +1,3 % к темпу 2025: драйверы — MS EA +15,7 млн, Checkpoint +64,9 млн.', + 'Главный риск 2027 — MS EA: 630 млн за 1пг 2026 = как за весь 2025. При сохранении темпа год ≈ +100 %. Следующий шаг — сверить периметры договоров и зафиксировать реестр статусов (прямой / посредник, инсорсинг / вендор).', +] +for b_ in bullets: + bullet(b_) + +# ---------- Подпись ---------- +doc.add_paragraph() +p = doc.add_paragraph() +r = p.add_run('Источник: «Расходы по сервисным контрактам.xlsx», факт. Детали: Excel-отчёт (6 листов) и презентация Power Point.') +r.font.size = Pt(8); r.font.italic = True; r.font.color.rgb = MUTE; r.font.name = 'Segoe UI' + +doc.save('Анализ_расходов_по_сервисным_контрактам.docx') +print('OK: Анализ_расходов_по_сервисным_контрактам.docx') diff --git a/server.js b/server.js index abc039d..a997c40 100644 --- a/server.js +++ b/server.js @@ -32,14 +32,15 @@ const srv = http.createServer((req, res) => { res.writeHead(200, { "Content-Type": "application/json" }); return fs.createReadStream(path.join(__dirname, "data.json")).pipe(res); } - // Отчёт — xlsx. Отдаём как приложение, чтобы браузер скачал, а не попробовал показать. - const m = url.match(/^\/([^\/]+\.(xlsx|csv|json|html|js|css))/i); + // Отчёт — xlsx/docx. Отдаём как приложение, чтобы браузер скачал, а не попробовал показать. + const m = url.match(/^\/([^\/]+\.(xlsx|docx|csv|json|html|js|css))/i); if (m) { const file = path.basename(decodeURIComponent(m[1])); const fp = path.join(__dirname, file); if (fs.existsSync(fp)) { const ct = file.toLowerCase().endsWith(".xlsx") ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : + file.toLowerCase().endsWith(".docx") ? "application/vnd.openxmlformats-officedocument.wordprocessingml.document" : file.toLowerCase().endsWith(".csv") ? "text/csv; charset=utf-8" : file.toLowerCase().endsWith(".json") ? "application/json" : file.toLowerCase().endsWith(".js") ? "text/javascript" : diff --git a/Анализ_расходов_по_сервисным_контрактам.docx b/Анализ_расходов_по_сервисным_контрактам.docx new file mode 100644 index 0000000..70683d8 Binary files /dev/null and b/Анализ_расходов_по_сервисным_контрактам.docx differ