From 0da9c131928113eae65e795fd0e7443d4b7c71bf Mon Sep 17 00:00:00 2001 From: lokses Date: Wed, 9 Sep 2026 18:03:44 +0300 Subject: [PATCH] =?UTF-8?q?it=5Fmanager+reports:=20=D0=B8=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=80=D1=85=D0=B8=D1=8F=20=D0=B4=D0=BE=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B2,=20=D0=B1=D0=B8=D0=BB=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20=D0=B8=20=D0=BC=D0=B5=D1=81=D1=8F=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Иерархия рамочных/дочерних договоров и платежей, интернет-биллинг, месячные IT-отчёты (импорт, DOCX/PDF, шаблоны выводов). Co-Authored-By: Claude Opus 4.8 --- apps/it_manager/admin.py | 44 +- apps/it_manager/billing.py | 268 +++++++ .../fixtures/report_tool_mini/company.json | 8 + .../report_tool_mini/data/2026-05.json | 24 + apps/it_manager/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/import_report_tool.py | 81 +++ .../0019_contract_hierarchy_and_payments.py | 75 ++ ...20_nullable_framework_contract_location.py | 33 + .../0021_seed_gpon_connection_type.py | 35 + apps/it_manager/models/__init__.py | 2 +- apps/it_manager/models/providers.py | 95 ++- apps/it_manager/monthly_report.py | 2 + apps/it_manager/report_conclusions.py | 2 + apps/it_manager/report_docx.py | 2 + apps/it_manager/report_import.py | 344 +++++++++ apps/it_manager/report_pdf.py | 2 + apps/it_manager/tests.py | 287 +++++++- apps/it_manager/tests_billing.py | 207 ++++++ apps/it_manager/tests_monthly_report.py | 1 + apps/it_manager/tests_report_import.py | 98 +++ apps/it_manager/urls.py | 24 + apps/it_manager/views.py | 538 +++++++++++--- apps/reports/__init__.py | 0 apps/reports/access.py | 18 + apps/reports/admin.py | 11 + apps/reports/apps.py | 7 + apps/reports/classic_seed.py | 179 +++++ apps/reports/engine/__init__.py | 25 + apps/reports/engine/blocks_v1.py | 308 ++++++++ apps/reports/engine/context.py | 64 ++ apps/reports/engine/django_tpl.py | 24 + apps/reports/engine/render.py | 62 ++ apps/reports/it_monthly/__init__.py | 19 + apps/reports/it_monthly/builder.py | 338 +++++++++ apps/reports/it_monthly/conclusions.py | 275 ++++++++ apps/reports/it_monthly/docx.py | 660 ++++++++++++++++++ apps/reports/it_monthly/pdf.py | 303 ++++++++ apps/reports/management/__init__.py | 0 apps/reports/management/commands/__init__.py | 0 .../commands/seed_report_templates.py | 28 + .../migrations/0001_report_template.py | 42 ++ apps/reports/migrations/0002_seed_classic.py | 38 + .../migrations/0003_classic_blocks_v1.py | 28 + apps/reports/migrations/__init__.py | 0 apps/reports/models.py | 81 +++ apps/reports/registry.py | 42 ++ apps/reports/tests.py | 413 +++++++++++ apps/reports/urls.py | 41 ++ apps/reports/views.py | 319 +++++++++ static/assets/js/it-manager.js | 176 +++++ static/assets/js/reports-template-editor.js | 329 +++++++++ templates/pages/internet-billing.html | 154 ++++ templates/pages/it-manager.html | 374 +++++++--- templates/pages/reports-it-monthly.html | 200 ++++++ templates/pages/reports-template-edit.html | 173 +++++ templates/pages/reports-templates.html | 73 ++ templates/pages/reports.html | 98 +-- .../partials/it-equipment-assign-fields.html | 54 ++ 59 files changed, 6861 insertions(+), 267 deletions(-) create mode 100644 apps/it_manager/billing.py create mode 100644 apps/it_manager/fixtures/report_tool_mini/company.json create mode 100644 apps/it_manager/fixtures/report_tool_mini/data/2026-05.json create mode 100644 apps/it_manager/management/__init__.py create mode 100644 apps/it_manager/management/commands/__init__.py create mode 100644 apps/it_manager/management/commands/import_report_tool.py create mode 100644 apps/it_manager/migrations/0019_contract_hierarchy_and_payments.py create mode 100644 apps/it_manager/migrations/0020_nullable_framework_contract_location.py create mode 100644 apps/it_manager/migrations/0021_seed_gpon_connection_type.py create mode 100644 apps/it_manager/monthly_report.py create mode 100644 apps/it_manager/report_conclusions.py create mode 100644 apps/it_manager/report_docx.py create mode 100644 apps/it_manager/report_import.py create mode 100644 apps/it_manager/report_pdf.py create mode 100644 apps/it_manager/tests_billing.py create mode 100644 apps/it_manager/tests_monthly_report.py create mode 100644 apps/it_manager/tests_report_import.py create mode 100644 apps/reports/__init__.py create mode 100644 apps/reports/access.py create mode 100644 apps/reports/admin.py create mode 100644 apps/reports/apps.py create mode 100644 apps/reports/classic_seed.py create mode 100644 apps/reports/engine/__init__.py create mode 100644 apps/reports/engine/blocks_v1.py create mode 100644 apps/reports/engine/context.py create mode 100644 apps/reports/engine/django_tpl.py create mode 100644 apps/reports/engine/render.py create mode 100644 apps/reports/it_monthly/__init__.py create mode 100644 apps/reports/it_monthly/builder.py create mode 100644 apps/reports/it_monthly/conclusions.py create mode 100644 apps/reports/it_monthly/docx.py create mode 100644 apps/reports/it_monthly/pdf.py create mode 100644 apps/reports/management/__init__.py create mode 100644 apps/reports/management/commands/__init__.py create mode 100644 apps/reports/management/commands/seed_report_templates.py create mode 100644 apps/reports/migrations/0001_report_template.py create mode 100644 apps/reports/migrations/0002_seed_classic.py create mode 100644 apps/reports/migrations/0003_classic_blocks_v1.py create mode 100644 apps/reports/migrations/__init__.py create mode 100644 apps/reports/models.py create mode 100644 apps/reports/registry.py create mode 100644 apps/reports/tests.py create mode 100644 apps/reports/urls.py create mode 100644 apps/reports/views.py create mode 100644 static/assets/js/reports-template-editor.js create mode 100644 templates/pages/internet-billing.html create mode 100644 templates/pages/reports-it-monthly.html create mode 100644 templates/pages/reports-template-edit.html create mode 100644 templates/pages/reports-templates.html create mode 100644 templates/partials/it-equipment-assign-fields.html diff --git a/apps/it_manager/admin.py b/apps/it_manager/admin.py index 35365f9..d60fa82 100644 --- a/apps/it_manager/admin.py +++ b/apps/it_manager/admin.py @@ -1,5 +1,6 @@ from django import forms from django.contrib import admin +from django.core.exceptions import ValidationError from django.utils import timezone from django.utils.html import format_html from django.utils.safestring import mark_safe @@ -9,7 +10,7 @@ from unfold.admin import ModelAdmin, TabularInline from .models import ( Equipment, EquipmentAssignment, EquipmentModel, Ticket, TicketComment, - InternetProvider, InternetContract, + InternetProvider, InternetContract, InternetContractPayment, WorkLog, ServiceCall, Consumable, ) @@ -17,23 +18,28 @@ from .models import ( class LocationChoiceField(forms.ModelChoiceField): def label_from_instance(self, obj): - type_label = obj.get_location_type_display() - addr = obj.get_full_address() or obj.address or '' - if obj.location_type == 'pharmacy': - try: - num = obj.pharmacy.pharmacy_number - return f'Аптека №{num} — {addr}' - except Exception: - pass - return f'{type_label} — {addr}' if addr else type_label + return str(obj) class InternetContractForm(forms.ModelForm): location = LocationChoiceField( queryset=None, + required=False, label=_('Локация'), ) + def clean(self): + cleaned = super().clean() + is_framework = cleaned.get('is_framework') + location = cleaned.get('location') + if is_framework and location: + raise ValidationError({'location': _('У рамочного договора локация не указывается.')}) + if not is_framework and not location: + raise ValidationError({'location': _('Укажите локацию для договора.')}) + if is_framework: + cleaned['location'] = None + return cleaned + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) from apps.locations.models import Location @@ -400,12 +406,20 @@ class TicketAdmin(ModelAdmin): class InternetContractInline(TabularInline): model = InternetContract + form = InternetContractForm extra = 0 - fields = ('location', 'contract_number', 'service_type', 'status', - 'bandwidth_down', 'bandwidth_up', 'monthly_cost', 'end_date') + fields = ('location', 'contract_number', 'is_framework', 'parent_contract', 'service_type', 'status', + 'start_date', 'bandwidth_down', 'bandwidth_up', 'monthly_cost', 'end_date') show_change_link = True +class InternetContractPaymentInline(TabularInline): + model = InternetContractPayment + extra = 0 + fields = ('period', 'amount', 'is_paid', 'paid_at', 'invoice_number', 'recorded_by') + readonly_fields = ('recorded_by',) + + @admin.register(InternetProvider) class InternetProviderAdmin(ModelAdmin): list_display = ['name', 'phone', 'support_phone', 'email', @@ -443,8 +457,10 @@ class InternetProviderAdmin(ModelAdmin): @admin.register(InternetContract) class InternetContractAdmin(ModelAdmin): form = InternetContractForm + inlines = [InternetContractPaymentInline] list_display = [ - 'provider', 'location', 'contract_number', 'service_type_display', + 'provider', 'location', 'contract_number', 'is_framework', 'parent_contract', + 'service_type_display', 'connection_type_display', 'status_display', 'bandwidth_display', 'monthly_cost', 'end_date', 'expiry_display', ] @@ -459,10 +475,12 @@ class InternetContractAdmin(ModelAdmin): fieldsets = ( (_('Договор'), { 'fields': ('provider', 'contract_number', 'account_number', + 'is_framework', 'parent_contract', 'service_type', 'connection_type', 'status'), }), (_('Локация'), { 'fields': ('location',), + 'description': _('Для рамочного договора локация не указывается.'), }), (_('Параметры'), { 'fields': ('bandwidth_down', 'bandwidth_up', 'monthly_cost'), diff --git a/apps/it_manager/billing.py b/apps/it_manager/billing.py new file mode 100644 index 0000000..e39707a --- /dev/null +++ b/apps/it_manager/billing.py @@ -0,0 +1,268 @@ +"""Учёт оплат договоров с интернет-провайдерами (вкладка «Учётные данные»).""" +from __future__ import annotations + +import datetime +from decimal import Decimal, InvalidOperation + +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError +from django.db.models import Prefetch +from django.utils import timezone +from django.views.decorators.http import require_POST + +from apps.it_manager.models import InternetContract, InternetContractPayment, InternetProvider +from core.portal_access import section_required + +UKR_MONTHS = ['', 'Січень', 'Лютий', 'Березень', 'Квітень', 'Травень', 'Червень', + 'Липень', 'Серпень', 'Вересень', 'Жовтень', 'Листопад', 'Грудень'] + + +def _parse_period(request) -> datetime.date: + month_str = request.GET.get('month') or request.POST.get('month') or timezone.localdate().strftime('%Y-%m') + try: + year, month = (int(x) for x in month_str.split('-')) + return datetime.date(year, month, 1) + except (ValueError, TypeError): + today = timezone.localdate() + return today.replace(day=1) + + +def _contracts_queryset(provider_id=None): + qs = ( + InternetContract.objects.select_related( + 'provider', 'location', 'parent_contract', 'status', + ) + .prefetch_related( + Prefetch( + 'child_contracts', + queryset=InternetContract.objects.select_related('location', 'status').order_by('contract_number'), + ), + ) + .filter(status__code='active') + .order_by('provider__name', 'is_framework', 'parent_contract_id', 'contract_number') + ) + if provider_id: + qs = qs.filter(provider_id=provider_id) + return qs + + +def _infer_parent_map(contracts) -> dict[int, int]: + """Связь дочернего договора с рамочным по префиксу номера (если parent не задан в БД).""" + by_provider: dict[int, list[InternetContract]] = {} + for contract in contracts: + by_provider.setdefault(contract.provider_id, []).append(contract) + + inferred: dict[int, int] = {} + for prov_contracts in by_provider.values(): + frameworks = [c for c in prov_contracts if c.is_framework] + for contract in prov_contracts: + if contract.parent_contract_id or contract.is_framework: + continue + matches = [ + fw for fw in frameworks + if contract.contract_number.startswith(fw.contract_number) + and contract.contract_number != fw.contract_number + ] + if not matches: + continue + parent = max(matches, key=lambda fw: len(fw.contract_number)) + inferred[contract.pk] = parent.pk + return inferred + + +def _effective_parent_id(contract, inferred: dict[int, int]) -> int | None: + if contract.parent_contract_id: + return contract.parent_contract_id + return inferred.get(contract.pk) + + +def _build_billing_rows(contracts, payments_by_contract: dict[int, InternetContractPayment]): + """Строки таблицы: группы по провайдеру, рамочный → дочерние.""" + inferred = _infer_parent_map(contracts) + by_provider: dict[int, list[InternetContract]] = {} + for contract in contracts: + by_provider.setdefault(contract.provider_id, []).append(contract) + + rows = [] + seen: set[int] = set() + show_provider_headers = len(by_provider) > 1 + + def append_contract(contract, indent: int, *, has_children: bool = False): + payment = payments_by_contract.get(contract.pk) + amount = payment.amount if payment and payment.amount is not None else contract.monthly_cost + rows.append({ + 'contract': contract, + 'indent': indent, + 'indent_rem': indent * 1.75, + 'payment': payment, + 'is_paid': bool(payment and payment.is_paid), + 'amount': amount, + 'is_framework': contract.is_framework, + 'has_children': has_children, + 'is_inferred_child': ( + contract.pk in inferred and not contract.parent_contract_id + ), + }) + + sorted_providers = sorted( + by_provider.items(), + key=lambda item: item[1][0].provider.name, + ) + + for provider_id, provider_contracts in sorted_providers: + provider = provider_contracts[0].provider + by_parent: dict[int | None, list[InternetContract]] = {} + for contract in provider_contracts: + parent_id = _effective_parent_id(contract, inferred) + by_parent.setdefault(parent_id, []).append(contract) + + if show_provider_headers: + rows.append({'is_provider_header': True, 'provider': provider}) + + def walk(contract: InternetContract, indent: int): + if contract.pk in seen: + return + seen.add(contract.pk) + children = sorted(by_parent.get(contract.pk, []), key=lambda c: c.contract_number) + append_contract(contract, indent, has_children=bool(children)) + for child in children: + walk(child, indent + 1) + + roots = sorted( + [ + c for c in provider_contracts + if _effective_parent_id(c, inferred) is None + ], + key=lambda c: (not c.is_framework, c.contract_number), + ) + for root in roots: + walk(root, 0) + + for contract in provider_contracts: + if contract.pk not in seen: + append_contract(contract, 0) + + return rows + + +def _internet_billing_context(request, **extra): + period = _parse_period(request) + prev_period = (period - datetime.timedelta(days=1)).replace(day=1) + if period.month == 12: + next_period = datetime.date(period.year + 1, 1, 1) + else: + next_period = datetime.date(period.year, period.month + 1, 1) + + provider_id = request.GET.get('provider') or request.POST.get('provider') or '' + contracts = list(_contracts_queryset(provider_id or None)) + contract_ids = [c.pk for c in contracts] + + payments = InternetContractPayment.objects.filter( + contract_id__in=contract_ids, period=period, + ).select_related('recorded_by') + payments_by_contract = {p.contract_id: p for p in payments} + + rows = _build_billing_rows(contracts, payments_by_contract) + contract_rows = [r for r in rows if not r.get('is_provider_header')] + + total_amount = Decimal('0') + paid_amount = Decimal('0') + unpaid_count = 0 + for row in contract_rows: + amt = row['amount'] or Decimal('0') + total_amount += amt + if row['is_paid']: + paid_amount += amt + else: + unpaid_count += 1 + + providers = list( + InternetProvider.objects.filter(is_active=True) + .filter(contracts__status__code='active') + .distinct() + .order_by('name') + ) + + context = { + 'billing_period': period.strftime('%Y-%m'), + 'billing_period_label': f'{UKR_MONTHS[period.month]} {period.year}', + 'billing_prev_month': prev_period.strftime('%Y-%m'), + 'billing_next_month': next_period.strftime('%Y-%m'), + 'billing_rows': rows, + 'billing_contract_count': len(contract_rows), + 'billing_providers': providers, + 'billing_filter_provider': provider_id, + 'billing_total_amount': total_amount, + 'billing_paid_amount': paid_amount, + 'billing_unpaid_count': unpaid_count, + 'billing_paid_count': len(contract_rows) - unpaid_count, + } + context.update(extra) + return context + + +def _parse_decimal(raw) -> Decimal | None: + raw = (raw or '').strip() + if not raw: + return None + try: + return Decimal(raw) + except InvalidOperation: + return None + + +@login_required +@section_required('internet_billing') +@require_POST +def internet_payment_update_view(request): + from core.accounting_portal import render_accounting_portal + + period = _parse_period(request) + contract_id = request.POST.get('contract_id') + action = request.POST.get('action', 'toggle') + + if not contract_id: + return render_accounting_portal(request, 'internet', billing_form_error='Не указан договор.') + + contract = InternetContract.objects.filter(pk=contract_id).first() + if contract is None: + return render_accounting_portal(request, 'internet', billing_form_error='Договор не найден.') + + payment, _created = InternetContractPayment.objects.get_or_create( + contract=contract, + period=period, + defaults={'amount': contract.monthly_cost}, + ) + + if action == 'toggle': + payment.is_paid = not payment.is_paid + elif action == 'mark_paid': + payment.is_paid = True + elif action == 'mark_unpaid': + payment.is_paid = False + else: + payment.is_paid = request.POST.get('is_paid') in ('1', 'true', 'on') + + amount = _parse_decimal(request.POST.get('amount')) + if amount is not None: + payment.amount = amount + elif payment.amount is None: + payment.amount = contract.monthly_cost + + payment.invoice_number = request.POST.get('invoice_number', payment.invoice_number or '').strip() + payment.notes = request.POST.get('notes', payment.notes or '').strip() + payment.recorded_by = request.user + + try: + payment.full_clean() + payment.save() + except ValidationError as exc: + messages = [] + for msgs in exc.message_dict.values(): + messages.extend(msgs) + return render_accounting_portal( + request, 'internet', + billing_form_error=' '.join(messages) or 'Не удалось сохранить оплату.', + ) + + return render_accounting_portal(request, 'internet', billing_form_message='Статус оплаты сохранён.') diff --git a/apps/it_manager/fixtures/report_tool_mini/company.json b/apps/it_manager/fixtures/report_tool_mini/company.json new file mode 100644 index 0000000..3151daf --- /dev/null +++ b/apps/it_manager/fixtures/report_tool_mini/company.json @@ -0,0 +1,8 @@ +{ + "directions": [ + {"id": "prro", "name": "ПРРО", "keywords": ["прро"]}, + {"id": "network", "name": "Сеть", "keywords": ["сет"]}, + {"id": "infra", "name": "Инфра", "keywords": ["proxmox"]} + ], + "staff": [] +} \ No newline at end of file diff --git a/apps/it_manager/fixtures/report_tool_mini/data/2026-05.json b/apps/it_manager/fixtures/report_tool_mini/data/2026-05.json new file mode 100644 index 0000000..9abb136 --- /dev/null +++ b/apps/it_manager/fixtures/report_tool_mini/data/2026-05.json @@ -0,0 +1,24 @@ +{ + "id": "2026-05", + "year": 2026, + "month": 5, + "employees": [ + { + "id": "aprodov", + "name": "Апродов Артур", + "tasks": [ + {"date": "01.05.2026", "object": "А12", "work": "Восстановление работы ПРРО"}, + {"date": "02.05.2026", "object": "ОФИС", "work": "Мониторинг загрузки баз"} + ], + "systems": [] + }, + { + "id": "babich", + "name": "Бабич Виктор Игоревич", + "tasks": [], + "systems": [ + {"name": "Proxmox-кластер", "work": "Мониторинг инфраструктуры (ежедневно)"} + ] + } + ] +} \ No newline at end of file diff --git a/apps/it_manager/management/__init__.py b/apps/it_manager/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/it_manager/management/commands/__init__.py b/apps/it_manager/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/it_manager/management/commands/import_report_tool.py b/apps/it_manager/management/commands/import_report_tool.py new file mode 100644 index 0000000..151fe54 --- /dev/null +++ b/apps/it_manager/management/commands/import_report_tool.py @@ -0,0 +1,81 @@ +"""Импорт журналов report-tool (data/YYYY-MM.json) в WorkLog.""" +from pathlib import Path + +from django.core.management.base import BaseCommand, CommandError + +from apps.it_manager.report_import import default_report_tool_root, import_report_tool + + +class Command(BaseCommand): + help = 'Импорт месячных IT-журналов из report-tool/data в WorkLog' + + def add_arguments(self, parser): + parser.add_argument( + '--root', + type=str, + default='', + help='Путь к каталогу report-tool (по умолчанию ../report-tool от ACORE)', + ) + parser.add_argument( + '--months', + nargs='+', + default=None, + help='Месяцы YYYY-MM (по умолчанию все файлы в data/)', + ) + parser.add_argument( + '--map', + action='append', + default=[], + metavar='staff_id=username', + help='Сопоставление сотрудника report-tool с username в ACORE (можно несколько)', + ) + parser.add_argument( + '--create-missing-employees', + action='store_true', + help='Создать отсутствующих IT-сотрудников (username it.)', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Только посчитать, без записи в БД', + ) + + def handle(self, *args, **options): + root = Path(options['root']).resolve() if options['root'] else default_report_tool_root() + if not root.is_dir(): + raise CommandError(f'Каталог report-tool не найден: {root}') + if not (root / 'data').is_dir(): + raise CommandError(f'Нет {root / "data"}') + + username_map = {} + for item in options['map'] or []: + if '=' not in item: + raise CommandError(f'Ожидается staff_id=username, получено: {item}') + key, value = item.split('=', 1) + username_map[key.strip()] = value.strip() + + stats = import_report_tool( + root=root, + months=options['months'], + username_map=username_map, + create_missing_employees=options['create_missing_employees'], + dry_run=options['dry_run'], + ) + + prefix = '[dry-run] ' if options['dry_run'] else '' + self.stdout.write( + f'{prefix}Месяцы: {", ".join(stats.months) or "—"}; ' + f'создано: {stats.created}; пропущено (уже есть): {stats.skipped}' + ) + if stats.missing_employees: + self.stdout.write(self.style.WARNING( + 'Нет сотрудников в ACORE для: ' + + ', '.join(stats.missing_employees) + + ' (передайте --map staff_id=username или --create-missing-employees)' + )) + if stats.unresolved_objects: + sample = ', '.join(stats.unresolved_objects[:20]) + more = '' if len(stats.unresolved_objects) <= 20 else f' … (+{len(stats.unresolved_objects) - 20})' + self.stdout.write(self.style.WARNING( + f'Объекты без локации в БД ({len(stats.unresolved_objects)}): {sample}{more}' + )) diff --git a/apps/it_manager/migrations/0019_contract_hierarchy_and_payments.py b/apps/it_manager/migrations/0019_contract_hierarchy_and_payments.py new file mode 100644 index 0000000..540cebd --- /dev/null +++ b/apps/it_manager/migrations/0019_contract_hierarchy_and_payments.py @@ -0,0 +1,75 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('it_manager', '0018_equipment_model_catalog'), + ] + + operations = [ + migrations.AddField( + model_name='internetcontract', + name='is_framework', + field=models.BooleanField( + default=False, + help_text='Групповой договор провайдера; к нему привязываются дочерние.', + verbose_name='Общий (рамочный) договор', + ), + ), + migrations.AddField( + model_name='internetcontract', + name='parent_contract', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='child_contracts', + to='it_manager.internetcontract', + verbose_name='Общий договор', + ), + ), + migrations.CreateModel( + name='InternetContractPayment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('period', models.DateField(help_text='Первое число месяца', verbose_name='Период (месяц)')), + ('amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Сумма')), + ('is_paid', models.BooleanField(default=False, verbose_name='Оплачено')), + ('paid_at', models.DateField(blank=True, null=True, verbose_name='Дата оплаты')), + ('invoice_number', models.CharField(blank=True, max_length=100, verbose_name='Номер счёта/акта')), + ('notes', models.TextField(blank=True, verbose_name='Примечания')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('contract', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='payments', + to='it_manager.internetcontract', + verbose_name='Договор', + )), + ('recorded_by', models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='internet_payments_recorded', + to=settings.AUTH_USER_MODEL, + verbose_name='Кто отметил', + )), + ], + options={ + 'verbose_name': 'Оплата по договору', + 'verbose_name_plural': 'Оплаты по договорам', + 'ordering': ['-period', 'contract'], + }, + ), + migrations.AddConstraint( + model_name='internetcontractpayment', + constraint=models.UniqueConstraint( + fields=('contract', 'period'), + name='unique_internet_payment_per_contract_period', + ), + ), + ] diff --git a/apps/it_manager/migrations/0020_nullable_framework_contract_location.py b/apps/it_manager/migrations/0020_nullable_framework_contract_location.py new file mode 100644 index 0000000..0ee9866 --- /dev/null +++ b/apps/it_manager/migrations/0020_nullable_framework_contract_location.py @@ -0,0 +1,33 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('locations', '0012_pharmacy_card_visibility'), + ('it_manager', '0019_contract_hierarchy_and_payments'), + ] + + operations = [ + migrations.AlterField( + model_name='internetcontract', + name='location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='internet_contracts', + to='locations.location', + verbose_name='Локация', + ), + ), + migrations.AlterModelOptions( + name='internetcontract', + options={ + 'ordering': ['provider__name', 'contract_number'], + 'verbose_name': 'Договор с провайдером', + 'verbose_name_plural': 'Договоры с провайдерами', + }, + ), + ] diff --git a/apps/it_manager/migrations/0021_seed_gpon_connection_type.py b/apps/it_manager/migrations/0021_seed_gpon_connection_type.py new file mode 100644 index 0000000..a602d05 --- /dev/null +++ b/apps/it_manager/migrations/0021_seed_gpon_connection_type.py @@ -0,0 +1,35 @@ +from django.db import migrations + + +def seed_gpon(apps, schema_editor): + DirectoryType = apps.get_model('core', 'DirectoryType') + Directory = apps.get_model('core', 'Directory') + directory_type, _ = DirectoryType.objects.get_or_create( + code='contract_connection_type', + defaults={'name': 'Технологія підключення', 'hierarchy_type': 'strict'}, + ) + Directory.objects.get_or_create( + directory_type=directory_type, + code='gpon', + defaults={'name': 'GPON', 'is_active': True}, + ) + + +def unseed_gpon(apps, schema_editor): + Directory = apps.get_model('core', 'Directory') + Directory.objects.filter( + directory_type__code='contract_connection_type', + code='gpon', + ).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('it_manager', '0020_nullable_framework_contract_location'), + ('core', '0015_alter_portalaccess_options'), + ] + + operations = [ + migrations.RunPython(seed_gpon, unseed_gpon), + ] diff --git a/apps/it_manager/models/__init__.py b/apps/it_manager/models/__init__.py index 02ddeab..e8d0a8a 100644 --- a/apps/it_manager/models/__init__.py +++ b/apps/it_manager/models/__init__.py @@ -1,5 +1,5 @@ from .equipment import Equipment, EquipmentAssignment, EquipmentModel from .tickets import Ticket, TicketComment -from .providers import InternetProvider, InternetContract +from .providers import InternetProvider, InternetContract, InternetContractPayment from .worklogs import WorkLog, ServiceCall from .consumables import Consumable diff --git a/apps/it_manager/models/providers.py b/apps/it_manager/models/providers.py index 7e6fe38..5477e4b 100644 --- a/apps/it_manager/models/providers.py +++ b/apps/it_manager/models/providers.py @@ -1,3 +1,5 @@ +from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone @@ -36,6 +38,7 @@ class InternetContract(models.Model): location = models.ForeignKey( 'locations.Location', on_delete=models.PROTECT, related_name='internet_contracts', verbose_name='Локация', + null=True, blank=True, ) contract_number = models.CharField('Номер договора', max_length=100) @@ -71,17 +74,35 @@ class InternetContract(models.Model): account_number = models.CharField('Лицевой счёт', max_length=100, blank=True) notes = models.TextField('Примечания', blank=True) + is_framework = models.BooleanField( + 'Общий (рамочный) договор', + default=False, + help_text='Групповой договор провайдера; к нему привязываются дочерние.', + ) + parent_contract = models.ForeignKey( + 'self', null=True, blank=True, on_delete=models.PROTECT, + related_name='child_contracts', verbose_name='Общий договор', + ) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = 'Договор с провайдером' verbose_name_plural = 'Договоры с провайдерами' - ordering = ['location', 'provider'] + ordering = ['provider__name', 'contract_number'] unique_together = [('provider', 'contract_number')] def __str__(self): - return f'{self.provider} — {self.location} (№{self.contract_number})' + return f'{self.provider} — {self.location_display} (№{self.contract_number})' + + @property + def location_display(self) -> str: + if self.location_id: + return str(self.location) + if self.is_framework: + return '— общий договор —' + return '—' @property def is_expiring_soon(self): @@ -100,3 +121,73 @@ class InternetContract(models.Model): @property def static_ip_list(self): return [ip.strip() for ip in self.static_ips.splitlines() if ip.strip()] + + def clean(self): + super().clean() + if self.parent_contract_id: + if self.parent_contract_id == self.pk: + raise ValidationError({'parent_contract': 'Договор не может ссылаться сам на себя.'}) + if self.parent_contract.provider_id != self.provider_id: + raise ValidationError({'parent_contract': 'Общий договор должен быть у того же провайдера.'}) + if self.parent_contract.parent_contract_id: + raise ValidationError({'parent_contract': 'Дочерний договор можно привязать только к рамочному.'}) + if self.is_framework and self.parent_contract_id: + raise ValidationError({ + 'is_framework': 'Рамочный договор не может иметь родителя.', + 'parent_contract': 'Рамочный договор не может иметь родителя.', + }) + if self.is_framework: + if self.location_id: + raise ValidationError({'location': 'У рамочного договора локация не указывается.'}) + elif not self.location_id: + raise ValidationError({'location': 'Укажите локацию для договора.'}) + + +class InternetContractPayment(models.Model): + """Оплата по договору за календарный месяц (учёт в бухгалтерии).""" + + contract = models.ForeignKey( + InternetContract, on_delete=models.CASCADE, + related_name='payments', verbose_name='Договор', + ) + period = models.DateField('Период (месяц)', help_text='Первое число месяца') + amount = models.DecimalField( + 'Сумма', max_digits=10, decimal_places=2, null=True, blank=True, + ) + is_paid = models.BooleanField('Оплачено', default=False) + paid_at = models.DateField('Дата оплаты', null=True, blank=True) + invoice_number = models.CharField('Номер счёта/акта', max_length=100, blank=True) + notes = models.TextField('Примечания', blank=True) + recorded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, blank=True, related_name='internet_payments_recorded', + verbose_name='Кто отметил', + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Оплата по договору' + verbose_name_plural = 'Оплаты по договорам' + ordering = ['-period', 'contract'] + constraints = [ + models.UniqueConstraint( + fields=['contract', 'period'], + name='unique_internet_payment_per_contract_period', + ), + ] + + def __str__(self): + status = 'оплачено' if self.is_paid else 'не оплачено' + return f'{self.contract.contract_number} · {self.period:%m.%Y} · {status}' + + def clean(self): + super().clean() + if self.period and self.period.day != 1: + raise ValidationError({'period': 'Укажите первое число месяца.'}) + if self.is_paid and not self.paid_at: + self.paid_at = timezone.localdate() + if not self.is_paid: + self.paid_at = None + if self.amount is None and self.contract_id: + self.amount = self.contract.monthly_cost diff --git a/apps/it_manager/monthly_report.py b/apps/it_manager/monthly_report.py new file mode 100644 index 0000000..11aebc4 --- /dev/null +++ b/apps/it_manager/monthly_report.py @@ -0,0 +1,2 @@ +"""Совместимость: месячный IT-отчёт перенесён в apps.reports.it_monthly.""" +from apps.reports.it_monthly.builder import * # noqa: F401,F403 diff --git a/apps/it_manager/report_conclusions.py b/apps/it_manager/report_conclusions.py new file mode 100644 index 0000000..a42fe5e --- /dev/null +++ b/apps/it_manager/report_conclusions.py @@ -0,0 +1,2 @@ +"""Совместимость: перенесено в apps.reports.it_monthly.conclusions.""" +from apps.reports.it_monthly.conclusions import * # noqa: F401,F403 diff --git a/apps/it_manager/report_docx.py b/apps/it_manager/report_docx.py new file mode 100644 index 0000000..e89e647 --- /dev/null +++ b/apps/it_manager/report_docx.py @@ -0,0 +1,2 @@ +"""Совместимость: перенесено в apps.reports.it_monthly.docx.""" +from apps.reports.it_monthly.docx import * # noqa: F401,F403 diff --git a/apps/it_manager/report_import.py b/apps/it_manager/report_import.py new file mode 100644 index 0000000..a001938 --- /dev/null +++ b/apps/it_manager/report_import.py @@ -0,0 +1,344 @@ +"""Импорт месячных JSON из report-tool/ в WorkLog.""" +from __future__ import annotations + +import calendar +import json +import re +from dataclasses import dataclass, field +from datetime import date, datetime +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.db import transaction + +from apps.it_manager.models import WorkLog +from apps.locations.models.base import Location, organization_object_letter +from apps.locations.models.pharmacies import Pharmacy +from core.models.directories import Directory + +Employee = get_user_model() + +UA_LOWER_TO_RU = {'і': 'и', 'ї': 'и', 'є': 'е', 'ґ': 'г'} + +STAFF_LAST_NAMES = { + 'babich': ('бабич',), + 'aprodov': ('апродов',), + 'khizhniak': ('хижняк', 'хіжняк'), + 'kogutenko': ('когутенко',), +} + +DIRECTION_TO_WORK_TYPE = { + 'prro': 'software', + 'terminal': 'installation', + 'print': 'repair', + 'network': 'network', + 'cctv': 'installation', + 'pc': 'repair', + 'install': 'installation', + 'access': 'software', + 'infra': 'maintenance', +} + +OBJ_CODE_RE = re.compile(r'^([АAПPТTГGЛL])(\d+)$', re.IGNORECASE) +IMPORT_MARKER = 'import:report-tool:{month_id}' + + +def normalize_name(value: str) -> str: + value = (value or '').strip().lower() + return ''.join(UA_LOWER_TO_RU.get(c, c) for c in value) + + +def default_report_tool_root() -> Path: + # apps/it_manager/report_import.py → apms/ + return Path(__file__).resolve().parents[3] / 'report-tool' + + +def load_company(root: Path) -> dict: + return json.loads((root / 'company.json').read_text(encoding='utf-8')) + + +def load_month_json(path: Path) -> dict: + return json.loads(path.read_text(encoding='utf-8')) + + +def parse_task_date(value: str, year: int, month: int) -> date | None: + value = (value or '').strip() + if not value: + return None + for fmt in ('%d.%m.%Y', '%d.%m.%y', '%Y-%m-%d'): + try: + return datetime.strptime(value, fmt).date() + except ValueError: + continue + m = re.fullmatch(r'(\d{1,2})/(\d{1,2})/(\d{2,4})', value) + if m: + mm, dd, yy = int(m.group(1)), int(m.group(2)), int(m.group(3)) + if yy < 100: + yy += 2000 + try: + return date(yy, mm, dd) + except ValueError: + return None + m = re.fullmatch(r'(\d{1,2})\.(\d{1,2})', value) + if m: + try: + return date(year, int(m.group(2)), int(m.group(1))) + except ValueError: + return None + return None + + +def month_end(year: int, month: int) -> date: + return date(year, month, calendar.monthrange(year, month)[1]) + + +def classify_work_type_code(text: str, directions: list[dict]) -> str: + low = (text or '').lower() + for direction in directions: + for kw in direction.get('keywords') or []: + if kw.lower() in low: + return DIRECTION_TO_WORK_TYPE.get(direction.get('id'), 'other') + return 'other' + + +@dataclass +class ImportStats: + created: int = 0 + skipped: int = 0 + missing_employees: list[str] = field(default_factory=list) + unresolved_objects: list[str] = field(default_factory=list) + months: list[str] = field(default_factory=list) + + +class LocationResolver: + def __init__(self): + self._by_prefix_num: dict[tuple[str, int], Location] = {} + self._by_num: dict[int, list[Location]] = {} + self._office: Location | None = None + self._warehouse: Location | None = None + self._built = False + + def build(self): + if self._built: + return + self._built = True + for pharmacy in Pharmacy.objects.select_related('organization').filter(is_active=True): + num = pharmacy.pharmacy_number + if num is None: + continue + letter = organization_object_letter(pharmacy.organization) + if letter: + self._by_prefix_num[(letter[0].lower(), int(num))] = pharmacy + self._by_num.setdefault(int(num), []).append(pharmacy) + self._office = Location.objects.filter(location_type='office', is_active=True).order_by('id').first() + self._warehouse = Location.objects.filter(location_type='warehouse', is_active=True).order_by('id').first() + + def resolve(self, object_code: str) -> Location | None: + self.build() + raw = (object_code or '').strip() + if not raw: + return None + upper = raw.upper().replace(' ', '') + if upper in ('ОФИС', 'ОФІС', 'OFFICE'): + return self._office + if upper in ('СКЛАД',): + return self._warehouse + m = OBJ_CODE_RE.fullmatch(raw.replace(' ', '')) + if not m: + return None + letter = m.group(1).upper().translate(str.maketrans('APTGL', 'АПТГЛ')) + num = int(m.group(2)) + letter_l = letter.lower() + hit = self._by_prefix_num.get((letter_l, num)) + if hit: + return hit + # fallback: единственная аптека с таким номером + candidates = self._by_num.get(num) or [] + if len(candidates) == 1: + return candidates[0] + return None + + +def resolve_employee(staff_id: str, staff_name: str, username_map: dict[str, str]) -> Employee | None: + if staff_id in username_map: + return Employee.objects.filter(username=username_map[staff_id]).first() + + last_names = STAFF_LAST_NAMES.get(staff_id) + if last_names: + for emp in Employee.objects.filter(is_active=True): + if normalize_name(emp.last_name) in {normalize_name(n) for n in last_names}: + return emp + + # по фамилии из полного имени + parts = (staff_name or '').split() + if parts: + target = normalize_name(parts[0]) + for emp in Employee.objects.filter(is_active=True): + if normalize_name(emp.last_name) == target: + return emp + return None + + +def ensure_employee(staff_id: str, staff_name: str) -> Employee: + parts = (staff_name or staff_id).split() + last_name = parts[0] if parts else staff_id + first_name = parts[1] if len(parts) > 1 else staff_id + username = f'it.{staff_id}' + emp, created = Employee.objects.get_or_create( + username=username, + defaults={ + 'last_name': last_name, + 'first_name': first_name, + 'email': f'{username}@company.local', + 'hire_date': date(2020, 1, 1), + 'user_location_type': 'office', + 'mattermost_sync_enabled': False, + 'ad_sync_enabled': False, + 'is_active': True, + }, + ) + if created: + emp.set_unusable_password() + emp.save() + from django.contrib.auth.models import Group + it_group, _ = Group.objects.get_or_create(name='IT') + emp.groups.add(it_group) + return emp + + +def work_type_id(code: str, cache: dict[str, int]) -> int: + if code not in cache: + pk = Directory.objects.filter( + directory_type__code='worklog_type', code=code, + ).values_list('id', flat=True).first() + if not pk: + pk = Directory.objects.filter( + directory_type__code='worklog_type', code='other', + ).values_list('id', flat=True).first() + cache[code] = pk + return cache[code] + + +def import_marker(month_id: str) -> str: + return IMPORT_MARKER.format(month_id=month_id) + + +def iter_month_files(root: Path, months: list[str] | None) -> list[Path]: + data_dir = root / 'data' + files = sorted(data_dir.glob('????-??.json')) + if months: + wanted = set(months) + files = [p for p in files if p.stem in wanted] + return files + + +@transaction.atomic +def import_report_tool( + *, + root: Path | None = None, + months: list[str] | None = None, + username_map: dict[str, str] | None = None, + create_missing_employees: bool = False, + dry_run: bool = False, +) -> ImportStats: + root = root or default_report_tool_root() + company = load_company(root) + directions = company.get('directions') or [] + username_map = username_map or {} + locations = LocationResolver() + type_cache: dict[str, int] = {} + stats = ImportStats() + + for path in iter_month_files(root, months): + month = load_month_json(path) + month_id = month.get('id') or path.stem + year = int(month['year']) + month_num = int(month['month']) + marker = import_marker(month_id) + stats.months.append(month_id) + default_date = month_end(year, month_num) + + for emp_data in month.get('employees') or []: + staff_id = emp_data.get('id') or '' + staff_name = emp_data.get('name') or staff_id + employee = resolve_employee(staff_id, staff_name, username_map) + if employee is None: + if create_missing_employees: + if dry_run: + employee = None + else: + employee = ensure_employee(staff_id, staff_name) + else: + if staff_id not in stats.missing_employees: + stats.missing_employees.append(staff_id or staff_name) + continue + + for task in emp_data.get('tasks') or []: + work = (task.get('work') or '').strip() + if not work: + continue + obj = (task.get('object') or '').strip() + log_date = parse_task_date(task.get('date') or '', year, month_num) or default_date + location = locations.resolve(obj) if obj else None + if obj and location is None and obj.upper() not in stats.unresolved_objects: + stats.unresolved_objects.append(obj) + description = work if location or not obj else f'{obj} — {work}' + wt = classify_work_type_code(f'{obj} {work}', directions) + _create_or_skip( + stats=stats, + dry_run=dry_run, + employee=employee, + log_date=log_date, + location=location, + work_type_id=work_type_id(wt, type_cache), + description=description, + marker=marker, + ) + + for system in emp_data.get('systems') or []: + name = (system.get('name') or '').strip() + work = (system.get('work') or '').strip() + if not name or not work: + continue + description = f'{name}: {work}' + _create_or_skip( + stats=stats, + dry_run=dry_run, + employee=employee, + log_date=default_date, + location=locations.resolve('ОФИС'), + work_type_id=work_type_id('maintenance', type_cache), + description=description, + marker=marker, + ) + + return stats + + +def _create_or_skip(*, stats, dry_run, employee, log_date, location, work_type_id, description, marker): + if employee is None: + # dry-run без создания сотрудников + stats.created += 1 + return + exists = WorkLog.objects.filter( + technician=employee, + date=log_date, + description=description, + result=marker, + ).exists() + if exists: + stats.skipped += 1 + return + if dry_run: + stats.created += 1 + return + WorkLog.objects.create( + date=log_date, + technician=employee, + location=location, + work_type_id=work_type_id, + description=description, + result=marker, + hours_spent=None, + ) + stats.created += 1 diff --git a/apps/it_manager/report_pdf.py b/apps/it_manager/report_pdf.py new file mode 100644 index 0000000..1eba34f --- /dev/null +++ b/apps/it_manager/report_pdf.py @@ -0,0 +1,2 @@ +"""Совместимость: перенесено в apps.reports.it_monthly.pdf.""" +from apps.reports.it_monthly.pdf import * # noqa: F401,F403 diff --git a/apps/it_manager/tests.py b/apps/it_manager/tests.py index ca075eb..b5ab5b3 100644 --- a/apps/it_manager/tests.py +++ b/apps/it_manager/tests.py @@ -10,6 +10,7 @@ from apps.it_manager.models import ( Equipment, EquipmentAssignment, EquipmentModel, Ticket, WorkLog, ) from core.models.directories import Directory +from core.test_utils import login_2fa Employee = get_user_model() @@ -67,7 +68,8 @@ class ItManagerAccessTests(TestCase): def test_page_forbidden_for_non_it(self): r = self.client.get('/it-manager/') - self.assertEqual(r.status_code, 403) + self.assertEqual(r.status_code, 302) + self.assertEqual(r['Location'], '/dashboard/') def test_equipment_create_forbidden_for_non_it(self): r = self.client.post('/it-manager/equipment/bulk-create/', { @@ -88,7 +90,7 @@ class ItManagerEquipmentTests(TestCase): self.it_user = make_employee(is_staff=False) self.it_user.groups.add(Group.objects.get(name='IT')) self.client = Client() - self.client.force_login(self.it_user) + login_2fa(self.client, self.it_user) def test_page_accessible_for_it_group(self): r = self.client.get('/it-manager/') @@ -119,6 +121,7 @@ class ItManagerEquipmentTests(TestCase): emp = make_employee() r = self.client.post('/it-manager/equipment/bulk-assign/', { 'equipment_ids': [str(units[0].pk), str(units[1].pk)], + 'assign_target': 'employee', 'assigned_to_id': str(emp.id), }, HTTP_HX_REQUEST='true') self.assertEqual(r.status_code, 200) @@ -134,7 +137,7 @@ class ItManagerEquipmentTests(TestCase): emp2 = make_employee() self.client.post(f'/it-manager/equipment/{equipment.id}/update/', { - 'status': 'assigned', 'assigned_to_id': emp1.id, + 'status': 'assigned', 'assign_target': 'employee', 'assigned_to_id': emp1.id, }) equipment.refresh_from_db() self.assertEqual(equipment.assigned_to_id, emp1.id) @@ -142,7 +145,7 @@ class ItManagerEquipmentTests(TestCase): self.assertEqual(EquipmentAssignment.objects.filter(equipment=equipment, returned_at__isnull=True).count(), 1) self.client.post(f'/it-manager/equipment/{equipment.id}/update/', { - 'status': 'assigned', 'assigned_to_id': emp2.id, + 'status': 'assigned', 'assign_target': 'employee', 'assigned_to_id': emp2.id, }) equipment.refresh_from_db() self.assertEqual(equipment.assigned_to_id, emp2.id) @@ -156,16 +159,115 @@ class ItManagerEquipmentTests(TestCase): equipment = make_equipment() emp1 = make_employee() self.client.post(f'/it-manager/equipment/{equipment.id}/update/', { - 'status': 'assigned', 'assigned_to_id': emp1.id, + 'status': 'assigned', 'assign_target': 'employee', 'assigned_to_id': emp1.id, }) self.client.post(f'/it-manager/equipment/{equipment.id}/update/', { - 'status': 'stock', 'assigned_to_id': '', + 'status': 'stock', 'assign_target': '', }) equipment.refresh_from_db() self.assertIsNone(equipment.assigned_to_id) self.assertEqual(equipment.status.code, 'stock') self.assertEqual(EquipmentAssignment.objects.filter(equipment=equipment, returned_at__isnull=True).count(), 0) + def test_bulk_assign_to_location_only(self): + from apps.locations.models.base import Location + from core.models.directories import DirectoryType + + region_type, _ = DirectoryType.objects.get_or_create( + code='location_region', defaults={'name': 'Регион'}, + ) + region, _ = Directory.objects.get_or_create( + directory_type=region_type, code='eq-reg', defaults={'name': 'Тест'}, + ) + location = Location.objects.create( + address='Аптека тест', location_type='pharmacy', + location_region=region, is_active=True, + ) + model = make_equipment_model(name='RB952Ui') + unit = Equipment.objects.create(equipment_model=model, serial_number='RT-LOC-1') + r = self.client.post('/it-manager/equipment/bulk-assign/', { + 'equipment_ids': [str(unit.pk)], + 'assign_target': 'pharmacy', + 'location_id': str(location.pk), + }, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + unit.refresh_from_db() + self.assertEqual(unit.location_id, location.pk) + self.assertIsNone(unit.assigned_to_id) + self.assertEqual(unit.status.code, 'assigned') + hist = EquipmentAssignment.objects.get(equipment=unit, returned_at__isnull=True) + self.assertEqual(hist.location_id, location.pk) + self.assertIsNone(hist.assigned_to_id) + + def test_update_assign_to_pharmacy(self): + from apps.locations.models.base import Location + from core.models.directories import DirectoryType + + region_type, _ = DirectoryType.objects.get_or_create( + code='location_region', defaults={'name': 'Регион'}, + ) + region, _ = Directory.objects.get_or_create( + directory_type=region_type, code='eq-reg2', defaults={'name': 'Тест2'}, + ) + location = Location.objects.create( + address='А12', location_type='pharmacy', + location_region=region, is_active=True, + ) + equipment = make_equipment(serial_number='RT-UPD-1') + self.client.post(f'/it-manager/equipment/{equipment.id}/update/', { + 'status': 'assigned', + 'assign_target': 'pharmacy', + 'location_id': str(location.pk), + }) + equipment.refresh_from_db() + self.assertEqual(equipment.location_id, location.pk) + self.assertIsNone(equipment.assigned_to_id) + self.assertEqual(equipment.status.code, 'assigned') + self.assertEqual( + EquipmentAssignment.objects.filter(equipment=equipment, returned_at__isnull=True).count(), 1, + ) + + # повернення на склад + self.client.post(f'/it-manager/equipment/{equipment.id}/update/', { + 'status': 'stock', + 'assign_target': '', + }) + equipment.refresh_from_db() + self.assertIsNone(equipment.location_id) + self.assertEqual(equipment.status.code, 'stock') + self.assertEqual( + EquipmentAssignment.objects.filter(equipment=equipment, returned_at__isnull=True).count(), 0, + ) + + def test_bulk_create_with_location_writes_history(self): + from apps.locations.models.base import Location + from core.models.directories import DirectoryType + + region_type, _ = DirectoryType.objects.get_or_create( + code='location_region', defaults={'name': 'Регион'}, + ) + region, _ = Directory.objects.get_or_create( + directory_type=region_type, code='eq-reg3', defaults={'name': 'Тест3'}, + ) + location = Location.objects.create( + address='Склад-офіс', location_type='office', + location_region=region, is_active=True, + ) + model = make_equipment_model() + r = self.client.post('/it-manager/equipment/bulk-create/', { + 'equipment_model_id': str(model.pk), + 'serial_numbers': 'SN-LOC-A', + 'assign_target': 'office', + 'location_id': str(location.pk), + }, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + unit = Equipment.objects.get(serial_number='SN-LOC-A') + self.assertEqual(unit.location_id, location.pk) + self.assertEqual(unit.status.code, 'assigned') + self.assertEqual( + EquipmentAssignment.objects.filter(equipment=unit, location=location).count(), 1, + ) + class ItManagerTicketTests(TestCase): @@ -174,7 +276,7 @@ class ItManagerTicketTests(TestCase): self.it_user.groups.add(Group.objects.get(name='IT')) self.requester = make_employee() self.client = Client() - self.client.force_login(self.it_user) + login_2fa(self.client, self.it_user) def test_resolve_ticket_stamps_resolved_at(self): ticket = Ticket.objects.create( @@ -210,7 +312,7 @@ class ItManagerWorkLogTests(TestCase): self.it_user = make_employee(is_staff=False) self.it_user.groups.add(Group.objects.get(name='IT')) self.client = Client() - self.client.force_login(self.it_user) + login_2fa(self.client, self.it_user) def test_create_worklog(self): r = self.client.post('/it-manager/worklog/create/', { @@ -221,3 +323,172 @@ class ItManagerWorkLogTests(TestCase): log = WorkLog.objects.get(description='Профілактика серверної') self.assertEqual(log.technician_id, self.it_user.id) self.assertEqual(float(log.hours_spent), 2.5) + self.assertContains(r, 'Запись о выполненной работе сохранена') + + def test_create_worklog_requires_description(self): + before = WorkLog.objects.count() + r = self.client.post('/it-manager/worklog/create/', { + 'work_type': 'maintenance', 'description': ' ', + }) + self.assertEqual(r.status_code, 200) + self.assertEqual(WorkLog.objects.count(), before) + self.assertContains(r, 'Укажите описание работ') + + def test_create_worklog_linked_to_ticket(self): + from apps.it_manager.models import Ticket + from core.models.directories import Directory, DirectoryType + + ttype, _ = DirectoryType.objects.get_or_create(code='ticket_type', defaults={'name': 'Тип заявки'}) + ptype, _ = DirectoryType.objects.get_or_create(code='ticket_priority', defaults={'name': 'Приоритет'}) + stype, _ = DirectoryType.objects.get_or_create(code='ticket_status', defaults={'name': 'Статус'}) + ticket_type, _ = Directory.objects.get_or_create( + directory_type=ttype, code='incident', defaults={'name': 'Инцидент'}, + ) + priority, _ = Directory.objects.get_or_create( + directory_type=ptype, code='medium', defaults={'name': 'Средний'}, + ) + status, _ = Directory.objects.get_or_create( + directory_type=stype, code='in_progress', defaults={'name': 'В работе'}, + ) + ticket = Ticket.objects.create( + title='Не працює каса', + ticket_type=ticket_type, + priority=priority, + status=status, + requester=self.it_user, + description='Каса не відкривається', + ) + r = self.client.post('/it-manager/worklog/create/', { + 'work_type': 'repair', + 'description': 'Перезапуск касового ПЗ', + 'ticket_id': ticket.pk, + 'hours_spent': '1', + }) + self.assertEqual(r.status_code, 200) + log = WorkLog.objects.get(description='Перезапуск касового ПЗ') + self.assertEqual(log.ticket_id, ticket.pk) + self.assertContains(r, 'Работа привязана к заявке') + self.assertContains(r, ticket.number) + + +class ItManagerWorkLogFilterEditTests(TestCase): + """Фильтр по сотруднику и inline-редактирование для руководителя.""" + + def setUp(self): + from core.models.directories import DirectoryType + + dept_type, _ = DirectoryType.objects.get_or_create( + code='department', defaults={'name': 'Відділи', 'hierarchy_type': 'strict'}, + ) + self.dept = Directory.objects.create( + directory_type=dept_type, code='it-wl', name='IT-відділ-wl', + ) + self.head = make_employee( + last_name='Керівник', first_name='ІТ', username='it.head.wl', + department=self.dept, user_location_type='office', + ) + self.tech_a = make_employee( + last_name='Альфа', first_name='Тех', username='tech.a.wl', + department=self.dept, manager=self.head, user_location_type='office', + ) + self.tech_b = make_employee( + last_name='Бета', first_name='Тех', username='tech.b.wl', + department=self.dept, manager=self.head, user_location_type='office', + ) + it_group = Group.objects.get(name='IT') + for u in (self.head, self.tech_a, self.tech_b): + u.groups.add(it_group) + + self.log_a = WorkLog.objects.create( + date=date(2026, 9, 1), + technician=self.tech_a, + work_type_id=_dir_id('worklog_type', 'maintenance'), + description='Робота А', + hours_spent=1, + ) + self.log_b = WorkLog.objects.create( + date=date(2026, 9, 2), + technician=self.tech_b, + work_type_id=_dir_id('worklog_type', 'repair'), + description='Робота Б', + hours_spent=2, + ) + self.client = Client() + + def test_filter_by_technician(self): + login_2fa(self.client, self.tech_a) + r = self.client.get(f'/it-manager/?tab=worklog&worklog_technician={self.tech_a.pk}') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'Робота А') + self.assertNotContains(r, 'Робота Б') + self.assertContains(r, 'name="worklog_technician"') + self.assertContains(r, 'name="worklog_month"') + self.assertContains(r, 'data-it-active-tab="worklog"') + self.assertContains(r, 'id="it-panel-equipment"') + self.assertRegex(r.content.decode(), r'id="it-panel-equipment"[^>]*style="display:none;"') + self.assertRegex(r.content.decode(), r'id="it-panel-worklog"(?! style="display:none;")') + + def test_filter_by_month(self): + WorkLog.objects.create( + date=date(2026, 8, 15), + technician=self.tech_a, + work_type_id=_dir_id('worklog_type', 'maintenance'), + description='Серпень робота', + ) + login_2fa(self.client, self.tech_a) + r = self.client.get('/it-manager/?tab=worklog&worklog_month=2026-09') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'Робота А') + self.assertContains(r, 'Робота Б') + self.assertNotContains(r, 'Серпень робота') + + r_all = self.client.get('/it-manager/?tab=worklog&worklog_month=all') + self.assertContains(r_all, 'Серпень робота') + + def test_filter_month_and_technician(self): + login_2fa(self.client, self.tech_a) + r = self.client.get( + f'/it-manager/?tab=worklog&worklog_month=2026-09&worklog_technician={self.tech_b.pk}' + ) + self.assertEqual(r.status_code, 200) + self.assertNotContains(r, 'Робота А') + self.assertContains(r, 'Робота Б') + + def test_head_can_inline_update(self): + login_2fa(self.client, self.head) + r = self.client.post(f'/it-manager/worklog/{self.log_a.pk}/update/', { + 'date': '2026-09-03', + 'technician_id': self.tech_b.pk, + 'work_type': 'software', + 'description': 'Виправлено керівником', + 'result': 'Ок', + 'hours_spent': '3.5', + 'location_id': '', + }) + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'Сохранено') + self.log_a.refresh_from_db() + self.assertEqual(self.log_a.description, 'Виправлено керівником') + self.assertEqual(self.log_a.technician_id, self.tech_b.pk) + self.assertEqual(float(self.log_a.hours_spent), 3.5) + self.assertEqual(self.log_a.work_type.code, 'software') + + def test_non_head_cannot_inline_update(self): + login_2fa(self.client, self.tech_a) + r = self.client.post(f'/it-manager/worklog/{self.log_a.pk}/update/', { + 'date': '2026-09-03', + 'technician_id': self.tech_a.pk, + 'work_type': 'maintenance', + 'description': 'Спроба змінити', + 'hours_spent': '9', + }) + self.assertEqual(r.status_code, 403) + self.log_a.refresh_from_db() + self.assertEqual(self.log_a.description, 'Робота А') + + def test_head_sees_editable_controls(self): + login_2fa(self.client, self.head) + r = self.client.get('/it-manager/?tab=worklog') + self.assertEqual(r.status_code, 200) + self.assertContains(r, f'worklog-form-{self.log_a.pk}') + self.assertContains(r, 'править записи прямо в таблице') diff --git a/apps/it_manager/tests_billing.py b/apps/it_manager/tests_billing.py new file mode 100644 index 0000000..259b603 --- /dev/null +++ b/apps/it_manager/tests_billing.py @@ -0,0 +1,207 @@ +from datetime import date +from decimal import Decimal + +from django.contrib.auth.models import Group +from django.test import TestCase + +from apps.it_manager.admin import InternetContractForm +from apps.it_manager.billing import _build_billing_rows +from apps.it_manager.models import InternetContract, InternetContractPayment, InternetProvider +from apps.locations.models.base import Location +from core.models.directories import Directory, DirectoryType +from core.portal_access import user_can_access +from core.test_utils import login_2fa +from core.tests_portal_access import make_employee + + +def _ensure_contract_status(): + dtype, _ = DirectoryType.objects.get_or_create(code='contract_status', defaults={'name': 'Статус договора'}) + status, _ = Directory.objects.get_or_create( + directory_type=dtype, code='active', defaults={'name': 'Активный'}, + ) + return status + + +def _make_location(**overrides): + region_type, _ = DirectoryType.objects.get_or_create(code='location_region', defaults={'name': 'Регион'}) + region, _ = Directory.objects.get_or_create(directory_type=region_type, code='test', defaults={'name': 'Тест'}) + defaults = { + 'address': 'Test addr', + 'location_type': 'office', + 'location_region': region, + 'is_active': True, + } + defaults.update(overrides) + return Location.objects.create(**defaults) + + +def _ensure_service_type(): + dtype, _ = DirectoryType.objects.get_or_create(code='contract_service_type', defaults={'name': 'Тип услуги'}) + service, _ = Directory.objects.get_or_create( + directory_type=dtype, code='internet', defaults={'name': 'Интернет'}, + ) + return service + + +class InternetContractHierarchyTests(TestCase): + def setUp(self): + self.status = _ensure_contract_status() + self.service = _ensure_service_type() + self.provider = InternetProvider.objects.create(name='WESTELECOM') + self.location = _make_location(location_type='pharmacy', address='Pharmacy addr') + self.master = InternetContract.objects.create( + provider=self.provider, + location=None, + contract_number='708052', + service_type=self.service, + status=self.status, + start_date=date(2024, 1, 1), + is_framework=True, + monthly_cost=Decimal('1000.00'), + ) + self.child = InternetContract.objects.create( + provider=self.provider, + location=self.location, + contract_number='70805208', + service_type=self.service, + status=self.status, + start_date=date(2024, 1, 1), + parent_contract=self.master, + monthly_cost=Decimal('300.00'), + ) + + def test_child_links_to_master(self): + self.assertIsNone(self.master.location_id) + self.assertEqual(self.child.parent_contract, self.master) + self.assertIn(self.child, self.master.child_contracts.all()) + + def test_framework_cannot_have_parent(self): + contract = InternetContract( + provider=self.provider, + location=self.location, + contract_number='X', + service_type=self.service, + status=self.status, + start_date=date(2024, 1, 1), + is_framework=True, + parent_contract=self.master, + ) + from django.core.exceptions import ValidationError + with self.assertRaises(ValidationError): + contract.full_clean() + + def test_billing_tree_nests_child_under_framework(self): + rows = _build_billing_rows( + [self.master, self.child], + payments_by_contract={}, + ) + contract_rows = [r for r in rows if not r.get('is_provider_header')] + self.assertEqual(len(contract_rows), 2) + self.assertEqual(contract_rows[0]['contract'], self.master) + self.assertEqual(contract_rows[0]['indent'], 0) + self.assertTrue(contract_rows[0]['has_children']) + self.assertEqual(contract_rows[1]['contract'], self.child) + self.assertEqual(contract_rows[1]['indent'], 1) + + def test_billing_tree_infers_parent_by_contract_number_prefix(self): + orphan = InternetContract.objects.create( + provider=self.provider, + location=self.location, + contract_number='70805211', + service_type=self.service, + status=self.status, + start_date=date(2024, 1, 1), + monthly_cost=Decimal('400.00'), + ) + rows = _build_billing_rows( + [self.master, orphan], + payments_by_contract={}, + ) + contract_rows = [r for r in rows if not r.get('is_provider_header')] + self.assertEqual(len(contract_rows), 2) + self.assertEqual(contract_rows[1]['contract'], orphan) + self.assertEqual(contract_rows[1]['indent'], 1) + self.assertTrue(contract_rows[1]['is_inferred_child']) + + +class InternetContractAdminFormTests(TestCase): + def setUp(self): + self.status = _ensure_contract_status() + self.service = _ensure_service_type() + self.provider = InternetProvider.objects.create(name='WESTELECOM') + + def test_framework_contract_form_without_location(self): + form = InternetContractForm(data={ + 'provider': self.provider.pk, + 'location': '', + 'contract_number': '708052', + 'service_type': self.service.pk, + 'status': self.status.pk, + 'start_date': '2024-01-01', + 'is_framework': True, + }) + self.assertTrue(form.is_valid(), form.errors) + + def test_framework_contract_form_requires_start_date(self): + form = InternetContractForm(data={ + 'provider': self.provider.pk, + 'location': '', + 'contract_number': '708052', + 'service_type': self.service.pk, + 'status': self.status.pk, + 'is_framework': True, + }) + self.assertFalse(form.is_valid()) + self.assertIn('start_date', form.errors) + + +class InternetBillingPortalTests(TestCase): + def setUp(self): + self.status = _ensure_contract_status() + self.service = _ensure_service_type() + self.provider = InternetProvider.objects.create(name='Лекол') + self.location = _make_location(location_type='pharmacy', address='Pharmacy addr') + self.contract = InternetContract.objects.create( + provider=self.provider, + location=self.location, + contract_number='70805205', + service_type=self.service, + status=self.status, + start_date=date(2024, 1, 1), + monthly_cost=Decimal('500.00'), + ) + self.user = make_employee() + self.user.groups.add(Group.objects.get(name='Офисный сотрудник')) + for attr in ('_perm_cache', '_user_perm_cache', '_group_perm_cache'): + if hasattr(self.user, attr): + delattr(self.user, attr) + + def test_office_user_can_access_internet_billing_tab(self): + self.assertTrue(user_can_access(self.user, 'internet_billing')) + + def test_accounting_internet_tab_renders(self): + login_2fa(self.client, self.user) + response = self.client.get('/accounting/?tab=internet', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['accounting_active_tab'], 'internet') + contract_rows = [ + r for r in response.context['billing_rows'] + if not r.get('is_provider_header') + ] + self.assertEqual(len(contract_rows), 1) + + def test_mark_payment_paid(self): + login_2fa(self.client, self.user) + response = self.client.post( + '/accounting/internet-payment/', + { + 'month': '2026-09', + 'contract_id': self.contract.pk, + 'action': 'mark_paid', + }, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(response.status_code, 200) + payment = InternetContractPayment.objects.get(contract=self.contract, period=date(2026, 9, 1)) + self.assertTrue(payment.is_paid) + self.assertEqual(payment.amount, Decimal('500.00')) diff --git a/apps/it_manager/tests_monthly_report.py b/apps/it_manager/tests_monthly_report.py new file mode 100644 index 0000000..939c748 --- /dev/null +++ b/apps/it_manager/tests_monthly_report.py @@ -0,0 +1 @@ +"""Месячный IT-отчёт: тесты перенесены в apps.reports.tests.""" diff --git a/apps/it_manager/tests_report_import.py b/apps/it_manager/tests_report_import.py new file mode 100644 index 0000000..cef8925 --- /dev/null +++ b/apps/it_manager/tests_report_import.py @@ -0,0 +1,98 @@ +"""Тесты импорта report-tool → WorkLog.""" +from datetime import date +from pathlib import Path + +from django.contrib.auth.models import Group +from django.test import TestCase + +from apps.it_manager.models import WorkLog +from apps.it_manager.report_import import import_report_tool +from apps.it_manager.tests import make_employee +from apps.locations.models.pharmacies import Pharmacy +from core.models.directories import Directory, DirectoryType + + +class ReportToolImportTests(TestCase): + def setUp(self): + self.it_group, _ = Group.objects.get_or_create(name='IT') + self.babich = make_employee(last_name='Бабич', first_name='Виктор', username='babich.v') + self.aprodov = make_employee(last_name='Апродов', first_name='Артур', username='aprodov.a') + self.babich.groups.add(self.it_group) + self.aprodov.groups.add(self.it_group) + + region_type, _ = DirectoryType.objects.get_or_create(code='region', defaults={'name': 'Регион'}) + org_type, _ = DirectoryType.objects.get_or_create(code='organization', defaults={'name': 'Организация'}) + self.region = Directory.objects.create(directory_type=region_type, code='od-imp', name='Одесса-imp') + self.org = Directory.objects.create( + directory_type=org_type, code='akc-imp', name='АКС-imp', + extra_data={'mail_prefix': 'a'}, + ) + Pharmacy.objects.create( + organization=self.org, location_region=self.region, + pharmacy_number=12, location_type='pharmacy', + street='Тест', house_number='1', + ) + + self.fixture = Path(__file__).resolve().parent / 'fixtures' / 'report_tool_mini' + self.fixture.mkdir(parents=True, exist_ok=True) + (self.fixture / 'company.json').write_text( + '''{ + "directions": [ + {"id": "prro", "name": "ПРРО", "keywords": ["прро"]}, + {"id": "network", "name": "Сеть", "keywords": ["сет"]}, + {"id": "infra", "name": "Инфра", "keywords": ["proxmox"]} + ], + "staff": [] +}''', + encoding='utf-8', + ) + (self.fixture / 'data').mkdir(exist_ok=True) + (self.fixture / 'data' / '2026-05.json').write_text( + '''{ + "id": "2026-05", + "year": 2026, + "month": 5, + "employees": [ + { + "id": "aprodov", + "name": "Апродов Артур", + "tasks": [ + {"date": "01.05.2026", "object": "А12", "work": "Восстановление работы ПРРО"}, + {"date": "02.05.2026", "object": "ОФИС", "work": "Мониторинг загрузки баз"} + ], + "systems": [] + }, + { + "id": "babich", + "name": "Бабич Виктор Игоревич", + "tasks": [], + "systems": [ + {"name": "Proxmox-кластер", "work": "Мониторинг инфраструктуры (ежедневно)"} + ] + } + ] +}''', + encoding='utf-8', + ) + + def test_import_creates_worklogs(self): + stats = import_report_tool(root=self.fixture, create_missing_employees=False) + self.assertEqual(stats.created, 3) + self.assertEqual(WorkLog.objects.count(), 3) + log = WorkLog.objects.get(description='Восстановление работы ПРРО') + self.assertEqual(log.technician_id, self.aprodov.id) + self.assertEqual(log.date, date(2026, 5, 1)) + self.assertEqual(log.location_id, Pharmacy.objects.get(pharmacy_number=12).pk) + self.assertEqual(log.work_type.code, 'software') + self.assertEqual(log.result, 'import:report-tool:2026-05') + + sys_log = WorkLog.objects.get(description__startswith='Proxmox') + self.assertEqual(sys_log.technician_id, self.babich.id) + self.assertEqual(sys_log.date, date(2026, 5, 31)) + + def test_import_is_idempotent(self): + import_report_tool(root=self.fixture) + stats = import_report_tool(root=self.fixture) + self.assertEqual(stats.created, 0) + self.assertEqual(stats.skipped, 3) + self.assertEqual(WorkLog.objects.count(), 3) diff --git a/apps/it_manager/urls.py b/apps/it_manager/urls.py index ebbd133..fe18879 100644 --- a/apps/it_manager/urls.py +++ b/apps/it_manager/urls.py @@ -1,9 +1,32 @@ +from functools import partial + from django.urls import path +from apps.reports.views import redirect_legacy_it_report from . import views urlpatterns = [ path('it-manager/', views.it_manager_view, name='it_manager'), + path( + 'it-manager/report/.md', + partial(redirect_legacy_it_report, fmt='md'), + name='it_monthly_report_md', + ), + path( + 'it-manager/report/.docx', + partial(redirect_legacy_it_report, fmt='docx'), + name='it_monthly_report_docx', + ), + path( + 'it-manager/report/.pdf', + partial(redirect_legacy_it_report, fmt='pdf'), + name='it_monthly_report_pdf', + ), + path( + 'it-manager/report//preview/', + partial(redirect_legacy_it_report, fmt='preview'), + name='it_monthly_report_preview', + ), path('it-manager/equipment/models/create/', views.equipment_model_create_view, name='it_equipment_model_create'), path('it-manager/equipment/bulk-create/', views.equipment_bulk_create_view, name='it_equipment_bulk_create'), path('it-manager/equipment/bulk-assign/', views.equipment_bulk_assign_view, name='it_equipment_bulk_assign'), @@ -11,6 +34,7 @@ urlpatterns = [ path('it-manager/tickets//update/', views.ticket_update_view, name='it_ticket_update'), path('it-manager/tickets//comment/', views.ticket_comment_view, name='it_ticket_comment'), path('it-manager/worklog/create/', views.worklog_create_view, name='it_worklog_create'), + path('it-manager/worklog//update/', views.worklog_update_view, name='it_worklog_update'), path('it-manager/consumables/create/', views.consumable_create_view, name='it_consumable_create'), path('it-manager/consumables//update/', views.consumable_update_view, name='it_consumable_update'), path('it-manager/providers/create/', views.provider_create_view, name='it_provider_create'), diff --git a/apps/it_manager/views.py b/apps/it_manager/views.py index 7256d6b..60307c4 100644 --- a/apps/it_manager/views.py +++ b/apps/it_manager/views.py @@ -1,10 +1,12 @@ -from datetime import datetime +from datetime import date, datetime +import calendar from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError from django.db import models from django.db.models import Q, Sum -from django.http import HttpResponseForbidden +from django.http import HttpResponse, HttpResponseForbidden from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -13,14 +15,89 @@ from apps.it_manager.models import ( InternetContract, InternetProvider, ServiceCall, Ticket, TicketComment, WorkLog, ) +from apps.it_manager.monthly_report import ( + MONTHS_RU, + user_can_view_it_monthly_report, +) +from apps.reports.it_monthly import parse_month_id from apps.locations.models.base import Location +from apps.locations.models.pharmacies import Pharmacy from core.models.directories import Directory +from core.portal_access import deny_section_access from core.utils.htmx import render_page from core.utils.pagination import paginate Employee = get_user_model() +def _it_locations_qs(): + """Локации для селектов IT: аптеки с организацией (чтобы отличать №12 у разных юрлиц).""" + return ( + Location.objects.filter(is_active=True) + .select_related('location_region') + .prefetch_related( + models.Prefetch( + 'pharmacy', + queryset=Pharmacy.objects.select_related('organization'), + ), + ) + .order_by('location_type', 'address') + ) + + +def _split_it_locations(locations): + pharmacies, offices = [], [] + for loc in locations: + if loc.location_type == 'pharmacy': + pharmacies.append(loc) + else: + # office + warehouse → «Офис / отдел» + offices.append(loc) + return pharmacies, offices + + +def _infer_assign_target(equipment) -> str: + if equipment.assigned_to_id: + return 'employee' + if equipment.location_id: + loc = getattr(equipment, 'location', None) + if loc is not None and loc.location_type == 'pharmacy': + return 'pharmacy' + return 'office' + return '' + + +def _parse_assign_post(request) -> tuple[str, str | None, str | None, str | None]: + """assign_target, location_id, assigned_to_id, error_message.""" + target = (request.POST.get('assign_target') or '').strip() + location_id = request.POST.get('location_id') or None + assigned_to_id = request.POST.get('assigned_to_id') or None + + if not target: + return '', None, None, None + + if target == 'employee': + if not assigned_to_id: + return target, None, None, 'Оберіть співробітника.' + return target, None, assigned_to_id, None + + if target in ('pharmacy', 'office'): + if not location_id: + return target, None, None, ( + 'Оберіть аптеку.' if target == 'pharmacy' else 'Оберіть офіс / відділ.' + ) + loc = Location.objects.filter(pk=location_id, is_active=True).first() + if not loc: + return target, None, None, 'Локацію не знайдено.' + if target == 'pharmacy' and loc.location_type != 'pharmacy': + return target, None, None, 'Оберіть аптеку зі списку аптек.' + if target == 'office' and loc.location_type == 'pharmacy': + return target, None, None, 'Оберіть офіс / відділ зі списку.' + return target, str(loc.pk), None, None + + return target, None, None, 'Невідомий тип видачі.' + + def _is_it_staff(user): from core.portal_access import user_can_access return user_can_access(user, 'it_manager') @@ -53,6 +130,55 @@ def _resolve_directory(type_code, code): return Directory.objects.filter(directory_type__code=type_code, code=code).values_list('id', flat=True).first() +def _close_open_assignments(equipment): + """Закрити всі активні записи історії переміщень для одиниці.""" + EquipmentAssignment.objects.filter( + equipment=equipment, returned_at__isnull=True, + ).update(returned_at=timezone.now()) + + +def _apply_equipment_issue(unit, *, location_id=None, assigned_to_id=None, user=None, + set_location=False, set_assignee=False, force_assigned_status=True): + """Видача / переміщення одиниці з записом історії. + + set_location / set_assignee — чи оновлювати відповідне поле + (для bulk: міняємо лише те, що передали у формі). + """ + new_location = location_id if set_location else unit.location_id + new_assignee = assigned_to_id if set_assignee else unit.assigned_to_id + + loc_changed = str(unit.location_id or '') != str(new_location or '') + emp_changed = str(unit.assigned_to_id or '') != str(new_assignee or '') + if not loc_changed and not emp_changed: + return False + + _close_open_assignments(unit) + if set_location: + unit.location_id = location_id or None + if set_assignee: + unit.assigned_to_id = assigned_to_id or None + + has_target = bool(unit.location_id or unit.assigned_to_id) + if force_assigned_status and has_target: + assigned_status_id = _resolve_directory('equipment_status', 'assigned') + if assigned_status_id: + unit.status_id = assigned_status_id + elif not has_target: + stock_id = _resolve_directory('equipment_status', 'stock') + if stock_id: + unit.status_id = stock_id + + unit.save() + if has_target: + EquipmentAssignment.objects.create( + equipment=unit, + assigned_to_id=unit.assigned_to_id, + location_id=unit.location_id, + assigned_by=user, + ) + return True + + def _it_manager_context(request): equipment_qs = ( Equipment.objects.select_related( @@ -73,10 +199,22 @@ def _it_manager_context(request): .annotate(units_total=models.Count('units')) .order_by('equipment_type__name', 'brand__name', 'name') ) + locations_all = list(_it_locations_qs()) + it_pharmacies, it_offices = _split_it_locations(locations_all) tickets_qs = ( - Ticket.objects.select_related('requester', 'assignee', 'equipment', 'ticket_type', 'priority', 'status') - .prefetch_related('comments__author') + Ticket.objects.select_related( + 'requester', 'assignee', 'equipment', 'location', + 'ticket_type', 'priority', 'status', + ) + .prefetch_related( + 'comments__author', + models.Prefetch( + 'work_logs', + queryset=WorkLog.objects.select_related('technician', 'work_type').order_by('-date', '-created_at'), + ), + ) + .annotate(worklogs_count=models.Count('work_logs', distinct=True)) .order_by('-created_at') ) tickets = list(tickets_qs) @@ -87,14 +225,92 @@ def _it_manager_context(request): ticket_overdue_count = sum(1 for t in tickets if t.is_overdue) tickets_page = paginate(request, tickets_qs, page_param='tickets_page', size_param='tickets_per_page') + # Для формы журнала: сначала открытые заявки, потом остальные + open_ticket_codes = ('new', 'in_progress', 'waiting') + worklog_tickets = sorted( + tickets, + key=lambda t: (0 if t.status.code in open_ticket_codes else 1, -t.pk), + ) + month_start = timezone.localdate().replace(day=1) + worklog_technician_raw = (request.GET.get('worklog_technician') or '').strip() + worklog_technician_id = int(worklog_technician_raw) if worklog_technician_raw.isdigit() else None + + worklog_month_raw = (request.GET.get('worklog_month') or '').strip() + worklog_month_start = None + worklog_month_end = None + if worklog_month_raw and worklog_month_raw != 'all': + y, m = parse_month_id(worklog_month_raw) + last_day = calendar.monthrange(y, m)[1] + worklog_month_start = date(y, m, 1) + worklog_month_end = date(y, m, last_day) + worklog_month_id = f'{y:04d}-{m:02d}' + else: + worklog_month_id = 'all' + worklogs_qs = ( - WorkLog.objects.select_related('technician', 'location', 'equipment', 'ticket', 'work_type') + WorkLog.objects.select_related( + 'technician', 'location', 'equipment', 'ticket', 'work_type', + ) + .prefetch_related( + models.Prefetch( + 'location__pharmacy', + queryset=Pharmacy.objects.select_related('organization'), + ), + ) .order_by('-date', '-created_at') ) + if worklog_technician_id: + worklogs_qs = worklogs_qs.filter(technician_id=worklog_technician_id) + if worklog_month_start and worklog_month_end: + worklogs_qs = worklogs_qs.filter( + date__gte=worklog_month_start, date__lte=worklog_month_end, + ) worklogs_page = paginate(request, worklogs_qs, page_param='worklogs_page', size_param='worklogs_per_page') - month_hours = WorkLog.objects.filter(date__gte=month_start).aggregate(total=Sum('hours_spent'))['total'] or 0 - month_count = WorkLog.objects.filter(date__gte=month_start).count() + + # Статистика: выбранный месяц или текущий календарный + if worklog_month_start and worklog_month_end: + stats_start, stats_end = worklog_month_start, worklog_month_end + worklog_stats_label = ( + f'{MONTHS_RU[worklog_month_start.month]} {worklog_month_start.year}' + ) + else: + stats_start, stats_end = month_start, None + worklog_stats_label = 'этом месяце' + + month_logs = WorkLog.objects.filter(date__gte=stats_start) + if stats_end is not None: + month_logs = month_logs.filter(date__lte=stats_end) + if worklog_technician_id: + month_logs = month_logs.filter(technician_id=worklog_technician_id) + month_hours = month_logs.aggregate(total=Sum('hours_spent'))['total'] or 0 + month_count = month_logs.count() + + worklog_month_choices = [] + seen_months = set() + for d in WorkLog.objects.dates('date', 'month', order='DESC'): + mid = f'{d.year:04d}-{d.month:02d}' + seen_months.add(mid) + worklog_month_choices.append((mid, f'{MONTHS_RU[d.month]} {d.year}')) + today = timezone.localdate() + current_mid = f'{today.year:04d}-{today.month:02d}' + if current_mid not in seen_months: + worklog_month_choices.insert( + 0, (current_mid, f'{MONTHS_RU[today.month]} {today.year}'), + ) + + worklog_technicians = ( + Employee.objects.filter(is_active=True) + .filter( + Q(pk__in=WorkLog.objects.values('technician_id')) + | Q(groups__name='IT') + | Q(is_staff=True) + | Q(is_superuser=True) + ) + .distinct() + .order_by('last_name', 'first_name') + ) + can_edit_worklogs = user_can_view_it_monthly_report(request.user) consumables_qs = ( Consumable.objects.select_related('consumable_type', 'color', 'status', 'equipment', 'location') @@ -108,8 +324,11 @@ def _it_manager_context(request): providers = list(InternetProvider.objects.order_by('name')) contracts_qs = ( - InternetContract.objects.select_related('provider', 'location', 'service_type', 'connection_type', 'status') - .order_by('location__address', 'provider__name') + InternetContract.objects.select_related( + 'provider', 'location', 'service_type', 'connection_type', 'status', 'parent_contract', + ) + .prefetch_related('child_contracts') + .order_by('provider__name', 'contract_number') ) contracts = list(contracts_qs) contracts_expiring_count = sum(1 for c in contracts if c.is_expiring_soon) @@ -132,16 +351,14 @@ def _it_manager_context(request): 'equipment_statuses': equipment_statuses, 'equipment_brands': Directory.objects.filter(directory_type__code='equipment_brand', is_active=True).order_by('name'), 'equipment_models': equipment_models, - 'it_locations': ( - Location.objects.filter(is_active=True) - .select_related('location_region') - .prefetch_related('pharmacy') - .order_by('location_type', 'address') - ), + 'it_locations': locations_all, + 'it_pharmacies': it_pharmacies, + 'it_offices': it_offices, 'it_employees': Employee.objects.filter(is_active=True).order_by('last_name', 'first_name'), 'tickets': tickets, 'tickets_page': tickets_page, + 'worklog_tickets': worklog_tickets, 'ticket_counts': ticket_counts, 'ticket_overdue_count': ticket_overdue_count, 'ticket_statuses': ticket_statuses, @@ -151,6 +368,12 @@ def _it_manager_context(request): 'worklogs_page': worklogs_page, 'worklog_types': _directory_choices('worklog_type'), + 'worklog_technicians': worklog_technicians, + 'worklog_technician_id': worklog_technician_id, + 'worklog_month_id': worklog_month_id, + 'worklog_month_choices': worklog_month_choices, + 'worklog_stats_label': worklog_stats_label, + 'it_can_edit_worklogs': can_edit_worklogs, 'month_hours': month_hours, 'month_count': month_count, @@ -169,18 +392,38 @@ def _it_manager_context(request): 'contract_service_types': _directory_choices('contract_service_type'), 'contract_connection_types': _directory_choices('contract_connection_type'), 'contract_statuses': _directory_choices('contract_status'), + 'framework_contracts': list( + InternetContract.objects.filter(is_framework=True) + .select_related('provider') + .order_by('provider__name', 'contract_number') + ), 'service_calls_page': service_calls_page, 'service_calls_count': service_calls_count, 'servicecall_channels': _directory_choices('servicecall_channel'), + + 'it_can_view_monthly_report': user_can_view_it_monthly_report(request.user), } @login_required def it_manager_view(request): if not _is_it_staff(request.user): - return HttpResponseForbidden() - return render_page(request, 'it-manager.html', _it_manager_context(request)) + return deny_section_access(request) + context = _it_manager_context(request) + tab = (request.GET.get('tab') or '').strip() + if tab in { + 'equipment', 'tickets', 'worklog', 'consumables', + 'providers', 'servicecalls', + }: + context['it_active_tab'] = tab + elif ( + 'worklog_technician' in request.GET + or 'worklog_month' in request.GET + or request.GET.get('worklogs_page') + ): + context['it_active_tab'] = 'worklog' + return render_page(request, 'it-manager.html', context) @login_required @@ -253,9 +496,18 @@ def equipment_bulk_create_view(request): warranty_until = d.replace(year=year, month=month % 12 + 1, day=day) except ValueError: warranty_until = None - location_id = request.POST.get('location_id') or None - # якщо одразу видаємо на локацію — статус assigned, інакше stock - status_code = 'assigned' if location_id else 'stock' + location_id = None + assigned_to_id = None + target, location_id, assigned_to_id, assign_err = _parse_assign_post(request) + if assign_err: + context_extra['equipment_form_error'] = assign_err + context = _it_manager_context(request) + context['it_active_tab'] = 'equipment' + context.update(context_extra) + return render_page(request, 'it-manager.html', context) + + # якщо одразу видаємо — статус assigned, інакше stock + status_code = 'assigned' if (location_id or assigned_to_id) else 'stock' status_id = _resolve_directory('equipment_status', status_code) \ or _resolve_directory('equipment_status', 'stock') @@ -267,17 +519,27 @@ def equipment_bulk_create_view(request): for s in serials: if s in existing: continue - Equipment.objects.create( + unit = Equipment.objects.create( equipment_model=model, serial_number=s, status_id=status_id, location_id=location_id, + assigned_to_id=assigned_to_id, purchase_date=purchase_date, warranty_until=warranty_until, ) + if location_id or assigned_to_id: + EquipmentAssignment.objects.create( + equipment=unit, + location_id=location_id, + assigned_to_id=assigned_to_id, + assigned_by=request.user, + ) created += 1 skipped = len(serials) - created msg = f'Додано {created} од. «{model}»' + if (location_id or assigned_to_id) and created: + msg += ' і видано' if skipped: msg += f'; пропущено {skipped} (серійник вже існує)' context_extra['equipment_form_message'] = msg @@ -290,45 +552,34 @@ def equipment_bulk_create_view(request): @login_required def equipment_bulk_assign_view(request): - """Масова видача: відмічені екземпляри → локація (та/або співробітник) - за один крок, з записом у історію переміщень.""" + """Масова видача: тип (аптека / офіс / співробітник) → список.""" if not _is_it_staff(request.user): return HttpResponseForbidden() context_extra = {} if request.method == 'POST': ids = request.POST.getlist('equipment_ids') - location_id = request.POST.get('location_id') or None - assigned_to_id = request.POST.get('assigned_to_id') or None + target, location_id, assigned_to_id, assign_err = _parse_assign_post(request) if not ids: context_extra['equipment_form_error'] = 'Відмітьте хоча б один екземпляр.' - elif not location_id and not assigned_to_id: - context_extra['equipment_form_error'] = 'Оберіть локацію або співробітника для видачі.' + elif not target: + context_extra['equipment_form_error'] = 'Оберіть тип видачі.' + elif assign_err: + context_extra['equipment_form_error'] = assign_err else: - assigned_status_id = _resolve_directory('equipment_status', 'assigned') units = Equipment.objects.filter(pk__in=ids) count = 0 for unit in units: - # закриваємо попередню активну видачу, якщо була - if unit.assigned_to_id: - EquipmentAssignment.objects.filter( - equipment=unit, assigned_to_id=unit.assigned_to_id, returned_at__isnull=True, - ).update(returned_at=timezone.now()) - if location_id: - unit.location_id = location_id - if assigned_to_id: - unit.assigned_to_id = assigned_to_id - if assigned_status_id: - unit.status_id = assigned_status_id - unit.save() - EquipmentAssignment.objects.create( - equipment=unit, - assigned_to_id=assigned_to_id, + if _apply_equipment_issue( + unit, location_id=location_id, - assigned_by=request.user, - ) - count += 1 + assigned_to_id=assigned_to_id, + user=request.user, + set_location=True, + set_assignee=True, + ): + count += 1 context_extra['equipment_form_message'] = f'Видано {count} од. обладнання' context = _it_manager_context(request) @@ -345,24 +596,41 @@ def equipment_update_view(request, pk): if request.method == 'POST': equipment = get_object_or_404(Equipment, pk=pk) - new_status_id = _resolve_directory('equipment_status', request.POST.get('status')) - if new_status_id: - equipment.status_id = new_status_id + new_status_code = request.POST.get('status') or '' + new_status_id = _resolve_directory('equipment_status', new_status_code) + target, location_id, assigned_to_id, assign_err = _parse_assign_post(request) - new_assigned_to = request.POST.get('assigned_to_id') or None - if str(equipment.assigned_to_id or '') != str(new_assigned_to or ''): - if equipment.assigned_to_id: - EquipmentAssignment.objects.filter( - equipment=equipment, assigned_to_id=equipment.assigned_to_id, returned_at__isnull=True, - ).update(returned_at=timezone.now()) - equipment.assigned_to_id = new_assigned_to - if new_assigned_to: - EquipmentAssignment.objects.create( - equipment=equipment, assigned_to_id=new_assigned_to, - location=equipment.location, assigned_by=request.user, - ) - - equipment.save() + # Повернення на склад: статус stock або порожній тип видачі + if new_status_code == 'stock' or not target: + _close_open_assignments(equipment) + if new_status_code == 'stock' and new_status_id: + equipment.status_id = new_status_id + else: + stock_id = _resolve_directory('equipment_status', 'stock') + if stock_id: + equipment.status_id = stock_id + equipment.location_id = None + equipment.assigned_to_id = None + equipment.save() + elif assign_err: + context = _it_manager_context(request) + context['it_active_tab'] = 'equipment' + context['equipment_form_error'] = assign_err + return render_page(request, 'it-manager.html', context) + else: + _apply_equipment_issue( + equipment, + location_id=location_id, + assigned_to_id=assigned_to_id, + user=request.user, + set_location=True, + set_assignee=True, + force_assigned_status=True, + ) + equipment.refresh_from_db() + if new_status_id and new_status_code not in ('', 'stock') and equipment.status.code != new_status_code: + equipment.status_id = new_status_id + equipment.save(update_fields=['status_id', 'updated_at']) context = _it_manager_context(request) context['it_active_tab'] = 'equipment' @@ -413,11 +681,18 @@ def worklog_create_view(request): if not _is_it_staff(request.user): return HttpResponseForbidden() + worklog_error = '' + worklog_message = '' if request.method == 'POST': description = request.POST.get('description', '').strip() - work_type_id = _resolve_directory('worklog_type', request.POST.get('work_type', '')) + work_type_raw = request.POST.get('work_type', '') + work_type_id = _resolve_directory('worklog_type', work_type_raw) - if description and work_type_id: + if not description: + worklog_error = 'Укажите описание работ.' + elif not work_type_id or work_type_raw == '__new__': + worklog_error = 'Выберите вид работ.' + else: try: log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date() except ValueError: @@ -427,23 +702,104 @@ def worklog_create_view(request): except ValueError: hours_spent = None - WorkLog.objects.create( - date=log_date, - technician=request.user, - work_type_id=work_type_id, - location_id=request.POST.get('location_id') or None, - equipment_id=request.POST.get('equipment_id') or None, - ticket_id=request.POST.get('ticket_id') or None, - description=description, - result=request.POST.get('result', '').strip(), - hours_spent=hours_spent, - ) + location_id = request.POST.get('location_id') or None + equipment_id = request.POST.get('equipment_id') or None + ticket_id = request.POST.get('ticket_id') or None + + try: + WorkLog.objects.create( + date=log_date, + technician=request.user, + work_type_id=work_type_id, + location_id=location_id, + equipment_id=equipment_id, + ticket_id=ticket_id, + description=description, + result=request.POST.get('result', '').strip(), + hours_spent=hours_spent, + ) + except Exception: + worklog_error = 'Не удалось сохранить запись. Проверьте локацию, заявку и оборудование.' + else: + if ticket_id: + worklog_message = 'Работа привязана к заявке и сохранена.' + else: + worklog_message = 'Запись о выполненной работе сохранена.' context = _it_manager_context(request) context['it_active_tab'] = 'worklog' + if worklog_message: + context['it_worklog_message'] = worklog_message + if worklog_error: + context['it_worklog_error'] = worklog_error return render_page(request, 'it-manager.html', context) +@login_required +def worklog_update_view(request, pk): + """Inline-редактирование записи журнала — только руководителю отдела.""" + if not _is_it_staff(request.user): + return HttpResponseForbidden() + if not user_can_view_it_monthly_report(request.user): + return HttpResponseForbidden() + if request.method != 'POST': + return HttpResponseForbidden() + + log = get_object_or_404(WorkLog, pk=pk) + description = request.POST.get('description', '').strip() + work_type_raw = request.POST.get('work_type', '') + work_type_id = _resolve_directory('worklog_type', work_type_raw) + + if not description: + return HttpResponse( + 'Нужно описание', + status=400, + ) + if not work_type_id or work_type_raw == '__new__': + return HttpResponse( + 'Укажите вид работ', + status=400, + ) + + try: + log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date() + except ValueError: + log_date = log.date + try: + hours_raw = (request.POST.get('hours_spent') or '').strip() + hours_spent = float(hours_raw) if hours_raw else None + if hours_spent == 0: + hours_spent = None + except ValueError: + hours_spent = log.hours_spent + + technician_raw = (request.POST.get('technician_id') or '').strip() + technician_id = int(technician_raw) if technician_raw.isdigit() else log.technician_id + if not Employee.objects.filter(pk=technician_id, is_active=True).exists(): + technician_id = log.technician_id + + location_raw = (request.POST.get('location_id') or '').strip() + location_id = int(location_raw) if location_raw.isdigit() else None + + log.date = log_date + log.technician_id = technician_id + log.work_type_id = work_type_id + log.description = description + log.result = request.POST.get('result', '').strip() + log.hours_spent = hours_spent + log.location_id = location_id + try: + log.save() + except Exception: + return HttpResponse( + 'Ошибка сохранения', + status=400, + ) + return HttpResponse( + 'Сохранено' + ) + + @login_required def consumable_create_view(request): if not _is_it_staff(request.user): @@ -528,9 +884,12 @@ def contract_create_view(request): contract_number = request.POST.get('contract_number', '').strip() start_date = request.POST.get('start_date') or None service_type_id = _resolve_directory('contract_service_type', request.POST.get('service_type', '')) + is_framework = bool(request.POST.get('is_framework')) + location_id = None if is_framework else (request.POST.get('location_id') or None) - if provider_id and location_id and contract_number and start_date and service_type_id: - InternetContract.objects.create( + if provider_id and contract_number and start_date and service_type_id and (is_framework or location_id): + parent_id = request.POST.get('parent_contract_id') or None + contract = InternetContract( provider_id=provider_id, location_id=location_id, contract_number=contract_number, @@ -544,7 +903,14 @@ def contract_create_view(request): account_number=request.POST.get('account_number', '').strip(), static_ips=request.POST.get('static_ips', '').strip() if request.POST.get('dedicated_ip') else '', notes=request.POST.get('notes', '').strip(), + is_framework=is_framework, + parent_contract_id=parent_id if not is_framework else None, ) + try: + contract.full_clean() + contract.save() + except ValidationError: + pass context = _it_manager_context(request) context['it_active_tab'] = 'providers' @@ -561,7 +927,21 @@ def contract_update_view(request, pk): new_status_id = _resolve_directory('contract_status', request.POST.get('status')) if new_status_id: contract.status_id = new_status_id - contract.save() + parent_id = request.POST.get('parent_contract_id') + if parent_id is not None: + contract.parent_contract_id = parent_id or None + if 'is_framework' in request.POST: + contract.is_framework = bool(request.POST.get('is_framework')) + if contract.is_framework: + contract.parent_contract_id = None + contract.location_id = None + elif contract.is_framework: + contract.location_id = None + try: + contract.full_clean() + contract.save() + except ValidationError: + pass context = _it_manager_context(request) context['it_active_tab'] = 'providers' diff --git a/apps/reports/__init__.py b/apps/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reports/access.py b/apps/reports/access.py new file mode 100644 index 0000000..a394173 --- /dev/null +++ b/apps/reports/access.py @@ -0,0 +1,18 @@ +"""Права на шаблоны отчётов.""" +from __future__ import annotations + +from apps.reports.it_monthly.builder import user_can_view_it_monthly_report +from apps.reports.models import ReportTemplate + + +def user_can_manage_templates(user) -> bool: + """Список/копирование/правка несистемных — руководитель IT или superuser.""" + return user_can_view_it_monthly_report(user) + + +def user_can_edit_template(user, tpl: ReportTemplate) -> bool: + if not user_can_manage_templates(user): + return False + if tpl.is_system and not getattr(user, 'is_superuser', False): + return False + return True diff --git a/apps/reports/admin.py b/apps/reports/admin.py new file mode 100644 index 0000000..bd69e56 --- /dev/null +++ b/apps/reports/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from apps.reports.models import ReportTemplate + + +@admin.register(ReportTemplate) +class ReportTemplateAdmin(admin.ModelAdmin): + list_display = ['code', 'name', 'report_type', 'engine', 'is_default', 'is_system', 'is_active'] + list_filter = ['report_type', 'engine', 'is_default', 'is_system', 'is_active'] + search_fields = ['code', 'name'] + readonly_fields = ['created_at', 'updated_at'] diff --git a/apps/reports/apps.py b/apps/reports/apps.py new file mode 100644 index 0000000..23509a8 --- /dev/null +++ b/apps/reports/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ReportsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.reports' + verbose_name = 'Отчёты' diff --git a/apps/reports/classic_seed.py b/apps/reports/classic_seed.py new file mode 100644 index 0000000..205e107 --- /dev/null +++ b/apps/reports/classic_seed.py @@ -0,0 +1,179 @@ +"""Текст классического IT-шаблона (seed).""" + +CLASSIC_HTML = r""" + + + +{{ labels.main_title }} — {{ month_title }} + + + +
+
{{ company.company }}
+
{{ company.department }}
+

{{ labels.main_title }}

+
{{ month_title }}
+ + + + + + + + +
ПоказательЗначение
{{ 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 }}
+ +

1. {{ labels.staff_heading }}

+
    + {% for emp in employees %} +
  • {{ emp.name }}{% if emp.role %} — {{ emp.role }}{% endif %}
  • + {% empty %} +
  • Нет данных
  • + {% endfor %} +
+ +

2. {{ labels.summary_heading }}

+ {% if report.summary %} +
    + {% for row in report.summary %} +
  • {{ row.name }} — {{ row.count }}{% if row.people %} ({{ row.people }}){% endif %}
  • + {% endfor %} +
+ {% else %} +

Нет записей за месяц.

+ {% endif %} + +

3. {{ 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 %} + +

4. Выполненные работы по сотрудникам

+ {% 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 %} + {% if emp.tasks %} + + + + {% for task in emp.tasks %} + + + + + + {% endfor %} + +
ДатаОбъектРаботы
{{ task.date }}{{ task.object|default:"—" }} + {% if task.work_items|length > 1 %} +
    {% for item in task.work_items %}
  • {{ item }}
  • {% endfor %}
+ {% else %} + {{ task.work }} + {% endif %} +
+ {% endif %} +
+ {% endif %} + {% empty %} +

Нет данных.

+ {% endfor %} + + {% if conclusions %} +

5. {{ labels.conclusions_heading }}

+ {% for item in conclusions %} +

{{ item.text }}

+ {% if item.bullets %} +
    {% for b in item.bullets %}
  • {{ b }}
  • {% endfor %}
+ {% endif %} + {% endfor %} + {% endif %} + +

Подготовлено: {{ prepared_date }}

+
+ + +""" + +CLASSIC_MD = r"""# IT-отчёт — {{ report.title }} + +**Период:** {{ report.period_from }} – {{ report.period_to }} +**Всего работ:** {{ report.total_tasks }} +**Активных сотрудников:** {{ report.people_active }} +**Объектов:** {{ report.objects_count }} + +## {{ labels.summary_heading }} + +{% for row in report.summary %}- **{{ row.name }}** — {{ row.count }}{% if row.people %} ({{ row.people }}){% endif %} +{% empty %}_Нет записей за месяц._ +{% endfor %} +{% if report.pharmacies or report.other_points %} +## {{ 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 %}{% endif %} +{% 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 %}{% endfor %} +{% if conclusions %} +## {{ labels.conclusions_heading }} + +{% for item in conclusions %}{{ item.text }} +{% for b in item.bullets %} - {{ b }} +{% endfor %} +{% endfor %}{% endif %} +""" + +CLASSIC_BLOCKS = { + 'labels': { + 'main_title': 'ОТЧЁТ О ВЫПОЛНЕННЫХ РАБОТАХ', + 'summary_heading': 'Сводка по направлениям', + 'objects_heading': 'Объекты', + 'staff_heading': 'Состав IT-отдела', + 'employees_heading': 'Выполненные работы по сотрудникам', + 'conclusions_heading': 'Основные выводы и рекомендации', + 'period_label': 'Отчётный период', + 'staff_label': 'Состав отдела', + 'objects_label': 'Охвачено объектов', + 'work_mode_label': 'Режим работы', + 'tasks_label': 'Всего задач', + 'pharmacies_label': 'Аптеки', + 'other_label': 'Другие объекты', + 'extra_label': 'Прочее', + }, + 'sections': [ + 'stats', 'staff', 'summary', 'objects', 'employees', 'conclusions', 'signature', + ], +} + +# Системный классический шаблон работает через блочный конструктор +CLASSIC_ENGINE = 'blocks_v1' + diff --git a/apps/reports/engine/__init__.py b/apps/reports/engine/__init__.py new file mode 100644 index 0000000..48b101f --- /dev/null +++ b/apps/reports/engine/__init__.py @@ -0,0 +1,25 @@ +from .blocks_v1 import ( + DEFAULT_SECTIONS, + LABEL_FIELDS, + SECTION_CATALOG, + labels_for_ui, + normalize_blocks, + sections_for_ui, +) +from .context import DEFAULT_LABELS, build_template_context +from .django_tpl import TemplateRenderError, render_django_string +from .render import render_report + +__all__ = [ + 'DEFAULT_LABELS', + 'DEFAULT_SECTIONS', + 'LABEL_FIELDS', + 'SECTION_CATALOG', + 'TemplateRenderError', + 'build_template_context', + 'labels_for_ui', + 'normalize_blocks', + 'render_django_string', + 'render_report', + 'sections_for_ui', +] diff --git a/apps/reports/engine/blocks_v1.py b/apps/reports/engine/blocks_v1.py new file mode 100644 index 0000000..62bd0fb --- /dev/null +++ b/apps/reports/engine/blocks_v1.py @@ -0,0 +1,308 @@ +"""Блочный конструктор 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 = """ +
{{ company.company }}
+
{{ company.department }}
+

{{ labels.main_title }}

+
{{ month_title }}
+""" + +_SECTION_HTML = { + '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 }}

+ {% if report.summary %} +
    + {% for row in report.summary %} +
  • {{ row.name }} — {{ row.count }}{% if row.people %} ({{ row.people }}){% endif %}
  • + {% endfor %} +
+ {% else %} +

Нет записей за месяц.

+ {% endif %} +""", + '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 %} + {% if emp.tasks %} + + + + {% for task in emp.tasks %} + + + + + + {% endfor %} + +
ДатаОбъектРаботы
{{ task.date }}{{ task.object|default:"—" }} + {% if task.work_items|length > 1 %} +
    {% for item in task.work_items %}
  • {{ item }}
  • {% endfor %}
+ {% else %} + {{ task.work }} + {% endif %} +
+ {% endif %} +
+ {% endif %} + {% empty %} +

Нет данных.

+ {% endfor %} +""", + 'conclusions': """ + {% if conclusions %} +

{{ section_num }}. {{ labels.conclusions_heading }}

+ {% for item in conclusions %} +

{{ item.text }}

+ {% if item.bullets %} +
    {% for b in item.bullets %}
  • {{ b }}
  • {% endfor %}
+ {% endif %} + {% endfor %} + {% endif %} +""", + 'signature': """ +

Подготовлено: {{ prepared_date }}

+""", +} + +_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 ( + '\n' + f'{ctx["labels"].get("main_title", "")} — {ctx.get("month_title", "")}' + f'
{inner}
' + ) + + +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', +] diff --git a/apps/reports/engine/context.py b/apps/reports/engine/context.py new file mode 100644 index 0000000..edee76a --- /dev/null +++ b/apps/reports/engine/context.py @@ -0,0 +1,64 @@ +"""Контекст для шаблонов IT-месячного отчёта. + +Доступные переменные в Django-шаблоне: + report, company, month, stats, month_title, prepared_date, labels, + employees, conclusions +""" +from __future__ import annotations + +from apps.it_manager.report_import import default_report_tool_root, load_company +from apps.reports.it_monthly.docx import ( + default_prepared_date, + month_title, + report_as_month, + stats_from_report, +) + + +DEFAULT_LABELS = { + 'main_title': 'ОТЧЁТ О ВЫПОЛНЕННЫХ РАБОТАХ', + 'summary_heading': 'Сводка по направлениям', + 'objects_heading': 'Объекты', + 'staff_heading': 'Состав IT-отдела', + 'employees_heading': 'Выполненные работы по сотрудникам', + 'conclusions_heading': 'Основные выводы и рекомендации', + 'period_label': 'Отчётный период', + 'staff_label': 'Состав отдела', + 'objects_label': 'Охвачено объектов', + 'work_mode_label': 'Режим работы', + 'tasks_label': 'Всего задач', + 'pharmacies_label': 'Аптеки', + 'other_label': 'Другие объекты', + 'extra_label': 'Прочее', +} + + +def load_default_company() -> dict: + root = default_report_tool_root() + if (root / 'company.json').exists(): + return load_company(root) + return { + 'company': 'АКС-Аптека', + 'department': 'Отдел информационных технологий', + 'prepared_by': {'name': '', 'role': ''}, + 'work_mode': 'Ежедневно (включая выходные)', + 'staff': [], + } + + +def build_template_context(report: dict, *, company: dict | None = None, labels: dict | None = None) -> dict: + company = company or load_default_company() + month = report_as_month(report, company) + stats = stats_from_report(report, month) + merged_labels = {**DEFAULT_LABELS, **(labels or {})} + return { + 'report': report, + 'company': company, + 'month': month, + 'stats': stats, + 'month_title': month_title(report['year'], report['month']), + 'prepared_date': default_prepared_date(report['year'], report['month']), + 'labels': merged_labels, + 'employees': report.get('employees') or [], + 'conclusions': report.get('conclusions') or [], + } diff --git a/apps/reports/engine/django_tpl.py b/apps/reports/engine/django_tpl.py new file mode 100644 index 0000000..5c1c44a --- /dev/null +++ b/apps/reports/engine/django_tpl.py @@ -0,0 +1,24 @@ +"""Рендер строковых Django-шаблонов (без Jinja2 — уже есть в Django).""" +from __future__ import annotations + +from django.template import Context, Engine, TemplateSyntaxError + + +class TemplateRenderError(ValueError): + """Ошибка синтаксиса или рендера шаблона.""" + + +_engine = Engine( + debug=False, + string_if_invalid='', +) + + +def render_django_string(source: str, context: dict) -> str: + try: + template = _engine.from_string(source or '') + return template.render(Context(context, autoescape=True)) + except TemplateSyntaxError as exc: + raise TemplateRenderError(str(exc)) from exc + except Exception as exc: # noqa: BLE001 — отдаём пользователю понятный текст + raise TemplateRenderError(str(exc)) from exc diff --git a/apps/reports/engine/render.py b/apps/reports/engine/render.py new file mode 100644 index 0000000..2755504 --- /dev/null +++ b/apps/reports/engine/render.py @@ -0,0 +1,62 @@ +"""Единая точка: ReportTemplate + report dict → html | md | docx | pdf.""" +from __future__ import annotations + +from apps.reports.engine.blocks_v1 import render_blocks_html, render_blocks_md +from apps.reports.engine.context import DEFAULT_LABELS, build_template_context, load_default_company +from apps.reports.engine.django_tpl import TemplateRenderError, render_django_string +from apps.reports.it_monthly import report_to_markdown +from apps.reports.it_monthly.docx import report_to_docx, report_to_preview_html +from apps.reports.it_monthly.pdf import report_to_pdf +from apps.reports.models import ReportTemplate + + +def _labels_from_template(tpl: ReportTemplate | None) -> dict: + if not tpl: + return dict(DEFAULT_LABELS) + blocks = tpl.blocks or {} + labels = blocks.get('labels') if isinstance(blocks, dict) else None + if isinstance(labels, dict): + return {**DEFAULT_LABELS, **labels} + return dict(DEFAULT_LABELS) + + +def render_report( + report: dict, + tpl: ReportTemplate | None, + fmt: str, +) -> str | bytes: + """ + fmt: 'html' | 'md' | 'docx' | 'pdf' + blocks_v1 — сборка из секций; django_html — body_html/body_md. + DOCX/PDF пока builtin-сборщики. + """ + company = load_default_company() + labels = _labels_from_template(tpl) + ctx = build_template_context(report, company=company, labels=labels) + + if fmt == 'html': + if tpl and tpl.engine == ReportTemplate.ENGINE_BLOCKS_V1: + return render_blocks_html(ctx, tpl.blocks) + if tpl and tpl.engine == ReportTemplate.ENGINE_DJANGO_HTML and (tpl.body_html or '').strip(): + return render_django_string(tpl.body_html, ctx) + html = report_to_preview_html(report, company) + if labels.get('main_title') and labels['main_title'] != DEFAULT_LABELS['main_title']: + html = html.replace(DEFAULT_LABELS['main_title'], labels['main_title'], 1) + return html + + if fmt == 'md': + if tpl and tpl.engine == ReportTemplate.ENGINE_BLOCKS_V1: + return render_blocks_md(ctx, tpl.blocks) + if tpl and tpl.engine == ReportTemplate.ENGINE_DJANGO_HTML and (tpl.body_md or '').strip(): + return render_django_string(tpl.body_md, ctx) + return report_to_markdown(report) + + if fmt == 'docx': + return report_to_docx(report, company) + if fmt == 'pdf': + return report_to_pdf(report, company) + + raise ValueError(f'Unknown format: {fmt}') + + +__all__ = ['TemplateRenderError', 'render_report'] diff --git a/apps/reports/it_monthly/__init__.py b/apps/reports/it_monthly/__init__.py new file mode 100644 index 0000000..efa2cf0 --- /dev/null +++ b/apps/reports/it_monthly/__init__.py @@ -0,0 +1,19 @@ +"""IT monthly report builders and exporters.""" + +from .builder import ( + MONTHS_RU, + available_report_months, + build_monthly_report, + parse_month_id, + report_to_markdown, + user_can_view_it_monthly_report, +) + +__all__ = [ + 'MONTHS_RU', + 'available_report_months', + 'build_monthly_report', + 'parse_month_id', + 'report_to_markdown', + 'user_can_view_it_monthly_report', +] diff --git a/apps/reports/it_monthly/builder.py b/apps/reports/it_monthly/builder.py new file mode 100644 index 0000000..88191e0 --- /dev/null +++ b/apps/reports/it_monthly/builder.py @@ -0,0 +1,338 @@ +"""Месячный IT-отчёт из WorkLog (логика как в report-tool).""" +from __future__ import annotations + +import calendar +import re +from collections import Counter, defaultdict +from datetime import date + +from django.contrib.auth import get_user_model +from django.utils import timezone + +from apps.it_manager.models import WorkLog +from apps.it_manager.report_import import default_report_tool_root, load_company, normalize_name +from apps.locations.models.base import pharmacy_object_code +from apps.reports.it_monthly.conclusions import build_conclusions + +Employee = get_user_model() + +MONTHS_RU = [ + '', 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', + 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь', +] + +INFRA_DIRECTION = 'Администрирование серверов, БД, мониторинг и безопасность' +SYSTEM_DESC_RE = re.compile(r'^([^:]{3,80}):\s+(.+)$', re.DOTALL) +# Коды аптек по юрлицам: А12, П10, Т5, Г3, Л1 (лат./кир.) +_PHARMACY_OBJECT_RE = re.compile(r'^[АAПPТTГGЛL]\d+$', re.I) +_OBJECT_LAT_TO_CYR = str.maketrans('APTGL', 'АПТГЛ') + + +def _is_pharmacy_object_code(code: str) -> bool: + raw = (code or '').strip().upper().translate(_OBJECT_LAT_TO_CYR) + return bool(_PHARMACY_OBJECT_RE.match(raw)) + + +def split_work_items(text: str) -> list[str]: + """Длинные журналы вида «а; б; в» → отдельные пункты для читаемого UI.""" + text = re.sub(r'\s+', ' ', (text or '').strip()) + if not text: + return [] + parts = [p.strip(' .;') for p in re.split(r'\s*;\s*', text) if p.strip(' .;')] + if len(parts) <= 1: + return [text] + return [p[0].upper() + p[1:] if p else p for p in parts] + + +def department_head(department_id) -> Employee | None: + """Керівник відділу — та сама евристика, що на /structure/.""" + if not department_id: + return None + emps = list( + Employee.objects.filter( + is_active=True, + department_id=department_id, + user_location_type='office', + ) + ) + ids = {e.pk for e in emps} + managers_in_dept = {e.manager_id for e in emps if e.manager_id in ids} + candidates = [e for e in emps if e.pk in managers_in_dept] + if not candidates: + return None + return next((e for e in candidates if e.manager_id not in ids), None) or candidates[0] + + +def user_can_view_it_monthly_report(user) -> bool: + """Месячный отчёт — только руководителю отдела (или superuser).""" + if not getattr(user, 'is_authenticated', False): + return False + if getattr(user, 'is_superuser', False): + return True + head = department_head(getattr(user, 'department_id', None)) + return head is not None and head.pk == user.pk + + +def parse_month_id(value: str | None) -> tuple[int, int]: + today = timezone.localdate() + if not value: + return today.year, today.month + m = re.fullmatch(r'(\d{4})-(\d{2})', (value or '').strip()) + if not m: + return today.year, today.month + year, month = int(m.group(1)), int(m.group(2)) + if not (1 <= month <= 12): + return today.year, today.month + return year, month + + +def month_id(year: int, month: int) -> str: + return f'{year:04d}-{month:02d}' + + +def available_report_months(limit: int = 24) -> list[str]: + """YYYY-MM из журнала + текущий месяц, новые сверху.""" + today = timezone.localdate() + months = {month_id(today.year, today.month)} + for row in WorkLog.objects.dates('date', 'month', order='DESC')[:limit]: + months.add(month_id(row.year, row.month)) + return sorted(months, reverse=True) + + +def location_object_code(location) -> str: + if location is None: + return '' + if location.location_type == 'office': + return 'ОФИС' + if location.location_type == 'warehouse': + return 'Склад' + pharmacy = getattr(location, 'pharmacy', None) + if pharmacy is not None: + return pharmacy_object_code(pharmacy) + return '' + + +def classify_direction(text: str, directions: list[dict]) -> str: + low = (text or '').lower() + for direction in directions: + for kw in direction.get('keywords') or []: + if kw.lower() in low: + return direction.get('name') or direction.get('id') or 'Прочие работы' + return 'Прочие работы' + + +def _staff_order(company: dict) -> list[dict]: + return list(company.get('staff') or []) + + +def _match_staff_id(employee, company: dict) -> str | None: + last = normalize_name(employee.last_name) + full = normalize_name(employee.get_full_name() or '') + for staff in _staff_order(company): + for token in staff.get('match') or []: + if normalize_name(token) and normalize_name(token) in full: + return staff['id'] + name_last = normalize_name((staff.get('name') or '').split()[0] if staff.get('name') else '') + if name_last and name_last == last: + return staff['id'] + return None + + +def build_monthly_report(year: int, month: int, *, company: dict | None = None) -> dict: + if company is None: + root = default_report_tool_root() + company = load_company(root) if (root / 'company.json').exists() else {'staff': [], 'directions': []} + + directions = company.get('directions') or [] + start = date(year, month, 1) + end = date(year, month, calendar.monthrange(year, month)[1]) + + logs = list( + WorkLog.objects.filter(date__gte=start, date__lte=end) + .select_related('technician', 'location', 'work_type', 'ticket') + .prefetch_related('location__pharmacy__organization') + .order_by('date', 'id') + ) + + by_tech: dict[int, list] = defaultdict(list) + for log in logs: + by_tech[log.technician_id].append(log) + + direction_counts: Counter[str] = Counter() + direction_people: dict[str, set[str]] = defaultdict(set) + objects: list[str] = [] + seen_obj: set[str] = set() + + # упорядочить сотрудников: сначала staff из company, потом остальные + staff_by_id = {s['id']: s for s in _staff_order(company)} + tech_to_staff: dict[int, str | None] = {} + for tech_id, tech_logs in by_tech.items(): + tech_to_staff[tech_id] = _match_staff_id(tech_logs[0].technician, company) + + ordered_tech_ids: list[int] = [] + used: set[int] = set() + for staff in _staff_order(company): + for tech_id, sid in tech_to_staff.items(): + if sid == staff['id'] and tech_id not in used: + ordered_tech_ids.append(tech_id) + used.add(tech_id) + break + for tech_id in by_tech: + if tech_id not in used: + ordered_tech_ids.append(tech_id) + + employees_out = [] + people_active = 0 + for tech_id in ordered_tech_ids: + tech_logs = by_tech[tech_id] + tech = tech_logs[0].technician + staff_id = tech_to_staff.get(tech_id) + staff_meta = staff_by_id.get(staff_id) if staff_id else None + name = (staff_meta or {}).get('name') or tech.get_full_name() or tech.username + short = (staff_meta or {}).get('short') or name + role = (staff_meta or {}).get('role') or '' + tasks = [] + systems = [] + for log in tech_logs: + obj = location_object_code(log.location) + if not obj: + # префикс «А12 — …» из импорта без локации + m = re.match(r'^([АAПPТTГGЛL]\d+|ОФИС|Склад)\s*[—\-]\s*(.+)$', log.description, re.I | re.S) + if m: + obj = m.group(1).upper().replace('A', 'А') + work = m.group(2).strip() + else: + work = log.description + else: + work = log.description + + sys_m = SYSTEM_DESC_RE.match(work) + if sys_m and (log.result or '').startswith('import:report-tool:'): + systems.append({'name': sys_m.group(1).strip(), 'work': sys_m.group(2).strip()}) + direction_counts[INFRA_DIRECTION] += 1 + direction_people[INFRA_DIRECTION].add(short) + else: + tasks.append({ + 'date': log.date.strftime('%d.%m.%Y'), + 'object': obj, + 'work': work, + 'work_items': split_work_items(work), + 'work_type': log.work_type.name if log.work_type_id else '', + 'ticket': log.ticket.number if log.ticket_id else '', + 'hours': float(log.hours_spent) if log.hours_spent is not None else None, + }) + label = classify_direction(f'{obj} {work}', directions) + direction_counts[label] += 1 + direction_people[label].add(short) + + for code in filter(None, [obj]): + key = code.upper() + if key not in seen_obj: + seen_obj.add(key) + objects.append(code) + + if tasks or systems: + people_active += 1 + employees_out.append({ + 'id': staff_id or f'user-{tech_id}', + 'name': name, + 'short': short, + 'role': role, + 'tasks': tasks, + 'systems': systems, + 'tasks_count': len(tasks), + 'systems_count': len(systems), + }) + + summary = [ + { + 'name': name, + 'count': count, + 'people': ', '.join(sorted(direction_people.get(name, []))), + } + for name, count in direction_counts.most_common() + ] + + pharmacies = [o for o in objects if _is_pharmacy_object_code(o)] + # Офис, склад и прочие точки без кода аптеки — в «Другие объекты» + others = [o for o in objects if not _is_pharmacy_object_code(o)] + + report = { + 'id': month_id(year, month), + 'year': year, + 'month': month, + 'title': f'{MONTHS_RU[month]} {year} года', + 'period_from': start.strftime('%d.%m.%Y'), + 'period_to': end.strftime('%d.%m.%Y'), + 'total_tasks': sum(e['tasks_count'] for e in employees_out), + 'people_active': people_active, + 'objects_count': len(objects), + 'objects': objects, + 'pharmacies': pharmacies, + 'other_points': others, + 'extra_points': [], + 'summary': summary, + 'employees': employees_out, + 'available_months': available_report_months(), + } + report['conclusions'] = build_conclusions(report, company) + return report + + +def report_to_markdown(report: dict) -> str: + lines = [ + f"# IT-отчёт — {report['title']}", + '', + f"**Период:** {report['period_from']} – {report['period_to']}", + f"**Всего работ:** {report['total_tasks']}", + f"**Активных сотрудников:** {report['people_active']}", + f"**Объектов:** {report['objects_count']}", + '', + '## Сводка по направлениям', + '', + ] + if report['summary']: + for row in report['summary']: + people = f" ({row['people']})" if row['people'] else '' + lines.append(f"- **{row['name']}** — {row['count']}{people}") + else: + lines.append('_Нет записей за месяц._') + + if report['pharmacies'] or report['other_points']: + lines.extend(['', '## Объекты', '']) + if report['pharmacies']: + lines.append(f"- Аптеки: {', '.join(report['pharmacies'])}.") + if report['other_points']: + lines.append(f"- Другие объекты: {', '.join(report['other_points'])}.") + + for emp in report['employees']: + if not emp['tasks'] and not emp['systems']: + continue + lines.extend(['', f"## {emp['name']}", '']) + if emp.get('role'): + lines.append(f"*{emp['role']}*") + lines.append('') + for sys in emp['systems']: + lines.append(f"### {sys['name']}") + lines.append(sys['work']) + lines.append('') + for task in emp['tasks']: + obj = f" {task['object']}" if task['object'] else '' + items = task.get('work_items') or [task['work']] + if len(items) == 1: + lines.append(f"- {task['date']}{obj} — {items[0]}") + else: + lines.append(f"- {task['date']}{obj}:") + for item in items: + lines.append(f" - {item}") + + if report.get('conclusions'): + lines.extend(['', '## Основные выводы и рекомендации', '']) + for item in report['conclusions']: + lines.append(item.get('text') or '') + for bullet in item.get('bullets') or []: + lines.append(f' - {bullet}') + lines.append('') + + lines.append('') + return '\n'.join(lines) diff --git a/apps/reports/it_monthly/conclusions.py b/apps/reports/it_monthly/conclusions.py new file mode 100644 index 0000000..d93487d --- /dev/null +++ b/apps/reports/it_monthly/conclusions.py @@ -0,0 +1,275 @@ +"""Автогенерация блока «Основные выводы и рекомендации» для месячного IT-отчёта.""" +from __future__ import annotations + +import re + +MONTHS_PREP = [ + '', 'январе', 'феврале', 'марте', 'апреле', 'мае', 'июне', + 'июле', 'августе', 'сентябре', 'октябре', 'ноябре', 'декабре', +] + +# Склонения для шаблонных фраз (как в июльском отчёте report-tool). +STAFF_PHRASES = { + 'aprodov': { + 'genitive': 'Артура Апродова', + 'bucket': 'remote', + }, + 'kogutenko': { + 'genitive': 'Сергея Когутенко', + 'bucket': 'field', + }, + 'khizhniak': { + 'instrumental': 'Николаем Хижняком', + 'bucket': 'office', + }, + 'babich': { + 'bucket': 'infra', + }, +} + +FIELD_HINTS = [ + (r'mikro.?tik|роутер', 'замена роутеров на MikroTik'), + (r'принтер|мфу|печат', 'установка и обслуживание принтеров/МФУ'), + (r'демонтаж|монтаж|транспортир', 'демонтаж/монтаж оборудования на точках'), + (r'терминал', 'настройка платёжных терминалов'), + (r'сет|свитч|свич|коммутатор', 'модернизация локальной сети'), +] + +OFFICE_HINTS = [ + (r'пк|ноутбук|windows|сотрудник', 'подготовка ПК для новых сотрудников'), + (r'мфу|принтер|картридж|печат|оргтехник', 'обслуживание МФУ и оргтехники'), + (r'почт|1с|доступ', 'настройка доступов и почтовых клиентов'), +] + + +def _emp_by_id(report: dict) -> dict[str, dict]: + return {e.get('id'): e for e in (report.get('employees') or []) if e.get('id')} + + +def _task_blob(emp: dict) -> str: + parts = [] + for t in emp.get('tasks') or []: + parts.append(f"{t.get('object', '')} {t.get('work', '')}") + for s in emp.get('systems') or []: + parts.append(f"{s.get('name', '')} {s.get('work', '')}") + return ' '.join(parts).lower() + + +def _unique_objects(emp: dict) -> list[str]: + seen = set() + out = [] + for t in emp.get('tasks') or []: + obj = (t.get('object') or '').strip() + if not obj or obj.upper() in ('ОФИС', 'ОФІС'): + continue + key = obj.upper() + if key not in seen: + seen.add(key) + out.append(obj) + return out + + +def _match_hints(text: str, hints: list[tuple[str, str]]) -> list[str]: + found = [] + for pat, label in hints: + if re.search(pat, text, re.I) and label not in found: + found.append(label) + return found + + +def _approx_pct(part: int, total: int) -> int: + if total <= 0 or part <= 0: + return 0 + return max(5, int(round(100 * part / total / 5.0) * 5)) + + +def _normalize_pcts(parts: dict[str, int]) -> dict[str, int]: + """Округляет доли до кратных 5% и подгоняет сумму к 100.""" + total = sum(parts.values()) + if total <= 0: + return {k: 0 for k in parts} + raw = {k: _approx_pct(v, total) for k, v in parts.items()} + # если все нули из-за tiny parts — пропорционально + if sum(raw.values()) == 0: + return {k: _approx_pct(v, total) for k, v in parts.items()} + drift = 100 - sum(raw.values()) + if drift and raw: + # корректируем наибольшую долю + key = max(raw, key=raw.get) + raw[key] = max(0, raw[key] + drift) + return raw + + +def _top_direction_name(summary: list[dict], prefer_ids_text: str = '') -> str: + if not summary: + return 'обслуживанием инфраструктуры' + # предпочитаем кассовое ПО, если оно в топе + for row in summary: + name = row.get('name') or '' + low = name.lower() + if 'прро' in low or 'касс' in low or 'экселлио' in low: + return name + return summary[0].get('name') or 'оперативной поддержкой' + + +def build_conclusions(report: dict, company: dict | None = None) -> list[dict]: + """Список пунктов выводов: {text, bullets?}.""" + company = company or {} + by_id = _emp_by_id(report) + month = int(report.get('month') or 1) + in_month = MONTHS_PREP[month] if 1 <= month <= 12 else 'отчётном периоде' + total_tasks = int(report.get('total_tasks') or 0) + summary = list(report.get('summary') or []) + + counts = {'remote': 0, 'field': 0, 'office': 0, 'infra': 0} + for staff_id, emp in by_id.items(): + bucket = (STAFF_PHRASES.get(staff_id) or {}).get('bucket') + n = int(emp.get('tasks_count') or 0) + int(emp.get('systems_count') or 0) + if bucket == 'infra': + counts['infra'] += n + elif bucket == 'remote': + counts['remote'] += n + elif bucket == 'field': + counts['field'] += n + elif bucket == 'office': + counts['office'] += n + else: + # неизвестный сотрудник — по объектам + office_n = sum( + 1 for t in (emp.get('tasks') or []) + if (t.get('object') or '').upper() in ('ОФИС', 'ОФІС', '') + ) + field_n = len(emp.get('tasks') or []) - office_n + counts['office'] += office_n + counts['field'] += max(0, field_n) + + operational = { + 'remote': counts['remote'], + 'field': counts['field'], + 'office': counts['office'], + } + pcts = _normalize_pcts(operational) + + items: list[dict] = [] + + # 1. Удалённая поддержка / общая сеть + aprodov = by_id.get('aprodov') + top_dir = _top_direction_name(summary) + if aprodov and aprodov.get('tasks_count'): + name = STAFF_PHRASES['aprodov']['genitive'] + remote_pct = pcts.get('remote') or _approx_pct(counts['remote'], sum(operational.values()) or 1) + items.append({ + 'text': ( + f'Поддержка аптечной сети осуществлялась в ежедневном режиме. ' + f'Наибольшее количество обращений связано с работой по направлению «{top_dir}» — ' + f'удаленная поддержка {name} обеспечила оперативное решение ' + f'~{remote_pct}% задач без выезда.' + ), + }) + elif total_tasks: + items.append({ + 'text': ( + 'Поддержка аптечной сети осуществлялась в ежедневном режиме. ' + f'Наибольшее количество обращений связано с направлением «{top_dir}».' + ), + }) + + # 2. Выезды + kogutenko = by_id.get('kogutenko') + if kogutenko and kogutenko.get('tasks_count'): + name = STAFF_PHRASES['kogutenko']['genitive'] + hints = _match_hints(_task_blob(kogutenko), FIELD_HINTS) + objs = _unique_objects(kogutenko) + focus = ', '.join(hints[:3]) if hints else 'модернизации и обслуживании оборудования на объектах' + obj_note = '' + if objs: + obj_note = f' ({len(objs)} объектов в {in_month})' + items.append({ + 'text': ( + f'Выездные работы {name} были сфокусированы на {focus}{obj_note}.' + ), + }) + + # 3. Офис + khizhniak = by_id.get('khizhniak') + if khizhniak and khizhniak.get('tasks_count'): + name = STAFF_PHRASES['khizhniak']['instrumental'] + hints = _match_hints(_task_blob(khizhniak), OFFICE_HINTS) + detail = '' + if hints: + detail = ' Выполнены: ' + '; '.join(hints[:3]) + '.' + else: + detail = ' Выполнены текущие работы по поддержке пользователей и оргтехники.' + items.append({ + 'text': ( + f'Офисная инфраструктура поддерживалась {name} в штатном режиме.{detail}' + ), + }) + + # 4–6. Инфраструктура / безопасность / бэкапы + babich = by_id.get('babich') + has_infra = bool( + (babich and ((babich.get('systems_count') or 0) > 0 or (babich.get('tasks_count') or 0) > 0)) + or any('сервер' in (s.get('name') or '').lower() or 'proxmox' in (s.get('name') or '').lower() + or 'безопас' in (s.get('name') or '').lower() or 'резерв' in (s.get('name') or '').lower() + for s in (summary or [])) + or any('администрирован' in (s.get('name') or '').lower() for s in summary) + ) + if has_infra or babich: + items.append({ + 'text': ( + 'Инфраструктурные системы (Proxmox, Zabbix, PBS, Carbonio, базы данных) ' + 'находятся в стабильном состоянии благодаря постоянному мониторингу ' + 'и профилактике со стороны руководителя отдела.' + ), + }) + items.append({ + 'text': ( + 'Безопасность обеспечена на должном уровне: Traefik, CrowdSec, ' + 'межсетевые экраны MikroTik, регулярные обновления правил и анализ логов.' + ), + }) + items.append({ + 'text': ( + 'Резервное копирование выполняется в полном объеме: локальные бэкапы на PBS ' + '+ offsite-копии на серверах в Германии.' + ), + }) + + # 7. Итоги по каналам + if total_tasks: + approx = f'порядка {total_tasks}' if total_tasks < 20 else f'порядка {total_tasks}+' + # «порядка 100+» стиль для крупных месяцев + if total_tasks >= 80: + approx = f'порядка {round(total_tasks, -1)}+' + bullets = [ + f'удаленно решено – ~{pcts.get("remote", 0)}%', + f'с выездом – ~{pcts.get("field", 0)}%', + f'офисные работы – ~{pcts.get("office", 0)}%', + 'инфраструктурные и стратегические задачи – выполняются руководителем отдела на постоянной основе', + ] + items.append({ + 'text': f'Общее количество заявок/задач за месяц – {approx}, из них:', + 'bullets': bullets, + }) + + # Нумерация в тексте + numbered = [] + for i, item in enumerate(items, 1): + numbered.append({ + 'text': f'{i}. {item["text"]}', + 'bullets': list(item.get('bullets') or []), + }) + return numbered + + +def conclusions_as_plain_lines(conclusions: list[dict]) -> list[str]: + """Плоский список строк для DOCX (как в июльском JSON).""" + lines = [] + for item in conclusions or []: + text = (item.get('text') or '').strip() + if text: + lines.append(text) + for bullet in item.get('bullets') or []: + lines.append(f' ◦ {bullet}') + return lines diff --git a/apps/reports/it_monthly/docx.py b/apps/reports/it_monthly/docx.py new file mode 100644 index 0000000..268bb3a --- /dev/null +++ b/apps/reports/it_monthly/docx.py @@ -0,0 +1,660 @@ +"""Сборка 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 []} + employees = [] + seen = set() + for staff in company.get('staff') or []: + sid = staff.get('id') + seen.add(sid) + src = by_id.get(sid) or {} + 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': staff.get('zone') or '', + '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: + 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': '', + '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""" + + + + + + + + +""" + +RELS = """ + + + + + +""" + +DOC_RELS = """ + + + +""" + +STYLES = f""" + + + + + + + + + + + + + + + + + + +""" + +APP = """ + + IT Report Tool + +""" + +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_esc(title)} + {xml_esc(creator)} + {xml_esc(creator)} + +""" + + +def _rpr(bold=False, italic=False, size=20, color=TEXT, font="Arial") -> str: + b = "" if bold else "" + i = "" if italic else "" + return ( + "" + f'' + f"{b}{i}" + f'' + f'' + "" + ) + + +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", "") + return f"{_rpr(**fmt)}{body}" + + +def _p(runs: str, align="left", before=0, after=80, keep=False) -> str: + jc = f'' if align else "" + kn = "" if keep else "" + return ( + "" + f"{kn}{jc}" + "" + f"{runs}" + ) + + +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'' + + +def _border(sz=4, color="C5CDD8") -> str: + attrs = f'w:val="single" w:sz="{sz}" w:space="0" w:color="{color}"' + return ( + "" + f"" + f"" + "" + ) + + +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'' + f"{shd}" + f'' + f"{inner}" + ) + + +def table(headers: list[str], rows: list[list[str]], widths: list[int], zebra=True) -> str: + grid = "".join(f'' for w in widths) + xml = ( + "" + f'' + '' + f"{_border()}" + "" + f"{grid}" + ) + xml += "" + "".join(_cell(h, widths[i], header=True) for i, h in enumerate(headers)) + "" + 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 += "" + "".join(cells) + "" + xml += "" + xml += p("", after=160, size=4) + return xml + + +def hrule() -> str: + return table([""], [], [TW], zebra=False).replace( + "", + f'{_cell("", TW, fill=BLUE)}', + ) + + +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""" + + + {body} + + + + + + +""" + + +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', '
') + + def html_table(headers, rows): + th = ''.join(f'{esc(h)}' for h in headers) + body = [] + for row in rows: + tds = ''.join(f'{esc(c)}' for c in row) + body.append(f'{tds}') + return f"{th}{''.join(body)}
" + + chunks = [ + f"
{esc(company.get('company'))}
", + f"
{esc(company.get('department'))}
", + '

ОТЧЁТ О ВЫПОЛНЕННЫХ РАБОТАХ

', + f"
{esc(title)}
", + html_table( + ['Показатель', 'Значение'], + [ + ['Отчётный период', stats['period']], + ['Состав отдела', f"{stats['staff_count']} сотрудника"], + ['Охвачено объектов', str(stats['objects_count'])], + ['Режим работы', month.get('work_mode') or ''], + ['Всего задач', str(stats['total_tasks'])], + ], + ), + '

1. Состав IT-отдела

', + html_table( + ['№', 'Сотрудник', 'Должность / Роль', 'Зона ответственности'], + [[i, e.get('name'), e.get('role'), e.get('zone')] for i, e in enumerate(month.get('employees') or [], 1)], + ), + '

2. Детальный отчёт по сотрудникам

', + ] + for i, emp in enumerate(month.get('employees') or [], 1): + chunks.append( + f"

2.{i}. {esc(emp.get('name'))} — {esc(emp.get('role'))}

" + ) + if emp.get('intro'): + chunks.append(f"

{esc(emp['intro'])}

") + 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("

Задачи не внесены.

") + + n = 3 + chunks.append(f'

{n}. Сводные итоги по направлениям

') + 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'

{n}. Объекты

') + n += 1 + for label, value in group_objects_text(stats, month.get('objects_note') or ''): + chunks.append(f'

{esc(label)} {esc(value)}

') + chunks.append(f"

Итого объектов: {stats['objects_count']}.

") + + conclusions = [c for c in (month.get('conclusions') or []) if (c or '').strip()] + if conclusions: + chunks.append(f'

{n}. Основные выводы и рекомендации

') + for para in conclusions: + if para.lstrip().startswith('◦'): + chunks.append(f"

{esc(para.strip())}

") + else: + chunks.append(f'

{esc(para.strip())}

') + + chunks.append("
") + chunks.append(f"

Отчёт подготовил: {esc(month.get('prepared_name'))}

") + chunks.append(f"

Должность: {esc(month.get('prepared_role'))}

") + chunks.append(f"

Дата: {esc(month.get('prepared_date'))}

") + chunks.append('

Подпись: ___________________________

') + + return f""" + +{esc(title)} + +
{''.join(chunks)}
+""" + + +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) diff --git a/apps/reports/it_monthly/pdf.py b/apps/reports/it_monthly/pdf.py new file mode 100644 index 0000000..68312fc --- /dev/null +++ b/apps/reports/it_monthly/pdf.py @@ -0,0 +1,303 @@ +"""PDF месячного IT-отчёта (fpdf2, тот же каркас, что Word из report-tool).""" +from __future__ import annotations + +from pathlib import Path + +from apps.it_manager.report_import import default_report_tool_root, load_company +from apps.reports.it_monthly.docx import ( + filled_systems, + filled_tasks, + group_objects_text, + month_title, + report_as_month, + stats_from_report, +) + +_FONT_REGULAR = ( + Path('/usr/share/fonts/google-noto/NotoSans-Regular.ttf'), + Path('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'), + Path('/usr/share/fonts/dejavu/DejaVuSans.ttf'), +) +_FONT_BOLD = ( + Path('/usr/share/fonts/google-noto/NotoSans-Bold.ttf'), + Path('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'), + Path('/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf'), +) +_FONT_ITALIC = ( + Path('/usr/share/fonts/google-noto/NotoSans-Italic.ttf'), + Path('/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf'), + Path('/usr/share/fonts/dejavu/DejaVuSans-Oblique.ttf'), +) + +NAVY = (26, 44, 91) +BLUE = (31, 92, 153) +GRAY = (102, 102, 102) +TEXT = (34, 34, 34) +ZEBRA = (244, 247, 251) +HEADER_BG = NAVY +BORDER = (197, 205, 216) + + +def _first_existing(candidates) -> str | None: + for path in candidates: + if path.is_file(): + return str(path) + return None + + +def _load_company(company: dict | None) -> dict: + if company is not None: + return company + root = default_report_tool_root() + if (root / 'company.json').exists(): + return load_company(root) + return { + 'company': 'АКС-Аптека', + 'department': 'Отдел информационных технологий', + 'prepared_by': {'name': '', 'role': ''}, + 'work_mode': 'Ежедневно (включая выходные)', + 'staff': [], + } + + +class _ReportPDF: + def __init__(self): + from fpdf import FPDF + + self.pdf = FPDF(format='A4', unit='mm') + self.pdf.set_auto_page_break(auto=True, margin=16) + self.pdf.set_margins(18, 16, 18) + self.pdf.add_page() + + regular = _first_existing(_FONT_REGULAR) + bold = _first_existing(_FONT_BOLD) + italic = _first_existing(_FONT_ITALIC) + self.font = 'Report' + if regular: + self.pdf.add_font('Report', '', regular) + self.pdf.add_font('Report', 'B', bold or regular) + self.pdf.add_font('Report', 'I', italic or regular) + else: + self.font = 'Helvetica' + + @property + def w(self) -> float: + return self.pdf.epw + + def _ensure_space(self, need_mm: float): + if self.pdf.get_y() + need_mm > self.pdf.page_break_trigger: + self.pdf.add_page() + + def title_block(self, company: dict, title: str): + pdf = self.pdf + pdf.set_text_color(*NAVY) + pdf.set_font(self.font, 'I', 16) + pdf.multi_cell(self.w, 8, company.get('company') or 'АКС-Аптека', align='C') + pdf.set_text_color(*GRAY) + pdf.set_font(self.font, 'I', 10) + pdf.multi_cell(self.w, 5, company.get('department') or '', align='C') + pdf.ln(3) + pdf.set_text_color(26, 26, 46) + pdf.set_font(self.font, 'B', 15) + pdf.multi_cell(self.w, 7, 'ОТЧЁТ О ВЫПОЛНЕННЫХ РАБОТАХ', align='C') + pdf.set_text_color(*BLUE) + pdf.set_font(self.font, 'B', 12) + pdf.multi_cell(self.w, 6, title, align='C') + pdf.ln(4) + pdf.set_text_color(*TEXT) + + def heading(self, text: str): + self._ensure_space(12) + pdf = self.pdf + pdf.ln(2) + pdf.set_text_color(*NAVY) + pdf.set_font(self.font, 'B', 11) + pdf.multi_cell(self.w, 6, text) + y = pdf.get_y() + pdf.set_draw_color(*BLUE) + pdf.set_line_width(0.5) + pdf.line(pdf.l_margin, y, pdf.l_margin + self.w, y) + pdf.ln(2) + pdf.set_text_color(*TEXT) + + def subheading(self, text: str, rgb=NAVY): + self._ensure_space(10) + pdf = self.pdf + pdf.set_text_color(*rgb) + pdf.set_font(self.font, 'B', 10) + pdf.multi_cell(self.w, 5.5, text) + pdf.set_text_color(*TEXT) + + def intro(self, text: str): + if not text: + return + pdf = self.pdf + pdf.set_text_color(*GRAY) + pdf.set_font(self.font, 'I', 9) + pdf.multi_cell(self.w, 4.5, text) + pdf.set_text_color(*TEXT) + pdf.ln(1) + + def paragraph(self, text: str, *, bold=False, size=9.5): + pdf = self.pdf + pdf.set_font(self.font, 'B' if bold else '', size) + pdf.multi_cell(self.w, 4.8, text or '') + + def mixed_line(self, label: str, value: str): + pdf = self.pdf + self._ensure_space(6) + pdf.set_font(self.font, 'B', 9.5) + label_w = pdf.get_string_width(label) + 1 + pdf.cell(label_w, 5, label) + pdf.set_font(self.font, '', 9.5) + pdf.multi_cell(self.w - label_w, 5, value or '') + + def table(self, headers: list[str], rows: list[list[str]], widths: list[float]): + """Простая таблица с переносом текста в ячейках.""" + from fpdf.enums import XPos, YPos + + pdf = self.pdf + assert len(headers) == len(widths) + line_h = 4.2 + + def row_height(cells: list[str], *, header=False) -> float: + pdf.set_font(self.font, 'B' if header else '', 8 if header else 8.5) + tallest = line_h + for i, cell in enumerate(cells): + lines = pdf.multi_cell(widths[i], line_h, str(cell or ''), dry_run=True, output='LINES') + tallest = max(tallest, line_h * max(1, len(lines))) + return tallest + 1.6 + + def draw_row(cells: list[str], *, header=False, fill=None): + h = row_height(cells, header=header) + self._ensure_space(h + 1) + x0 = pdf.l_margin + y0 = pdf.get_y() + if fill: + pdf.set_fill_color(*fill) + pdf.rect(x0, y0, sum(widths), h, style='F') + pdf.set_draw_color(*BORDER) + pdf.set_line_width(0.2) + pdf.rect(x0, y0, sum(widths), h) + x = x0 + for i, w in enumerate(widths[:-1]): + x += w + pdf.line(x, y0, x, y0 + h) + + pdf.set_text_color(*(255, 255, 255) if header else TEXT) + pdf.set_font(self.font, 'B' if header else '', 8 if header else 8.5) + x = x0 + for i, cell in enumerate(cells): + pdf.set_xy(x + 1.2, y0 + 0.8) + pdf.multi_cell( + widths[i] - 2.4, line_h, str(cell or ''), + new_x=XPos.RIGHT, new_y=YPos.TOP, + ) + x += widths[i] + pdf.set_xy(x0, y0 + h) + pdf.set_text_color(*TEXT) + + draw_row(headers, header=True, fill=HEADER_BG) + for i, row in enumerate(rows): + padded = list(row) + [''] * (len(headers) - len(row)) + draw_row(padded[:len(headers)], fill=ZEBRA if i % 2 else None) + pdf.ln(2) + + +def report_to_pdf(report: dict, company: dict | None = None) -> bytes: + company = _load_company(company) + month = report_as_month(report, company) + stats = stats_from_report(report, month) + title = month_title(month['year'], month['month']) + + doc = _ReportPDF() + doc.title_block(company, title) + + 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'])], + ] + doc.table(['Показатель', 'Значение'], info_rows, [70, doc.w - 70]) + + doc.heading('1. СОСТАВ IT-ОТДЕЛА') + staff_rows = [ + [str(i), emp.get('name', ''), emp.get('role', ''), emp.get('zone', '')] + for i, emp in enumerate(month.get('employees') or [], 1) + ] + doc.table( + ['№', 'Сотрудник', 'Должность / Роль', 'Зона ответственности'], + staff_rows, + [10, 40, 48, doc.w - 98], + ) + + doc.heading('2. ДЕТАЛЬНЫЙ ОТЧЁТ ПО СОТРУДНИКАМ') + for idx, emp in enumerate(month.get('employees') or [], 1): + color_hex = (emp.get('color') or '1A2C5B').lstrip('#') + try: + rgb = tuple(int(color_hex[i:i + 2], 16) for i in (0, 2, 4)) + except ValueError: + rgb = NAVY + doc.subheading(f"2.{idx}. {emp.get('name', '')} — {emp.get('role', '')}", rgb=rgb) + doc.intro(emp.get('intro') or '') + systems = filled_systems(emp) + if systems: + doc.table( + ['Система / Направление', 'Выполненные работы'], + [[s['name'], s['work']] for s in systems], + [55, doc.w - 55], + ) + tasks = filled_tasks(emp) + if tasks: + doc.table( + ['Дата', 'Объект', 'Выполненные работы'], + [[t.get('date', ''), t.get('object', ''), t.get('work', '')] for t in tasks], + [22, 22, doc.w - 44], + ) + if not systems and not tasks: + doc.intro('За отчётный период отдельные задачи не внесены.') + + n = 3 + doc.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: + doc.table( + ['Направление', 'Кол-во задач', 'Основные исполнители'], + summary_rows, + [80, 28, doc.w - 108], + ) + else: + doc.intro('Недостаточно данных для сводки.') + + doc.heading(f'{n}. ОБЪЕКТЫ, НА КОТОРЫХ ВЫПОЛНЯЛИСЬ РАБОТЫ') + n += 1 + for label, value in group_objects_text(stats, month.get('objects_note') or ''): + doc.mixed_line(label + ' ', value) + doc.mixed_line('Итого объектов: ', f"{stats['objects_count']}.") + + conclusions = [c for c in (month.get('conclusions') or []) if (c or '').strip()] + if conclusions: + doc.heading(f'{n}. ОСНОВНЫЕ ВЫВОДЫ И РЕКОМЕНДАЦИИ') + for para in conclusions: + if para.lstrip().startswith('◦'): + doc.paragraph(' ' + para.strip(), size=9) + else: + doc.paragraph(para.strip(), size=9.5) + doc.pdf.ln(1) + + doc.pdf.ln(8) + doc.mixed_line('Отчёт подготовил: ', month.get('prepared_name') or '') + doc.mixed_line('Должность: ', month.get('prepared_role') or '') + doc.mixed_line('Дата: ', month.get('prepared_date') or '') + doc.paragraph('Подпись: ___________________________', bold=True) + + return bytes(doc.pdf.output()) diff --git a/apps/reports/management/__init__.py b/apps/reports/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reports/management/commands/__init__.py b/apps/reports/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reports/management/commands/seed_report_templates.py b/apps/reports/management/commands/seed_report_templates.py new file mode 100644 index 0000000..e875369 --- /dev/null +++ b/apps/reports/management/commands/seed_report_templates.py @@ -0,0 +1,28 @@ +from django.core.management.base import BaseCommand + +from apps.reports.classic_seed import CLASSIC_BLOCKS, CLASSIC_ENGINE, CLASSIC_HTML, CLASSIC_MD +from apps.reports.models import ReportTemplate + + +class Command(BaseCommand): + help = 'Создаёт/обновляет системный шаблон it_monthly_classic' + + def handle(self, *args, **options): + obj, created = ReportTemplate.objects.update_or_create( + code='it_monthly_classic', + defaults={ + 'report_type': ReportTemplate.REPORT_IT_MONTHLY, + 'name': 'Классический IT (как report-tool)', + 'description': 'Системный шаблон: блочный конструктор (секции + подписи).', + 'engine': CLASSIC_ENGINE, + 'body_html': CLASSIC_HTML, + 'body_md': CLASSIC_MD, + 'blocks': CLASSIC_BLOCKS, + 'is_default': True, + 'is_active': True, + 'is_system': True, + }, + ) + self.stdout.write(self.style.SUCCESS( + f'{"Created" if created else "Updated"} {obj.code} ({obj.engine})' + )) diff --git a/apps/reports/migrations/0001_report_template.py b/apps/reports/migrations/0001_report_template.py new file mode 100644 index 0000000..6c00992 --- /dev/null +++ b/apps/reports/migrations/0001_report_template.py @@ -0,0 +1,42 @@ +# Generated by Django 6.0.3 on 2026-09-04 17:42 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ReportTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.SlugField(max_length=64, unique=True, verbose_name='Код')), + ('report_type', models.CharField(choices=[('it_monthly', 'Месячный IT-отчёт')], db_index=True, max_length=32, verbose_name='Тип отчёта')), + ('name', models.CharField(max_length=200, verbose_name='Название')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('engine', models.CharField(choices=[('django_html', 'Django HTML / Markdown'), ('blocks_v1', 'Блоки (заголовки секций)')], default='django_html', max_length=32, verbose_name='Движок')), + ('body_html', models.TextField(blank=True, verbose_name='Шаблон HTML')), + ('body_md', models.TextField(blank=True, verbose_name='Шаблон Markdown')), + ('blocks', models.JSONField(blank=True, default=dict, verbose_name='Блоки / подписи')), + ('is_default', models.BooleanField(default=False, verbose_name='По умолчанию')), + ('is_active', models.BooleanField(default=True, verbose_name='Активен')), + ('is_system', models.BooleanField(default=False, help_text='Системный шаблон нельзя править обычным пользователям — только копировать.', verbose_name='Системный')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='report_templates_created', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Шаблон отчёта', + 'verbose_name_plural': 'Шаблоны отчётов', + 'ordering': ['report_type', '-is_default', 'name'], + }, + ), + ] diff --git a/apps/reports/migrations/0002_seed_classic.py b/apps/reports/migrations/0002_seed_classic.py new file mode 100644 index 0000000..5e65d82 --- /dev/null +++ b/apps/reports/migrations/0002_seed_classic.py @@ -0,0 +1,38 @@ +from django.db import migrations + + +def seed_classic(apps, schema_editor): + from apps.reports.classic_seed import CLASSIC_BLOCKS, CLASSIC_HTML, CLASSIC_MD + + ReportTemplate = apps.get_model('reports', 'ReportTemplate') + ReportTemplate.objects.update_or_create( + code='it_monthly_classic', + defaults={ + 'report_type': 'it_monthly', + 'name': 'Классический IT (как report-tool)', + 'description': 'Системный шаблон: HTML/MD превью и подписи секций.', + 'engine': 'django_html', + 'body_html': CLASSIC_HTML, + 'body_md': CLASSIC_MD, + 'blocks': CLASSIC_BLOCKS, + 'is_default': True, + 'is_active': True, + 'is_system': True, + }, + ) + + +def unseed(apps, schema_editor): + ReportTemplate = apps.get_model('reports', 'ReportTemplate') + ReportTemplate.objects.filter(code='it_monthly_classic').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('reports', '0001_report_template'), + ] + + operations = [ + migrations.RunPython(seed_classic, unseed), + ] diff --git a/apps/reports/migrations/0003_classic_blocks_v1.py b/apps/reports/migrations/0003_classic_blocks_v1.py new file mode 100644 index 0000000..561443b --- /dev/null +++ b/apps/reports/migrations/0003_classic_blocks_v1.py @@ -0,0 +1,28 @@ +from django.db import migrations + + +def switch_classic_to_blocks(apps, schema_editor): + from apps.reports.classic_seed import CLASSIC_BLOCKS + + ReportTemplate = apps.get_model('reports', 'ReportTemplate') + ReportTemplate.objects.filter(code='it_monthly_classic').update( + engine='blocks_v1', + blocks=CLASSIC_BLOCKS, + description='Системный шаблон: блочный конструктор (секции + подписи).', + ) + + +def revert_classic(apps, schema_editor): + ReportTemplate = apps.get_model('reports', 'ReportTemplate') + ReportTemplate.objects.filter(code='it_monthly_classic').update(engine='django_html') + + +class Migration(migrations.Migration): + + dependencies = [ + ('reports', '0002_seed_classic'), + ] + + operations = [ + migrations.RunPython(switch_classic_to_blocks, revert_classic), + ] diff --git a/apps/reports/migrations/__init__.py b/apps/reports/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reports/models.py b/apps/reports/models.py new file mode 100644 index 0000000..2d12783 --- /dev/null +++ b/apps/reports/models.py @@ -0,0 +1,81 @@ +"""Шаблоны оформления отчётов (отдельно от сборки данных).""" +from __future__ import annotations + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class ReportTemplate(models.Model): + ENGINE_DJANGO_HTML = 'django_html' + ENGINE_BLOCKS_V1 = 'blocks_v1' + ENGINE_CHOICES = [ + (ENGINE_DJANGO_HTML, 'Django HTML / Markdown'), + (ENGINE_BLOCKS_V1, 'Блоки (заголовки секций)'), + ] + + REPORT_IT_MONTHLY = 'it_monthly' + REPORT_TYPE_CHOICES = [ + (REPORT_IT_MONTHLY, 'Месячный IT-отчёт'), + ] + + code = models.SlugField(_('Код'), max_length=64, unique=True) + report_type = models.CharField( + _('Тип отчёта'), max_length=32, choices=REPORT_TYPE_CHOICES, db_index=True, + ) + name = models.CharField(_('Название'), max_length=200) + description = models.TextField(_('Описание'), blank=True) + engine = models.CharField( + _('Движок'), max_length=32, choices=ENGINE_CHOICES, default=ENGINE_DJANGO_HTML, + ) + # Django-шаблон для HTML-превью + body_html = models.TextField(_('Шаблон HTML'), blank=True) + # Django-шаблон для Markdown-экспорта + body_md = models.TextField(_('Шаблон Markdown'), blank=True) + # labels / sections для blocks_v1 и подписей в DOCX/PDF + blocks = models.JSONField(_('Блоки / подписи'), default=dict, blank=True) + + is_default = models.BooleanField(_('По умолчанию'), default=False) + is_active = models.BooleanField(_('Активен'), default=True) + is_system = models.BooleanField( + _('Системный'), default=False, + help_text=_('Системный шаблон нельзя править обычным пользователям — только копировать.'), + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, blank=True, related_name='report_templates_created', + ) + + class Meta: + verbose_name = _('Шаблон отчёта') + verbose_name_plural = _('Шаблоны отчётов') + ordering = ['report_type', '-is_default', 'name'] + + def __str__(self): + return f'{self.name} ({self.code})' + + def clean(self): + if self.engine == self.ENGINE_DJANGO_HTML and not (self.body_html or '').strip(): + raise ValidationError({'body_html': _('Для Django HTML нужен шаблон HTML.')}) + if self.engine == self.ENGINE_BLOCKS_V1 and not self.blocks: + raise ValidationError({'blocks': _('Для блочного движка заполните JSON blocks.')}) + + def save(self, *args, **kwargs): + if self.is_default: + type(self).objects.filter( + report_type=self.report_type, is_default=True, + ).exclude(pk=self.pk).update(is_default=False) + super().save(*args, **kwargs) + + @classmethod + def resolve(cls, report_type: str, code: str | None = None) -> ReportTemplate | None: + qs = cls.objects.filter(report_type=report_type, is_active=True) + if code: + found = qs.filter(code=code).first() + if found: + return found + return qs.filter(is_default=True).first() or qs.first() diff --git a/apps/reports/registry.py b/apps/reports/registry.py new file mode 100644 index 0000000..5cdd9cd --- /dev/null +++ b/apps/reports/registry.py @@ -0,0 +1,42 @@ +"""Реестр отчётов портала — какие карточки видит пользователь на /reports/.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from django.contrib.auth.models import AbstractBaseUser + +from apps.reports.it_monthly.builder import user_can_view_it_monthly_report + + +@dataclass(frozen=True) +class ReportSpec: + code: str + title: str + department: str + description: str + path: str + can_view: Callable[[AbstractBaseUser], bool] + + +REPORTS: tuple[ReportSpec, ...] = ( + ReportSpec( + code='it_monthly', + title='Месячный IT-отчёт', + department='it', + description='Сводка выполненных работ IT-отдела за месяц: сотрудники, объекты, выводы. Экспорт MD/DOCX/PDF.', + path='/reports/it/monthly/', + can_view=user_can_view_it_monthly_report, + ), +) + + +def visible_reports(user) -> list[ReportSpec]: + return [spec for spec in REPORTS if spec.can_view(user)] + + +def get_report(code: str) -> ReportSpec | None: + for spec in REPORTS: + if spec.code == code: + return spec + return None diff --git a/apps/reports/tests.py b/apps/reports/tests.py new file mode 100644 index 0000000..89696e4 --- /dev/null +++ b/apps/reports/tests.py @@ -0,0 +1,413 @@ +"""Тесты раздела Отчёты и месячного IT-отчёта.""" +from datetime import date +import json + +from django.contrib.auth.models import Group +from django.test import Client, TestCase + +from apps.it_manager.models import WorkLog +from apps.it_manager.tests import _dir_id, make_employee +from apps.locations.models.pharmacies import Pharmacy +from apps.reports.it_monthly import ( + build_monthly_report, report_to_markdown, user_can_view_it_monthly_report, +) +from apps.reports.it_monthly.builder import split_work_items +from apps.reports.it_monthly.conclusions import build_conclusions +from apps.reports.registry import visible_reports +from core.models.directories import Directory, DirectoryType +from core.test_utils import login_2fa + + +class MonthlyReportTests(TestCase): + def setUp(self): + dept_type, _ = DirectoryType.objects.get_or_create( + code='department', defaults={'name': 'Відділи', 'hierarchy_type': 'strict'}, + ) + self.dept = Directory.objects.create( + directory_type=dept_type, code='it-rep', name='IT-відділ-rep', + ) + self.head = make_employee( + last_name='Бабич', first_name='Виктор', username='babich.rep', + department=self.dept, user_location_type='office', + ) + self.it_user = make_employee( + last_name='Апродов', first_name='Артур', username='apr.rep', + department=self.dept, manager=self.head, user_location_type='office', + ) + it_group = Group.objects.get(name='IT') + self.head.groups.add(it_group) + self.it_user.groups.add(it_group) + self.client = Client() + + region_type, _ = DirectoryType.objects.get_or_create(code='region', defaults={'name': 'Регион'}) + org_type, _ = DirectoryType.objects.get_or_create(code='organization', defaults={'name': 'Организация'}) + region = Directory.objects.create(directory_type=region_type, code='od-rep', name='Одесса-rep') + org = Directory.objects.create( + directory_type=org_type, code='akc-rep', name='АКС-rep', + extra_data={'mail_prefix': 'a'}, + ) + pharmacy = Pharmacy.objects.create( + organization=org, location_region=region, + pharmacy_number=35, location_type='pharmacy', + street='Реп', house_number='2', + ) + WorkLog.objects.create( + date=date(2026, 5, 3), + technician=self.it_user, + location=pharmacy, + work_type_id=_dir_id('worklog_type', 'software'), + description='Восстановление ПРРО на кассе', + ) + WorkLog.objects.create( + date=date(2026, 5, 4), + technician=self.it_user, + work_type_id=_dir_id('worklog_type', 'network'), + description='Диагностика локальной сети', + ) + + def test_only_department_head_can_view(self): + self.assertTrue(user_can_view_it_monthly_report(self.head)) + self.assertFalse(user_can_view_it_monthly_report(self.it_user)) + self.assertEqual([s.code for s in visible_reports(self.head)], ['it_monthly']) + self.assertEqual(visible_reports(self.it_user), []) + + def test_build_report(self): + company = { + 'staff': [{ + 'id': 'aprodov', 'name': 'Апродов Артур', 'short': 'Апродов А.', + 'role': 'L1', 'match': ['апродов'], + }], + 'directions': [ + {'id': 'prro', 'name': 'ПРРО', 'keywords': ['прро']}, + {'id': 'network', 'name': 'Сеть', 'keywords': ['сет']}, + ], + } + report = build_monthly_report(2026, 5, company=company) + self.assertEqual(report['total_tasks'], 2) + self.assertEqual(report['people_active'], 1) + self.assertIn('А35', report['pharmacies']) + self.assertEqual(report['employees'][0]['tasks_count'], 2) + self.assertTrue(report['conclusions']) + self.assertTrue(any('ежедневном режиме' in (c.get('text') or '') for c in report['conclusions'])) + md = report_to_markdown(report) + self.assertIn('Восстановление ПРРО', md) + self.assertIn('## Сводка по направлениям', md) + self.assertIn('Основные выводы и рекомендации', md) + items = report['employees'][0]['tasks'][0]['work_items'] + self.assertEqual(items, ['Восстановление ПРРО на кассе']) + + def test_conclusions_cover_roles(self): + company = { + 'staff': [ + {'id': 'aprodov', 'name': 'Апродов Артур', 'short': 'Апродов А.', 'match': ['апродов']}, + {'id': 'kogutenko', 'name': 'Когутенко Сергей', 'short': 'Когутенко С.', 'match': ['когутенко']}, + {'id': 'khizhniak', 'name': 'Хижняк Николай', 'short': 'Хижняк Н.', 'match': ['хижняк']}, + {'id': 'babich', 'name': 'Бабич Виктор', 'short': 'Бабич В.И.', 'match': ['бабич']}, + ], + 'directions': [ + {'id': 'prro', 'name': 'ПРРО', 'keywords': ['прро']}, + {'id': 'network', 'name': 'Сеть', 'keywords': ['mikrotik', 'роутер']}, + ], + } + report = { + 'month': 7, + 'total_tasks': 100, + 'summary': [{'name': 'Обслуживание кассового ПО (ПРРО)', 'count': 40, 'people': 'Апродов А.'}], + 'employees': [ + { + 'id': 'aprodov', 'name': 'Апродов Артур', 'tasks_count': 50, 'systems_count': 0, + 'tasks': [{'object': 'А10', 'work': 'Восстановление ПРРО'}], 'systems': [], + }, + { + 'id': 'kogutenko', 'name': 'Когутенко Сергей', 'tasks_count': 30, 'systems_count': 0, + 'tasks': [ + {'object': 'А12', 'work': 'Замена роутера MikroTik'}, + {'object': 'А15', 'work': 'Монтаж оборудования'}, + ], + 'systems': [], + }, + { + 'id': 'khizhniak', 'name': 'Хижняк Николай', 'tasks_count': 20, 'systems_count': 0, + 'tasks': [{'object': 'ОФИС', 'work': 'Подготовка ПК сотруднику; обслуживание МФУ'}], + 'systems': [], + }, + { + 'id': 'babich', 'name': 'Бабич', 'tasks_count': 0, 'systems_count': 2, + 'tasks': [], + 'systems': [{'name': 'Proxmox', 'work': 'Мониторинг'}], + }, + ], + } + conclusions = build_conclusions(report, company) + text = '\n'.join(c['text'] for c in conclusions) + self.assertIn('Артура Апродова', text) + self.assertIn('Сергея Когутенко', text) + self.assertIn('Николаем Хижняком', text) + self.assertIn('Proxmox', text) + last = conclusions[-1] + self.assertTrue(last.get('bullets')) + self.assertTrue(any('удаленно' in b for b in last['bullets'])) + + def test_split_semicolon_work_items(self): + items = split_work_items('восстановление печати на МФУ; добавление баз; настройка сканера') + self.assertEqual(len(items), 3) + self.assertEqual(items[0], 'Восстановление печати на МФУ') + + def test_pharmacy_object_codes_all_org_letters(self): + from apps.reports.it_monthly.builder import _is_pharmacy_object_code + + for code in ('А37', 'A37', 'Т2', 'T2', 'П10', 'Г3', 'Л1'): + self.assertTrue(_is_pharmacy_object_code(code), code) + for code in ('ОФИС', 'Склад', 'Вайтер Юг', '№5', ''): + self.assertFalse(_is_pharmacy_object_code(code), code) + + def test_reports_hub_and_it_monthly_for_head(self): + login_2fa(self.client, self.head) + hub = self.client.get('/reports/') + self.assertEqual(hub.status_code, 200) + self.assertContains(hub, 'Месячный IT-отчёт') + self.assertContains(hub, '/reports/it/monthly/') + + page = self.client.get('/reports/it/monthly/?month=2026-05') + self.assertEqual(page.status_code, 200) + self.assertContains(page, 'Восстановление ПРРО') + self.assertContains(page, 'Скачать Word') + self.assertContains(page, 'Скачать PDF') + + it_page = self.client.get('/it-manager/') + self.assertEqual(it_page.status_code, 200) + self.assertContains(it_page, '/reports/it/monthly/') + self.assertNotContains(it_page, 'id="it-panel-report"') + + md = self.client.get('/reports/it/monthly/2026-05.md') + self.assertEqual(md.status_code, 200) + self.assertIn('attachment', md['Content-Disposition']) + self.assertIn('Восстановление ПРРО', md.content.decode()) + + docx = self.client.get('/reports/it/monthly/2026-05.docx') + self.assertEqual(docx.status_code, 200) + self.assertTrue(docx.content.startswith(b'PK')) + + pdf = self.client.get('/reports/it/monthly/2026-05.pdf') + self.assertEqual(pdf.status_code, 200) + self.assertTrue(pdf.content.startswith(b'%PDF')) + + preview = self.client.get('/reports/it/monthly/2026-05/preview/') + self.assertEqual(preview.status_code, 200) + self.assertContains(preview, 'ОТЧЁТ О ВЫПОЛНЕННЫХ РАБОТАХ') + self.assertEqual(preview.get('X-Frame-Options'), 'SAMEORIGIN') + + def test_legacy_urls_redirect(self): + login_2fa(self.client, self.head) + for fmt, path in [ + ('md', '/it-manager/report/2026-05.md'), + ('docx', '/it-manager/report/2026-05.docx'), + ('pdf', '/it-manager/report/2026-05.pdf'), + ]: + r = self.client.get(path) + self.assertEqual(r.status_code, 302, path) + self.assertEqual(r['Location'], f'/reports/it/monthly/2026-05.{fmt}') + r = self.client.get('/it-manager/report/2026-05/preview/') + self.assertEqual(r.status_code, 302) + self.assertEqual(r['Location'], '/reports/it/monthly/2026-05/preview/') + + def test_report_forbidden_for_regular_it(self): + login_2fa(self.client, self.it_user) + r = self.client.get('/it-manager/') + self.assertEqual(r.status_code, 200) + self.assertNotContains(r, '/reports/it/monthly/') + + self.assertEqual(self.client.get('/reports/it/monthly/').status_code, 403) + self.assertEqual(self.client.get('/reports/it/monthly/2026-05.md').status_code, 403) + self.assertEqual(self.client.get('/reports/it/monthly/2026-05.docx').status_code, 403) + self.assertEqual(self.client.get('/reports/it/monthly/2026-05.pdf').status_code, 403) + self.assertEqual(self.client.get('/reports/it/monthly/2026-05/preview/').status_code, 403) + + +class ReportTemplateTests(TestCase): + def setUp(self): + from apps.reports.classic_seed import CLASSIC_BLOCKS, CLASSIC_HTML, CLASSIC_MD + from apps.reports.models import ReportTemplate + + dept_type, _ = DirectoryType.objects.get_or_create( + code='department', defaults={'name': 'Відділи', 'hierarchy_type': 'strict'}, + ) + self.dept = Directory.objects.create( + directory_type=dept_type, code='it-tpl', name='IT-відділ-tpl', + ) + self.head = make_employee( + last_name='Бабич', first_name='Виктор', username='babich.tpl', + department=self.dept, user_location_type='office', + ) + self.it_user = make_employee( + last_name='Апродов', first_name='Артур', username='apr.tpl', + department=self.dept, manager=self.head, user_location_type='office', + ) + it_group = Group.objects.get(name='IT') + self.head.groups.add(it_group) + self.it_user.groups.add(it_group) + self.client = Client() + + self.classic, _ = ReportTemplate.objects.update_or_create( + code='it_monthly_classic', + defaults={ + 'report_type': ReportTemplate.REPORT_IT_MONTHLY, + 'name': 'Классический IT', + 'engine': ReportTemplate.ENGINE_BLOCKS_V1, + 'body_html': CLASSIC_HTML, + 'body_md': CLASSIC_MD, + 'blocks': CLASSIC_BLOCKS, + 'is_default': True, + 'is_active': True, + 'is_system': True, + }, + ) + WorkLog.objects.create( + date=date(2026, 5, 3), + technician=self.it_user, + work_type_id=_dir_id('worklog_type', 'software'), + description='Восстановление ПРРО на кассе', + ) + + def test_classic_render_html(self): + from apps.reports.engine import render_report + report = build_monthly_report(2026, 5, company={'staff': [], 'directions': []}) + html = render_report(report, self.classic, 'html') + self.assertIn('ОТЧЁТ О ВЫПОЛНЕННЫХ РАБОТАХ', html) + self.assertIn('Восстановление ПРРО', html) + + def test_blocks_v1_hides_section(self): + from apps.reports.engine import render_report + from apps.reports.models import ReportTemplate + + tpl = ReportTemplate.objects.create( + code='it-no-staff', + report_type=ReportTemplate.REPORT_IT_MONTHLY, + name='Без состава', + engine=ReportTemplate.ENGINE_BLOCKS_V1, + blocks={ + 'labels': {'main_title': 'КОРОТКИЙ', 'summary_heading': 'Сводка'}, + 'sections': ['stats', 'summary', 'signature'], + }, + is_system=False, + ) + report = build_monthly_report(2026, 5, company={'staff': [], 'directions': []}) + html = render_report(report, tpl, 'html') + self.assertIn('КОРОТКИЙ', html) + self.assertIn('Сводка', html) + self.assertNotIn('Состав IT-отдела', html) + self.assertNotIn('Выполненные работы по сотрудникам', html) + + def test_custom_title_in_template(self): + from apps.reports.engine import render_report + from apps.reports.models import ReportTemplate + + custom = ReportTemplate.objects.create( + code='it-custom-title', + report_type=ReportTemplate.REPORT_IT_MONTHLY, + name='Свой заголовок', + engine=ReportTemplate.ENGINE_DJANGO_HTML, + body_html='

{{ labels.main_title }}

{{ report.total_tasks }}

', + body_md='# {{ labels.main_title }}', + blocks={'labels': {'main_title': 'КАСТОМНЫЙ ОТЧЁТ IT'}}, + is_system=False, + ) + report = build_monthly_report(2026, 5, company={'staff': [], 'directions': []}) + html = render_report(report, custom, 'html') + self.assertIn('КАСТОМНЫЙ ОТЧЁТ IT', html) + + def test_invalid_template_returns_400(self): + from apps.reports.engine import TemplateRenderError, render_report + from apps.reports.models import ReportTemplate + + bad = ReportTemplate.objects.create( + code='it-bad', + report_type=ReportTemplate.REPORT_IT_MONTHLY, + name='Bad', + engine=ReportTemplate.ENGINE_DJANGO_HTML, + body_html='{% invalid_tag %}', + is_system=False, + is_default=True, + ) + self.classic.is_default = False + self.classic.save(update_fields=['is_default']) + report = build_monthly_report(2026, 5, company={'staff': [], 'directions': []}) + with self.assertRaises(TemplateRenderError): + render_report(report, bad, 'html') + login_2fa(self.client, self.head) + r = self.client.get('/reports/it/monthly/2026-05/preview/') + self.assertEqual(r.status_code, 400) + + def test_head_can_open_editor_and_copy(self): + login_2fa(self.client, self.head) + r = self.client.get('/reports/templates/') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'it_monthly_classic') + + r = self.client.post(f'/reports/templates/{self.classic.pk}/copy/') + self.assertEqual(r.status_code, 302) + from apps.reports.models import ReportTemplate + copy = ReportTemplate.objects.exclude(pk=self.classic.pk).get() + self.assertFalse(copy.is_system) + self.assertIn('копия', copy.name.lower()) + self.assertEqual(copy.engine, ReportTemplate.ENGINE_BLOCKS_V1) + + r = self.client.get(f'/reports/templates/{copy.pk}/') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'Конструктор') + self.assertContains(r, 'Секции отчёта') + + # системный нельзя сохранить через POST + r = self.client.post(f'/reports/templates/{self.classic.pk}/', { + 'name': 'Hacked', 'description': '', 'body_html': 'x', 'body_md': '', + 'blocks': '{}', 'engine': 'blocks_v1', + }) + self.assertEqual(r.status_code, 403) + self.classic.refresh_from_db() + self.assertNotEqual(self.classic.name, 'Hacked') + + # копию можно сохранить через конструктор + r = self.client.post(f'/reports/templates/{copy.pk}/', { + 'name': copy.name, + 'description': '', + 'engine': 'blocks_v1', + 'body_html': '', + 'body_md': '', + 'blocks': json.dumps({ + 'labels': {'main_title': 'МОЙ ОТЧЁТ'}, + 'sections': ['stats', 'summary', 'signature'], + }), + 'is_active': 'on', + }) + self.assertEqual(r.status_code, 200) + copy.refresh_from_db() + self.assertEqual(copy.blocks['labels']['main_title'], 'МОЙ ОТЧЁТ') + self.assertEqual(copy.blocks['sections'], ['stats', 'summary', 'signature']) + + def test_regular_it_forbidden_templates(self): + login_2fa(self.client, self.it_user) + self.assertEqual(self.client.get('/reports/templates/').status_code, 403) + + def test_preview_draft_renders_and_rejects_bad_syntax(self): + login_2fa(self.client, self.head) + url = f'/reports/templates/{self.classic.pk}/preview-draft/' + ok = self.client.post(url, { + 'engine': 'blocks_v1', + 'body_html': '', + 'blocks': json.dumps({ + 'labels': {'main_title': 'ПРЕВЬЮ'}, + 'sections': ['stats', 'signature'], + }), + 'month': '2026-05', + }) + self.assertEqual(ok.status_code, 200) + self.assertIn('ПРЕВЬЮ', ok.content.decode()) + + bad = self.client.post(url, { + 'engine': 'django_html', + 'body_html': '{% broken %}', + 'blocks': '{}', + 'month': '2026-05', + }) + self.assertEqual(bad.status_code, 400) + self.assertIn('Ошибка', bad.content.decode()) diff --git a/apps/reports/urls.py b/apps/reports/urls.py new file mode 100644 index 0000000..e860bf2 --- /dev/null +++ b/apps/reports/urls.py @@ -0,0 +1,41 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('reports/', views.reports_hub_view, name='reports'), + path('reports/templates/', views.templates_list_view, name='reports_templates'), + path('reports/templates//', views.template_edit_view, name='reports_template_edit'), + path( + 'reports/templates//preview-draft/', + views.template_preview_draft_view, + name='reports_template_preview_draft', + ), + path('reports/templates//copy/', views.template_copy_view, name='reports_template_copy'), + path( + 'reports/templates//set-default/', + views.template_set_default_view, + name='reports_template_set_default', + ), + path('reports/it/monthly/', views.it_monthly_view, name='reports_it_monthly'), + path( + 'reports/it/monthly/.md', + views.it_monthly_md_view, + name='reports_it_monthly_md', + ), + path( + 'reports/it/monthly/.docx', + views.it_monthly_docx_view, + name='reports_it_monthly_docx', + ), + path( + 'reports/it/monthly/.pdf', + views.it_monthly_pdf_view, + name='reports_it_monthly_pdf', + ), + path( + 'reports/it/monthly//preview/', + views.it_monthly_preview_view, + name='reports_it_monthly_preview', + ), +] diff --git a/apps/reports/views.py b/apps/reports/views.py new file mode 100644 index 0000000..6fa0d1e --- /dev/null +++ b/apps/reports/views.py @@ -0,0 +1,319 @@ +"""Views раздела «Отчёты».""" +from __future__ import annotations + +import json +import re + +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.views.decorators.clickjacking import xframe_options_sameorigin +from django.views.decorators.http import require_POST + +from apps.reports.access import user_can_edit_template, user_can_manage_templates +from apps.reports.engine import TemplateRenderError, render_report +from apps.reports.it_monthly import ( + available_report_months, + build_monthly_report, + parse_month_id, + user_can_view_it_monthly_report, +) +from apps.reports.models import ReportTemplate +from apps.reports.registry import visible_reports +from core.portal_access import deny_section_access, user_can_access +from core.utils.htmx import render_page + +_SLUG_RE = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$') + + +def _can_open_reports_hub(user) -> bool: + if user_can_access(user, 'reports'): + return True + return bool(visible_reports(user)) + + +def _default_it_template() -> ReportTemplate | None: + """Шаблон по умолчанию для IT (без выбора в UI отчёта).""" + return ReportTemplate.resolve(ReportTemplate.REPORT_IT_MONTHLY) + + +@login_required +def reports_hub_view(request): + if not _can_open_reports_hub(request.user): + return deny_section_access(request) + specs = visible_reports(request.user) + return render_page(request, 'reports.html', { + 'report_specs': specs, + 'can_manage_templates': user_can_manage_templates(request.user), + }) + + +@login_required +def it_monthly_view(request): + if not user_can_view_it_monthly_report(request.user): + return HttpResponseForbidden() + year, month = parse_month_id(request.GET.get('month') or request.GET.get('report_month')) + report = build_monthly_report(year, month) + return render_page(request, 'reports-it-monthly.html', { + 'report': report, + 'can_manage_templates': user_can_manage_templates(request.user), + }) + + +def _export_with_template(request, month_id, fmt: str): + if not user_can_view_it_monthly_report(request.user): + return HttpResponseForbidden() + year, month = parse_month_id(month_id) + report = build_monthly_report(year, month) + tpl = _default_it_template() + try: + payload = render_report(report, tpl, fmt) + except TemplateRenderError as exc: + return HttpResponseBadRequest(f'Ошибка шаблона: {exc}') + if fmt == 'md': + response = HttpResponse(payload, content_type='text/markdown; charset=utf-8') + response['Content-Disposition'] = f'attachment; filename="it-report-{report["id"]}.md"' + return response + if fmt == 'docx': + response = HttpResponse( + payload, + content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ) + response['Content-Disposition'] = f'attachment; filename="IT-report-{report["id"]}.docx"' + return response + if fmt == 'pdf': + response = HttpResponse(payload, content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="IT-report-{report["id"]}.pdf"' + return response + return HttpResponseBadRequest('Unknown format') + + +@login_required +def it_monthly_md_view(request, month_id): + return _export_with_template(request, month_id, 'md') + + +@login_required +def it_monthly_docx_view(request, month_id): + return _export_with_template(request, month_id, 'docx') + + +@login_required +def it_monthly_pdf_view(request, month_id): + return _export_with_template(request, month_id, 'pdf') + + +@login_required +@xframe_options_sameorigin +def it_monthly_preview_view(request, month_id): + if not user_can_view_it_monthly_report(request.user): + return HttpResponseForbidden() + year, month = parse_month_id(month_id) + report = build_monthly_report(year, month) + tpl = _default_it_template() + try: + html = render_report(report, tpl, 'html') + except TemplateRenderError as exc: + return HttpResponse( + f'

Ошибка шаблона: {exc}

', + content_type='text/html; charset=utf-8', + status=400, + ) + return HttpResponse(html, content_type='text/html; charset=utf-8') + + +def redirect_legacy_it_report(request, month_id, fmt: str): + if fmt == 'preview': + return HttpResponseRedirect(f'/reports/it/monthly/{month_id}/preview/') + return HttpResponseRedirect(f'/reports/it/monthly/{month_id}.{fmt}') + + +# ── Шаблоны ────────────────────────────────────────────────────────────── + + +@login_required +def templates_list_view(request): + if not user_can_manage_templates(request.user): + return HttpResponseForbidden() + templates = ReportTemplate.objects.filter( + report_type=ReportTemplate.REPORT_IT_MONTHLY, + ).order_by('-is_default', 'name') + return render_page(request, 'reports-templates.html', { + 'templates': templates, + 'can_manage_templates': True, + }) + + +@login_required +def template_edit_view(request, pk): + if not user_can_manage_templates(request.user): + return HttpResponseForbidden() + tpl = get_object_or_404(ReportTemplate, pk=pk) + can_edit = user_can_edit_template(request.user, tpl) + error = '' + message = '' + + if request.method == 'POST': + if not can_edit: + return HttpResponseForbidden('Системный шаблон можно только скопировать.') + name = request.POST.get('name', '').strip() + description = request.POST.get('description', '').strip() + body_html = request.POST.get('body_html', '') + body_md = request.POST.get('body_md', '') + blocks_raw = request.POST.get('blocks', '').strip() + engine = request.POST.get('engine', '').strip() + if engine not in (ReportTemplate.ENGINE_BLOCKS_V1, ReportTemplate.ENGINE_DJANGO_HTML): + engine = tpl.engine or ReportTemplate.ENGINE_BLOCKS_V1 + is_default = bool(request.POST.get('is_default')) + is_active = request.POST.get('is_active') == 'on' + + from apps.reports.engine.blocks_v1 import normalize_blocks + + blocks = normalize_blocks(tpl.blocks) + if blocks_raw: + try: + parsed = json.loads(blocks_raw) + if not isinstance(parsed, dict): + raise ValueError('JSON должен быть объектом {...}') + blocks = normalize_blocks(parsed) + except (json.JSONDecodeError, ValueError) as exc: + error = f'Некорректный JSON blocks: {exc}' + + if not error: + if not name: + error = 'Укажите название.' + elif engine == ReportTemplate.ENGINE_DJANGO_HTML: + try: + from apps.reports.engine.django_tpl import render_django_string + render_django_string(body_html or ' ', { + 'report': {}, 'labels': {}, 'company': {}, + 'month': {}, 'stats': {}, 'employees': [], + 'conclusions': [], 'month_title': '', + 'prepared_date': '', + }) + except TemplateRenderError as exc: + error = f'Ошибка HTML-шаблона: {exc}' + + if not error: + tpl.name = name + tpl.description = description + tpl.engine = engine + tpl.body_html = body_html + tpl.body_md = body_md + tpl.blocks = blocks + tpl.is_default = is_default + tpl.is_active = is_active + tpl.save() + message = 'Шаблон сохранён.' + can_edit = user_can_edit_template(request.user, tpl) + + from apps.reports.engine.blocks_v1 import labels_for_ui, normalize_blocks, sections_for_ui + + blocks_norm = normalize_blocks(tpl.blocks) + months = available_report_months() + return render_page(request, 'reports-template-edit.html', { + 'tpl': tpl, + 'can_edit': can_edit, + 'blocks_json': json.dumps(blocks_norm, ensure_ascii=False, indent=2), + 'label_fields': labels_for_ui(blocks_norm), + 'section_rows': sections_for_ui(blocks_norm), + 'editor_mode': ( + 'advanced' if tpl.engine == ReportTemplate.ENGINE_DJANGO_HTML else 'constructor' + ), + 'error': error, + 'message': message, + 'can_manage_templates': True, + 'preview_months': months, + 'preview_month': months[0] if months else '', + }) + + +@login_required +@require_POST +def template_preview_draft_view(request, pk): + """Рендер черновика без сохранения (конструктор или Ace).""" + if not user_can_manage_templates(request.user): + return HttpResponseForbidden() + get_object_or_404(ReportTemplate, pk=pk) + + from apps.reports.engine.blocks_v1 import normalize_blocks, render_blocks_html + from apps.reports.engine.context import build_template_context, load_default_company + from apps.reports.engine.django_tpl import render_django_string as render_str + + engine = (request.POST.get('engine') or ReportTemplate.ENGINE_BLOCKS_V1).strip() + body_html = request.POST.get('body_html', '') + blocks_raw = (request.POST.get('blocks') or '').strip() + blocks = normalize_blocks(None) + if blocks_raw: + try: + parsed = json.loads(blocks_raw) + if isinstance(parsed, dict): + blocks = normalize_blocks(parsed) + except json.JSONDecodeError: + pass + + year, month = parse_month_id(request.POST.get('month')) + report = build_monthly_report(year, month) + ctx = build_template_context( + report, company=load_default_company(), labels=blocks.get('labels'), + ) + try: + if engine == ReportTemplate.ENGINE_DJANGO_HTML: + html = render_str(body_html or ' ', ctx) + else: + html = render_blocks_html(ctx, blocks) + except TemplateRenderError as exc: + return HttpResponse( + f'' + f'Ошибка шаблона
{exc}
' + f'', + content_type='text/html; charset=utf-8', + status=400, + ) + return HttpResponse(html, content_type='text/html; charset=utf-8') + + +@login_required +@require_POST +def template_copy_view(request, pk): + if not user_can_manage_templates(request.user): + return HttpResponseForbidden() + src = get_object_or_404(ReportTemplate, pk=pk) + base = f'{src.code}-copy' + code = base + n = 2 + while ReportTemplate.objects.filter(code=code).exists(): + code = f'{base}-{n}' + n += 1 + if not _SLUG_RE.match(code): + code = f'copy-{src.pk}-{n}' + + from apps.reports.engine.blocks_v1 import normalize_blocks + + copy = ReportTemplate.objects.create( + code=code, + report_type=src.report_type, + name=f'{src.name} (копия)', + description=src.description, + # копия всегда в конструкторе — проще править + engine=ReportTemplate.ENGINE_BLOCKS_V1, + body_html=src.body_html, + body_md=src.body_md, + blocks=normalize_blocks(src.blocks), + is_default=False, + is_active=True, + is_system=False, + created_by=request.user, + ) + return HttpResponseRedirect(f'/reports/templates/{copy.pk}/') + + +@login_required +@require_POST +def template_set_default_view(request, pk): + if not user_can_manage_templates(request.user): + return HttpResponseForbidden() + tpl = get_object_or_404(ReportTemplate, pk=pk, is_active=True) + tpl.is_default = True + tpl.save() + return HttpResponseRedirect('/reports/templates/') diff --git a/static/assets/js/it-manager.js b/static/assets/js/it-manager.js index e85b0ea..0713e3a 100644 --- a/static/assets/js/it-manager.js +++ b/static/assets/js/it-manager.js @@ -49,7 +49,69 @@ function itContractServiceToggle(serviceTypeCode) { } } +function itContractFrameworkToggle(isFramework) { + var parentWrap = document.getElementById('it-contract-parent-wrap'); + var locationWrap = document.getElementById('it-contract-location-wrap'); + var locationSelect = document.getElementById('it-contract-location-select'); + var locationRequired = document.getElementById('it-contract-location-required'); + + if (parentWrap) { + parentWrap.style.display = isFramework ? 'none' : ''; + } + if (isFramework) { + var sel = document.getElementById('it-contract-parent-select'); + if (sel) sel.value = ''; + } + + if (locationWrap) { + locationWrap.style.display = isFramework ? 'none' : ''; + } + if (locationSelect) { + locationSelect.required = !isFramework; + if (isFramework) locationSelect.value = ''; + } + if (locationRequired) { + locationRequired.style.display = isFramework ? 'none' : ''; + } +} + +function itContractFilterParents() { + var providerSelect = document.getElementById('it-contract-provider-select'); + var parentSelect = document.getElementById('it-contract-parent-select'); + if (!providerSelect || !parentSelect) return; + var providerId = providerSelect.value; + Array.prototype.forEach.call(parentSelect.options, function (opt) { + if (!opt.value) { + opt.hidden = false; + return; + } + opt.hidden = providerId && opt.getAttribute('data-provider') !== providerId; + }); + if (parentSelect.selectedOptions[0] && parentSelect.selectedOptions[0].hidden) { + parentSelect.value = ''; + } +} + // --- Масовий вибір екземплярів обладнання --- +// --- Выдача оборудования: тип → список --- +function itAssignTargetChanged(selectEl) { + var root = selectEl.closest('[data-assign-widget]'); + if (!root) return; + var target = selectEl.value || ''; + root.querySelectorAll('[data-assign-pick]').forEach(function (pick) { + var match = pick.getAttribute('data-assign-pick') === target; + pick.style.display = match ? '' : 'none'; + pick.querySelectorAll('select').forEach(function (inp) { + inp.disabled = !match; + if (!match) { + inp.value = ''; + // searchable-select слушает change + inp.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + }); +} + function itBulkToggleAll(headerCheckbox) { document.querySelectorAll('.it-equip-check').forEach(function (cb) { cb.checked = headerCheckbox.checked; @@ -88,3 +150,117 @@ function itBulkClearSelection() { if (headerAll) headerAll.checked = false; itBulkUpdate(); } + +function itSetSearchableSelectValue(selectId, value) { + var select = document.getElementById(selectId); + if (!select) return; + select.value = value || ''; + select.dispatchEvent(new Event('change', { bubbles: true })); + var widget = select.closest('.searchable-select'); + if (widget && widget._search) { + var opt = select.options[select.selectedIndex]; + widget._search.value = (opt && opt.value) ? opt.textContent : ''; + if (typeof widget._updateClear === 'function') widget._updateClear(); + else if (widget._clear) widget._clear.style.display = select.value ? 'flex' : 'none'; + } +} + +function itWorklogFromTicket(ticket) { + if (!ticket || !ticket.id) return; + itTabSwitch('worklog'); + itPanelOpen('it-worklog-panel'); + + itSetSearchableSelectValue('it-worklog-ticket', String(ticket.id)); + if (ticket.locationId) { + itSetSearchableSelectValue('it-worklog-location', String(ticket.locationId)); + } + var equipment = document.getElementById('it-worklog-equipment'); + if (equipment && ticket.equipmentId) { + equipment.value = String(ticket.equipmentId); + } + var description = document.getElementById('it-worklog-description'); + if (description && !description.value) { + description.value = 'Работа по заявке ' + ticket.number + (ticket.title ? ': ' + ticket.title : ''); + } + var hint = document.getElementById('it-worklog-ticket-hint'); + if (hint) { + hint.style.display = ''; + hint.textContent = 'Заявка ' + ticket.number + (ticket.title ? ' — ' + ticket.title : ''); + } + var typeSelect = document.getElementById('it-worklog-type'); + if (typeSelect) typeSelect.focus(); +} + +function itWorklogTicketChanged() { + var select = document.getElementById('it-worklog-ticket'); + if (!select) return; + var opt = select.options[select.selectedIndex]; + var hint = document.getElementById('it-worklog-ticket-hint'); + if (!opt || !opt.value) { + if (hint) { + hint.style.display = 'none'; + hint.textContent = ''; + } + return; + } + var locationId = opt.getAttribute('data-location') || ''; + var equipmentId = opt.getAttribute('data-equipment') || ''; + if (locationId) itSetSearchableSelectValue('it-worklog-location', locationId); + var equipment = document.getElementById('it-worklog-equipment'); + if (equipment && equipmentId) equipment.value = equipmentId; + if (hint) { + hint.style.display = ''; + hint.textContent = opt.textContent.trim(); + } +} + +document.addEventListener('change', function (ev) { + if (ev.target && ev.target.id === 'it-worklog-ticket') { + itWorklogTicketChanged(); + } +}); + +function itReportPreviewOpen(url, title) { + var overlay = document.getElementById('it-report-preview-overlay'); + var modal = document.getElementById('it-report-preview-modal'); + var frame = document.getElementById('it-report-preview-frame'); + var subtitle = document.getElementById('it-report-preview-subtitle'); + var openTab = document.getElementById('it-report-preview-open-tab'); + if (!overlay || !modal || !frame) return; + + if (subtitle) subtitle.textContent = title || ''; + if (openTab) openTab.href = url; + frame.src = url; + overlay.style.display = 'block'; + modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; +} + +function itReportPreviewClose() { + var overlay = document.getElementById('it-report-preview-overlay'); + var modal = document.getElementById('it-report-preview-modal'); + var frame = document.getElementById('it-report-preview-frame'); + if (overlay) overlay.style.display = 'none'; + if (modal) modal.style.display = 'none'; + if (frame) frame.src = 'about:blank'; + document.body.style.overflow = ''; +} + +document.addEventListener('keydown', function (ev) { + if (ev.key !== 'Escape') return; + var modal = document.getElementById('it-report-preview-modal'); + if (modal && modal.style.display === 'flex') itReportPreviewClose(); +}); + +document.addEventListener('htmx:afterSwap', function (ev) { + var target = ev.detail && ev.detail.target; + if (!target || target.id !== 'main-content') return; + var root = target.querySelector('[data-it-active-tab]'); + if (root && typeof itTabSwitch === 'function') { + itTabSwitch(root.getAttribute('data-it-active-tab') || 'equipment'); + } + if (typeof initSearchableSelects === 'function') { + initSearchableSelects(target); + } +}); + diff --git a/static/assets/js/reports-template-editor.js b/static/assets/js/reports-template-editor.js new file mode 100644 index 0000000..2818657 --- /dev/null +++ b/static/assets/js/reports-template-editor.js @@ -0,0 +1,329 @@ +/** + * Конструктор шаблонов отчётов (секции + подписи) и Ace для advanced HTML. + */ +(function () { + var ACE_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.2/ace.js'; + var editors = {}; + var previewTimer = null; + var aceReady = false; + + function csrfToken() { + var el = document.querySelector('#report-tpl-form input[name=csrfmiddlewaretoken]'); + if (el && el.value) return el.value; + var m = document.cookie.match(/csrftoken=([^;]+)/); + return m ? decodeURIComponent(m[1]) : ''; + } + + function currentMode() { + var root = document.getElementById('report-tpl-editor-root'); + return (root && root.getAttribute('data-mode')) || 'constructor'; + } + + function setMode(mode) { + var root = document.getElementById('report-tpl-editor-root'); + if (!root) return; + root.setAttribute('data-mode', mode); + var engine = document.getElementById('tpl-engine'); + if (engine) engine.value = mode === 'advanced' ? 'django_html' : 'blocks_v1'; + + var c = document.getElementById('tpl-panel-constructor'); + var a = document.getElementById('tpl-panel-advanced'); + if (c) c.hidden = mode !== 'constructor'; + if (a) a.hidden = mode !== 'advanced'; + + document.querySelectorAll('.tpl-mode-btn').forEach(function (btn) { + var on = btn.getAttribute('data-mode') === mode; + btn.style.fontWeight = on ? '600' : ''; + btn.style.borderBottom = on ? '2px solid var(--color-primary)' : ''; + btn.style.borderRadius = on ? '0' : ''; + }); + + if (mode === 'advanced') { + ensureAce(function () { + mountEditor('tpl-body-html', 'ace/mode/html'); + mountEditor('tpl-body-md', 'ace/mode/markdown'); + // sync blocks JSON into a visible ace if host exists + var blocksTa = document.getElementById('tpl-blocks'); + var adv = document.getElementById('tpl-blocks-adv'); + if (blocksTa && adv) adv.value = blocksTa.value; + mountEditor('tpl-blocks-adv', 'ace/mode/json'); + // mirror: when blocks-adv changes, copy to tpl-blocks + if (editors['tpl-blocks-adv']) { + editors['tpl-blocks-adv'].session.off('change', editors['tpl-blocks-adv']._blocksMirror); + editors['tpl-blocks-adv']._blocksMirror = function () { + var ta = document.getElementById('tpl-blocks'); + if (ta) ta.value = editors['tpl-blocks-adv'].getValue(); + schedulePreview(); + }; + editors['tpl-blocks-adv'].session.on('change', editors['tpl-blocks-adv']._blocksMirror); + } + }); + } else { + syncConstructorToBlocks(); + } + schedulePreview(); + } + + function loadAce(cb) { + if (window.ace) { + cb(); + return; + } + if (window._aceLoading) { + window._aceLoading.push(cb); + return; + } + window._aceLoading = [cb]; + var s = document.createElement('script'); + s.src = ACE_CDN; + s.onload = function () { + var q = window._aceLoading || []; + window._aceLoading = null; + q.forEach(function (fn) { fn(); }); + }; + s.onerror = function () { + window._aceLoading = null; + console.error('Не удалось загрузить Ace editor'); + }; + document.head.appendChild(s); + } + + function ensureAce(cb) { + loadAce(function () { + aceReady = true; + cb(); + }); + } + + function syncAceToTextareas() { + Object.keys(editors).forEach(function (id) { + var ta = document.getElementById(id); + if (ta && editors[id]) ta.value = editors[id].getValue(); + }); + var adv = document.getElementById('tpl-blocks-adv'); + var blocks = document.getElementById('tpl-blocks'); + if (adv && blocks && currentMode() === 'advanced') { + blocks.value = adv.value; + } + } + + function mountEditor(textareaId, mode) { + var root = document.getElementById('report-tpl-editor-root'); + var readOnly = root && root.getAttribute('data-readonly') === '1'; + var ta = document.getElementById(textareaId); + if (!ta || !window.ace) return; + var hostId = textareaId === 'tpl-blocks-adv' ? 'tpl-blocks-ace' : (textareaId + '-ace'); + var host = document.getElementById(hostId); + if (!host) return; + if (editors[textareaId]) { + try { editors[textareaId].destroy(); } catch (e) { /* ignore */ } + delete editors[textareaId]; + } + host.innerHTML = ''; + host.style.display = 'block'; + ta.style.position = 'absolute'; + ta.style.left = '-9999px'; + ta.style.height = '1px'; + ta.style.width = '1px'; + ta.setAttribute('aria-hidden', 'true'); + + var ed = ace.edit(host); + ed.setTheme('ace/theme/textmate'); + ed.session.setMode(mode); + ed.setValue(ta.value || '', -1); + ed.setReadOnly(!!readOnly); + ed.setOptions({ + fontSize: '13px', + showPrintMargin: false, + wrap: true, + useSoftTabs: true, + tabSize: 2, + }); + ed.session.on('change', function () { + ta.value = ed.getValue(); + if (textareaId === 'tpl-body-html' || textareaId === 'tpl-blocks-adv') { + schedulePreview(); + } + }); + editors[textareaId] = ed; + } + + function readBlocksJson() { + var ta = document.getElementById('tpl-blocks'); + try { + return JSON.parse((ta && ta.value) || '{}'); + } catch (e) { + return { labels: {}, sections: [] }; + } + } + + function writeBlocksJson(obj) { + var ta = document.getElementById('tpl-blocks'); + if (ta) ta.value = JSON.stringify(obj, null, 2); + if (editors['tpl-blocks-adv']) { + editors['tpl-blocks-adv'].setValue(ta.value, -1); + } + } + + function syncConstructorToBlocks() { + var data = readBlocksJson(); + if (!data.labels || typeof data.labels !== 'object') data.labels = {}; + document.querySelectorAll('.tpl-label-input').forEach(function (inp) { + var key = inp.getAttribute('data-label-key'); + if (key) data.labels[key] = inp.value; + }); + var sections = []; + document.querySelectorAll('#tpl-sections-list .tpl-section-row').forEach(function (row) { + var on = row.querySelector('.tpl-section-on'); + if (on && on.checked) sections.push(row.getAttribute('data-section-id')); + }); + data.sections = sections; + writeBlocksJson(data); + return data; + } + + function moveSection(row, dir) { + var list = document.getElementById('tpl-sections-list'); + if (!list || !row) return; + if (dir < 0 && row.previousElementSibling) { + list.insertBefore(row, row.previousElementSibling); + } else if (dir > 0 && row.nextElementSibling) { + list.insertBefore(row.nextElementSibling, row); + } + syncConstructorToBlocks(); + schedulePreview(); + } + + function schedulePreview() { + if (previewTimer) clearTimeout(previewTimer); + previewTimer = setTimeout(runPreview, 500); + } + + function runPreview() { + var root = document.getElementById('report-tpl-editor-root'); + var frame = document.getElementById('report-tpl-preview-frame'); + if (!root || !frame) return; + var pk = root.getAttribute('data-tpl-id'); + if (!pk) return; + + if (currentMode() === 'constructor') { + syncConstructorToBlocks(); + } else { + syncAceToTextareas(); + } + + var htmlTa = document.getElementById('tpl-body-html'); + var blocksTa = document.getElementById('tpl-blocks'); + var engineEl = document.getElementById('tpl-engine'); + var monthSel = document.getElementById('tpl-preview-month'); + var status = document.getElementById('report-tpl-preview-status'); + + var fd = new FormData(); + fd.append('engine', engineEl ? engineEl.value : 'blocks_v1'); + fd.append('body_html', htmlTa ? htmlTa.value : ''); + fd.append('blocks', blocksTa ? blocksTa.value : '{}'); + if (monthSel) fd.append('month', monthSel.value); + + if (status) status.textContent = 'Обновление…'; + fetch('/reports/templates/' + pk + '/preview-draft/', { + method: 'POST', + headers: { 'X-CSRFToken': csrfToken() }, + body: fd, + credentials: 'same-origin', + }).then(function (res) { + return res.text().then(function (text) { + return { ok: res.ok, text: text }; + }); + }).then(function (r) { + frame.srcdoc = r.text; + if (status) status.textContent = r.ok ? 'Предпросмотр' : 'Ошибка в шаблоне'; + }).catch(function () { + if (status) status.textContent = 'Сеть / ошибка запроса'; + }); + } + + function bindConstructor() { + var list = document.getElementById('tpl-sections-list'); + if (list && !list._bound) { + list._bound = true; + list.addEventListener('change', function (e) { + if (e.target && e.target.classList.contains('tpl-section-on')) { + var row = e.target.closest('.tpl-section-row'); + if (row) row.style.opacity = e.target.checked ? '' : '0.55'; + syncConstructorToBlocks(); + schedulePreview(); + } + }); + list.addEventListener('click', function (e) { + var up = e.target.closest('.tpl-sec-up'); + var down = e.target.closest('.tpl-sec-down'); + if (up) { + e.preventDefault(); + moveSection(up.closest('.tpl-section-row'), -1); + } else if (down) { + e.preventDefault(); + moveSection(down.closest('.tpl-section-row'), 1); + } + }); + } + document.querySelectorAll('.tpl-label-input').forEach(function (inp) { + if (inp._bound) return; + inp._bound = true; + inp.addEventListener('input', function () { + syncConstructorToBlocks(); + schedulePreview(); + }); + }); + } + + function init() { + var root = document.getElementById('report-tpl-editor-root'); + if (!root) return; + editors = {}; + + document.querySelectorAll('.tpl-mode-btn').forEach(function (btn) { + if (btn._bound) return; + btn._bound = true; + btn.addEventListener('click', function () { + setMode(btn.getAttribute('data-mode') || 'constructor'); + }); + }); + + bindConstructor(); + + var form = document.getElementById('report-tpl-form'); + if (form && !form._tplBound) { + form._tplBound = true; + form.addEventListener('submit', function () { + if (currentMode() === 'constructor') { + syncConstructorToBlocks(); + } else { + syncAceToTextareas(); + } + }); + } + var monthSel = document.getElementById('tpl-preview-month'); + if (monthSel && !monthSel._tplBound) { + monthSel._tplBound = true; + monthSel.addEventListener('change', schedulePreview); + } + var btn = document.getElementById('report-tpl-preview-btn'); + if (btn && !btn._tplBound) { + btn._tplBound = true; + btn.addEventListener('click', function (e) { + e.preventDefault(); + runPreview(); + }); + } + + setMode(currentMode()); + } + + document.addEventListener('DOMContentLoaded', init); + document.addEventListener('htmx:afterSwap', function (ev) { + var t = ev.detail && ev.detail.target; + if (t && (t.id === 'main-content' || (t.querySelector && t.querySelector('#report-tpl-editor-root')))) { + init(); + } + }); +})(); diff --git a/templates/pages/internet-billing.html b/templates/pages/internet-billing.html new file mode 100644 index 0000000..80eee76 --- /dev/null +++ b/templates/pages/internet-billing.html @@ -0,0 +1,154 @@ +{% load i18n %} +
+ + {% if billing_form_message %} +
+ {{ billing_form_message }} +
+ {% endif %} + {% if billing_form_error %} +
+ {{ billing_form_error }} +
+ {% endif %} + +
+
+ + + + {{ billing_period_label }} + + + +
+
+ + + +
+
+ +
+
+
{% trans "К оплате" %}
+
{{ billing_total_amount|default:"0" }} {% trans "грн" %}
+
{{ billing_contract_count }} {% trans "договоров" %}
+
+
+
{% trans "Оплачено" %}
+
{{ billing_paid_amount|default:"0" }} {% trans "грн" %}
+
{{ billing_paid_count }} {% trans "договоров" %}
+
+
+
{% trans "Не оплачено" %}
+
{{ billing_unpaid_count }}
+
{% trans "договоров" %}
+
+
+ +
+
+ + + + + + + + + + + + + {% for row in billing_rows %} + {% if row.is_provider_header %} + + + + {% else %} + {% with c=row.contract %} + + + + + + + + + {% endwith %} + {% endif %} + {% empty %} + + + + {% endfor %} + +
{% trans "Договор / локация" %}{% trans "Провайдер" %}{% trans "Сумма" %}{% trans "Статус" %}{% trans "Счёт" %}
+ {{ row.provider.name }} +
+
+ {% if row.indent %} + └ + {% elif row.is_framework and row.has_children %} + ▾ + {% else %} + + {% endif %} +
+ {% if row.is_framework %} + {% trans "Общий" %} + {% endif %} + №{{ c.contract_number }} +
{{ c.location_display }}
+ {% if row.has_children %} +
{% trans "группа договоров" %}
+ {% endif %} +
+
+
+ {% if row.indent %}—{% else %}{{ c.provider.name }}{% endif %} + + {{ row.amount|default:"—" }}{% if row.amount %} {% trans "грн" %}{% endif %} + + {% if row.is_paid %} + {% trans "Оплачено" %} + {% if row.payment.paid_at %} +
{{ row.payment.paid_at|date:"d.m.Y" }}
+ {% endif %} + {% else %} + {% trans "Не оплачено" %} + {% endif %} +
+ {{ row.payment.invoice_number|default:"—" }} + +
+ {% csrf_token %} + + + + {% if billing_filter_provider %}{% endif %} + +
+
+ {% trans "Нет активных договоров для учёта оплат." %} +
+
+
+
diff --git a/templates/pages/it-manager.html b/templates/pages/it-manager.html index 4360ba2..5589793 100644 --- a/templates/pages/it-manager.html +++ b/templates/pages/it-manager.html @@ -1,6 +1,7 @@ {% load common_tags %} {% load i18n %} -
+{% with active_tab=it_active_tab|default:"equipment" %} +

{% trans "IT-отдел" %}

@@ -9,22 +10,30 @@
- - - - - - + + + + + + + {% if it_can_view_monthly_report %} + + {% trans "Месячный отчёт" %} → + + {% endif %}
-
+