Files
ACORE/apps/reports/it_monthly/docx.py
T
lokses 32ad359879 Publish IT monthly as editable Word; fix staff order and zones.
Form Word draft for OnlyOffice, publish the file to unlock the month; roster is head-first then by name with Employee responsibility zones.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 15:54:38 +03:00

693 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Сборка Word-отчёта по шаблону report-tool/docxbuild.py (без внешних зависимостей)."""
from __future__ import annotations
from datetime import date
from io import BytesIO
from zipfile import ZIP_DEFLATED, ZipFile
from apps.it_manager.report_import import default_report_tool_root, load_company
from apps.reports.it_monthly.conclusions import conclusions_as_plain_lines
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
CT = "application/vnd.openxmlformats-officedocument.wordprocessingml"
def xml_esc(text: str) -> str:
return (
(text or '')
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
)
MONTHS_TITLE = [
'', 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь',
]
def month_title(year: int, month: int) -> str:
return f'{MONTHS_TITLE[month]} {year} года'
def filled_tasks(emp: dict) -> list[dict]:
out = []
for task in emp.get('tasks') or []:
work = (task.get('work') or '').strip()
obj = (task.get('object') or '').strip()
if not work and not obj:
continue
out.append({
'date': task.get('date') or '',
'object': obj,
'work': work,
})
return out
def filled_systems(emp: dict) -> list[dict]:
out = []
for row in emp.get('systems') or []:
name = (row.get('name') or '').strip()
work = (row.get('work') or '').strip()
if name and work:
out.append({'name': name, 'work': work})
return out
def group_objects_text(stats: dict, note: str = '') -> list[tuple[str, str]]:
if (note or '').strip():
return [('Объекты', note.strip())]
lines = []
if stats.get('pharmacies'):
lines.append(('Аптеки:', ', '.join(stats['pharmacies']) + '.'))
if stats.get('other_points'):
lines.append(('Другие объекты:', ', '.join(stats['other_points']) + '.'))
if not lines:
lines.append(('Объекты:', 'не указаны.'))
return lines
def default_prepared_date(year: int, month: int) -> str:
if month == 12:
d = date(year + 1, 1, 5)
else:
d = date(year, month + 1, 5)
return d.strftime('%d.%m.%Y')
def report_as_month(report: dict, company: dict) -> dict:
"""Приводит отчёт портала к формату month JSON из report-tool."""
prepared = company.get('prepared_by') or {}
by_id = {e.get('id'): e for e in report.get('employees') or []}
by_emp_pk = {
e.get('employee_id'): e
for e in (report.get('employees') or [])
if e.get('employee_id')
}
employees = []
seen_ids: set = set()
roster = report.get('staff_roster') or []
if roster:
for row in roster:
sid = row.get('id')
seen_ids.add(sid)
src = by_emp_pk.get(row.get('employee_id')) or by_id.get(sid) or {}
zone = (row.get('zone') or src.get('zone') or '').strip()
employees.append({
'id': sid,
'name': row.get('name') or src.get('name') or '',
'short': row.get('short') or src.get('short') or '',
'role': row.get('role') or src.get('role') or '',
'zone': zone,
'kind': row.get('kind') or 'log',
'color': row.get('color') or '1A2C5B',
'intro': row.get('intro') or '',
'systems': list(src.get('systems') or []),
'tasks': [
{'date': t.get('date', ''), 'object': t.get('object', ''), 'work': t.get('work', '')}
for t in (src.get('tasks') or [])
],
})
else:
# Fallback: company.json order (legacy / tests without roster).
for staff in company.get('staff') or []:
sid = staff.get('id')
seen_ids.add(sid)
src = by_id.get(sid) or {}
zone = (src.get('zone') or staff.get('zone') or '').strip()
employees.append({
'id': sid,
'name': staff.get('name') or src.get('name') or '',
'short': staff.get('short') or src.get('short') or '',
'role': staff.get('role') or src.get('role') or '',
'zone': zone,
'kind': staff.get('kind') or 'log',
'color': staff.get('color') or '1A2C5B',
'intro': staff.get('intro') or '',
'systems': list(src.get('systems') or []),
'tasks': [
{'date': t.get('date', ''), 'object': t.get('object', ''), 'work': t.get('work', '')}
for t in (src.get('tasks') or [])
],
})
for emp in report.get('employees') or []:
if emp.get('id') in seen_ids:
continue
employees.append({
'id': emp.get('id'),
'name': emp.get('name') or '',
'short': emp.get('short') or emp.get('name') or '',
'role': emp.get('role') or '',
'zone': (emp.get('zone') or '').strip(),
'kind': 'log',
'color': '1F5C99',
'intro': '',
'systems': list(emp.get('systems') or []),
'tasks': [
{'date': t.get('date', ''), 'object': t.get('object', ''), 'work': t.get('work', '')}
for t in (emp.get('tasks') or [])
],
})
return {
'id': report.get('id'),
'year': report['year'],
'month': report['month'],
'period_from': report.get('period_from'),
'period_to': report.get('period_to'),
'work_mode': company.get('work_mode') or 'Ежедневно (включая выходные)',
'summary_mode': 'manual',
'summary': list(report.get('summary') or []),
'achievements': [],
'conclusions': conclusions_as_plain_lines(report.get('conclusions') or []),
'infrastructure': [],
'objects_note': '',
'prepared_name': prepared.get('name') or '',
'prepared_role': prepared.get('role') or '',
'prepared_date': default_prepared_date(report['year'], report['month']),
'employees': employees,
}
def stats_from_report(report: dict, month: dict) -> dict:
return {
'title': report.get('title') or month_title(report['year'], report['month']),
'period': f"{report.get('period_from')} – {report.get('period_to')}",
'staff_count': len(month.get('employees') or []),
'people_active': report.get('people_active') or 0,
'total_tasks': report.get('total_tasks') or 0,
'objects_count': report.get('objects_count') or 0,
'objects': report.get('objects') or [],
'pharmacies': report.get('pharmacies') or [],
'other_points': report.get('other_points') or [],
'extra_points': report.get('extra_points') or [],
'summary': report.get('summary') or [],
}
CONTENT_TYPES = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="{CT}.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="{CT}.styles+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
</Types>
"""
RELS = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>
"""
DOC_RELS = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>
"""
STYLES = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="{W_NS}">
<w:docDefaults>
<w:rPrDefault>
<w:rPr>
<w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:cs="Arial" w:eastAsia="Arial"/>
<w:sz w:val="20"/><w:szCs w:val="20"/>
<w:lang w:val="ru-RU"/>
</w:rPr>
</w:rPrDefault>
<w:pPrDefault>
<w:pPr><w:spacing w:after="80" w:line="240" w:lineRule="auto"/></w:pPr>
</w:pPrDefault>
</w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
<w:qFormat/>
</w:style>
</w:styles>
"""
APP = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
<Application>IT Report Tool</Application>
</Properties>
"""
NAVY = "1A2C5B"
BLUE = "1F5C99"
GRAY = "666666"
TEXT = "222222"
ZEBRA = "F4F7FB"
TW = 9638 # usable A4 width with ~2cm margins
def _core(title: str, creator: str) -> str:
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:dcterms="http://purl.org/dc/terms/"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:title>{xml_esc(title)}</dc:title>
<dc:creator>{xml_esc(creator)}</dc:creator>
<cp:lastModifiedBy>{xml_esc(creator)}</cp:lastModifiedBy>
</cp:coreProperties>
"""
def _rpr(bold=False, italic=False, size=20, color=TEXT, font="Arial") -> str:
b = "<w:b/><w:bCs/>" if bold else ""
i = "<w:i/><w:iCs/>" if italic else ""
return (
"<w:rPr>"
f'<w:rFonts w:ascii="{font}" w:hAnsi="{font}" w:cs="{font}"/>'
f"{b}{i}"
f'<w:color w:val="{color}"/>'
f'<w:sz w:val="{size}"/><w:szCs w:val="{size}"/>'
"</w:rPr>"
)
def _run(text: str, **fmt) -> str:
text = text or ""
space = ' xml:space="preserve"' if text[:1] == " " or text[-1:] == " " or "\n" in text else ""
body = xml_esc(text).replace("\n", "</w:t><w:br/><w:t xml:space=\"preserve\">")
return f"<w:r>{_rpr(**fmt)}<w:t{space}>{body}</w:t></w:r>"
def _p(runs: str, align="left", before=0, after=80, keep=False) -> str:
jc = f'<w:jc w:val="{align}"/>' if align else ""
kn = "<w:keepNext/>" if keep else ""
return (
"<w:p><w:pPr>"
f"{kn}<w:spacing w:before=\"{before}\" w:after=\"{after}\"/>{jc}"
"</w:pPr>"
f"{runs}</w:p>"
)
def p(text: str, align="left", before=0, after=80, **fmt) -> str:
return _p(_run(text, **fmt), align=align, before=before, after=after)
def mixed(parts: list[tuple], align="left", before=0, after=80) -> str:
runs = "".join(_run(text, **fmt) for text, fmt in parts)
return _p(runs, align=align, before=before, after=after)
def _shd(fill: str) -> str:
return f'<w:shd w:val="clear" w:color="auto" w:fill="{fill}"/>'
def _border(sz=4, color="C5CDD8") -> str:
attrs = f'w:val="single" w:sz="{sz}" w:space="0" w:color="{color}"'
return (
"<w:tblBorders>"
f"<w:top {attrs}/><w:left {attrs}/><w:bottom {attrs}/><w:right {attrs}/>"
f"<w:insideH {attrs}/><w:insideV {attrs}/>"
"</w:tblBorders>"
)
def _cell(text: str, width: int, *, header=False, fill=None, bold=False, color=TEXT, align="left", size=18) -> str:
shd = _shd(fill) if fill else (_shd(NAVY) if header else "")
tc_align = "center" if header else align
font_color = "FFFFFF" if header else color
is_bold = True if header else bold
paragraphs = str(text or "").split("\n") or [""]
inner = ""
for i, line in enumerate(paragraphs):
inner += _p(
_run(line, bold=is_bold, size=size, color=font_color),
align=tc_align,
after=40 if i == len(paragraphs) - 1 else 20,
)
v_align = "center" if header else "top"
return (
f'<w:tc><w:tcPr><w:tcW w:w="{width}" w:type="dxa"/>'
f"<w:vAlign w:val=\"{v_align}\"/>{shd}"
f'<w:tcMar><w:top w:w="60"/><w:left w:w="80"/><w:bottom w:w="60"/><w:right w:w="80"/></w:tcMar>'
f"</w:tcPr>{inner}</w:tc>"
)
def table(headers: list[str], rows: list[list[str]], widths: list[int], zebra=True) -> str:
grid = "".join(f'<w:gridCol w:w="{w}"/>' for w in widths)
xml = (
"<w:tbl><w:tblPr>"
f'<w:tblW w:w="{sum(widths)}" w:type="dxa"/>'
'<w:tblLayout w:type="fixed"/>'
f"{_border()}"
"</w:tblPr>"
f"<w:tblGrid>{grid}</w:tblGrid>"
)
xml += "<w:tr>" + "".join(_cell(h, widths[i], header=True) for i, h in enumerate(headers)) + "</w:tr>"
for ri, row in enumerate(rows):
fill = ZEBRA if zebra and ri % 2 == 1 else "FFFFFF"
cells = []
for i, width in enumerate(widths):
val = row[i] if i < len(row) else ""
bold = (ri == len(rows) - 1 and str(val).upper().startswith("ИТОГО")) or (
i == 0 and headers[0] in {"№"}
)
cells.append(_cell(val, width, fill=fill, bold=bool(bold and i == 0)))
xml += "<w:tr>" + "".join(cells) + "</w:tr>"
xml += "</w:tbl>"
xml += p("", after=160, size=4)
return xml
def hrule() -> str:
return table([""], [], [TW], zebra=False).replace(
"<w:tr></w:tr>",
f'<w:tr><w:trPr><w:trHeight w:val="80"/></w:trPr>{_cell("", TW, fill=BLUE)}</w:tr>',
)
def heading(text: str, color=NAVY) -> str:
return p(text, before=280, after=120, bold=True, size=24, color=color)
def subheading(text: str, color=NAVY) -> str:
return p(text, before=200, after=80, bold=True, size=22, color=color)
def build_document_xml(month: dict, company: dict, stats: dict) -> str:
title = month_title(month["year"], month["month"])
parts: list[str] = []
parts.append(p(company.get("company", "АКС-Аптека"), align="center", after=40, bold=True, italic=True, size=36, color=NAVY))
parts.append(p(company.get("department", ""), align="center", after=200, italic=True, size=20, color=GRAY))
parts.append(p("ОТЧЁТ О ВЫПОЛНЕННЫХ РАБОТАХ", align="center", after=40, bold=True, size=40, color="1A1A2E"))
parts.append(p(title, align="center", after=200, bold=True, size=28, color=BLUE))
info_rows = [
["Отчётный период", stats["period"]],
["Состав отдела", f"{stats['staff_count']} сотрудника"],
["Охвачено объектов", f"{stats['objects_count']} аптек и точек" if stats["objects_count"] else "—"],
["Режим работы", month.get("work_mode") or "Ежедневно"],
["Всего зафиксированных задач", str(stats["total_tasks"])],
]
parts.append(table(["Показатель", "Значение"], info_rows, [4000, 5638]))
parts.append(heading("1. СОСТАВ IT-ОТДЕЛА"))
staff_rows = []
for i, emp in enumerate(month.get("employees") or [], 1):
staff_rows.append([str(i), emp.get("name", ""), emp.get("role", ""), emp.get("zone", "")])
parts.append(table(["№", "Сотрудник", "Должность / Роль", "Зона ответственности"], staff_rows, [600, 2200, 2800, 4038]))
parts.append(heading("2. ДЕТАЛЬНЫЙ ОТЧЁТ ПО СОТРУДНИКАМ"))
for idx, emp in enumerate(month.get("employees") or [], 1):
color = emp.get("color") or NAVY
parts.append(subheading(f"2.{idx}. {emp.get('name', '')} — {emp.get('role', '')}", color=color))
if emp.get("intro"):
parts.append(p(emp["intro"], after=120, italic=True, size=18, color=GRAY))
systems = filled_systems(emp)
if systems:
work_header = f"Выполненные работы — {MONTHS_LOWER(month)}"
parts.append(
table(
["Система / Направление", work_header],
[[s["name"], s["work"]] for s in systems],
[3200, 6438],
)
)
tasks = filled_tasks(emp)
if tasks:
rows = [[t.get("date", ""), t.get("object", ""), t.get("work", "")] for t in tasks]
parts.append(table(["Дата", "Объект", "Выполненные работы"], rows, [1400, 1600, 6638]))
if not systems and not tasks:
parts.append(p("За отчётный период отдельные задачи не внесены.", italic=True, size=18, color=GRAY))
infra = [x for x in (month.get("infrastructure") or []) if x.get("name") or x.get("description") or x.get("status")]
n = 3
if infra:
parts.append(heading(f"{n}. СТРУКТУРА ИНФРАСТРУКТУРЫ"))
parts.append(
table(
["Компонент", "Описание", "Статус"],
[[x.get("name", ""), x.get("description", ""), x.get("status", "")] for x in infra],
[2600, 4238, 2800],
)
)
n += 1
parts.append(heading(f"{n}. СВОДНЫЕ ИТОГИ ПО НАПРАВЛЕНИЯМ"))
n += 1
summary_rows = [[s.get("name", ""), str(s.get("count", "")), s.get("people", "")] for s in stats.get("summary") or []]
if summary_rows:
parts.append(table(["Направление", "Кол-во задач", "Основные исполнители"], summary_rows, [4600, 1600, 3438]))
else:
parts.append(p("Недостаточно данных для сводки.", italic=True, size=18, color=GRAY))
parts.append(heading(f"{n}. ОБЪЕКТЫ, НА КОТОРЫХ ВЫПОЛНЯЛИСЬ РАБОТЫ"))
n += 1
for label, value in group_objects_text(stats, month.get("objects_note") or ""):
parts.append(mixed([(label + " ", {"bold": True, "size": 19}), (value, {"size": 19})], after=80))
parts.append(mixed([("Итого объектов: ", {"bold": True, "size": 19}), (str(stats["objects_count"]) + ".", {"size": 19})], after=160))
achievements = [a for a in (month.get("achievements") or []) if a.get("title") or a.get("description")]
if achievements:
parts.append(heading(f"{n}. КЛЮЧЕВЫЕ ДОСТИЖЕНИЯ"))
n += 1
rows = [[str(i), a.get("title", ""), a.get("description", "")] for i, a in enumerate(achievements, 1)]
parts.append(table(["№", "Достижение", "Описание"], rows, [600, 2800, 6238]))
conclusions = [c for c in (month.get("conclusions") or []) if (c or "").strip()]
if conclusions:
parts.append(heading(f"{n}. ОСНОВНЫЕ ВЫВОДЫ И РЕКОМЕНДАЦИИ"))
for para in conclusions:
# подпункты «◦ …» чуть мельче и с отступом через пробелы в тексте
if para.lstrip().startswith('◦'):
parts.append(p(para.strip(), after=60, size=18, color=TEXT))
else:
parts.append(p(para.strip(), after=100, size=20))
parts.append(p("", after=240, size=4))
parts.append(mixed([("Отчёт подготовил: ", {"bold": True, "size": 20}), (month.get("prepared_name") or "", {"size": 20})]))
parts.append(mixed([("Должность: ", {"bold": True, "size": 20}), (month.get("prepared_role") or "", {"size": 20})]))
parts.append(mixed([("Дата: ", {"bold": True, "size": 20}), (month.get("prepared_date") or "", {"size": 20})]))
parts.append(p("Подпись: ___________________________", after=80, bold=True, size=20))
body = "".join(parts)
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="{W_NS}">
<w:body>
{body}
<w:sectPr>
<w:pgSz w:w="11906" w:h="16838"/>
<w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="0" w:footer="0"/>
</w:sectPr>
</w:body>
</w:document>
"""
def MONTHS_LOWER(month: dict) -> str:
names = [
"", "январь", "февраль", "март", "апрель", "май", "июнь",
"июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь",
]
return f"{names[month['month']]} {month['year']}"
def build_docx(month: dict, company: dict, stats: dict | None = None) -> bytes:
if stats is None:
# совместимость: stats уже должны быть переданы из портала
stats = {
'title': month_title(month['year'], month['month']),
'period': f"{month.get('period_from')} – {month.get('period_to')}",
'staff_count': len(month.get('employees') or []),
'people_active': 0,
'total_tasks': sum(len(filled_tasks(e)) for e in (month.get('employees') or [])),
'objects_count': 0,
'objects': [],
'pharmacies': [],
'other_points': [],
'extra_points': [],
'summary': month.get('summary') or [],
}
title = f"Отчёт о выполненных работах — {month_title(month['year'], month['month'])}"
creator = month.get('prepared_name') or 'IT-отдел'
buf = BytesIO()
with ZipFile(buf, 'w', ZIP_DEFLATED) as zf:
zf.writestr('[Content_Types].xml', CONTENT_TYPES)
zf.writestr('_rels/.rels', RELS)
zf.writestr('word/_rels/document.xml.rels', DOC_RELS)
zf.writestr('word/styles.xml', STYLES)
zf.writestr('word/document.xml', build_document_xml(month, company, stats))
zf.writestr('docProps/core.xml', _core(title, creator))
zf.writestr('docProps/app.xml', APP)
return buf.getvalue()
def report_to_docx(report: dict, company: dict | None = None) -> bytes:
"""Точка входа портала: месячный отчёт WorkLog → DOCX по шаблону report-tool."""
if company is None:
root = default_report_tool_root()
company = load_company(root) if (root / 'company.json').exists() else {
'company': 'АКС-Аптека',
'department': 'Отдел информационных технологий',
'prepared_by': {'name': '', 'role': ''},
'work_mode': 'Ежедневно (включая выходные)',
'staff': [],
}
month = report_as_month(report, company)
stats = stats_from_report(report, month)
return build_docx(month, company, stats)
def build_preview_html(month: dict, company: dict, stats: dict | None = None) -> str:
if stats is None:
stats = {
'title': month_title(month['year'], month['month']),
'period': f"{month.get('period_from')} – {month.get('period_to')}",
'staff_count': len(month.get('employees') or []),
'total_tasks': sum(len(filled_tasks(e)) for e in (month.get('employees') or [])),
'objects_count': 0,
'summary': month.get('summary') or [],
'pharmacies': [],
'other_points': [],
'extra_points': [],
}
title = month_title(month['year'], month['month'])
def esc(s):
return xml_esc(str(s or '')).replace('\n', '<br>')
def html_table(headers, rows):
th = ''.join(f'<th>{esc(h)}</th>' for h in headers)
body = []
for row in rows:
tds = ''.join(f'<td>{esc(c)}</td>' for c in row)
body.append(f'<tr>{tds}</tr>')
return f"<table><thead><tr>{th}</tr></thead><tbody>{''.join(body)}</tbody></table>"
chunks = [
f"<div class='brand'>{esc(company.get('company'))}</div>",
f"<div class='dept'>{esc(company.get('department'))}</div>",
'<h1>ОТЧЁТ О ВЫПОЛНЕННЫХ РАБОТАХ</h1>',
f"<div class='period'>{esc(title)}</div>",
html_table(
['Показатель', 'Значение'],
[
['Отчётный период', stats['period']],
['Состав отдела', f"{stats['staff_count']} сотрудника"],
['Охвачено объектов', str(stats['objects_count'])],
['Режим работы', month.get('work_mode') or ''],
['Всего задач', str(stats['total_tasks'])],
],
),
'<h2>1. Состав IT-отдела</h2>',
html_table(
['№', 'Сотрудник', 'Должность / Роль', 'Зона ответственности'],
[[i, e.get('name'), e.get('role'), e.get('zone')] for i, e in enumerate(month.get('employees') or [], 1)],
),
'<h2>2. Детальный отчёт по сотрудникам</h2>',
]
for i, emp in enumerate(month.get('employees') or [], 1):
chunks.append(
f"<h3 style='color:#{emp.get('color') or NAVY}'>2.{i}. {esc(emp.get('name'))} — {esc(emp.get('role'))}</h3>"
)
if emp.get('intro'):
chunks.append(f"<p class='intro'>{esc(emp['intro'])}</p>")
systems = filled_systems(emp)
if systems:
chunks.append(
html_table(
['Система / Направление', 'Выполненные работы'],
[[s['name'], s['work']] for s in systems],
)
)
tasks = filled_tasks(emp)
if tasks:
chunks.append(
html_table(
['Дата', 'Объект', 'Выполненные работы'],
[[t['date'], t['object'], t['work']] for t in tasks],
)
)
if not systems and not tasks:
chunks.append("<p class='intro'>Задачи не внесены.</p>")
n = 3
chunks.append(f'<h2>{n}. Сводные итоги по направлениям</h2>')
n += 1
chunks.append(
html_table(
['Направление', 'Кол-во', 'Исполнители'],
[[s.get('name'), s.get('count'), s.get('people')] for s in stats.get('summary') or []],
)
)
chunks.append(f'<h2>{n}. Объекты</h2>')
n += 1
for label, value in group_objects_text(stats, month.get('objects_note') or ''):
chunks.append(f'<p><b>{esc(label)}</b> {esc(value)}</p>')
chunks.append(f"<p><b>Итого объектов:</b> {stats['objects_count']}.</p>")
conclusions = [c for c in (month.get('conclusions') or []) if (c or '').strip()]
if conclusions:
chunks.append(f'<h2>{n}. Основные выводы и рекомендации</h2>')
for para in conclusions:
if para.lstrip().startswith('◦'):
chunks.append(f"<p class='bullet'>{esc(para.strip())}</p>")
else:
chunks.append(f'<p>{esc(para.strip())}</p>')
chunks.append("<div class='sign'>")
chunks.append(f"<p><b>Отчёт подготовил:</b> {esc(month.get('prepared_name'))}</p>")
chunks.append(f"<p><b>Должность:</b> {esc(month.get('prepared_role'))}</p>")
chunks.append(f"<p><b>Дата:</b> {esc(month.get('prepared_date'))}</p>")
chunks.append('<p><b>Подпись:</b> ___________________________</p></div>')
return f"""<!doctype html>
<html lang="ru"><head><meta charset="utf-8">
<title>{esc(title)}</title>
<style>
body {{ margin:0; background:#e8edf4; font: 12.5px/1.45 Arial, sans-serif; color:#222; }}
.page {{ width: 210mm; min-height: 297mm; margin: 16px auto; background:#fff; padding: 18mm 16mm;
box-shadow: 0 8px 30px rgba(26,44,91,.12); }}
.brand {{ text-align:center; font-weight:700; font-style:italic; font-size:22px; color:#1A2C5B; }}
.dept {{ text-align:center; color:#888; font-style:italic; margin-bottom:14px; }}
h1 {{ text-align:center; font-size:22px; margin: 8px 0 4px; letter-spacing:.02em; }}
.period {{ text-align:center; color:#1F5C99; font-weight:700; font-size:16px; margin-bottom:18px; }}
h2 {{ color:#1A2C5B; font-size:14px; margin: 22px 0 8px; border-bottom:2px solid #1F5C99; padding-bottom:4px; }}
h3 {{ font-size:13px; margin: 16px 0 6px; }}
.intro {{ color:#777; font-style:italic; margin: 0 0 8px; }}
.bullet {{ margin: 2px 0 2px 18px; }}
table {{ width:100%; border-collapse:collapse; margin: 0 0 14px; }}
th {{ background:#1A2C5B; color:#fff; text-align:left; padding:6px 8px; font-size:11px; }}
td {{ border:1px solid #C5CDD8; padding:5px 8px; vertical-align:top; }}
tbody tr:nth-child(even) td {{ background:#F4F7FB; }}
.sign {{ margin-top: 28px; }}
@media print {{
body {{ background:#fff; }}
.page {{ box-shadow:none; margin:0; }}
}}
</style></head>
<body><div class="page">{''.join(chunks)}</div></body></html>
"""
def report_to_preview_html(report: dict, company: dict | None = None) -> str:
if company is None:
root = default_report_tool_root()
company = load_company(root) if (root / 'company.json').exists() else {
'company': 'АКС-Аптека',
'department': 'Отдел информационных технологий',
'prepared_by': {'name': '', 'role': ''},
'work_mode': 'Ежедневно (включая выходные)',
'staff': [],
}
month = report_as_month(report, company)
stats = stats_from_report(report, month)
return build_preview_html(month, company, stats)