32ad359879
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>
693 lines
30 KiB
Python
693 lines
30 KiB
Python
"""Сборка 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('&', '&')
|
||
.replace('<', '<')
|
||
.replace('>', '>')
|
||
.replace('"', '"')
|
||
)
|
||
|
||
|
||
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)
|