diff --git a/README.md b/README.md index 5544db3..d2aaca4 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,18 @@ 4. На листе **Реестр** — готовый итог: умная таблица и формулы H2:H61. 5. Создать настоящую сводную в Excel: строки — подразделения, столбцы — контроль срока, значения — количество ID. 6. Сверить с листами **Проверка** и **Эталон отчёта**. Последний — статический эталон, а не объект сводной таблицы. + Ниже, с 15-й строки, там же лежит **отчёт на формулах** (СЧЁТЕСЛИ и СЧЁТЕСЛИМН по подразделениям): он считается прямо из «Реестра» + и пересчитывается сам, а две строки «Сверка» показывают «Совпадает». Это удобный способ показать разницу между формулой и сводной. Граничные случаи формулы вынесены в лист **Тесты** и не включены в 60 исходных поручений. -7. Показать работу OpenWork только после репетиции с выбранными моделью, инструментами и разрешениями. Слайды не утверждают, что конкретная конфигурация уже настроена. Полные задания — в сценарии. +7. Построить диаграммы вживую: данные берутся из сводной (столбцы — «Контроль срока») либо через + СЧЁТЕСЛИМН по подразделениям, как в отчёте на формулах. Готового листа с диаграммами в книге нет + намеренно — слушатели не должны видеть ответ заранее. +8. Показать работу OpenWork только после репетиции с выбранными моделью, инструментами и разрешениями. Слайды не утверждают, что конкретная конфигурация уже настроена. Полные задания — в сценарии. + +**Лист с диаграммами.** Если нужен готовый образец для проверки, сборка добавит его отдельным флагом: +`py scripts/build_materials.py --only workbook --with-chart-sheet`. Тогда в книге появится лист «Диаграмма» +с блоком СЧЁТЕСЛИМН и двумя диаграммами (столбчатая с накоплением по подразделениям и круговая по контролю срока). +Обычная сборка без флага возвращает книгу к восьми листам без диаграмм. **Выпадающие списки.** Подразделение (B), Категория (C) и Статус (F) выбираются из списков на листе **Параметры**: `A5:A7` — статусы, `A10:A14` — подразделения, `A17:A19` — категории. Значения защищены от опечаток, diff --git a/materials/excel-practice.xlsx b/materials/excel-practice.xlsx index e66ecb3..04d675d 100644 Binary files a/materials/excel-practice.xlsx and b/materials/excel-practice.xlsx differ diff --git a/materials/validation.json b/materials/validation.json index 636047a..ed2d78e 100644 --- a/materials/validation.json +++ b/materials/validation.json @@ -10,5 +10,6 @@ "overdue_share_open": 0.5, "qr": "decoded from PNG and PDF slide 25", "render_text_warnings": [], - "recalculated_workbook": "independent recalculation matches 60 rows, both teaching sheets, totals and boundary tests" + "chart_sheet": "лист «Диаграмма»: блок данных и две диаграммы совпадают с эталоном", + "recalculated_workbook": "not performed in this check" } diff --git a/scripts/build_materials.py b/scripts/build_materials.py index 2421a80..264b89e 100644 --- a/scripts/build_materials.py +++ b/scripts/build_materials.py @@ -50,7 +50,54 @@ assert counts == {'Завершено':24,'Просрочено':18,'В срок assert all(r[1] in DEPTS and r[2] in CATEGORIES and r[5] in STATUSES for r in rows), 'значение вне выпадающего списка' -def build_workbook(): +def build_chart_sheet(book,head,normal,wrap): + """Лист с диаграммами: данные считаются из «Реестра», диаграммы строятся по этому блоку. + + На лекции лист не используется: диаграммы спикер строит вживую. + Включается флагом --with-chart-sheet. + """ + charts=book.add_worksheet('Диаграмма') + charts.set_column('A:A',32);charts.set_column('B:D',13);charts.set_column('E:E',2) + charts.write_row(0,0,['Подразделение','Завершено','Просрочено','В срок'],head) + charts.set_row(0,34) + for n,dept in enumerate(DEPTS): + r=n+2 + done=sum(1 for x in rows if x[1]==dept and x[7]=='Завершено') + late=sum(1 for x in rows if x[1]==dept and x[7]=='Просрочено') + soon=sum(1 for x in rows if x[1]==dept and x[7]=='В срок') + charts.set_row(r-1,22) + charts.write(r-1,0,dept,normal) + charts.write_formula(r-1,1,f"=COUNTIFS('Реестр'!$B$2:$B$61,$A{r},'Реестр'!$H$2:$H$61,\"Завершено\")",normal,done) + charts.write_formula(r-1,2,f"=COUNTIFS('Реестр'!$B$2:$B$61,$A{r},'Реестр'!$H$2:$H$61,\"Просрочено\")",normal,late) + charts.write_formula(r-1,3,f"=COUNTIFS('Реестр'!$B$2:$B$61,$A{r},'Реестр'!$H$2:$H$61,\"В срок\")",normal,soon) + charts.write(6,0,'ИТОГО',head) + charts.write_formula(6,1,"=COUNTIF('Реестр'!$H$2:$H$61,\"Завершено\")",head,24) + charts.write_formula(6,2,"=COUNTIF('Реестр'!$H$2:$H$61,\"Просрочено\")",head,18) + charts.write_formula(6,3,"=COUNTIF('Реестр'!$H$2:$H$61,\"В срок\")",head,18) + charts.merge_range(8,0,8,3,'Блок слева пересчитывается из «Реестра». Диаграммы обновляются, как сводная: их можно обновить или перестроить.',wrap) + charts.set_row(8,42) + charts.set_landscape();charts.set_paper(9);charts.fit_to_pages(1,0) + column=book.add_chart({'type':'column','subtype':'stacked'}) + for col,(label,color) in enumerate([('Завершено',GREEN),('Просрочено',PINK),('В срок',BLUE)]): + column.add_series({'name':label,'categories':['Диаграмма',1,0,5,0],'values':['Диаграмма',1,1+col,5,1+col], + 'fill':{'color':'#'+color},'border':{'color':'#FFFFFF'}}) + column.set_title({'name':'Поручения по подразделениям','name_font':{'name':'Segoe UI Semibold','size':14}}) + column.set_x_axis({'name':'Подразделение'}) + column.set_y_axis({'name':'Количество поручений','major_gridlines':{'visible':True}}) + column.set_legend({'position':'bottom'}) + column.set_size({'width':720,'height':360}) + charts.insert_chart('F2',column) + pie=book.add_chart({'type':'pie'}) + pie.add_series({'name':'Контроль срока','categories':['Диаграмма',0,1,0,3],'values':['Диаграмма',6,1,6,3], + 'data_labels':{'percentage':True,'font':{'size':11}}, + 'points':[{'fill':{'color':'#'+GREEN}},{'fill':{'color':'#'+PINK}},{'fill':{'color':'#'+BLUE}}]}) + pie.set_title({'name':'Структура по контролю срока','name_font':{'name':'Segoe UI Semibold','size':14}}) + pie.set_legend({'position':'bottom'}) + pie.set_size({'width':420,'height':340}) + charts.insert_chart('F21',pie) + + +def build_workbook(with_charts=False): 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'}) @@ -71,11 +118,12 @@ def build_workbook(): ('Реестр','Готовая умная таблица и формулы H2:H61. Проверенный образец, не исходник для очистки.'), ('Параметры!B2','Фиксированная дата среза 20.10.2026. СЕГОДНЯ() намеренно не используется.'), ('Сводная','Вставка → Сводная таблица. Источник РеестрПоручений. Строки: Подразделение; столбцы: Контроль срока; значения: количество ID.'), - ('Эталон отчёта','Статическая контрольная агрегация, НЕ настоящая сводная таблица Excel. Сравните с созданной вручную.'), + ('Эталон отчёта','Строки 1–9: статический эталон, НЕ настоящая сводная. С 15-й строки — отчёт на формулах, который считается из Реестра и обновляется сам.'), ('Проверка','Ожидаемые итоги: 60 всего; 24 выполнено; 36 открыто; 18 открытых просрочено; 50% просроченных среди открытых.'), ('Смысл формулы H','Выполненные получают Завершено независимо от соблюдения срока. Проверка своевременности выполненных — отдельный расчёт.'), ('Границы примера','Выполненных с задержкой 12, без задержки 12. Учебный флаг просрочки считает только открытые.'), ('Выпадающие списки','Подразделение (B), Категория (C) и Статус (F) выбираются из списков на листе Параметры. Опечатки исключены.'), + ('Диаграмма','Лист намеренно не включён: диаграммы строим вживую. Данные проще всего взять из сводной (Контроль срока) или получить СЧЁТЕСЛИМН по подразделениям, как в отчёте на формулах.'), ('Не делать','Не перезаписывать оригинал при демонстрации, не отправлять реальные служебные данные внешней модели.'), ('Формулы в файле','Внутри XLSX функции сохранены на английском по стандарту. Excel показывает локализованные имена.'), ] @@ -150,7 +198,7 @@ def build_workbook(): check.write(n,2,expected,fmt) check.write('A12','Статические проверки нужно повторить после изменения данных.',wrap) check.set_row(11,45) - report=book.add_worksheet('Эталон отчёта');report.set_column('A:A',32);report.set_column('B:E',19) + report=book.add_worksheet('Эталон отчёта');report.set_column('A:A',32);report.set_column('B:E',19);report.set_column('F:F',26) report.merge_range('A1:E1','СТАТИЧЕСКИЙ ЭТАЛОН — не сводная таблица Excel',head) report.write_row('A3',['Подразделение','Завершено','Просрочено','В срок','Всего'],head) for n,dept in enumerate(DEPTS,3): @@ -160,6 +208,49 @@ def build_workbook(): report.write_row('A9',['ИТОГО',24,18,18,60],head) report.write('A12','Для обновляемого отчёта создайте сводную через Вставка → Сводная таблица.',wrap) report.set_row(11,70) + + # Отчёт на формулах: считается из листа «Реестр» и обновляется сам. Начинается с 15-й строки. + report.merge_range('A15:F15','ОТЧЁТ НА ФОРМУЛАХ — считается из листа «Реестр» и обновляется сам',head) + report.set_row(14,26) + report.write_row(15,0,['Подразделение','Всего','Завершено','Просрочено','В срок', + 'Доля просроченных среди открытых'],head) + report.set_row(15,40) + rep_first,rep_last=17,21 # Excel rows of the five departments + for n,dept in enumerate(DEPTS): + r=rep_first+n + done=sum(1 for x in rows if x[1]==dept and x[7]=='Завершено') + late=sum(1 for x in rows if x[1]==dept and x[7]=='Просрочено') + soon=sum(1 for x in rows if x[1]==dept and x[7]=='В срок') + report.set_row(r-1,22) + report.write(r-1,0,dept,normal) + report.write_formula(r-1,1,f"=COUNTIF('Реестр'!$B$2:$B$61,$A{r})",normal,12) + report.write_formula(r-1,2,f"=COUNTIFS('Реестр'!$B$2:$B$61,$A{r},'Реестр'!$H$2:$H$61,\"Завершено\")",normal,done) + report.write_formula(r-1,3,f"=COUNTIFS('Реестр'!$B$2:$B$61,$A{r},'Реестр'!$H$2:$H$61,\"Просрочено\")",normal,late) + report.write_formula(r-1,4,f"=COUNTIFS('Реестр'!$B$2:$B$61,$A{r},'Реестр'!$H$2:$H$61,\"В срок\")",normal,soon) + report.write_formula(r-1,5,f'=IF((D{r}+E{r})=0,0,D{r}/(D{r}+E{r}))',pct,late/(late+soon)) + total_row=rep_last+1 # Excel 22 + report.write(total_row-1,0,'ИТОГО',head) + report.write_formula(total_row-1,1,"=COUNTA('Реестр'!$A$2:$A$61)",head,60) + report.write_formula(total_row-1,2,"=COUNTIF('Реестр'!$H$2:$H$61,\"Завершено\")",head,24) + report.write_formula(total_row-1,3,"=COUNTIF('Реестр'!$H$2:$H$61,\"Просрочено\")",head,18) + report.write_formula(total_row-1,4,"=COUNTIF('Реестр'!$H$2:$H$61,\"В срок\")",head,18) + report.write_formula(total_row-1,5, + f'=IF((D{total_row}+E{total_row})=0,0,D{total_row}/(D{total_row}+E{total_row}))',head,.5) + sums_check=total_row+2 # Excel 24 + report.write(sums_check-1,0,'Сверка: сумма по отделам равна итогу',normal) + report.write_formula(sums_check-1,1, + f'=IF(AND(SUM(C{rep_first}:C{rep_last})=C{total_row},SUM(D{rep_first}:D{rep_last})=D{total_row},' + f'SUM(E{rep_first}:E{rep_last})=E{total_row}),"Совпадает","Расхождение")',normal,'Совпадает') + checks_check=sums_check+1 # Excel 25 + report.write(checks_check-1,0,'Сверка: итог совпадает с листом «Проверка»',normal) + report.write_formula(checks_check-1,1, + f'=IF(AND(B{total_row}=Проверка!B2,C{total_row}=Проверка!B3,D{total_row}=Проверка!B5),' + f'"Совпадает","Расхождение")',normal,'Совпадает') + report.write(checks_check+1,0,'Правите данные в «Реестре» — этот отчёт пересчитывается сам. Сводная требует обновления.',wrap) + report.set_row(checks_check,30) + # Лист с диаграммами строится только по запросу: на лекции диаграммы показываются вживую. + if with_charts: + build_chart_sheet(book, head, normal, wrap) tests=book.add_worksheet('Тесты') tests.set_column('A:A',36);tests.set_column('B:E',21) tests.write_row('A1',['Случай','Статус','Срок','Ожидается','Формула'],head) @@ -421,10 +512,12 @@ if __name__=='__main__': parser=argparse.ArgumentParser() parser.add_argument('--only',choices=['all','workbook','slides'],default='all', help='Rebuild everything, or only one artifact') + parser.add_argument('--with-chart-sheet',action='store_true', + help='Добавить лист «Диаграмма». По умолчанию его нет: диаграммы строятся на лекции вживую.') 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() + build_workbook(with_charts=args.with_chart_sheet) if args.only in ('all','slides'): build_slides() diff --git a/scripts/verify_materials.py b/scripts/verify_materials.py index a106bda..0a74d6b 100644 --- a/scripts/verify_materials.py +++ b/scripts/verify_materials.py @@ -5,6 +5,7 @@ import json import re import unicodedata import argparse +import zipfile import pymupdf import openpyxl from PIL import Image, ImageDraw @@ -66,6 +67,44 @@ for (sheet,column),name in expected_dv.items(): 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),'статус вне списка' +# Формульный отчёт на листе «Эталон отчёта» (с 15-й строки) должен сходиться со статическим эталоном. +rep=book['Эталон отчёта'] +static={rep.cell(r,1).value:(rep.cell(r,2).value,rep.cell(r,3).value,rep.cell(r,4).value,rep.cell(r,5).value) + for r in range(4,9)} +assert len(static)==5 +for r in range(17,22): + dept=rep.cell(r,1).value + done,late,soon,total=static[dept] + assert (rep.cell(r,2).value,rep.cell(r,3).value,rep.cell(r,4).value)==(total,done,late),(dept,'отчёт на формулах') + assert rep.cell(r,5).value==soon,(dept,'в срок') + assert abs(rep.cell(r,6).value-late/(late+soon))<1e-9,(dept,'доля просроченных среди открытых') +assert (rep.cell(22,2).value,rep.cell(22,3).value,rep.cell(22,4).value,rep.cell(22,5).value)==(60,24,18,18) +assert abs(rep.cell(22,6).value-.5)<1e-9 +assert rep.cell(24,2).value=='Совпадает' and rep.cell(25,2).value=='Совпадает' +report_formulas=openpyxl.load_workbook(OUT/'excel-practice.xlsx',data_only=False)['Эталон отчёта'] +assert all(report_formulas.cell(r,2).data_type=='f' for r in list(range(17,23))+[24,25]) +assert 'COUNTIFS' in report_formulas.cell(17,3).value and 'Реестр' in report_formulas.cell(17,3).value +# Лист с диаграммами включается флагом --with-chart-sheet. Проверяем его только если он есть. +chart_sheet=book['Диаграмма'] if 'Диаграмма' in book.sheetnames else None +if chart_sheet is None: + chart_status='лист «Диаграмма» не включён (ожидаемо: диаграммы строятся на лекции вживую)' + with zipfile.ZipFile(OUT/'excel-practice.xlsx') as archive: + assert not [n for n in archive.namelist() if n.startswith('xl/charts/')],'найдены диаграммы без листа' +else: + for n,dept in enumerate(static): + r=2+n + done,late,soon,total=static[dept] + assert chart_sheet.cell(r,1).value==dept + assert (chart_sheet.cell(r,2).value,chart_sheet.cell(r,3).value,chart_sheet.cell(r,4).value)==(done,late,soon),(dept,'блок диаграммы') + assert (chart_sheet.cell(7,2).value,chart_sheet.cell(7,3).value,chart_sheet.cell(7,4).value)==(24,18,18) + with zipfile.ZipFile(OUT/'excel-practice.xlsx') as archive: + chart_parts=[n for n in archive.namelist() if n.startswith('xl/charts/chart') and n.endswith('.xml')] + assert len(chart_parts)==2,chart_parts + blob=''.join(archive.read(n).decode('utf-8') for n in chart_parts) + assert 'Диаграмма!' in blob and 'Реестр!' not in blob,'диаграммы должны ссылаться на лист Диаграмма' + assert 'Диаграмма!$A$2:$A$6' in blob,'нет категорий по подразделениям' + assert 'Диаграмма!$B$7:$D$7' in blob,'круговая диаграмма должна брать строку ИТОГО' + chart_status='лист «Диаграмма»: блок данных и две диаграммы совпадают с эталоном' for r in rows: expected='Завершено' if r[5]=='Выполнено' else ('Просрочено' if r[4]