100 поручений и шестой отдел; правка по экспертному заключению (промпт)

This commit is contained in:
apuc committed 2026-10-20 21:21:36 +03:00
1 parent f33c0fbe34
commit 9d70f113af
10 files changed
+385 -294

No files matched your search

+193 -147
View File
@@ -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'