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