Формульный отчёт на листе эталона; лист диаграмм по флагу

This commit is contained in:
apuc committed 2026-10-20 00:26:55 +03:00
1 parent 63c4a5ff40
commit f33c0fbe34
5 files changed
+163 -8

No files matched your search

+11 -1
View File
@@ -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` — категории. Значения защищены от опечаток,
Binary file not shown.
+2 -1
View File
@@ -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"
}
+97 -4
View File
@@ -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()
+53 -2
View File
@@ -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]<book['Параметры']['B2'].value else 'В срок')
assert expected==r[7]
@@ -79,7 +118,19 @@ if args.recalculated_workbook:
assert all(recalculated['Тесты'].cell(n,4).value==recalculated['Тесты'].cell(n,5).value for n in range(2,6))
assert [recalculated['Старт'].cell(r,8).value for r in range(2,62)]==simple
assert [recalculated['Старт 2'].cell(r,8).value for r in range(2,62)]==nested
recalc_status='independent recalculation matches 60 rows, both teaching sheets, totals and boundary tests'
recalc_report=recalculated['Эталон отчёта']
for r in range(17,23):
for col in range(2,7):
a,b=recalc_report.cell(r,col).value,rep.cell(r,col).value
same=abs(a-b)<1e-9 if isinstance(a,(int,float)) and isinstance(b,(int,float)) else a==b
assert same,(r,col,a,b)
assert recalc_report.cell(24,2).value=='Совпадает' and recalc_report.cell(25,2).value=='Совпадает'
if chart_sheet is not None:
recalc_charts=recalculated['Диаграмма']
for r in range(2,8):
for col in range(2,5):
assert recalc_charts.cell(r,col).value==chart_sheet.cell(r,col).value,(r,col)
recalc_status='independent recalculation matches 60 rows, both teaching sheets, formula report, totals and boundary tests'
pdf=pymupdf.open(OUT/'excel-ai-lecture.pdf')
assert len(pdf)==26
@@ -130,7 +181,7 @@ except ImportError:
report={
'slides':26,'total_seconds':3600,'notes_embedded':True,'pdf_pages':len(pdf),
'synthetic_rows':60,'completed':24,'open':36,'overdue_open':18,'overdue_share_open':.5,
'qr':qr_status,'render_text_warnings':warnings,
'qr':qr_status,'render_text_warnings':warnings,'chart_sheet':chart_status,
'recalculated_workbook':recalc_status,
}
(OUT/'validation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')