0da9c13192
Иерархия рамочных/дочерних договоров и платежей, интернет-биллинг, месячные IT-отчёты (импорт, DOCX/PDF, шаблоны выводов). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""Блочный конструктор 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',
|
||
]
|