sozday-analiz-rashodov-s-uka/mkword.py

332 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>−{b(abs(d24_25)):,.2f} млрд тг ({p24_25:+.1f} %)</b> — это не экономия, а смена номенклатуры (FLM Expenses → FLM, Ericsson CS/5G part → общий договор, MS EA/COGS/G&A).'.replace('<b>','').replace('</b>',''),
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')