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')