Формульный отчёт на листе эталона; лист диаграмм по флагу
This commit is contained in:
1 parent
63c4a5ff40
commit
f33c0fbe34
5 files changed
+163
-8
No files matched your search
@@ -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()
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user