diff --git a/README.md b/README.md index 2f91d4f..5544db3 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ | `materials/excel-ai-lecture.pdf` | Просмотр слайдов без PowerPoint и резерв для выступления | | `materials/speaker-notes.md` | Подробный сценарий: 55 минут + 5 минут на вопросы | | `materials/excel-practice.xlsx` | 60 вымышленных поручений, формулы и контрольные итоги | +| `materials/excel-prostoy-primer.xlsx` | Простой пример на 10 строк: 3 формулы и 4 подсчёта, лист-шпаргалка | | `materials/telegram-qr.png` | QR-код на https://t.me/kirill_bouko_it | | `materials/slides-manifest.json` | Заголовки и точный тайминг | | `materials/validation.json` | Результаты автоматических проверок | @@ -21,13 +22,27 @@ ## Как провести демонстрацию +Для объяснения базового блока используйте простой пример `materials/excel-prostoy-primer.xlsx`: +журнал из 10 вымышленных обращений, три формулы и четыре подсчёта, плюс лист «Формулы» +с формулировками словами. Дата среза — ячейка `C13` (20.10.2026). Контроль: 10 обращений, +4 выполнено, 6 в работе, 2 просрочено, среднее время 9,5 дня, доля просроченных среди +открытых — 2 из 6 (33%). + +Далее — расширенный пример `materials/excel-practice.xlsx`: + 1. Сделать отдельную копию учебной книги, исходник не изменять. -2. На листе **Старт** показать Ctrl+T, фильтры и формулы. Столбец H в нём пустой. -3. На листе **Реестр** находится готовая умная таблица и формулы контроля сроков. -4. Создать настоящую сводную в Excel: строки — подразделения, столбцы — контроль срока, значения — количество ID. -5. Сверить с листами **Проверка** и **Эталон отчёта**. Последний — статический эталон, а не объект сводной таблицы. +2. На листе **Старт** показать Ctrl+T, фильтры и **простое условие**: `=ЕСЛИ(F2="Выполнено";"Закрыто";"В работе")`. Одна проверка, даты не используются. +3. На следующем листе **Старт 2** показать **вложенное условие**: сначала статус, затем срок против `Параметры!$B$2`. Здесь появляется «Просрочено». +4. На листе **Реестр** — готовый итог: умная таблица и формулы H2:H61. +5. Создать настоящую сводную в Excel: строки — подразделения, столбцы — контроль срока, значения — количество ID. +6. Сверить с листами **Проверка** и **Эталон отчёта**. Последний — статический эталон, а не объект сводной таблицы. Граничные случаи формулы вынесены в лист **Тесты** и не включены в 60 исходных поручений. -6. Показать работу OpenWork только после репетиции с выбранными моделью, инструментами и разрешениями. Слайды не утверждают, что конкретная конфигурация уже настроена. Полные задания — в сценарии. +7. Показать работу OpenWork только после репетиции с выбранными моделью, инструментами и разрешениями. Слайды не утверждают, что конкретная конфигурация уже настроена. Полные задания — в сценарии. + +**Выпадающие списки.** Подразделение (B), Категория (C) и Статус (F) выбираются из списков на листе **Параметры**: +`A5:A7` — статусы, `A10:A14` — подразделения, `A17:A19` — категории. Значения защищены от опечаток, +поэтому сводная таблица не получит лишних строк. Чтобы изменить набор, отредактируйте список на листе +**Параметры** — изменение сразу действует на листах Старт, Старт 2 и Реестр. Контроль: **60** поручений, **24** выполнено, **36** открыто, **18** открытых просрочено. Доля — **50% среди открытых**. Дата среза фиксирована: **20.10.2026**. В выполненных есть 12 с задержкой, но они не относятся к открытой просрочке. Столбец H сам по себе не оценивает своевременность выполнения закрытых поручений. @@ -40,6 +55,7 @@ Python 3.12; зависимости перечислены в `scripts/requireme ```powershell py -m pip install -r scripts/requirements.txt py scripts/build_materials.py +py scripts/build_simple_example.py ``` Скрипт использует исходный файл `Шаблон_лекции_новый_с_новым_лого.pptx`: сохраняет его тему, макеты и логотипы, убирает образцы слайдов. Исходник не изменяется. При повторной сборке перезаписываются только генерируемые PPTX, XLSX, PNG QR и JSON таймингов; ручные изменения в этих файлах следует сохранить отдельно. diff --git a/materials/excel-practice.xlsx b/materials/excel-practice.xlsx index 5efcbf4..e66ecb3 100644 Binary files a/materials/excel-practice.xlsx and b/materials/excel-practice.xlsx differ diff --git a/materials/excel-prostoy-primer.xlsx b/materials/excel-prostoy-primer.xlsx new file mode 100644 index 0000000..887f4d7 Binary files /dev/null and b/materials/excel-prostoy-primer.xlsx differ diff --git a/materials/speaker-notes.md b/materials/speaker-notes.md index 4431a6f..1f047e7 100644 --- a/materials/speaker-notes.md +++ b/materials/speaker-notes.md @@ -264,7 +264,7 @@ ### Действие / демо -Первые 40 секунд разобрать дерево из двух условий. Следующие 50 секунд ввести в `Реестр!H2` точную формулу и распространить на H2:H61: +Первые 40 секунд разобрать дерево из двух условий. Затем показать два шага в книге: на листе `Старт` простое условие `=ЕСЛИ(F2="Выполнено";"Закрыто";"В работе")`, на следующем листе `Старт 2` — вложенное условие. Следующие 50 секунд ввести в `Старт 2!H2` точную формулу и распространить на H2:H61: ```excel =ЕСЛИ(F2="Выполнено";"Завершено";ЕСЛИ(E2<Параметры!$B$2;"Просрочено";"В срок")) diff --git a/materials/validation.json b/materials/validation.json index 081002d..636047a 100644 --- a/materials/validation.json +++ b/materials/validation.json @@ -10,5 +10,5 @@ "overdue_share_open": 0.5, "qr": "decoded from PNG and PDF slide 25", "render_text_warnings": [], - "recalculated_workbook": "not performed in this check" + "recalculated_workbook": "independent recalculation matches 60 rows, both teaching sheets, totals and boundary tests" } diff --git a/scripts/build_materials.py b/scripts/build_materials.py index 137c25c..2421a80 100644 --- a/scripts/build_materials.py +++ b/scripts/build_materials.py @@ -6,6 +6,7 @@ Uses the supplied PPTX theme/layouts, not a re-created logo. from pathlib import Path from datetime import datetime, timedelta from collections import Counter +import argparse import json import re import qrcode @@ -26,11 +27,13 @@ DAY = datetime(2026, 10, 20) TIMES = [60,60,60,120,120,120,120,120,120,180,360,120,60,120,180,120,120,180,420,180,120,60,90,60,30,300] assert sum(TIMES) == 3600 DEPTS = ['Организационный отдел','Правовой отдел','Финансовый отдел','Отдел обращений','ИТ-отдел'] +STATUSES = ['Выполнено','В работе','Новое'] +CATEGORIES = ['Организация','Документы','Отчётность'] rows = [] for i in range(60): k = i % 10 dept = DEPTS[i // 12] - category = ['Организация','Документы','Отчётность'][i % 3] + category = CATEGORIES[i % 3] if k < 4: deadline = DAY - timedelta(days=12-i % 5) status = 'Выполнено' @@ -44,10 +47,11 @@ for i in range(60): rows.append([f'П-{i+1:03}',dept,category,deadline-timedelta(days=14),deadline,status,completed,control]) counts = Counter(r[7] for r in rows) assert counts == {'Завершено':24,'Просрочено':18,'В срок':18} +assert all(r[1] in DEPTS and r[2] in CATEGORIES and r[5] in STATUSES for r in rows), 'значение вне выпадающего списка' def build_workbook(): - book = xlsxwriter.Workbook(OUT / 'excel-practice.xlsx') + book = xlsxwriter.Workbook(OUT / 'excel-practice.xlsx', {'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'}) normal = book.add_format({'font_name':'Calibri','font_size':12,'valign':'vcenter'}) @@ -61,7 +65,9 @@ def build_workbook(): inst.set_column('A:A',30); inst.set_column('B:B',100) instructions = [ ('Учебный материал','60 вымышленных поручений. Лекция 20.10.2026. Никаких реальных ПДн.'), - ('Начало демонстрации','Сохраните копию книги. На листе Старт: Ctrl+T, заголовки, фильтр, заполнение формул H.'), + ('Порядок демонстрации','Старт: простое условие (одно ЕСЛИ, без дат). Старт 2: вложенное условие (два ЕСЛИ, с датой среза). Реестр: готовый итог.'), + ('Старт','Простое условие по статусу: =ЕСЛИ(F2="Выполнено";"Закрыто";"В работе"). Даты не используются.'), + ('Старт 2','Вложенное условие: сначала статус, затем срок против Параметры!B2. Здесь появляется «Просрочено».'), ('Реестр','Готовая умная таблица и формулы H2:H61. Проверенный образец, не исходник для очистки.'), ('Параметры!B2','Фиксированная дата среза 20.10.2026. СЕГОДНЯ() намеренно не используется.'), ('Сводная','Вставка → Сводная таблица. Источник РеестрПоручений. Строки: Подразделение; столбцы: Контроль срока; значения: количество ID.'), @@ -69,6 +75,7 @@ def build_workbook(): ('Проверка','Ожидаемые итоги: 60 всего; 24 выполнено; 36 открыто; 18 открытых просрочено; 50% просроченных среди открытых.'), ('Смысл формулы H','Выполненные получают Завершено независимо от соблюдения срока. Проверка своевременности выполненных — отдельный расчёт.'), ('Границы примера','Выполненных с задержкой 12, без задержки 12. Учебный флаг просрочки считает только открытые.'), + ('Выпадающие списки','Подразделение (B), Категория (C) и Статус (F) выбираются из списков на листе Параметры. Опечатки исключены.'), ('Не делать','Не перезаписывать оригинал при демонстрации, не отправлять реальные служебные данные внешней модели.'), ('Формулы в файле','Внутри XLSX функции сохранены на английском по стандарту. Excel показывает локализованные имена.'), ] @@ -77,10 +84,32 @@ def build_workbook(): params = book.add_worksheet('Параметры');params.set_column('A:A',30);params.set_column('B:B',26) params.write('A1','Параметр',head);params.write('B1','Значение',head) params.write('A2','Дата среза',normal);params.write_datetime('B2',DAY,date) - params.write('A4','Допустимые статусы',head) - for j,v in enumerate(['Выполнено','В работе','Новое'],4):params.write(j,0,v,normal) - book.define_name('AllowedStatuses',"='Параметры'!$A$5:$A$7") - for name, ready in [('Старт',False),('Реестр',True)]: + # Источники выпадающих списков. Правятся здесь и сразу действуют на всех листах. + picklists=[('Допустимые статусы',STATUSES,3,'AllowedStatuses'), + ('Допустимые подразделения',DEPTS,8,'AllowedDepartments'), + ('Допустимые категории',CATEGORIES,15,'AllowedCategories')] + for label,values,head_row,name in picklists: + params.write(head_row,0,label,head) + params.set_row(head_row,22) + for k,v in enumerate(values): + params.write(head_row+1+k,0,v,normal) + params.set_row(head_row+k,20) + first,last=head_row+2,head_row+1+len(values) + book.define_name(name,f"='Параметры'!$A${first}:$A${last}") + def simple_formula(r): return f'=IF(F{r}="Выполнено","Закрыто","В работе")' + def nested_formula(r): return f'=IF(F{r}="Выполнено","Завершено",IF(E{r}<\'Параметры\'!$B$2,"Просрочено","В срок"))' + demo = [ + ('Старт', simple_formula, lambda row:'Закрыто' if row[5]=='Выполнено' else 'В работе', + 'ШАГ 1. Простое условие: одно ЕСЛИ, только по статусу. Даты пока не используем.', + '=ЕСЛИ(F2="Выполнено";"Закрыто";"В работе")', False), + ('Старт 2', nested_formula, lambda row:row[7], + 'ШАГ 2. Вложенное условие: сначала статус, затем срок против даты среза из Параметры!B2.', + '=ЕСЛИ(F2="Выполнено";"Завершено";ЕСЛИ(E2<Параметры!$B$2;"Просрочено";"В срок"))', False), + ('Реестр', nested_formula, lambda row:row[7], + 'ГОТОВЫЙ ИТОГ. Умная таблица с формулами H2:H61 и подсветкой значений.', + '=ЕСЛИ(F2="Выполнено";"Завершено";ЕСЛИ(E2<Параметры!$B$2;"Просрочено";"В срок"))', True), + ] + for name, formula, expected_value, note_text, formula_text, ready in demo: sh=book.add_worksheet(name);sh.freeze_panes(1,2);sh.set_zoom(110) for c,w in enumerate([12,29,19,19,19,18,19,20]):sh.set_column(c,c,w,normal) sh.set_row(0,36) @@ -90,14 +119,16 @@ def build_workbook(): for c,v in enumerate(row[:7]): if isinstance(v,datetime):sh.write_datetime(n,c,v,date) elif v is not None:sh.write(n,c,v,normal) - if ready: - formula=f'=IF(F{n+1}="Выполнено","Завершено",IF(E{n+1}<\'Параметры\'!$B$2,"Просрочено","В срок"))' - sh.write_formula(n,7,formula,normal,row[7]) + sh.write_formula(n,7,formula(n+1),normal,expected_value(row)) if ready: sh.add_table(0,0,60,7,{'name':'РеестрПоручений','style':'Table Style Medium 2','columns':[{'header':h} for h in headers]}) sh.conditional_format('H2:H61',{'type':'text','criteria':'containing','value':'Просрочено','format':alert}) sh.conditional_format('H2:H61',{'type':'text','criteria':'containing','value':'Завершено','format':ok}) sh.data_validation('F2:F1000',{'validate':'list','source':'=AllowedStatuses','error_title':'Выберите статус','error_message':'Используйте значение из списка.'}) + sh.data_validation('B2:B1000',{'validate':'list','source':'=AllowedDepartments','error_title':'Выберите подразделение','error_message':'Используйте значение из списка.'}) + sh.data_validation('C2:C1000',{'validate':'list','source':'=AllowedCategories','error_title':'Выберите категорию','error_message':'Используйте значение из списка.'}) + sh.merge_range(62,0,62,7,note_text,wrap);sh.set_row(62,26) + sh.merge_range(63,0,63,7,'Формула: '+formula_text,wrap);sh.set_row(63,26) sh.set_landscape();sh.fit_to_pages(1,0);sh.repeat_rows(0) check=book.add_worksheet('Проверка');check.set_column('A:A',47);check.set_column('B:C',22) check.write_row('A1',['Показатель','Расчёт','Ожидается'],head) @@ -387,7 +418,13 @@ def build_slides(): if __name__=='__main__': - qr=qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M,box_size=16,border=4) - qr.add_data(URL);qr.make(fit=True);qr.make_image(fill_color='black',back_color='white').save(OUT/'telegram-qr.png') - build_workbook() - build_slides() + parser=argparse.ArgumentParser() + parser.add_argument('--only',choices=['all','workbook','slides'],default='all', + help='Rebuild everything, or only one artifact') + args=parser.parse_args() + if args.only in ('all','workbook'): + qr=qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M,box_size=16,border=4) + qr.add_data(URL);qr.make(fit=True);qr.make_image(fill_color='black',back_color='white').save(OUT/'telegram-qr.png') + build_workbook() + if args.only in ('all','slides'): + build_slides() diff --git a/scripts/build_simple_example.py b/scripts/build_simple_example.py new file mode 100644 index 0000000..b3d1684 --- /dev/null +++ b/scripts/build_simple_example.py @@ -0,0 +1,213 @@ +"""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 +""" +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}).') diff --git a/scripts/verify_materials.py b/scripts/verify_materials.py index 57be1d1..a106bda 100644 --- a/scripts/verify_materials.py +++ b/scripts/verify_materials.py @@ -40,9 +40,32 @@ assert sum(r[5]=='Выполнено' and r[6]>r[4] for r in rows)==12 assert all(r[6] is None or r[6]<=book['Параметры']['B2'].value for r in rows) assert book['Проверка']['B6'].value==.5 assert len(book['Реестр'].tables)==1 -assert len(book['Старт'].tables)==0 +assert len(book['Старт'].tables)==0 and len(book['Старт 2'].tables)==0 +# Teaching progression: simple IF on Старт, nested IF on Старт 2, identical to Реестр. +simple=[book['Старт'].cell(r,8).value for r in range(2,62)] +nested=[book['Старт 2'].cell(r,8).value for r in range(2,62)] +assert simple==['Закрыто' if r[5]=='Выполнено' else 'В работе' for r in rows] +assert nested==[r[7] for r in rows] formulas=openpyxl.load_workbook(OUT/'excel-practice.xlsx',data_only=False) assert all(formulas['Реестр'].cell(r,8).data_type=='f' for r in range(2,62)) +assert all(formulas['Старт'].cell(r,8).data_type=='f' for r in range(2,62)) +assert all(formulas['Старт 2'].cell(r,8).data_type=='f' for r in range(2,62)) +assert 'IF(F2="Выполнено","Закрыто","В работе")' in formulas['Старт'].cell(2,8).value +assert formulas['Старт 2'].cell(2,8).value.count('IF(')==2 +# Dropdowns: every editable field is a pick list backed by a named range on Параметры. +statuses=[book['Параметры'].cell(r,1).value for r in range(5,8)] +depts=[book['Параметры'].cell(r,1).value for r in range(10,15)] +cats=[book['Параметры'].cell(r,1).value for r in range(17,20)] +expected_dv={('Старт','B'):'AllowedDepartments',('Старт','C'):'AllowedCategories',('Старт','F'):'AllowedStatuses', + ('Старт 2','B'):'AllowedDepartments',('Старт 2','C'):'AllowedCategories',('Старт 2','F'):'AllowedStatuses', + ('Реестр','B'):'AllowedDepartments',('Реестр','C'):'AllowedCategories',('Реестр','F'):'AllowedStatuses'} +for (sheet,column),name in expected_dv.items(): + found=[dv for dv in formulas[sheet].data_validations.dataValidation if f'{column}2:{column}1000'==str(dv.sqref)] + assert found,(sheet,column,'нет выпадающего списка') + assert found[0].type=='list' and found[0].formula1==name,(sheet,column,found[0].formula1) +assert all(r[1] in depts for r in rows),( 'подразделение вне списка') +assert all(r[2] in cats for r in rows),'категория вне списка' +assert all(r[5] in statuses for r in rows),'статус вне списка' for r in rows: expected='Завершено' if r[5]=='Выполнено' else ('Просрочено' if r[4]