Files
lesson_20_10_26/scripts/build_simple_example.py
T

214 lines
13 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.
"""Build the small, human-readable Excel example used for the basic block.
Run: py scripts/build_simple_example.py
Optional check against an independent recalculation:
py scripts/build_simple_example.py --recalculated <path-to-recalculated.xlsx>
"""
from pathlib import Path
from datetime import datetime, timedelta
import argparse
import openpyxl
import xlsxwriter
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / 'materials' / 'excel-prostoy-primer.xlsx'
CUTOFF = datetime(2026, 10, 20)
BLUE, RED, GREEN = '1F3B73', 'FFE3EA', 'DDFAEC'
# (заявитель, тема, дата обращения, срок в днях, дата исполнения, статус)
RECORDS = [
('Иванов И. И.', 'Справка', datetime(2026, 10, 1), 10, datetime(2026, 10, 8), 'Выполнено'),
('Петрова А. С.', 'Консультация', datetime(2026, 10, 3), 15, datetime(2026, 10, 16), 'Выполнено'),
('Сидоров П. Н.', 'Жалоба', datetime(2026, 10, 5), 30, None, 'В работе'),
('Кузнецова М. В.', 'Справка', datetime(2026, 10, 6), 10, datetime(2026, 10, 14), 'Выполнено'),
('Смирнов Д. А.', 'Обращение', datetime(2026, 10, 8), 10, None, 'В работе'),
('Волкова Е. П.', 'Консультация', datetime(2026, 10, 9), 15, datetime(2026, 10, 19), 'Выполнено'),
('Морозов В. И.', 'Справка', datetime(2026, 10, 12), 7, None, 'В работе'),
('Новикова О. С.', 'Обращение', datetime(2026, 10, 13), 10, None, 'В работе'),
('Фёдоров К. М.', 'Жалоба', datetime(2026, 10, 14), 30, None, 'В работе'),
('Гусева Т. А.', 'Справка', datetime(2026, 10, 15), 7, None, 'В работе'),
]
HEADERS = ['№', 'Заявитель', 'Тема обращения', 'Дата обращения', 'Срок, дней',
'Срок исполнения', 'Дата исполнения', 'Статус', 'Проверка срока', 'Дней на исполнение']
FIRST, LAST = 3, 12 # Excel rows of the ten records
CUTOFF_CELL = 'C13' # holds the fixed cut-off date
FIRST_TOTAL = 16 # Excel row of the first summary line
EPOCH = datetime(1899, 12, 30) # Excel serial-number origin
def serial(value):
"""Excel serial number, required for a cached numeric result of a date formula."""
return (value - EPOCH).days
def as_date(value):
"""Normalise a cached or recalculated cell to a date for comparison."""
if value is None:
return None
return value if isinstance(value, datetime) else EPOCH + timedelta(days=int(value))
def expected():
"""Independent expectation for every calculated column and summary."""
deadline, control, days = [], [], []
for _, _, received, term, done, status in RECORDS:
end = received + timedelta(days=term)
deadline.append(end)
control.append('Закрыто' if status == 'Выполнено' else ('Просрочено' if end < CUTOFF else 'В срок'))
days.append((done - received).days if done else None)
totals = [
('Всего обращений', 10),
('Выполнено', 4),
('В работе', 6),
('Просрочено', 2),
('Доля просроченных среди открытых', 2 / 6),
('Среднее число дней на исполнение', 9.5),
('Суммарный срок, дней', 144),
]
return deadline, control, days, totals
def check(totals, recalculated=None):
deadline, control, days, _ = expected()
books = [(OUT, openpyxl.load_workbook(OUT, data_only=True)['Пример'], 'cached')]
if recalculated:
books.append((recalculated, openpyxl.load_workbook(recalculated, data_only=True)['Пример'], 'recalculated'))
for path, sheet, mode in books:
got_deadline = [as_date(sheet.cell(r, 6).value) for r in range(FIRST, LAST + 1)]
got_days = [v if v not in ('', None) else None for v in (sheet.cell(r, 10).value for r in range(FIRST, LAST + 1))]
assert got_deadline == deadline, (mode, path, 'срок исполнения', got_deadline, deadline)
assert got_days == days, (mode, path, 'дней на исполнение', got_days, days)
if mode == 'cached':
assert [sheet.cell(r, 9).value for r in range(FIRST, LAST + 1)] == control
for n, (label, value) in enumerate(totals):
got = sheet.cell(FIRST_TOTAL + n, 3).value
close = isinstance(got, (int, float)) and isinstance(value, (int, float)) and abs(got - value) < 1e-9
assert close or got == value, (mode, path, label, got, value)
if mode == 'cached':
assert as_date(sheet.cell(13, 3).value) == CUTOFF
# The reference sheet must keep formulas as readable text, never as live formulas.
ref = openpyxl.load_workbook(OUT, data_only=False)['Формулы']
expected_text = ['=D3+E3', '=ЕСЛИ(H3="Выполнено";"Закрыто";\nЕСЛИ(F3<$C$13;"Просрочено";"В срок"))',
'=ЕСЛИ(G3<>"";G3-D3;"")', '=СЧЁТ(A3:A12)', '=СЧЁТЕСЛИ(H3:H12;"Выполнено")',
'=СЧЁТЕСЛИ(I3:I12;"Просрочено")', '=СРЗНАЧ(J3:J12)']
got_text = [ref.cell(r, 3).value for r in range(3, 10)]
assert got_text == expected_text, got_text
return True
def build():
deadline, control, days, totals = expected()
book = xlsxwriter.Workbook(OUT, {'strings_to_formulas': False})
book.set_properties({
'title': 'Простой пример с формулами',
'author': 'Бойко Кирилл',
'comments': 'Учебные вымышленные данные. Не реальные обращения граждан.',
})
head = book.add_format({'bold': True, 'bg_color': '#' + BLUE, 'font_color': 'white',
'text_wrap': True, 'valign': 'vcenter', 'align': 'center'})
text = book.add_format({'font_name': 'Calibri', 'font_size': 12, 'valign': 'vcenter'})
date = book.add_format({'num_format': 'dd.mm.yyyy', 'font_size': 12})
num = book.add_format({'num_format': '0', 'font_size': 12, 'align': 'center'})
avg = book.add_format({'num_format': '0.0', 'font_size': 12})
share = book.add_format({'num_format': '0%', 'font_size': 12})
bold = book.add_format({'bold': True, 'font_size': 12})
note = book.add_format({'font_size': 11, 'text_wrap': True, 'valign': 'top', 'font_color': '#555566'})
mono = book.add_format({'font_name': 'Consolas', 'font_size': 11, 'valign': 'vcenter', 'text_wrap': True})
red = book.add_format({'bg_color': '#' + RED, 'font_color': '#9D123E'})
green = book.add_format({'bg_color': '#' + GREEN, 'font_color': '#086B40'})
sheet = book.add_worksheet('Пример')
sheet.set_zoom(115)
for col, width in zip('ABCDEFGHIJ', [5, 18, 15, 15, 10, 15, 15, 12, 15, 18]):
sheet.set_column(col + ':' + col, width)
sheet.freeze_panes(2, 0)
sheet.set_row(0, 24)
sheet.merge_range(0, 0, 0, 9, 'ЖУРНАЛ ОБРАЩЕНИЙ — УЧЕБНЫЙ ПРИМЕР НА 10 СТРОК', head)
sheet.write_row(1, 0, HEADERS, head)
sheet.set_row(1, 34)
for n, (person, topic, received, term, done, status) in enumerate(RECORDS):
row = FIRST + n
sheet.set_row(row - 1, 20)
sheet.write(row - 1, 0, n + 1, num)
sheet.write(row - 1, 1, person, text)
sheet.write(row - 1, 2, topic, text)
sheet.write_datetime(row - 1, 3, received, date)
sheet.write(row - 1, 4, term, num)
sheet.write_formula(row - 1, 5, f'=D{row}+E{row}', date, serial(deadline[n]))
if done:
sheet.write_datetime(row - 1, 6, done, date)
else:
sheet.write(row - 1, 6, '', text)
sheet.write(row - 1, 7, status, text)
sheet.write_formula(row - 1, 8,
f'=IF(H{row}="Выполнено","Закрыто",IF(F{row}<${CUTOFF_CELL[0]}${CUTOFF_CELL[1:]},'
f'"Просрочено","В срок"))', text, control[n])
if days[n] is None:
sheet.write_formula(row - 1, 9, f'=IF(G{row}<>"",G{row}-D{row},"")', text, '')
else:
sheet.write_formula(row - 1, 9, f'=IF(G{row}<>"",G{row}-D{row},"")', avg, days[n])
sheet.data_validation(f'H{FIRST}:H{LAST}',
{'validate': 'list', 'source': ['Выполнено', 'В работе'],
'error_title': 'Выберите значение',
'error_message': 'Только «Выполнено» или «В работе».'})
sheet.set_row(12, 22)
sheet.merge_range(12, 0, 12, 1, 'Дата среза →', bold)
sheet.write_datetime(12, 2, CUTOFF, date)
sheet.set_row(14, 22)
sheet.write(14, 0, 'ИТОГИ', bold)
for n, (label, value) in enumerate(totals):
row = FIRST_TOTAL + n
sheet.set_row(row - 1, 20)
sheet.merge_range(row - 1, 0, row - 1, 1, label, text)
fmt = share if 'Доля' in label else (avg if 'Среднее' in label else num)
sheet.write(row - 1, 2, value, fmt)
sheet.conditional_format(f'I{FIRST}:I{LAST}', {'type': 'text', 'criteria': 'containing', 'value': 'Просрочено', 'format': red})
sheet.conditional_format(f'I{FIRST}:I{LAST}', {'type': 'text', 'criteria': 'containing', 'value': 'Закрыто', 'format': green})
sheet.merge_range(23, 0, 23, 9, 'Все записи вымышленные. Формулы в файле записаны по стандарту (IF, COUNTIF, AVERAGE); '
'в русском Excel они отображаются как ЕСЛИ, СЧЁТЕСЛИ, СРЗНАЧ.', note)
sheet.set_row(23, 30)
ref = book.add_worksheet('Формулы')
for col, width in zip('ABCD', [4, 30, 34, 52]):
ref.set_column(col + ':' + col, width)
ref.merge_range('A1:D1', 'ТРИ ФОРМУЛЫ И ЧЕТЫРЕ ПОДСЧЁТА — ШПАРГАЛКА', head)
ref.set_row(0, 26)
ref.write_row(1, 0, ['№', 'Что считаем', 'Формула', 'Словами'], head)
ref.set_row(1, 24)
rows = [
('Срок исполнения', '=D3+E3', 'Дата обращения плюс срок в днях.'),
('Проверка срока', '=ЕСЛИ(H3="Выполнено";"Закрыто";\nЕСЛИ(F3<$C$13;"Просрочено";"В срок"))',
'Если выполнено — «Закрыто». Иначе сравнить срок с датой среза.'),
('Дней на исполнение', '=ЕСЛИ(G3<>"";G3-D3;"")', 'Если есть дата исполнения, посчитать разницу. Иначе пусто.'),
('Сколько всего', '=СЧЁТ(A3:A12)', 'Число обращений в списке.'),
('Сколько выполнено', '=СЧЁТЕСЛИ(H3:H12;"Выполнено")', 'Считает строки со статусом «Выполнено».'),
('Сколько просрочено', '=СЧЁТЕСЛИ(I3:I12;"Просрочено")', 'Считает строки со значением «Просрочено».'),
('Среднее время', '=СРЗНАЧ(J3:J12)', 'Среднее по заполненным значениям. Пустые строки не учитываются.'),
]
for n, (what, formula, words) in enumerate(rows, start=2):
ref.set_row(n, 44 if '\n' in formula else 22)
ref.write(n, 0, n - 1, num)
ref.write(n, 1, what, text)
ref.write(n, 2, formula, mono)
ref.write(n, 3, words, note)
ref.merge_range('A11:D12', 'Правило: сначала формулируем словами, потом пишем формулу. Если словами объяснить нельзя — '
'формула будет считать «как-то не так».', note)
ref.set_row(10, 30)
ref.set_row(11, 30)
book.close()
return totals
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--recalculated', type=Path)
args = parser.parse_args()
result = build()
check(result, args.recalculated)
state = 'cached values + independent recalculation' if args.recalculated else 'cached values'
print(f'OK: 10 rows and 7 summary values verified ({state}).')