Files
ACORE/apps/reports/engine/blocks_v1.py
T
lokses 0da9c13192 it_manager+reports: иерархия договоров, биллинг и месячные отчёты
Иерархия рамочных/дочерних договоров и платежей, интернет-биллинг,
месячные IT-отчёты (импорт, DOCX/PDF, шаблоны выводов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 18:03:44 +03:00

309 lines
12 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.
"""Блочный конструктор IT-месячного отчёта (engine=blocks_v1).
JSON blocks:
{
"labels": { ... },
"sections": ["stats", "staff", "summary", "objects", "employees", "conclusions", "signature"]
}
Порядок списка = порядок в отчёте; отсутствующая секция = выключена.
"""
from __future__ import annotations
from apps.reports.engine.context import DEFAULT_LABELS
from apps.reports.engine.django_tpl import render_django_string
# Каталог секций для UI (id → подпись)
SECTION_CATALOG: list[dict] = [
{'id': 'stats', 'title': 'Таблица показателей', 'numbered': False},
{'id': 'staff', 'title': 'Состав отдела', 'numbered': True},
{'id': 'summary', 'title': 'Сводка по направлениям', 'numbered': True},
{'id': 'objects', 'title': 'Объекты', 'numbered': True},
{'id': 'employees', 'title': 'Работы по сотрудникам', 'numbered': True},
{'id': 'conclusions', 'title': 'Выводы и рекомендации', 'numbered': True},
{'id': 'signature', 'title': 'Дата подготовки', 'numbered': False},
]
DEFAULT_SECTIONS = [s['id'] for s in SECTION_CATALOG]
LABEL_FIELDS: list[dict] = [
{'key': 'main_title', 'title': 'Главный заголовок'},
{'key': 'staff_heading', 'title': 'Заголовок: состав'},
{'key': 'summary_heading', 'title': 'Заголовок: сводка'},
{'key': 'objects_heading', 'title': 'Заголовок: объекты'},
{'key': 'employees_heading', 'title': 'Заголовок: работы по сотрудникам'},
{'key': 'conclusions_heading', 'title': 'Заголовок: выводы'},
{'key': 'period_label', 'title': 'Показатель: период'},
{'key': 'staff_label', 'title': 'Показатель: состав'},
{'key': 'objects_label', 'title': 'Показатель: объекты'},
{'key': 'work_mode_label', 'title': 'Показатель: режим'},
{'key': 'tasks_label', 'title': 'Показатель: задачи'},
{'key': 'pharmacies_label', 'title': 'Подпись: аптеки'},
{'key': 'other_label', 'title': 'Подпись: другие объекты'},
{'key': 'extra_label', 'title': 'Подпись: прочее'},
]
_SHELL_CSS = """
body { font-family: "DejaVu Sans", Arial, sans-serif; color: #1a2332; background: #e8edf4; margin: 0; padding: 1.5rem; }
.sheet { max-width: 820px; margin: 0 auto; background: #fff; padding: 2rem 2.25rem; box-shadow: 0 8px 24px rgba(0,0,0,.08); }
.brand { text-align: center; font-size: 0.95rem; font-weight: 600; }
.dept { text-align: center; font-size: 0.85rem; color: #5a6577; font-style: italic; margin-bottom: 1rem; }
h1 { text-align: center; font-size: 1.15rem; letter-spacing: 0.04em; margin: 0.5rem 0; }
.period { text-align: center; margin-bottom: 1.25rem; color: #334; }
h2 { font-size: 1rem; margin: 1.25rem 0 0.5rem; border-bottom: 1px solid #d8dee8; padding-bottom: 0.25rem; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin: 0.5rem 0 1rem; }
th, td { border: 1px solid #cfd8e6; padding: 0.4rem 0.55rem; text-align: left; vertical-align: top; }
th { background: #f0f4fa; }
ul { margin: 0.25rem 0 0.75rem; padding-left: 1.2rem; }
.emp { margin-bottom: 1rem; }
.role { color: #5a6577; font-size: 0.8rem; }
"""
_HEADER_HTML = """
<div class="brand">{{ company.company }}</div>
<div class="dept">{{ company.department }}</div>
<h1>{{ labels.main_title }}</h1>
<div class="period">{{ month_title }}</div>
"""
_SECTION_HTML = {
'stats': """
<table>
<tr><th>Показатель</th><th>Значение</th></tr>
<tr><td>{{ labels.period_label }}</td><td>{{ stats.period }}</td></tr>
<tr><td>{{ labels.staff_label }}</td><td>{{ stats.staff_count }} сотрудника</td></tr>
<tr><td>{{ labels.objects_label }}</td><td>{{ stats.objects_count }}</td></tr>
<tr><td>{{ labels.work_mode_label }}</td><td>{{ month.work_mode }}</td></tr>
<tr><td>{{ labels.tasks_label }}</td><td>{{ stats.total_tasks }}</td></tr>
</table>
""",
'staff': """
<h2>{{ section_num }}. {{ labels.staff_heading }}</h2>
<ul>
{% for emp in employees %}
<li>{{ emp.name }}{% if emp.role %} — {{ emp.role }}{% endif %}</li>
{% empty %}
<li>Нет данных</li>
{% endfor %}
</ul>
""",
'summary': """
<h2>{{ section_num }}. {{ labels.summary_heading }}</h2>
{% if report.summary %}
<ul>
{% for row in report.summary %}
<li><strong>{{ row.name }}</strong> — {{ row.count }}{% if row.people %} ({{ row.people }}){% endif %}</li>
{% endfor %}
</ul>
{% else %}
<p>Нет записей за месяц.</p>
{% endif %}
""",
'objects': """
<h2>{{ section_num }}. {{ labels.objects_heading }}</h2>
{% if report.pharmacies %}<p>{{ labels.pharmacies_label }}: {{ report.pharmacies|join:", " }}.</p>{% endif %}
{% if report.other_points %}<p>{{ labels.other_label }}: {{ report.other_points|join:", " }}.</p>{% endif %}
{% if not report.pharmacies and not report.other_points %}
<p>Объекты не указаны.</p>
{% endif %}
""",
'employees': """
<h2>{{ section_num }}. {{ labels.employees_heading }}</h2>
{% for emp in employees %}
{% if emp.tasks or emp.systems %}
<div class="emp">
<strong>{{ emp.name }}</strong>
{% if emp.role %}<div class="role">{{ emp.role }}</div>{% endif %}
{% for sys in emp.systems %}
<p><strong>{{ sys.name }}</strong> — {{ sys.work }}</p>
{% endfor %}
{% if emp.tasks %}
<table>
<thead><tr><th>Дата</th><th>Объект</th><th>Работы</th></tr></thead>
<tbody>
{% for task in emp.tasks %}
<tr>
<td>{{ task.date }}</td>
<td>{{ task.object|default:"—" }}</td>
<td>
{% if task.work_items|length > 1 %}
<ul>{% for item in task.work_items %}<li>{{ item }}</li>{% endfor %}</ul>
{% else %}
{{ task.work }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% endif %}
{% empty %}
<p>Нет данных.</p>
{% endfor %}
""",
'conclusions': """
{% if conclusions %}
<h2>{{ section_num }}. {{ labels.conclusions_heading }}</h2>
{% for item in conclusions %}
<p>{{ item.text }}</p>
{% if item.bullets %}
<ul>{% for b in item.bullets %}<li>{{ b }}</li>{% endfor %}</ul>
{% endif %}
{% endfor %}
{% endif %}
""",
'signature': """
<p style="margin-top:2rem; color:#5a6577; font-size:0.85rem;">Подготовлено: {{ prepared_date }}</p>
""",
}
_SECTION_MD = {
'stats': """**{{ labels.period_label }}:** {{ stats.period }}
**{{ labels.staff_label }}:** {{ stats.staff_count }}
**{{ labels.objects_label }}:** {{ stats.objects_count }}
**{{ labels.work_mode_label }}:** {{ month.work_mode }}
**{{ labels.tasks_label }}:** {{ stats.total_tasks }}
""",
'staff': """## {{ section_num }}. {{ labels.staff_heading }}
{% for emp in employees %}- {{ emp.name }}{% if emp.role %} — {{ emp.role }}{% endif %}
{% empty %}_Нет данных_
{% endfor %}
""",
'summary': """## {{ section_num }}. {{ labels.summary_heading }}
{% for row in report.summary %}- **{{ row.name }}** — {{ row.count }}{% if row.people %} ({{ row.people }}){% endif %}
{% empty %}_Нет записей за месяц._
{% endfor %}
""",
'objects': """## {{ section_num }}. {{ labels.objects_heading }}
{% if report.pharmacies %}- {{ labels.pharmacies_label }}: {{ report.pharmacies|join:", " }}.
{% endif %}{% if report.other_points %}- {{ labels.other_label }}: {{ report.other_points|join:", " }}.
{% endif %}{% if not report.pharmacies and not report.other_points %}_Объекты не указаны._
{% endif %}
""",
'employees': """## {{ section_num }}. {{ labels.employees_heading }}
{% for emp in employees %}{% if emp.tasks or emp.systems %}
### {{ emp.name }}
{% if emp.role %}*{{ emp.role }}*
{% endif %}{% for sys in emp.systems %}
**{{ sys.name }}** — {{ sys.work }}
{% endfor %}{% for task in emp.tasks %}{% if task.work_items|length > 1 %}- {{ task.date }}{% if task.object %} {{ task.object }}{% endif %}:
{% for item in task.work_items %} - {{ item }}
{% endfor %}{% else %}- {{ task.date }}{% if task.object %} {{ task.object }}{% endif %} — {{ task.work }}
{% endif %}{% endfor %}
{% endif %}{% empty %}_Нет данных_
{% endfor %}
""",
'conclusions': """{% if conclusions %}## {{ section_num }}. {{ labels.conclusions_heading }}
{% for item in conclusions %}{{ item.text }}
{% for b in item.bullets %} - {{ b }}
{% endfor %}
{% endfor %}{% endif %}
""",
'signature': """_Подготовлено: {{ prepared_date }}_
""",
}
_NUMBERED = {s['id'] for s in SECTION_CATALOG if s.get('numbered')}
def normalize_blocks(blocks: dict | None) -> dict:
"""Привести blocks к {labels, sections} с дефолтами."""
raw = blocks if isinstance(blocks, dict) else {}
labels_in = raw.get('labels') if isinstance(raw.get('labels'), dict) else {}
labels = {**DEFAULT_LABELS, **{k: str(v) for k, v in labels_in.items() if v is not None}}
sections_in = raw.get('sections')
if isinstance(sections_in, list) and sections_in:
known = {s['id'] for s in SECTION_CATALOG}
sections = [s for s in sections_in if s in known]
if not sections:
sections = list(DEFAULT_SECTIONS)
else:
sections = list(DEFAULT_SECTIONS)
return {'labels': labels, 'sections': sections}
def sections_for_ui(blocks: dict | None) -> list[dict]:
"""Список секций для конструктора: enabled + порядок активных, потом выключенные."""
norm = normalize_blocks(blocks)
enabled = set(norm['sections'])
ordered = []
for sid in norm['sections']:
meta = next((s for s in SECTION_CATALOG if s['id'] == sid), None)
if meta:
ordered.append({**meta, 'enabled': True})
for meta in SECTION_CATALOG:
if meta['id'] not in enabled:
ordered.append({**meta, 'enabled': False})
return ordered
def labels_for_ui(blocks: dict | None) -> list[dict]:
norm = normalize_blocks(blocks)
return [
{**f, 'value': norm['labels'].get(f['key'], DEFAULT_LABELS.get(f['key'], ''))}
for f in LABEL_FIELDS
]
def render_blocks_html(ctx: dict, blocks: dict | None) -> str:
norm = normalize_blocks(blocks)
ctx = {**ctx, 'labels': {**DEFAULT_LABELS, **norm['labels']}}
body_parts = [render_django_string(_HEADER_HTML, ctx)]
num = 1
for sid in norm['sections']:
tpl = _SECTION_HTML.get(sid)
if not tpl:
continue
local = dict(ctx)
if sid in _NUMBERED:
local['section_num'] = num
num += 1
body_parts.append(render_django_string(tpl, local))
inner = ''.join(body_parts)
return (
'<!DOCTYPE html>\n<html lang="ru"><head><meta charset="utf-8">'
f'<title>{ctx["labels"].get("main_title", "")} — {ctx.get("month_title", "")}</title>'
f'<style>{_SHELL_CSS}</style></head><body><div class="sheet">{inner}</div></body></html>'
)
def render_blocks_md(ctx: dict, blocks: dict | None) -> str:
norm = normalize_blocks(blocks)
ctx = {**ctx, 'labels': {**DEFAULT_LABELS, **norm['labels']}}
parts = [
render_django_string(
'# {{ labels.main_title }} — {{ month_title }}\n\n',
ctx,
)
]
num = 1
for sid in norm['sections']:
tpl = _SECTION_MD.get(sid)
if not tpl:
continue
local = dict(ctx)
if sid in _NUMBERED:
local['section_num'] = num
num += 1
parts.append(render_django_string(tpl, local))
return ''.join(parts)
__all__ = [
'DEFAULT_SECTIONS',
'LABEL_FIELDS',
'SECTION_CATALOG',
'labels_for_ui',
'normalize_blocks',
'render_blocks_html',
'render_blocks_md',
'sections_for_ui',
]