100 поручений и шестой отдел; правка по экспертному заключению (промпт)
This commit is contained in:
1 parent
f33c0fbe34
commit
9d70f113af
10 files changed
+385
-294
No files matched your search
+110
-74
@@ -26,28 +26,54 @@ URL = 'https://t.me/kirill_bouko_it'
|
||||
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 = ['Организационный отдел','Правовой отдел','Финансовый отдел','Отдел обращений','ИТ-отдел']
|
||||
DEPTS = ['Организационный отдел','Правовой отдел','Финансовый отдел','Отдел обращений','ИТ-отдел',
|
||||
'Аналитический отдел']
|
||||
STATUSES = ['Выполнено','В работе','Новое']
|
||||
CATEGORIES = ['Организация','Документы','Отчётность']
|
||||
# План по подразделениям: (подразделение, выполнено в срок, выполнено с задержкой,
|
||||
# открытых просрочено, открытых в срок).
|
||||
# Итого: 100 поручений, 40 выполнено (20 в срок и 20 с задержкой), 30 открытых просрочено, 30 в срок.
|
||||
PLAN = [
|
||||
('Организационный отдел', 4, 3, 6, 4),
|
||||
('Правовой отдел', 4, 3, 6, 4),
|
||||
('Финансовый отдел', 3, 4, 4, 6),
|
||||
('Отдел обращений', 3, 4, 4, 6),
|
||||
('ИТ-отдел', 3, 3, 5, 5),
|
||||
('Аналитический отдел', 3, 3, 5, 5),
|
||||
]
|
||||
rows = []
|
||||
for i in range(60):
|
||||
k = i % 10
|
||||
dept = DEPTS[i // 12]
|
||||
category = CATEGORIES[i % 3]
|
||||
if k < 4:
|
||||
deadline = DAY - timedelta(days=12-i % 5)
|
||||
status = 'Выполнено'
|
||||
completed = deadline + timedelta(days=-2 if k < 2 else 3)
|
||||
control = 'Завершено'
|
||||
else:
|
||||
deadline = DAY + timedelta(days=-(1+i % 9) if k < 7 else i % 7)
|
||||
status = 'В работе' if i % 2 == 0 else 'Новое'
|
||||
completed = None
|
||||
control = 'Просрочено' if deadline < DAY else 'В срок'
|
||||
rows.append([f'П-{i+1:03}',dept,category,deadline-timedelta(days=14),deadline,status,completed,control])
|
||||
seq = 0
|
||||
for dept, in_time, late, overdue, on_time in PLAN:
|
||||
for kind, count in (('in_time', in_time), ('late', late), ('overdue', overdue), ('on_time', on_time)):
|
||||
for _ in range(count):
|
||||
seq += 1
|
||||
if kind == 'in_time':
|
||||
deadline = DAY - timedelta(days=5 + seq % 24)
|
||||
completed = deadline - timedelta(days=1 + seq % 3)
|
||||
elif kind == 'late':
|
||||
deadline = DAY - timedelta(days=8 + seq % 18)
|
||||
completed = deadline + timedelta(days=1 + seq % 4)
|
||||
elif kind == 'overdue':
|
||||
deadline = DAY - timedelta(days=1 + seq % 18)
|
||||
completed = None
|
||||
else:
|
||||
deadline = DAY + timedelta(days=seq % 21)
|
||||
completed = None
|
||||
received = min(deadline - timedelta(days=7 + seq % 12), DAY - timedelta(days=1))
|
||||
status = 'Выполнено' if kind in ('in_time', 'late') else ('В работе' if seq % 2 == 0 else 'Новое')
|
||||
control = ('Завершено' if status == 'Выполнено'
|
||||
else ('Просрочено' if deadline < DAY else 'В срок'))
|
||||
rows.append([f'П-{seq:03}',dept,CATEGORIES[seq % 3],received,deadline,status,completed,control])
|
||||
ROWS_TOTAL = len(rows)
|
||||
FIRST_DATA, LAST_DATA = 2, ROWS_TOTAL + 1 # строки Excel с данными листа «Реестр»
|
||||
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), 'значение вне выпадающего списка'
|
||||
assert ROWS_TOTAL == 100 and counts == {'Завершено':40,'Просрочено':30,'В срок':30}, counts
|
||||
assert sum(1 for r in rows if r[5]=='Выполнено')==40
|
||||
assert sum(1 for r in rows if r[6] is not None and r[6]>r[4])==20,'должно быть 20 выполненных с задержкой'
|
||||
assert all(r[3] < r[4] for r in rows),'дата поступления должна быть раньше срока'
|
||||
assert all(r[3] < DAY and (r[6] is None or r[6] <= DAY) for r in rows),'даты не должны выходить за дату среза'
|
||||
assert len({r[1] for r in rows})==6 and all(r[1] in DEPTS for r in rows)
|
||||
assert all(r[2] in CATEGORIES and r[5] in STATUSES for r in rows), 'значение вне выпадающего списка'
|
||||
|
||||
|
||||
def build_chart_sheet(book,head,normal,wrap):
|
||||
@@ -67,19 +93,22 @@ def build_chart_sheet(book,head,normal,wrap):
|
||||
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.write_formula(r-1,1,f"=COUNTIFS('Реестр'!$B$2:$B${LAST_DATA},$A{r},'Реестр'!$H$2:$H${LAST_DATA},\"Завершено\")",normal,done)
|
||||
charts.write_formula(r-1,2,f"=COUNTIFS('Реестр'!$B$2:$B${LAST_DATA},$A{r},'Реестр'!$H$2:$H${LAST_DATA},\"Просрочено\")",normal,late)
|
||||
charts.write_formula(r-1,3,f"=COUNTIFS('Реестр'!$B$2:$B${LAST_DATA},$A{r},'Реестр'!$H$2:$H${LAST_DATA},\"В срок\")",normal,soon)
|
||||
total_chart_row=len(DEPTS)+2 # Excel row of ИТОГО in the chart block
|
||||
charts.write(total_chart_row-1,0,'ИТОГО',head)
|
||||
charts.write_formula(total_chart_row-1,1,f"=COUNTIF('Реестр'!$H$2:$H${LAST_DATA},\"Завершено\")",head,40)
|
||||
charts.write_formula(total_chart_row-1,2,f"=COUNTIF('Реестр'!$H$2:$H${LAST_DATA},\"Просрочено\")",head,30)
|
||||
charts.write_formula(total_chart_row-1,3,f"=COUNTIF('Реестр'!$H$2:$H${LAST_DATA},\"В срок\")",head,30)
|
||||
charts.merge_range(total_chart_row,0,total_chart_row,3,'Блок слева пересчитывается из «Реестра». Диаграммы обновляются, как сводная: их можно обновить или перестроить.',wrap)
|
||||
charts.set_row(total_chart_row,42)
|
||||
charts.set_landscape();charts.set_paper(9);charts.fit_to_pages(1,0)
|
||||
last_dept_row=len(DEPTS)+1
|
||||
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],
|
||||
column.add_series({'name':label,'categories':['Диаграмма',1,0,last_dept_row,0],
|
||||
'values':['Диаграмма',1,1+col,last_dept_row,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':'Подразделение'})
|
||||
@@ -88,7 +117,8 @@ def build_chart_sheet(book,head,normal,wrap):
|
||||
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],
|
||||
pie.add_series({'name':'Контроль срока','categories':['Диаграмма',0,1,0,3],
|
||||
'values':['Диаграмма',total_chart_row-1,1,total_chart_row-1,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}})
|
||||
@@ -111,17 +141,17 @@ def build_workbook(with_charts=False):
|
||||
inst = book.add_worksheet('Инструкция')
|
||||
inst.set_column('A:A',30); inst.set_column('B:B',100)
|
||||
instructions = [
|
||||
('Учебный материал','60 вымышленных поручений. Лекция 20.10.2026. Никаких реальных ПДн.'),
|
||||
('Учебный материал',f'{ROWS_TOTAL} вымышленных поручений в шести подразделениях. Лекция 20.10.2026. Никаких реальных ПДн.'),
|
||||
('Порядок демонстрации','Старт: простое условие (одно ЕСЛИ, без дат). Старт 2: вложенное условие (два ЕСЛИ, с датой среза). Реестр: готовый итог.'),
|
||||
('Старт','Простое условие по статусу: =ЕСЛИ(F2="Выполнено";"Закрыто";"В работе"). Даты не используются.'),
|
||||
('Старт 2','Вложенное условие: сначала статус, затем срок против Параметры!B2. Здесь появляется «Просрочено».'),
|
||||
('Реестр','Готовая умная таблица и формулы H2:H61. Проверенный образец, не исходник для очистки.'),
|
||||
('Реестр',f'Готовая умная таблица и формулы H2:H{LAST_DATA}. Проверенный образец, не исходник для очистки.'),
|
||||
('Параметры!B2','Фиксированная дата среза 20.10.2026. СЕГОДНЯ() намеренно не используется.'),
|
||||
('Сводная','Вставка → Сводная таблица. Источник РеестрПоручений. Строки: Подразделение; столбцы: Контроль срока; значения: количество ID.'),
|
||||
('Эталон отчёта','Строки 1–9: статический эталон, НЕ настоящая сводная. С 15-й строки — отчёт на формулах, который считается из Реестра и обновляется сам.'),
|
||||
('Проверка','Ожидаемые итоги: 60 всего; 24 выполнено; 36 открыто; 18 открытых просрочено; 50% просроченных среди открытых.'),
|
||||
('Эталон отчёта','Строки 1–10: статический эталон, НЕ настоящая сводная. С 15-й строки — отчёт на формулах, который считается из Реестра и обновляется сам.'),
|
||||
('Проверка','Ожидаемые итоги: 100 всего; 40 выполнено; 60 открыто; 30 открытых просрочено; 50% просроченных среди открытых.'),
|
||||
('Смысл формулы H','Выполненные получают Завершено независимо от соблюдения срока. Проверка своевременности выполненных — отдельный расчёт.'),
|
||||
('Границы примера','Выполненных с задержкой 12, без задержки 12. Учебный флаг просрочки считает только открытые.'),
|
||||
('Границы примера','Выполненных с задержкой 20, без задержки 20. Учебный флаг просрочки считает только открытые.'),
|
||||
('Выпадающие списки','Подразделение (B), Категория (C) и Статус (F) выбираются из списков на листе Параметры. Опечатки исключены.'),
|
||||
('Диаграмма','Лист намеренно не включён: диаграммы строим вживую. Данные проще всего взять из сводной (Контроль срока) или получить СЧЁТЕСЛИМН по подразделениям, как в отчёте на формулах.'),
|
||||
('Не делать','Не перезаписывать оригинал при демонстрации, не отправлять реальные служебные данные внешней модели.'),
|
||||
@@ -169,26 +199,26 @@ def build_workbook(with_charts=False):
|
||||
elif v is not None:sh.write(n,c,v,normal)
|
||||
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.add_table(0,0,LAST_DATA-1,7,{'name':'РеестрПоручений','style':'Table Style Medium 2','columns':[{'header':h} for h in headers]})
|
||||
sh.conditional_format(f'H2:H{LAST_DATA}',{'type':'text','criteria':'containing','value':'Просрочено','format':alert})
|
||||
sh.conditional_format(f'H2:H{LAST_DATA}',{'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.merge_range(LAST_DATA+1,0,LAST_DATA+1,7,note_text,wrap);sh.set_row(LAST_DATA+1,26)
|
||||
sh.merge_range(LAST_DATA+2,0,LAST_DATA+2,7,'Формула: '+formula_text,wrap);sh.set_row(LAST_DATA+2,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)
|
||||
checks=[
|
||||
('Всего поручений',"=COUNTA('Реестр'!A2:A61)",60),
|
||||
('Выполнено','=COUNTIF(\'Реестр\'!F2:F61,"Выполнено")',24),
|
||||
('Открыто','=B2-B3',36),
|
||||
('Открытых просрочено','=COUNTIF(\'Реестр\'!H2:H61,"Просрочено")',18),
|
||||
('Всего поручений',f"=COUNTA('Реестр'!A2:A{LAST_DATA})",100),
|
||||
('Выполнено',f'=COUNTIF(\'Реестр\'!F2:F{LAST_DATA},"Выполнено")',40),
|
||||
('Открыто','=B2-B3',60),
|
||||
('Открытых просрочено',f'=COUNTIF(\'Реестр\'!H2:H{LAST_DATA},"Просрочено")',30),
|
||||
('Доля просроченных среди открытых','=IF(B4=0,0,B5/B4)',.5),
|
||||
('Уникальных ID (контроль при сборке)',None,60),
|
||||
('Пропусков обязательных полей (A:F)','=COUNTBLANK(\'Реестр\'!A2:F61)',0),
|
||||
('Выполненных с задержкой (контроль при сборке)',None,12),
|
||||
('Уникальных ID (контроль при сборке)',None,100),
|
||||
('Пропусков обязательных полей (A:F)',f'=COUNTBLANK(\'Реестр\'!A2:F{LAST_DATA})',0),
|
||||
('Выполненных с задержкой (контроль при сборке)',None,20),
|
||||
]
|
||||
for n,(label,formula,expected) in enumerate(checks,1):
|
||||
check.set_row(n,32);check.write(n,0,label,wrap)
|
||||
@@ -201,11 +231,15 @@ def build_workbook(with_charts=False):
|
||||
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):
|
||||
static_first=4
|
||||
for n,dept in enumerate(DEPTS):
|
||||
c=Counter(r[7] for r in rows if r[1]==dept)
|
||||
report.write_row(n,0,[dept,c['Завершено'],c['Просрочено'],c['В срок'],12],normal)
|
||||
report.set_row(n,28)
|
||||
report.write_row('A9',['ИТОГО',24,18,18,60],head)
|
||||
total=sum(1 for r in rows if r[1]==dept)
|
||||
report.write_row(static_first-1+n,0,[dept,c['Завершено'],c['Просрочено'],c['В срок'],total],normal)
|
||||
report.set_row(static_first-1+n,28)
|
||||
static_last=static_first+len(DEPTS)-1 # Excel 9
|
||||
static_total=static_last+1 # Excel 10
|
||||
report.write_row(static_total-1,0,['ИТОГО',40,30,30,100],head)
|
||||
report.write('A12','Для обновляемого отчёта создайте сводную через Вставка → Сводная таблица.',wrap)
|
||||
report.set_row(11,70)
|
||||
|
||||
@@ -215,28 +249,30 @@ def build_workbook(with_charts=False):
|
||||
report.write_row(15,0,['Подразделение','Всего','Завершено','Просрочено','В срок',
|
||||
'Доля просроченных среди открытых'],head)
|
||||
report.set_row(15,40)
|
||||
rep_first,rep_last=17,21 # Excel rows of the five departments
|
||||
rep_first=17
|
||||
rep_last=rep_first+len(DEPTS)-1 # Excel 22
|
||||
for n,dept in enumerate(DEPTS):
|
||||
r=rep_first+n
|
||||
total=sum(1 for x in rows if x[1]==dept)
|
||||
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,1,f"=COUNTIF('Реестр'!$B$2:$B${LAST_DATA},$A{r})",normal,total)
|
||||
report.write_formula(r-1,2,f"=COUNTIFS('Реестр'!$B$2:$B${LAST_DATA},$A{r},'Реестр'!$H$2:$H${LAST_DATA},\"Завершено\")",normal,done)
|
||||
report.write_formula(r-1,3,f"=COUNTIFS('Реестр'!$B$2:$B${LAST_DATA},$A{r},'Реестр'!$H$2:$H${LAST_DATA},\"Просрочено\")",normal,late)
|
||||
report.write_formula(r-1,4,f"=COUNTIFS('Реестр'!$B$2:$B${LAST_DATA},$A{r},'Реестр'!$H$2:$H${LAST_DATA},\"В срок\")",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
|
||||
total_row=rep_last+1 # Excel 23
|
||||
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,1,f"=COUNTA('Реестр'!$A$2:$A${LAST_DATA})",head,100)
|
||||
report.write_formula(total_row-1,2,f"=COUNTIF('Реестр'!$H$2:$H${LAST_DATA},\"Завершено\")",head,40)
|
||||
report.write_formula(total_row-1,3,f"=COUNTIF('Реестр'!$H$2:$H${LAST_DATA},\"Просрочено\")",head,30)
|
||||
report.write_formula(total_row-1,4,f"=COUNTIF('Реестр'!$H$2:$H${LAST_DATA},\"В срок\")",head,30)
|
||||
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
|
||||
sums_check=total_row+2 # Excel 25
|
||||
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},'
|
||||
@@ -365,7 +401,7 @@ def build_slides():
|
||||
s=slide('МАРШРУТ НА 60 МИНУТ','СТАРТ · ЧТО ПОЛУЧИТСЯ')
|
||||
for j,(h,b) in enumerate([('03 мин · Старт','Сквозная задача\nи правила работы'),('23 мин · Excel','Таблица → формулы\n→ сводный отчёт'),('29 мин · ИИ и итоги','Агент → файл\n→ проверка результата')]):card(s,.5+j*4.18,2.05,3.97,2.7,h,b)
|
||||
text(s,.65,5.1,11.9,.6,'+ 5 минут на вопросы. Ноутбуки участникам не нужны.',24,BLUE,True)
|
||||
banner(s,'Учебный реестр: 60 вымышленных поручений. Дата среза: 20.10.2026.')
|
||||
banner(s,'Учебный реестр: 100 вымышленных поручений в шести отделах. Дата среза: 20.10.2026.')
|
||||
|
||||
s=slide('ПРАВИЛЬНАЯ ТАБЛИЦА — ОСНОВА ОТЧЁТА')
|
||||
table(s,.5,1.95,[1.25,3.6,2.55,2.4,2.55],[['ID','Подразделение','Срок','Статус','Контроль'],['П-005','Организационный','15.10.2026','В работе','Просрочено'],['П-008','Организационный','20.10.2026','Новое','В срок']],.68,19)
|
||||
@@ -401,8 +437,8 @@ def build_slides():
|
||||
text(s,.55,1.92,11.6,.55,'Сколько поручений имеют статус «Выполнено»?',26,BLUE,True)
|
||||
box(s,.5,2.86,12.3,1.15,LIGHT,radius=True)
|
||||
text(s,.8,3.15,11.75,.55,'=СЧЁТЕСЛИ(F2:F61;"Выполнено")',29,BLUE,True)
|
||||
text(s,.6,4.5,2.6,1.2,'24',64,BLUE,True)
|
||||
text(s,3.3,4.53,9.2,1.4,'Из 60 учебных поручений.\nСчитаем совпадения, а не складываем строки.',25)
|
||||
text(s,.6,4.5,2.6,1.2,'40',64,BLUE,True)
|
||||
text(s,3.3,4.53,9.2,1.4,'Из 100 учебных поручений.\nСчитаем совпадения, а не складываем строки.',25)
|
||||
banner(s,'Обычный СЧЁТ считает числовые ячейки — для текстовых статусов он не подходит.')
|
||||
|
||||
s=slide('ЕСЛИ: ПРОЗРАЧНАЯ ЛОГИКА КОНТРОЛЯ СРОКОВ')
|
||||
@@ -413,16 +449,16 @@ def build_slides():
|
||||
|
||||
s=slide('СВОДНАЯ: ИЗ РЕЕСТРА В ОТЧЁТ','EXCEL · ДЕМОНСТРАЦИЯ 6 МИНУТ')
|
||||
text(s,.6,1.95,5.55,2.6,'Вставка → Сводная таблица\n\nСтроки: подразделение\nСтолбцы: контроль срока\nЗначения: количество ID',24)
|
||||
table(s,6.6,2.05,[2.15,1.18,1.22,1.15],[['Отдел','Готово','Проср.','В срок'],['Организ.',6,3,3],['Правовой',6,3,3],['Финансовый',4,5,3],['Обращений',4,4,4],['ИТ-отдел',4,3,5]],.57,17)
|
||||
text(s,.6,5.37,11.9,.52,'Проверка: 24 завершено + 18 просрочено + 18 в срок = 60.',24,BLUE,True)
|
||||
table(s,6.6,1.98,[2.15,1.18,1.22,1.15],[['Отдел','Готово','Проср.','В срок'],['Организ.',7,6,4],['Правовой',7,6,4],['Финансовый',7,4,6],['Обращений',7,4,6],['ИТ-отдел',6,5,5],['Аналитич.',6,5,5]],.45,15)
|
||||
text(s,.6,5.37,11.9,.52,'Проверка: 40 завершено + 30 просрочено + 30 в срок = 100.',24,BLUE,True)
|
||||
banner(s,'После изменения исходника обновляем сводную. В XLSX есть статический эталон.')
|
||||
|
||||
s=slide('ПРОВЕРЯЕМ ОТЧЁТ, А НЕ ТОЛЬКО ЕГО ВИД')
|
||||
for j,(num,label) in enumerate([('60','всего'),('24','выполнено'),('36','открыто'),('18','просрочено\nиз открытых')]):
|
||||
for j,(num,label) in enumerate([('100','всего'),('40','выполнено'),('60','открыто'),('30','просрочено\nиз открытых')]):
|
||||
box(s,.5+j*3.12,2.05,2.94,2.38,LIGHT,radius=True)
|
||||
text(s,.73+j*3.12,2.3,2.47,.95,num,52,BLUE,True)
|
||||
text(s,.73+j*3.12,3.36,2.47,.95,label,22)
|
||||
text(s,.6,5.02,11.9,.85,'18 / 36 = 50% открытых поручений просрочено.\nДругой знаменатель — другой показатель.',24,BLUE,True)
|
||||
text(s,.6,5.02,11.9,.85,'30 / 60 = 50% открытых поручений просрочено.\nДругой знаменатель — другой показатель.',24,BLUE,True)
|
||||
banner(s,'Контрольный лист «Проверка» — независимая точка сверки, а не украшение отчёта.')
|
||||
|
||||
s=slide('ОТ НАВЫКА К ПОМОЩНИКУ',dark=True)
|
||||
@@ -447,12 +483,12 @@ def build_slides():
|
||||
text(s,1.5,y+.05,5.2,.48,h,25,BLUE,True);text(s,7.0,y+.08,5.75,.55,b,22)
|
||||
banner(s,'Перед выступлением нужна репетиция в выбранной конфигурации OpenWork.')
|
||||
|
||||
s=slide('ХОРОШЕЕ ЗАДАНИЕ: ПЯТЬ СОСТАВЛЯЮЩИХ','ИИ · ШАБЛОН ПРОМПТА')
|
||||
s=slide('ХОРОШЕЕ ЗАДАНИЕ: ПЯТЬ СОСТАВЛЯЮЩИХ','ИИ · ШАБЛОН ЗАДАНИЯ')
|
||||
items=[('Цель','Какой рабочий вопрос решаем?'),('Данные','Какой файл, листы и дата среза?'),('Результат','Новый XLSX, отчёт, формула или текст?'),('Ограничения','Что нельзя менять, отправлять и запускать?'),('Проверка','Какими числами и тестами сверять итог?')]
|
||||
for j,(a,b) in enumerate(items):
|
||||
y=1.9+j*.76
|
||||
text(s,.6,y,3.0,.55,a,24,BLUE,True);text(s,3.85,y,8.85,.58,b,23)
|
||||
banner(s,'Промпт не заменяет технические ограничения доступа и правила организации.')
|
||||
banner(s,'Текст задания не заменяет технические ограничения доступа и правила организации.')
|
||||
|
||||
s=slide('ДЕМО 1. ПОЛУЧАЕМ И ПРОВЕРЯЕМ ФОРМУЛУ','ИИ · 3 МИНУТЫ')
|
||||
box(s,.5,1.97,12.3,2.47,LIGHT,radius=True)
|
||||
@@ -463,15 +499,15 @@ def build_slides():
|
||||
s=slide('ДЕМО 2. АГЕНТ РАБОТАЕТ С КНИГОЙ','ИИ · 7 МИНУТ')
|
||||
card(s,.5,1.95,5.96,3.93,'Что поручаем','Прочитать учебный реестр.\nПроверить структуру и пропуски.\nПосчитать итоги по отделам.\nСоздать новый отчёт XLSX.')
|
||||
card(s,6.72,1.95,6.1,3.93,'Что ограничиваем','Исходник не изменять.\nНе публиковать и не отправлять файл.\nНе запускать макросы.\nПеред установкой ПО — остановиться.')
|
||||
banner(s,'Результат принимаем после сверки: 60 / 24 / 36 / 18. Полное задание — в сценарии.')
|
||||
banner(s,'Результат принимаем после сверки: 100 / 40 / 60 / 30. Полное задание — в сценарии.')
|
||||
|
||||
s=slide('ДЕМО 3. ВЫВОДЫ БЕЗ ДОМЫСЛОВ','ИИ · ЧЕРНОВИК СЛУЖЕБНОЙ ЗАПИСКИ')
|
||||
card(s,.5,1.95,5.96,3.95,'Факт из учебного отчёта','«На 20.10.2026 открыто\n36 поручений; у 18 истёк срок.\nДоля среди открытых — 50%».\n\nПоказать источник расчёта.')
|
||||
card(s,.5,1.95,5.96,3.95,'Факт из учебного отчёта','«На 20.10.2026 открыто\n60 поручений; у 30 истёк срок.\nДоля среди открытых — 50%».\n\nПоказать источник расчёта.')
|
||||
card(s,6.72,1.95,6.1,3.95,'Недопустимый домысел','«Причина — низкая\nдисциплина сотрудников».\n\nВ таблице нет данных\nдля такого вывода.',PINK)
|
||||
banner(s,'Отделяем наблюдение от причины. ИИ готовит черновик, человек редактирует.')
|
||||
|
||||
s=slide('КАК ПРОВЕРИТЬ РЕЗУЛЬТАТ ИИ','ИИ · ПРИЁМКА РЕЗУЛЬТАТА')
|
||||
for j,(h,b) in enumerate([('Данные','60 строк, уникальные ID.\nНет потерь и дублей.\nИсходник не изменён.'),('Расчёт','Дата среза 20.10.2026.\n18 из 36, не из 60.\nГраничные тесты пройдены.'),('Файл','Открывается в Excel.\nФормулы понятны.\nИзменения перечислены.')]):card(s,.5+j*4.18,1.97,3.97,3.72,h,b)
|
||||
for j,(h,b) in enumerate([('Данные','100 строк, уникальные ID.\nНет потерь и дублей.\nИсходник не изменён.'),('Расчёт','Дата среза 20.10.2026.\n30 из 60, не из 100.\nГраничные тесты пройдены.'),('Файл','Открывается в Excel.\nФормулы понятны.\nИзменения перечислены.')]):card(s,.5+j*4.18,1.97,3.97,3.72,h,b)
|
||||
banner(s,'При расхождении сначала ищем причину, а не просим ИИ «сделать цифры красивее».')
|
||||
|
||||
s=slide('ЧТО НЕ ДЕЛЕГИРУЕМ ИИ','ИИ · ГРАНИЦЫ ОТВЕТСТВЕННОСТИ')
|
||||
@@ -505,7 +541,7 @@ def build_slides():
|
||||
assert len(p.slides)==26
|
||||
p.save(OUT/'excel-ai-lecture.pptx')
|
||||
(OUT/'slides-manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||
print('Saved 26 slides, 3600 seconds; workbook: 60 synthetic rows.')
|
||||
print(f'Saved 26 slides, 3600 seconds; workbook: {ROWS_TOTAL} synthetic rows in {len(DEPTS)} departments.')
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
|
||||
+193
-147
@@ -1,4 +1,7 @@
|
||||
"""Check generated content and export contact sheets for visual review."""
|
||||
"""Check generated content and export contact sheets for visual review.
|
||||
|
||||
Run: py scripts/verify_materials.py [--recalculated-workbook <path>]
|
||||
"""
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
import json
|
||||
@@ -11,179 +14,222 @@ import openpyxl
|
||||
from PIL import Image, ImageDraw
|
||||
from pptx import Presentation
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('--recalculated-workbook',type=Path,help='Optional independent LibreOffice/Excel recalculation copy')
|
||||
args=parser.parse_args()
|
||||
OUT=ROOT/'materials'
|
||||
PREV=OUT/'preview'
|
||||
PREV.mkdir(exist_ok=True)
|
||||
p=Presentation(OUT/'excel-ai-lecture.pptx')
|
||||
assert len(p.slides)==26
|
||||
manifest=json.loads((OUT/'slides-manifest.json').read_text(encoding='utf-8'))
|
||||
assert sum(s['seconds'] for s in manifest)==3600
|
||||
notes=(OUT/'speaker-notes.md').read_text(encoding='utf-8')
|
||||
durations=[int(v) for v in re.findall(r'\*\*Длительность:\*\* (\d+) секунд',notes)]
|
||||
assert durations==[s['seconds'] for s in manifest],durations
|
||||
assert all(s.notes_slide.notes_text_frame.text.strip() for s in p.slides)
|
||||
for n,s in enumerate(p.slides,1):
|
||||
for sh in s.shapes:
|
||||
assert sh.left>=0 and sh.top>=0,(n,sh.name,'negative position')
|
||||
assert sh.left+sh.width<=p.slide_width+10000,(n,sh.name,'too wide')
|
||||
assert sh.top+sh.height<=p.slide_height+10000,(n,sh.name,'too tall')
|
||||
# Ожидаемые контрольные значения учебного набора.
|
||||
ROWS_TOTAL = 100
|
||||
LAST = ROWS_TOTAL + 1 # последняя строка данных листа «Реестр»
|
||||
DEPTS_TOTAL = 6
|
||||
FIRST_DATA = 2
|
||||
STATIC_FIRST, STATIC_TOTAL = 4, 10 # строки статического эталона
|
||||
REP_FIRST = 17 # первая строка отчёта на формулах
|
||||
REP_LAST = REP_FIRST + DEPTS_TOTAL - 1
|
||||
REP_TOTAL = REP_LAST + 1
|
||||
REP_CHECKS = (REP_TOTAL + 2, REP_TOTAL + 3)
|
||||
|
||||
book=openpyxl.load_workbook(OUT/'excel-practice.xlsx',data_only=True)
|
||||
rows=list(book['Реестр'].iter_rows(min_row=2,max_row=61,values_only=True))
|
||||
assert len(rows)==60 and len({r[0] for r in rows})==60
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--recalculated-workbook', type=Path,
|
||||
help='Optional independent LibreOffice/Excel recalculation copy')
|
||||
args = parser.parse_args()
|
||||
OUT = ROOT / 'materials'
|
||||
PREV = OUT / 'preview'
|
||||
PREV.mkdir(exist_ok=True)
|
||||
WORKBOOK = OUT / 'excel-practice.xlsx'
|
||||
|
||||
p = Presentation(OUT / 'excel-ai-lecture.pptx')
|
||||
assert len(p.slides) == 26
|
||||
manifest = json.loads((OUT / 'slides-manifest.json').read_text(encoding='utf-8'))
|
||||
assert sum(s['seconds'] for s in manifest) == 3600
|
||||
notes = (OUT / 'speaker-notes.md').read_text(encoding='utf-8')
|
||||
durations = [int(v) for v in re.findall(r'\*\*Длительность:\*\* (\d+) секунд', notes)]
|
||||
assert durations == [s['seconds'] for s in manifest], durations
|
||||
assert all(s.notes_slide.notes_text_frame.text.strip() for s in p.slides)
|
||||
for n, s in enumerate(p.slides, 1):
|
||||
for sh in s.shapes:
|
||||
assert sh.left >= 0 and sh.top >= 0, (n, sh.name, 'negative position')
|
||||
assert sh.left + sh.width <= p.slide_width + 10000, (n, sh.name, 'too wide')
|
||||
assert sh.top + sh.height <= p.slide_height + 10000, (n, sh.name, 'too tall')
|
||||
|
||||
book = openpyxl.load_workbook(WORKBOOK, data_only=True)
|
||||
formulas = openpyxl.load_workbook(WORKBOOK, data_only=False)
|
||||
rows = list(book['Реестр'].iter_rows(min_row=FIRST_DATA, max_row=LAST, values_only=True))
|
||||
assert len(rows) == ROWS_TOTAL and len({r[0] for r in rows}) == ROWS_TOTAL
|
||||
assert all(all(v is not None for v in r[:6]) for r in rows)
|
||||
assert Counter(r[7] for r in rows)=={'Завершено':24,'Просрочено':18,'В срок':18}
|
||||
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 and len(book['Старт 2'].tables)==0
|
||||
assert Counter(r[7] for r in rows) == {'Завершено':40,'Просрочено':30,'В срок':30}
|
||||
assert sum(1 for r in rows if r[5] == 'Выполнено') == 40
|
||||
assert sum(1 for r in rows if r[5] == 'Выполнено' and r[6] > r[4]) == 20
|
||||
assert all(r[6] is None or r[6] <= book['Параметры']['B2'].value for r in rows)
|
||||
assert all(r[3] < r[4] for r in rows), 'дата поступления должна быть раньше срока'
|
||||
assert len({r[1] for r in rows}) == DEPTS_TOTAL
|
||||
assert book['Проверка']['B2'].value == 100 and book['Проверка']['B3'].value == 40
|
||||
assert book['Проверка']['B4'].value == 60 and book['Проверка']['B5'].value == 30
|
||||
assert book['Проверка']['B6'].value == .5
|
||||
assert len(book['Реестр'].tables) == 1
|
||||
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
|
||||
data_range = range(FIRST_DATA, LAST + 1)
|
||||
simple = [book['Старт'].cell(r, 8).value for r in data_range]
|
||||
nested = [book['Старт 2'].cell(r, 8).value for r in data_range]
|
||||
assert simple == ['Закрыто' if r[5] == 'Выполнено' else 'В работе' for r in rows]
|
||||
assert nested == [r[7] for r in rows]
|
||||
for name in ('Реестр', 'Старт', 'Старт 2'):
|
||||
assert all(formulas[name].cell(r, 8).data_type == 'f' for r in data_range), name
|
||||
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),'статус вне списка'
|
||||
# Формульный отчёт на листе «Эталон отчёта» (с 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
|
||||
statuses = [book['Параметры'].cell(r, 1).value for r in range(5, 8)]
|
||||
depts = [book['Параметры'].cell(r, 1).value for r in range(10, 10 + DEPTS_TOTAL)]
|
||||
cats = [book['Параметры'].cell(r, 1).value for r in range(17, 20)]
|
||||
assert len(depts) == DEPTS_TOTAL and len(set(depts)) == DEPTS_TOTAL
|
||||
expected_dv = {(sheet, col): name
|
||||
for sheet in ('Старт', 'Старт 2', 'Реестр')
|
||||
for col, name in (('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), 'статус вне списка'
|
||||
assert set(r[1] for r in rows) == set(depts), 'все подразделения из списка должны встречаться в реестре'
|
||||
|
||||
# Статический эталон и отчёт на формулах должны сходиться между собой.
|
||||
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(STATIC_FIRST, STATIC_TOTAL)}
|
||||
assert len(static) == DEPTS_TOTAL, static
|
||||
assert (rep.cell(STATIC_TOTAL, 2).value, rep.cell(STATIC_TOTAL, 3).value,
|
||||
rep.cell(STATIC_TOTAL, 4).value, rep.cell(STATIC_TOTAL, 5).value) == (40, 30, 30, 100)
|
||||
for r in range(REP_FIRST, REP_TOTAL):
|
||||
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(REP_TOTAL, 2).value, rep.cell(REP_TOTAL, 3).value,
|
||||
rep.cell(REP_TOTAL, 4).value, rep.cell(REP_TOTAL, 5).value) == (100, 40, 30, 30)
|
||||
assert abs(rep.cell(REP_TOTAL, 6).value - .5) < 1e-9
|
||||
assert all(rep.cell(r, 2).value == 'Совпадает' for r in REP_CHECKS), 'обе строки сверки должны совпадать'
|
||||
report_formulas = formulas['Эталон отчёта']
|
||||
assert all(report_formulas.cell(r, 2).data_type == 'f'
|
||||
for r in list(range(REP_FIRST, REP_TOTAL + 1)) + list(REP_CHECKS))
|
||||
assert 'COUNTIFS' in report_formulas.cell(REP_FIRST, 3).value
|
||||
assert f'$B${LAST}' in report_formulas.cell(REP_FIRST, 3).value, 'диапазон должен охватывать весь реестр'
|
||||
|
||||
# Лист с диаграммами включается флагом --with-chart-sheet. Проверяем его только если он есть.
|
||||
chart_sheet=book['Диаграмма'] if 'Диаграмма' in book.sheetnames else None
|
||||
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/')],'найдены диаграммы без листа'
|
||||
chart_status = 'лист «Диаграмма» не включён (ожидаемо: диаграммы строятся на лекции вживую)'
|
||||
with zipfile.ZipFile(WORKBOOK) 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='лист «Диаграмма»: блок данных и две диаграммы совпадают с эталоном'
|
||||
chart_last = 1 + DEPTS_TOTAL
|
||||
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(chart_last + 1, 2).value, chart_sheet.cell(chart_last + 1, 3).value,
|
||||
chart_sheet.cell(chart_last + 1, 4).value) == (40, 30, 30)
|
||||
with zipfile.ZipFile(WORKBOOK) 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 f'Диаграмма!$A$2:$A${chart_last + 1}' in blob, 'нет категорий по подразделениям'
|
||||
assert f'Диаграмма!$B${chart_last + 1}:$D${chart_last + 1}' in blob, 'круговая диаграмма должна брать строку ИТОГО'
|
||||
chart_status = 'лист «Диаграмма»: блок данных и две диаграммы совпадают с эталоном'
|
||||
|
||||
for r in rows:
|
||||
expected='Завершено' if r[5]=='Выполнено' else ('Просрочено' if r[4]<book['Параметры']['B2'].value else 'В срок')
|
||||
assert expected==r[7]
|
||||
assert all(book['Тесты'].cell(n,4).value==book['Тесты'].cell(n,5).value for n in range(2,6))
|
||||
recalc_status='not performed in this check'
|
||||
expected = 'Завершено' if r[5] == 'Выполнено' else ('Просрочено' if r[4] < book['Параметры']['B2'].value else 'В срок')
|
||||
assert expected == r[7]
|
||||
assert all(book['Тесты'].cell(n, 4).value == book['Тесты'].cell(n, 5).value for n in range(2, 6))
|
||||
|
||||
recalc_status = 'not performed in this check'
|
||||
if args.recalculated_workbook:
|
||||
recalculated=openpyxl.load_workbook(args.recalculated_workbook,data_only=True)
|
||||
assert list(recalculated['Реестр'].iter_rows(min_row=2,max_row=61,values_only=True))==rows
|
||||
for n in range(2,10):
|
||||
assert recalculated['Проверка'].cell(n,2).value==book['Проверка'].cell(n,2).value
|
||||
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_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=='Совпадает'
|
||||
recalculated = openpyxl.load_workbook(args.recalculated_workbook, data_only=True)
|
||||
assert list(recalculated['Реестр'].iter_rows(min_row=FIRST_DATA, max_row=LAST, values_only=True)) == rows
|
||||
for n in range(2, 10):
|
||||
assert recalculated['Проверка'].cell(n, 2).value == book['Проверка'].cell(n, 2).value, n
|
||||
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 data_range] == simple
|
||||
assert [recalculated['Старт 2'].cell(r, 8).value for r in data_range] == nested
|
||||
recalc_report = recalculated['Эталон отчёта']
|
||||
for r in list(range(REP_FIRST, REP_TOTAL + 1)) + list(REP_CHECKS):
|
||||
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)
|
||||
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
|
||||
recalc_charts = recalculated['Диаграмма']
|
||||
for r in range(2, chart_last + 2):
|
||||
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 100 rows, both teaching sheets, '
|
||||
'formula report, totals and boundary tests')
|
||||
|
||||
pdf = pymupdf.open(OUT / 'excel-ai-lecture.pdf')
|
||||
assert len(pdf) == 26
|
||||
|
||||
|
||||
def norm(t):
|
||||
return ''.join(c for c in unicodedata.normalize('NFKC',t).lower() if c.isalnum())
|
||||
return ''.join(c for c in unicodedata.normalize('NFKC', t).lower() if c.isalnum())
|
||||
|
||||
warnings=[]
|
||||
for i,s in enumerate(p.slides):
|
||||
page=pdf[i]
|
||||
pdf_text=norm(page.get_text())
|
||||
|
||||
warnings = []
|
||||
for i, s in enumerate(p.slides):
|
||||
page = pdf[i]
|
||||
pdf_text = norm(page.get_text())
|
||||
for sh in s.shapes:
|
||||
if not sh.has_text_frame:continue
|
||||
if not sh.has_text_frame:
|
||||
continue
|
||||
for para in sh.text_frame.paragraphs:
|
||||
expected=norm(para.text)
|
||||
expected = norm(para.text)
|
||||
if expected and expected not in pdf_text:
|
||||
warnings.append(f'Slide {i+1}: text not found intact: {para.text}')
|
||||
for block in page.get_text('dict')['blocks']:
|
||||
if 'lines' not in block:continue
|
||||
if 'lines' not in block:
|
||||
continue
|
||||
for line in block['lines']:
|
||||
for span in line['spans']:
|
||||
x0,y0,x1,y1=span['bbox']
|
||||
if min(x0,y0)<-1 or x1>page.rect.width+1 or y1>page.rect.height+1:
|
||||
x0, y0, x1, y1 = span['bbox']
|
||||
if min(x0, y0) < -1 or x1 > page.rect.width + 1 or y1 > page.rect.height + 1:
|
||||
warnings.append(f'Slide {i+1}: text exceeds page: {span["text"]}')
|
||||
page.get_pixmap(matrix=pymupdf.Matrix(1.4,1.4)).save(PREV/f'slide-{i+1:02}.png')
|
||||
page.get_pixmap(matrix=pymupdf.Matrix(1.4, 1.4)).save(PREV / f'slide-{i+1:02}.png')
|
||||
|
||||
for start in range(0,26,8):
|
||||
ids=list(range(start,min(start+8,26)))
|
||||
sheet=Image.new('RGB',(1200,360*((len(ids)+1)//2)),'#dedee8')
|
||||
for k,i in enumerate(ids):
|
||||
pm=pdf[i].get_pixmap(matrix=pymupdf.Matrix(.625,.625))
|
||||
im=Image.frombytes('RGB',(pm.width,pm.height),pm.samples)
|
||||
x=(k%2)*600;y=(k//2)*360
|
||||
sheet.paste(im,(x,y))
|
||||
ImageDraw.Draw(sheet).text((x+12,y+339),f'SLIDE {i+1:02}',fill='black')
|
||||
sheet.save(PREV/f'contact-{start//8+1}.png')
|
||||
for start in range(0, 26, 8):
|
||||
ids = list(range(start, min(start + 8, 26)))
|
||||
sheet = Image.new('RGB', (1200, 360 * ((len(ids) + 1) // 2)), '#dedee8')
|
||||
for k, i in enumerate(ids):
|
||||
pm = pdf[i].get_pixmap(matrix=pymupdf.Matrix(.625, .625))
|
||||
im = Image.frombytes('RGB', (pm.width, pm.height), pm.samples)
|
||||
x = (k % 2) * 600
|
||||
y = (k // 2) * 360
|
||||
sheet.paste(im, (x, y))
|
||||
ImageDraw.Draw(sheet).text((x + 12, y + 339), f'SLIDE {i+1:02}', fill='black')
|
||||
sheet.save(PREV / f'contact-{start//8+1}.png')
|
||||
|
||||
try:
|
||||
import zxingcpp
|
||||
result=zxingcpp.read_barcode(Image.open(OUT/'telegram-qr.png'))
|
||||
assert result and result.text=='https://t.me/kirill_bouko_it'
|
||||
result = zxingcpp.read_barcode(Image.open(OUT / 'telegram-qr.png'))
|
||||
assert result and result.text == 'https://t.me/kirill_bouko_it'
|
||||
# Verify QR survives PDF export, not only its standalone source image.
|
||||
result=zxingcpp.read_barcode(Image.open(PREV/'slide-25.png'))
|
||||
assert result and result.text=='https://t.me/kirill_bouko_it'
|
||||
qr_status='decoded from PNG and PDF slide 25'
|
||||
result = zxingcpp.read_barcode(Image.open(PREV / 'slide-25.png'))
|
||||
assert result and result.text == 'https://t.me/kirill_bouko_it'
|
||||
qr_status = 'decoded from PNG and PDF slide 25'
|
||||
except ImportError:
|
||||
qr_status='decoder unavailable; image generated from exact URL'
|
||||
qr_status = 'decoder unavailable; image generated from exact URL'
|
||||
|
||||
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,'chart_sheet':chart_status,
|
||||
report = {
|
||||
'slides':26, 'total_seconds':3600, 'notes_embedded':True, 'pdf_pages':len(pdf),
|
||||
'synthetic_rows':ROWS_TOTAL, 'departments':DEPTS_TOTAL, 'completed':40, 'open':60,
|
||||
'overdue_open':30, 'overdue_share_open':.5, 'completed_late':20,
|
||||
'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')
|
||||
print(json.dumps(report,ensure_ascii=False,indent=2))
|
||||
assert not warnings,'Inspect PDF text warnings before delivering'
|
||||
(OUT / 'validation.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
assert not warnings, 'Inspect PDF text warnings before delivering'
|
||||
Reference in new issue
Block a user