diff --git a/apps/power_assets/__init__.py b/apps/power_assets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/power_assets/admin.py b/apps/power_assets/admin.py new file mode 100644 index 0000000..4745858 --- /dev/null +++ b/apps/power_assets/admin.py @@ -0,0 +1,38 @@ +from django.contrib import admin + +from .models import PowerAsset, PowerAssetService + + +class PowerAssetServiceInline(admin.TabularInline): + model = PowerAssetService + extra = 0 + fields = ( + 'service_date', 'kind', 'description', 'performed_by_name', + 'engine_hours', 'cost', 'next_service_at', + ) + ordering = ('-service_date',) + + +@admin.register(PowerAsset) +class PowerAssetAdmin(admin.ModelAdmin): + list_display = ( + 'asset_type', 'brand', 'model_name', 'serial_number', + 'location', 'status', 'capacity_kw', + 'last_serviced_at', 'next_service_at', 'installed_at', + ) + list_filter = ('asset_type', 'status') + search_fields = ('brand', 'model_name', 'serial_number', 'inventory_number', 'notes') + autocomplete_fields = ('location',) + raw_id_fields = ('created_by',) + inlines = [PowerAssetServiceInline] + + +@admin.register(PowerAssetService) +class PowerAssetServiceAdmin(admin.ModelAdmin): + list_display = ( + 'service_date', 'asset', 'kind', 'performed_by_name', + 'engine_hours', 'next_service_at', + ) + list_filter = ('kind',) + search_fields = ('description', 'performed_by_name', 'asset__model_name', 'asset__serial_number') + raw_id_fields = ('asset', 'performed_by', 'created_by') diff --git a/apps/power_assets/apps.py b/apps/power_assets/apps.py new file mode 100644 index 0000000..9d22887 --- /dev/null +++ b/apps/power_assets/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PowerAssetsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.power_assets' + verbose_name = 'Энергооборудование' diff --git a/apps/power_assets/migrations/0001_initial.py b/apps/power_assets/migrations/0001_initial.py new file mode 100644 index 0000000..7add662 --- /dev/null +++ b/apps/power_assets/migrations/0001_initial.py @@ -0,0 +1,58 @@ +import functools +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import core.models.directories + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('core', '0015_alter_portalaccess_options'), + ('locations', '0013_pharmacy_edit_permission'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PowerAsset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('brand', models.CharField(blank=True, max_length=100, verbose_name='Бренд')), + ('model_name', models.CharField(max_length=120, verbose_name='Модель')), + ('serial_number', models.CharField(blank=True, help_text='Если известен — заполните для учёта.', max_length=100, verbose_name='Серийный номер')), + ('inventory_number', models.CharField(blank=True, max_length=50, verbose_name='Инвентарный номер')), + ('capacity_kw', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True, verbose_name='Мощность, кВт')), + ('fuel_type', models.CharField(blank=True, help_text='Для генераторов: бензин, дизель, газ…', max_length=50, verbose_name='Тип топлива')), + ('battery_capacity_kwh', models.DecimalField(blank=True, decimal_places=2, help_text='Для инверторных систем с накопителем.', max_digits=8, null=True, verbose_name='Ёмкость АКБ, кВт·ч')), + ('installed_at', models.DateField(blank=True, null=True, verbose_name='Дата установки')), + ('warranty_until', models.DateField(blank=True, null=True, verbose_name='Гарантия до')), + ('notes', models.TextField(blank=True, verbose_name='Примечания')), + ('asset_type', models.ForeignKey(limit_choices_to={'directory_type__code': 'power_asset_type', 'is_active': True}, on_delete=django.db.models.deletion.PROTECT, related_name='power_assets', to='core.directory', verbose_name='Тип')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='power_assets_created', to=settings.AUTH_USER_MODEL, verbose_name='Кто добавил')), + ('location', models.ForeignKey(help_text='Аптека, офис или склад, где установлено оборудование.', on_delete=django.db.models.deletion.PROTECT, related_name='power_assets', to='locations.location', verbose_name='Объект')), + ('status', models.ForeignKey(default=functools.partial(core.models.directories._resolve_directory_default, 'power_asset_status', 'active'), limit_choices_to={'directory_type__code': 'power_asset_status', 'is_active': True}, on_delete=django.db.models.deletion.PROTECT, related_name='power_assets_by_status', to='core.directory', verbose_name='Статус')), + ], + options={ + 'verbose_name': 'Энергооборудование', + 'verbose_name_plural': 'Энергооборудование', + 'ordering': ['location', 'asset_type__name', 'model_name'], + }, + ), + migrations.AddIndex( + model_name='powerasset', + index=models.Index(fields=['location', 'asset_type'], name='power_asset_loc_type_idx'), + ), + migrations.AddIndex( + model_name='powerasset', + index=models.Index(fields=['status'], name='power_asset_status_idx'), + ), + ] diff --git a/apps/power_assets/migrations/0002_seed_power_directories.py b/apps/power_assets/migrations/0002_seed_power_directories.py new file mode 100644 index 0000000..e44151a --- /dev/null +++ b/apps/power_assets/migrations/0002_seed_power_directories.py @@ -0,0 +1,55 @@ +from django.db import migrations + + +POWER_TYPES = [ + ('inverter', 'Инвертор'), + ('generator', 'Генератор'), +] + +POWER_STATUSES = [ + ('active', 'В работе'), + ('reserve', 'Резерв'), + ('repair', 'В ремонте'), + ('written_off', 'Списан'), +] + + +def forwards(apps, schema_editor): + DirectoryType = apps.get_model('core', 'DirectoryType') + Directory = apps.get_model('core', 'Directory') + + type_dt, _ = DirectoryType.objects.get_or_create( + code='power_asset_type', + defaults={'name': 'Тип энергооборудования', 'hierarchy_type': 'strict'}, + ) + for code, name in POWER_TYPES: + Directory.objects.get_or_create( + directory_type=type_dt, code=code, + defaults={'name': name, 'is_active': True}, + ) + + status_dt, _ = DirectoryType.objects.get_or_create( + code='power_asset_status', + defaults={'name': 'Статус энергооборудования', 'hierarchy_type': 'strict'}, + ) + for code, name in POWER_STATUSES: + Directory.objects.get_or_create( + directory_type=status_dt, code=code, + defaults={'name': name, 'is_active': True}, + ) + + +def backwards(apps, schema_editor): + DirectoryType = apps.get_model('core', 'DirectoryType') + DirectoryType.objects.filter(code__in=['power_asset_type', 'power_asset_status']).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('power_assets', '0001_initial'), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/apps/power_assets/migrations/0003_service_log.py b/apps/power_assets/migrations/0003_service_log.py new file mode 100644 index 0000000..d31d2cc --- /dev/null +++ b/apps/power_assets/migrations/0003_service_log.py @@ -0,0 +1,61 @@ +# Generated manually for service log + denormalized dates on PowerAsset + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('power_assets', '0002_seed_power_directories'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='powerasset', + name='last_serviced_at', + field=models.DateField(blank=True, null=True, verbose_name='Последнее обслуживание'), + ), + migrations.AddField( + model_name='powerasset', + name='next_service_at', + field=models.DateField(blank=True, help_text='Плановая дата; обновляется из журнала обслуживания.', null=True, verbose_name='Следующее обслуживание'), + ), + migrations.AddIndex( + model_name='powerasset', + index=models.Index(fields=['next_service_at'], name='power_asset_next_svc_idx'), + ), + migrations.CreateModel( + name='PowerAssetService', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('service_date', models.DateField(default=django.utils.timezone.localdate, verbose_name='Дата')), + ('kind', models.CharField(choices=[('inspection', 'Осмотр / проверка'), ('maintenance', 'Плановое ТО'), ('oil_change', 'Замена масла / фильтров'), ('battery', 'АКБ / инвертор'), ('test_run', 'Пробный запуск'), ('repair', 'Ремонт'), ('other', 'Прочее')], default='maintenance', max_length=32, verbose_name='Вид работ')), + ('description', models.TextField(verbose_name='Что сделано')), + ('performed_by_name', models.CharField(blank=True, help_text='ФИО или подрядчик, если не сотрудник портала.', max_length=120, verbose_name='Исполнитель')), + ('engine_hours', models.DecimalField(blank=True, decimal_places=1, help_text='Для генераторов — показания на момент ТО.', max_digits=10, null=True, verbose_name='Моточасы')), + ('cost', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Стоимость')), + ('next_service_at', models.DateField(blank=True, null=True, verbose_name='Следующее ТО')), + ('notes', models.CharField(blank=True, max_length=255, verbose_name='Комментарий')), + ('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_logs', to='power_assets.powerasset', verbose_name='Оборудование')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='power_services_created', to=settings.AUTH_USER_MODEL, verbose_name='Кто внёс')), + ('performed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='power_services_done', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), + ], + options={ + 'verbose_name': 'Обслуживание энергооборудования', + 'verbose_name_plural': 'Журнал обслуживания', + 'ordering': ['-service_date', '-id'], + }, + ), + migrations.AddIndex( + model_name='powerassetservice', + index=models.Index(fields=['asset', 'service_date'], name='power_svc_asset_date_idx'), + ), + ] diff --git a/apps/power_assets/migrations/0004_rename_power_asset_loc_type_idx_power_asset_locatio_391973_idx_and_more.py b/apps/power_assets/migrations/0004_rename_power_asset_loc_type_idx_power_asset_locatio_391973_idx_and_more.py new file mode 100644 index 0000000..c6c8d8e --- /dev/null +++ b/apps/power_assets/migrations/0004_rename_power_asset_loc_type_idx_power_asset_locatio_391973_idx_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.3 on 2026-09-08 19:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('power_assets', '0003_service_log'), + ] + + operations = [ + migrations.RenameIndex( + model_name='powerasset', + new_name='power_asset_locatio_391973_idx', + old_name='power_asset_loc_type_idx', + ), + migrations.RenameIndex( + model_name='powerasset', + new_name='power_asset_status__3b5c24_idx', + old_name='power_asset_status_idx', + ), + migrations.RenameIndex( + model_name='powerasset', + new_name='power_asset_next_se_7337c9_idx', + old_name='power_asset_next_svc_idx', + ), + migrations.RenameIndex( + model_name='powerassetservice', + new_name='power_asset_asset_i_c0243e_idx', + old_name='power_svc_asset_date_idx', + ), + ] diff --git a/apps/power_assets/migrations/__init__.py b/apps/power_assets/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/power_assets/models.py b/apps/power_assets/models.py new file mode 100644 index 0000000..cb67251 --- /dev/null +++ b/apps/power_assets/models.py @@ -0,0 +1,173 @@ +"""Учёт инверторов и генераторов с привязкой к объектам (аптека / офис / склад).""" +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from core.models import BaseModel +from core.models.directories import directory_default + + +class PowerAsset(BaseModel): + """Единица энергооборудования коммунального отдела.""" + + asset_type = models.ForeignKey( + 'core.Directory', on_delete=models.PROTECT, + limit_choices_to={'directory_type__code': 'power_asset_type', 'is_active': True}, + related_name='power_assets', verbose_name=_('Тип'), + ) + brand = models.CharField(_('Бренд'), max_length=100, blank=True) + model_name = models.CharField(_('Модель'), max_length=120) + serial_number = models.CharField( + _('Серийный номер'), max_length=100, blank=True, + help_text=_('Если известен — заполните для учёта.'), + ) + inventory_number = models.CharField( + _('Инвентарный номер'), max_length=50, blank=True, + ) + + location = models.ForeignKey( + 'locations.Location', on_delete=models.PROTECT, + related_name='power_assets', verbose_name=_('Объект'), + help_text=_('Аптека, офис или склад, где установлено оборудование.'), + ) + status = models.ForeignKey( + 'core.Directory', on_delete=models.PROTECT, + limit_choices_to={'directory_type__code': 'power_asset_status', 'is_active': True}, + related_name='power_assets_by_status', verbose_name=_('Статус'), + default=directory_default('power_asset_status', 'active'), + ) + + capacity_kw = models.DecimalField( + _('Мощность, кВт'), max_digits=8, decimal_places=2, + null=True, blank=True, + ) + fuel_type = models.CharField( + _('Тип топлива'), max_length=50, blank=True, + help_text=_('Для генераторов: бензин, дизель, газ…'), + ) + battery_capacity_kwh = models.DecimalField( + _('Ёмкость АКБ, кВт·ч'), max_digits=8, decimal_places=2, + null=True, blank=True, + help_text=_('Для инверторных систем с накопителем.'), + ) + + installed_at = models.DateField(_('Дата установки'), null=True, blank=True) + warranty_until = models.DateField(_('Гарантия до'), null=True, blank=True) + last_serviced_at = models.DateField(_('Последнее обслуживание'), null=True, blank=True) + next_service_at = models.DateField( + _('Следующее обслуживание'), null=True, blank=True, + help_text=_('Плановая дата; обновляется из журнала обслуживания.'), + ) + notes = models.TextField(_('Примечания'), blank=True) + + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, blank=True, + related_name='power_assets_created', verbose_name=_('Кто добавил'), + ) + + class Meta: + verbose_name = _('Энергооборудование') + verbose_name_plural = _('Энергооборудование') + ordering = ['location', 'asset_type__name', 'model_name'] + indexes = [ + models.Index(fields=['location', 'asset_type']), + models.Index(fields=['status']), + models.Index(fields=['next_service_at']), + ] + + def __str__(self): + brand = f'{self.brand} ' if self.brand else '' + return f'{self.asset_type.name}: {brand}{self.model_name}'.strip() + + @property + def label(self) -> str: + parts = [self.asset_type.name] + if self.brand: + parts.append(self.brand) + parts.append(self.model_name) + if self.serial_number: + parts.append(f'[{self.serial_number}]') + return ' '.join(parts) + + @property + def service_overdue(self) -> bool: + if not self.next_service_at: + return False + return self.next_service_at < timezone.localdate() + + def refresh_service_dates(self): + """Пересчитать last/next из журнала обслуживания.""" + latest = self.service_logs.order_by('-service_date', '-id').first() + self.last_serviced_at = latest.service_date if latest else None + upcoming = ( + self.service_logs.exclude(next_service_at__isnull=True) + .order_by('-service_date', '-id') + .values_list('next_service_at', flat=True) + .first() + ) + self.next_service_at = upcoming + self.save(update_fields=['last_serviced_at', 'next_service_at', 'updated_at']) + + +class PowerAssetService(BaseModel): + """Запись обслуживания / ТО энергооборудования.""" + + class ServiceKind(models.TextChoices): + INSPECTION = 'inspection', _('Осмотр / проверка') + MAINTENANCE = 'maintenance', _('Плановое ТО') + OIL_CHANGE = 'oil_change', _('Замена масла / фильтров') + BATTERY = 'battery', _('АКБ / инвертор') + TEST_RUN = 'test_run', _('Пробный запуск') + REPAIR = 'repair', _('Ремонт') + OTHER = 'other', _('Прочее') + + asset = models.ForeignKey( + PowerAsset, on_delete=models.CASCADE, + related_name='service_logs', verbose_name=_('Оборудование'), + ) + service_date = models.DateField(_('Дата'), default=timezone.localdate) + kind = models.CharField( + _('Вид работ'), max_length=32, + choices=ServiceKind.choices, default=ServiceKind.MAINTENANCE, + ) + description = models.TextField(_('Что сделано')) + performed_by_name = models.CharField( + _('Исполнитель'), max_length=120, blank=True, + help_text=_('ФИО или подрядчик, если не сотрудник портала.'), + ) + performed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, blank=True, + related_name='power_services_done', verbose_name=_('Сотрудник'), + ) + engine_hours = models.DecimalField( + _('Моточасы'), max_digits=10, decimal_places=1, + null=True, blank=True, + help_text=_('Для генераторов — показания на момент ТО.'), + ) + cost = models.DecimalField( + _('Стоимость'), max_digits=12, decimal_places=2, + null=True, blank=True, + ) + next_service_at = models.DateField( + _('Следующее ТО'), null=True, blank=True, + ) + notes = models.CharField(_('Комментарий'), max_length=255, blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, blank=True, + related_name='power_services_created', verbose_name=_('Кто внёс'), + ) + + class Meta: + verbose_name = _('Обслуживание энергооборудования') + verbose_name_plural = _('Журнал обслуживания') + ordering = ['-service_date', '-id'] + indexes = [ + models.Index(fields=['asset', 'service_date']), + ] + + def __str__(self): + return f'{self.asset} — {self.get_kind_display()} ({self.service_date:%d.%m.%Y})' diff --git a/apps/power_assets/tests.py b/apps/power_assets/tests.py new file mode 100644 index 0000000..d2e45b9 --- /dev/null +++ b/apps/power_assets/tests.py @@ -0,0 +1,109 @@ +from django.contrib.auth.models import Permission +from django.test import Client, TestCase + +from apps.locations.models.base import Location +from apps.power_assets.models import PowerAsset, PowerAssetService +from core.models.directories import Directory, DirectoryType +from core.test_utils import login_2fa +from core.tests_portal_access import make_employee + + +class PowerAssetsTests(TestCase): + def setUp(self): + self.user = make_employee(is_staff=True) + perm = Permission.objects.get(codename='access_utilities') + self.user.user_permissions.add(perm) + self.client = Client() + login_2fa(self.client, self.user) + + region_type, _ = DirectoryType.objects.get_or_create( + code='location_region', defaults={'name': 'Регион'}, + ) + region, _ = Directory.objects.get_or_create( + directory_type=region_type, code='pwr', defaults={'name': 'PWR'}, + ) + self.location = Location.objects.create( + address='Аптека энерго', location_type='pharmacy', + location_region=region, is_active=True, + ) + type_dt, _ = DirectoryType.objects.get_or_create( + code='power_asset_type', defaults={'name': 'Тип'}, + ) + status_dt, _ = DirectoryType.objects.get_or_create( + code='power_asset_status', defaults={'name': 'Статус'}, + ) + self.inv_type, _ = Directory.objects.get_or_create( + directory_type=type_dt, code='inverter', + defaults={'name': 'Инвертор', 'is_active': True}, + ) + self.gen_type, _ = Directory.objects.get_or_create( + directory_type=type_dt, code='generator', + defaults={'name': 'Генератор', 'is_active': True}, + ) + self.status, _ = Directory.objects.get_or_create( + directory_type=status_dt, code='active', + defaults={'name': 'В работе', 'is_active': True}, + ) + + def test_power_tab_visible_with_utilities(self): + r = self.client.get('/accounting/?tab=power', HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + self.assertEqual(r.context['accounting_active_tab'], 'power') + tab_keys = [t[0] for t in r.context['accounting_tabs']] + self.assertIn('power', tab_keys) + + def test_create_inverter_on_location(self): + r = self.client.post('/power-assets/create/', { + 'asset_type': str(self.inv_type.pk), + 'location_id': str(self.location.pk), + 'status': str(self.status.pk), + 'brand': 'Deye', + 'model_name': 'SUN-8K', + 'serial_number': 'INV-1', + 'capacity_kw': '8', + 'battery_capacity_kwh': '10', + 'installed_at': '2026-01-15', + }, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + asset = PowerAsset.objects.get(serial_number='INV-1') + self.assertEqual(asset.location_id, self.location.pk) + self.assertEqual(asset.asset_type_id, self.inv_type.pk) + self.assertEqual(str(asset.capacity_kw), '8.00') + + def test_create_generator(self): + r = self.client.post('/power-assets/create/', { + 'asset_type': str(self.gen_type.pk), + 'location_id': str(self.location.pk), + 'status': str(self.status.pk), + 'brand': 'Honda', + 'model_name': 'EU70is', + 'fuel_type': 'бензин', + 'capacity_kw': '7', + }, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + self.assertTrue(PowerAsset.objects.filter(model_name='EU70is', fuel_type='бензин').exists()) + + def test_service_log_updates_next_service_date(self): + asset = PowerAsset.objects.create( + asset_type=self.gen_type, + location=self.location, + status=self.status, + model_name='EU22i', + brand='Honda', + created_by=self.user, + ) + r = self.client.post(f'/power-assets/{asset.pk}/service/', { + 'service_date': '2026-09-01', + 'kind': 'oil_change', + 'description': 'Замена масла и фильтра', + 'performed_by_name': 'Иванов И.', + 'engine_hours': '120.5', + 'next_service_at': '2026-12-01', + }, HTTP_HX_REQUEST='true') + self.assertEqual(r.status_code, 200) + asset.refresh_from_db() + self.assertEqual(asset.last_serviced_at.isoformat(), '2026-09-01') + self.assertEqual(asset.next_service_at.isoformat(), '2026-12-01') + log = PowerAssetService.objects.get(asset=asset) + self.assertEqual(log.kind, 'oil_change') + self.assertEqual(str(log.engine_hours), '120.5') diff --git a/apps/power_assets/urls.py b/apps/power_assets/urls.py new file mode 100644 index 0000000..f463da1 --- /dev/null +++ b/apps/power_assets/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('power-assets/create/', views.power_asset_create_view, name='power_asset_create'), + path('power-assets//update/', views.power_asset_update_view, name='power_asset_update'), + path( + 'power-assets//service/', + views.power_asset_service_create_view, + name='power_asset_service_create', + ), +] diff --git a/apps/power_assets/views.py b/apps/power_assets/views.py new file mode 100644 index 0000000..36b054c --- /dev/null +++ b/apps/power_assets/views.py @@ -0,0 +1,223 @@ +from django.contrib.auth.decorators import login_required +from django.db import models +from django.db.models import Prefetch +from django.http import HttpResponseForbidden +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.views.decorators.http import require_POST + +from apps.locations.models.base import Location +from apps.locations.models.pharmacies import Pharmacy +from core.accounting_portal import render_accounting_portal +from core.models.directories import Directory +from core.portal_access import user_can_access +from core.utils.pagination import paginate + +from .models import PowerAsset, PowerAssetService + + +def _can_manage_power(user) -> bool: + return user_can_access(user, 'utilities') + + +def _locations_qs(): + 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 _directory_choices(type_code): + return list( + Directory.objects.filter(directory_type__code=type_code, is_active=True) + .order_by('name') + .values_list('id', 'name') + ) + + +def _power_assets_context(request, **extra): + type_filter = (request.GET.get('power_type') or '').strip() + location_filter = (request.GET.get('power_location') or '').strip() + status_filter = (request.GET.get('power_status') or '').strip() + due_only = (request.GET.get('power_due') or '').strip() == '1' + + qs = ( + PowerAsset.objects.select_related( + 'asset_type', 'status', 'location', 'location__location_region', + ) + .prefetch_related( + Prefetch( + 'service_logs', + queryset=PowerAssetService.objects.select_related('created_by', 'performed_by') + .order_by('-service_date', '-id'), + ), + ) + .order_by('location__address', 'asset_type__name', 'model_name') + ) + if type_filter.isdigit(): + qs = qs.filter(asset_type_id=int(type_filter)) + if location_filter.isdigit(): + qs = qs.filter(location_id=int(location_filter)) + if status_filter.isdigit(): + qs = qs.filter(status_id=int(status_filter)) + if due_only: + qs = qs.filter(next_service_at__isnull=False, next_service_at__lte=timezone.localdate()) + + page = paginate(request, qs, page_param='power_page', size_param='power_per_page') + + overdue_count = PowerAsset.objects.filter( + next_service_at__isnull=False, + next_service_at__lt=timezone.localdate(), + ).count() + + ctx = { + 'power_assets_page': page, + 'power_asset_types': _directory_choices('power_asset_type'), + 'power_asset_statuses': _directory_choices('power_asset_status'), + 'power_service_kinds': PowerAssetService.ServiceKind.choices, + 'power_locations': _locations_qs(), + 'power_type_filter': type_filter, + 'power_location_filter': location_filter, + 'power_status_filter': status_filter, + 'power_due_filter': due_only, + 'power_today': timezone.localdate(), + 'power_counts': { + 'total': PowerAsset.objects.count(), + 'inverter': PowerAsset.objects.filter(asset_type__code='inverter').count(), + 'generator': PowerAsset.objects.filter(asset_type__code='generator').count(), + 'overdue': overdue_count, + }, + } + ctx.update(extra) + return ctx + + +def _parse_asset_form(request, asset: PowerAsset | None = None) -> tuple[PowerAsset | None, str]: + asset_type_id = request.POST.get('asset_type') or None + location_id = request.POST.get('location_id') or None + status_id = request.POST.get('status') or None + model_name = (request.POST.get('model_name') or '').strip() + if not asset_type_id or not location_id or not model_name: + return None, 'Укажите тип, объект и модель.' + + obj = asset or PowerAsset() + obj.asset_type_id = asset_type_id + obj.location_id = location_id + if status_id: + obj.status_id = status_id + obj.brand = (request.POST.get('brand') or '').strip() + obj.model_name = model_name + obj.serial_number = (request.POST.get('serial_number') or '').strip() + obj.inventory_number = (request.POST.get('inventory_number') or '').strip() + obj.fuel_type = (request.POST.get('fuel_type') or '').strip() + obj.notes = (request.POST.get('notes') or '').strip() + obj.installed_at = request.POST.get('installed_at') or None + obj.warranty_until = request.POST.get('warranty_until') or None + + for field, raw in ( + ('capacity_kw', request.POST.get('capacity_kw')), + ('battery_capacity_kwh', request.POST.get('battery_capacity_kwh')), + ): + val = (raw or '').strip().replace(',', '.') + if val: + try: + setattr(obj, field, val) + except Exception: + return None, f'Некорректное значение: {field}' + else: + setattr(obj, field, None) + + if not asset: + obj.created_by = request.user + return obj, '' + + +def _parse_service_form(request, asset: PowerAsset) -> tuple[PowerAssetService | None, str]: + description = (request.POST.get('description') or '').strip() + service_date = request.POST.get('service_date') or None + kind = request.POST.get('kind') or PowerAssetService.ServiceKind.MAINTENANCE + if not description: + return None, 'Укажите, что сделано при обслуживании.' + if kind not in dict(PowerAssetService.ServiceKind.choices): + return None, 'Некорректный вид работ.' + + log = PowerAssetService( + asset=asset, + service_date=service_date or timezone.localdate(), + kind=kind, + description=description, + performed_by_name=(request.POST.get('performed_by_name') or '').strip(), + performed_by=request.user, + next_service_at=request.POST.get('next_service_at') or None, + notes=(request.POST.get('notes') or '').strip(), + created_by=request.user, + ) + hours = (request.POST.get('engine_hours') or '').strip().replace(',', '.') + cost = (request.POST.get('cost') or '').strip().replace(',', '.') + if hours: + try: + log.engine_hours = hours + except Exception: + return None, 'Некорректные моточасы.' + if cost: + try: + log.cost = cost + except Exception: + return None, 'Некорректная стоимость.' + return log, '' + + +@login_required +@require_POST +def power_asset_create_view(request): + if not _can_manage_power(request.user): + return HttpResponseForbidden() + obj, err = _parse_asset_form(request) + extra = {} + if err: + extra['power_form_error'] = err + else: + obj.save() + extra['power_form_message'] = f'Добавлено: {obj.label}' + return render_accounting_portal(request, 'power', **extra) + + +@login_required +@require_POST +def power_asset_update_view(request, pk): + if not _can_manage_power(request.user): + return HttpResponseForbidden() + asset = get_object_or_404(PowerAsset, pk=pk) + obj, err = _parse_asset_form(request, asset) + extra = {} + if err: + extra['power_form_error'] = err + else: + obj.save() + extra['power_form_message'] = 'Сохранено.' + return render_accounting_portal(request, 'power', **extra) + + +@login_required +@require_POST +def power_asset_service_create_view(request, pk): + if not _can_manage_power(request.user): + return HttpResponseForbidden() + asset = get_object_or_404(PowerAsset, pk=pk) + log, err = _parse_service_form(request, asset) + extra = {} + if err: + extra['power_form_error'] = err + else: + log.save() + asset.refresh_service_dates() + extra['power_form_message'] = f'Обслуживание записано: {log.get_kind_display()}' + extra['power_open_asset_id'] = asset.pk + return render_accounting_portal(request, 'power', **extra) diff --git a/apps/rro/access.py b/apps/rro/access.py new file mode 100644 index 0000000..20f8a17 --- /dev/null +++ b/apps/rro/access.py @@ -0,0 +1,55 @@ +"""Доступ к реестру РРО: полный (офис/IT) или только своя аптека (заведующий).""" +from __future__ import annotations + +from typing import Literal + +from apps.locations.models import Pharmacy +from core.portal_access import user_can_access + +MANAGER_GROUP = 'Заведующий аптекой' +FULL_REGISTRY_GROUPS = frozenset({ + 'IT', + 'Ревизор', + 'Администратор сети', +}) + +RroScope = Literal['all'] | Pharmacy | None + + +def is_pharmacy_scoped_rro_user(user) -> bool: + """Заведующий аптекой — только записи своей аптеки (без полного реестра).""" + if not user or not getattr(user, 'is_authenticated', False): + return False + if getattr(user, 'is_superuser', False): + return False + if not user.groups.filter(name=MANAGER_GROUP).exists(): + return False + if user.groups.filter(name__in=FULL_REGISTRY_GROUPS).exists(): + return False + return True + + +def rro_list_scope(user) -> RroScope: + """'all' — полный реестр; Pharmacy — одна аптека; None — нет привязки.""" + if not user_can_access(user, 'rro'): + return None + if getattr(user, 'is_superuser', False): + return 'all' + if is_pharmacy_scoped_rro_user(user): + return getattr(user, 'user_pharmacy', None) + return 'all' + + +def can_edit_rro_registers(user) -> bool: + """Создание/редактирование — только staff (офис/IT), не заведующие.""" + return bool(user and user.is_authenticated and (user.is_staff or user.is_superuser)) + + +def user_can_access_register(user, register) -> bool: + """Проверка доступа к конкретной записи (для update).""" + scope = rro_list_scope(user) + if scope == 'all': + return True + if scope is None: + return False + return register.pharmacy_id == scope.pk diff --git a/apps/rro/tests.py b/apps/rro/tests.py index 8f0dcf1..3531a5d 100644 --- a/apps/rro/tests.py +++ b/apps/rro/tests.py @@ -1,17 +1,41 @@ from datetime import date from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.core.exceptions import ValidationError from django.db import IntegrityError, transaction from django.test import TestCase -from core.models import Directory, DirectoryType +from core.models import Directory, DirectoryType, PortalAccess +from apps.employees.models import EmployeePermissionOverride from apps.locations.models import Pharmacy from apps.rro.models import CashRegister Employee = get_user_model() +def grant_office_access(user): + user.groups.add(Group.objects.get(name='Офисный сотрудник')) + for attr in ('_perm_cache', '_user_perm_cache', '_group_perm_cache'): + if hasattr(user, attr): + delattr(user, attr) + + +def grant_rro_access(user): + from django.contrib.auth.models import Permission + from django.contrib.contenttypes.models import ContentType + ct = ContentType.objects.get_for_model(PortalAccess, for_concrete_model=False) + perm = Permission.objects.get(content_type=ct, codename='access_rro') + EmployeePermissionOverride.objects.get_or_create( + employee=user, + permission=perm, + defaults={'override_type': 'grant'}, + ) + for attr in ('_perm_cache', '_user_perm_cache', '_group_perm_cache'): + if hasattr(user, attr): + delattr(user, attr) + + class CashRegisterModelTests(TestCase): @classmethod def setUpTestData(cls): @@ -113,6 +137,7 @@ class CashRegisterPortalTests(TestCase): ad_sync_enabled=False, totp_enabled=True, ) + grant_rro_access(cls.user) def _login(self): self.client.force_login(self.user) @@ -170,6 +195,76 @@ class CashRegisterPortalTests(TestCase): self.assertNotContains(resp, 'ph-panel-utilities') +class ManagerRroScopeTests(TestCase): + @classmethod + def setUpTestData(cls): + org_type = DirectoryType.objects.create(code='organization', name='Организация') + region_type = DirectoryType.objects.create(code='region', name='Регион') + status_type, _ = DirectoryType.objects.get_or_create( + code='rro_status', defaults={'name': 'Статус РРО/ПРРО', 'hierarchy_type': 'strict'}, + ) + cls.status_active, _ = Directory.objects.get_or_create( + directory_type=status_type, code='active', + defaults={'name': 'Активный', 'is_active': True}, + ) + region = Directory.objects.create(directory_type=region_type, code='kv2', name='Киев') + org = Directory.objects.create(directory_type=org_type, code='orgm', name='ТестОрг') + cls.pharmacy_a = Pharmacy.objects.create( + organization=org, location_region=region, + pharmacy_number=1, location_type='pharmacy', + street='A', house_number='1', + ) + cls.pharmacy_b = Pharmacy.objects.create( + organization=org, location_region=region, + pharmacy_number=2, location_type='pharmacy', + street='B', house_number='2', + ) + CashRegister.objects.create( + pharmacy=cls.pharmacy_a, kind=CashRegister.Kind.RRO, + fiscal_number='FN-MINE', status=cls.status_active, + ) + CashRegister.objects.create( + pharmacy=cls.pharmacy_b, kind=CashRegister.Kind.RRO, + fiscal_number='FN-OTHER', status=cls.status_active, + ) + cls.manager = Employee.objects.create( + username='rro.manager', + email='rro.manager@company.local', + first_name='Зав', + last_name='Аптекой', + hire_date=date(2026, 1, 1), + user_location_type='pharmacy', + user_pharmacy=cls.pharmacy_a, + is_staff=False, + mattermost_sync_enabled=False, + ad_sync_enabled=False, + ) + cls.manager.groups.add(Group.objects.get(name='Заведующий аптекой')) + + def _login(self): + self.client.force_login(self.manager) + session = self.client.session + session['2fa_verified'] = True + session.save() + + def test_manager_sees_only_own_pharmacy(self): + self._login() + resp = self.client.get('/rro/', HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'FN-MINE') + self.assertNotContains(resp, 'FN-OTHER') + self.assertTrue(resp.context['rro_pharmacy_scoped']) + + def test_manager_cannot_bypass_with_query_param(self): + self._login() + resp = self.client.get( + f'/rro/?pharmacy={self.pharmacy_b.pk}', + HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 200) + self.assertNotContains(resp, 'FN-OTHER') + + class CashRegisterCrudTests(TestCase): @classmethod def setUpTestData(cls): @@ -217,6 +312,7 @@ class CashRegisterCrudTests(TestCase): mattermost_sync_enabled=False, ad_sync_enabled=False, ) + grant_rro_access(cls.staff) def _login(self, user): self.client.force_login(user) @@ -244,6 +340,8 @@ class CashRegisterCrudTests(TestCase): self.assertContains(resp, 'Кассовый аппарат добавлен') def test_non_staff_create_forbidden(self): + # Есть просмотр РРО, но без staff — create запрещён (не редирект раздела). + grant_rro_access(self.regular) self._login(self.regular) resp = self.client.post('/rro/create/', self._payload(fiscal_number='FN-FORBIDDEN')) self.assertEqual(resp.status_code, 403) diff --git a/apps/rro/views.py b/apps/rro/views.py index 3d33dc6..1243806 100644 --- a/apps/rro/views.py +++ b/apps/rro/views.py @@ -12,13 +12,15 @@ from django.views.decorators.http import require_POST from apps.locations.models.pharmacies import Pharmacy from core.models.directories import Directory +from core.portal_access import section_required from core.utils.htmx import render_page from .models import CashRegister - - -def _can_edit_rro(user) -> bool: - return bool(user and user.is_authenticated and (user.is_staff or user.is_superuser)) +from .access import ( + can_edit_rro_registers, + rro_list_scope, + user_can_access_register, +) def _parse_date(raw): @@ -121,9 +123,10 @@ def _parse_register_form(post, *, instance=None): def _rro_list_context(request, **extra): - is_staff = request.user.is_staff - can_edit = _can_edit_rro(request.user) - own_pharmacy = request.user.user_pharmacy + user = request.user + scope = rro_list_scope(user) + can_edit = can_edit_rro_registers(user) + full_registry = scope == 'all' registers = CashRegister.objects.select_related( 'pharmacy', 'pharmacy__organization', 'vendor', 'status', @@ -136,16 +139,17 @@ def _rro_list_context(request, **extra): q = (request.GET.get('q') or '').strip() show_create = request.GET.get('new') == '1' - if is_staff: + if full_registry: if filter_pharmacy: registers = registers.filter(pharmacy_id=filter_pharmacy) if filter_org: registers = registers.filter(pharmacy__organization_id=filter_org) + elif scope is not None: + registers = registers.filter(pharmacy=scope) + filter_pharmacy = str(scope.pk) + filter_org = '' else: - if own_pharmacy: - registers = registers.filter(pharmacy=own_pharmacy) - else: - registers = registers.none() + registers = registers.none() if filter_kind in {CashRegister.Kind.RRO, CashRegister.Kind.PRRO}: registers = registers.filter(kind=filter_kind) @@ -187,8 +191,10 @@ def _rro_list_context(request, **extra): ] context = { - 'is_staff': is_staff, + 'rro_full_registry': full_registry, + 'is_staff': full_registry, # совместимость шаблона 'can_edit_rro': can_edit, + 'rro_pharmacy_scoped': scope is not None and scope != 'all', 'registers': register_list, 'registers_json': json.dumps(registers_js, cls=DjangoJSONEncoder, ensure_ascii=False), 'statuses': statuses, @@ -205,11 +211,11 @@ def _rro_list_context(request, **extra): Pharmacy.objects.filter(is_active=True) .select_related('organization') .order_by('organization__name', 'pharmacy_number') - if is_staff else [] + if full_registry else [] ), 'organizations': ( Directory.objects.filter(directory_type__code='organization', is_active=True).order_by('name') - if is_staff else [] + if full_registry else [] ), } context.update(extra) @@ -224,24 +230,30 @@ def _rro_list_context(request, **extra): def _render_rro(request, **extra): - return render_page(request, 'rro.html', _rro_list_context(request, **extra)) + from core.accounting_portal import render_accounting_portal + return render_accounting_portal(request, 'rro', **extra) @login_required +@section_required('rro') def rro_list_view(request): """Реестр РРО/ПРРО по аптекам. Staff видит все; сотрудник аптеки — свою.""" return _render_rro(request) @login_required +@section_required('rro') @require_POST def rro_create_view(request): - if not _can_edit_rro(request.user): + if not can_edit_rro_registers(request.user): return HttpResponseForbidden() form_data = dict(request.POST.items()) try: register, form_data = _parse_register_form(request.POST) + scope = rro_list_scope(request.user) + if scope is not None and scope != 'all' and register.pharmacy_id != scope.pk: + return HttpResponseForbidden() with transaction.atomic(): register.save() return _render_rro(request, rro_form_message='Кассовый аппарат добавлен') @@ -262,15 +274,20 @@ def rro_create_view(request): @login_required +@section_required('rro') @require_POST def rro_update_view(request, pk): - if not _can_edit_rro(request.user): + if not can_edit_rro_registers(request.user): return HttpResponseForbidden() register = get_object_or_404(CashRegister, pk=pk) + if not user_can_access_register(request.user, register): + return HttpResponseForbidden() form_data = dict(request.POST.items()) try: register, form_data = _parse_register_form(request.POST, instance=register) + if not user_can_access_register(request.user, register): + return HttpResponseForbidden() with transaction.atomic(): register.save() return _render_rro(request, rro_form_message='Изменения сохранены') diff --git a/apps/utilities/admin.py b/apps/utilities/admin.py index cae2a29..bf822ae 100644 --- a/apps/utilities/admin.py +++ b/apps/utilities/admin.py @@ -1,13 +1,21 @@ from django.contrib import admin from unfold.admin import ModelAdmin -from .models import UtilityReading +from .models import UtilityMeterConfig, UtilityReading + + +@admin.register(UtilityMeterConfig) +class UtilityMeterConfigAdmin(ModelAdmin): + list_display = ['pharmacy', 'utility_type', 'reading_mode', 'is_active'] + list_filter = ['reading_mode', 'utility_type', 'pharmacy__organization'] + list_select_related = ['pharmacy', 'pharmacy__organization', 'utility_type'] + autocomplete_fields = ['pharmacy'] @admin.register(UtilityReading) class UtilityReadingAdmin(ModelAdmin): - list_display = ['pharmacy', 'utility_type', 'reading_date', 'value', 'submitted_by'] - list_filter = ['utility_type', 'pharmacy__organization'] + list_display = ['pharmacy', 'utility_type', 'reading_date', 'value', 'value_day', 'value_night', 'submitted_by'] + list_filter = ['utility_type', 'pharmacy__organization'] search_fields = ['pharmacy__pharmacy_number', 'comment'] list_select_related = ['pharmacy', 'pharmacy__organization', 'utility_type', 'submitted_by'] ordering = ['-reading_date'] diff --git a/apps/utilities/helpers.py b/apps/utilities/helpers.py new file mode 100644 index 0000000..41ff341 --- /dev/null +++ b/apps/utilities/helpers.py @@ -0,0 +1,68 @@ +"""Допоміжні функції для комунальних показань.""" +from __future__ import annotations + +from apps.utilities.models import UtilityMeterConfig + +READING_MODE_SINGLE = UtilityMeterConfig.READING_MODE_SINGLE +READING_MODE_DAY_NIGHT = UtilityMeterConfig.READING_MODE_DAY_NIGHT + + +def get_reading_mode(pharmacy_id, utility_type) -> str: + """Режим зняття показань: спочатку конфіг аптеки, інакше extra_data типу.""" + if not pharmacy_id or not utility_type: + return READING_MODE_SINGLE + + utility_type_id = getattr(utility_type, 'pk', utility_type) + config = UtilityMeterConfig.objects.filter( + pharmacy_id=pharmacy_id, + utility_type_id=utility_type_id, + is_active=True, + ).first() + if config: + return config.reading_mode + + if hasattr(utility_type, 'get_extra_value'): + ut = utility_type + else: + from core.models.directories import Directory + ut = Directory.objects.filter(pk=utility_type_id).first() + if ut: + return ut.get_extra_value('reading_mode', READING_MODE_SINGLE) or READING_MODE_SINGLE + return READING_MODE_SINGLE + + +def meter_configs_map() -> dict[str, str]: + """Ключ «pharmacy_id:utility_type_id» → reading_mode для JS.""" + return { + f'{c.pharmacy_id}:{c.utility_type_id}': c.reading_mode + for c in UtilityMeterConfig.objects.filter(is_active=True).only( + 'pharmacy_id', 'utility_type_id', 'reading_mode', + ) + } + + +def utility_type_modes_map(utility_types) -> dict[str, str]: + """Дефолтний режим з довідника типів (fallback без конфігу аптеки).""" + result = {} + for ut in utility_types: + result[str(ut.pk)] = ut.get_extra_value('reading_mode', READING_MODE_SINGLE) or READING_MODE_SINGLE + return result + + +def attach_reading_display(readings): + """Додає reading_mode та споживання для шаблону.""" + type_ids = {r.utility_type_id for r in readings} + pharmacy_ids = {r.pharmacy_id for r in readings} + configs = { + (c.pharmacy_id, c.utility_type_id): c.reading_mode + for c in UtilityMeterConfig.objects.filter( + pharmacy_id__in=pharmacy_ids, + utility_type_id__in=type_ids, + is_active=True, + ) + } + for reading in readings: + reading.reading_mode = configs.get( + (reading.pharmacy_id, reading.utility_type_id), + reading.utility_type.get_extra_value('reading_mode', READING_MODE_SINGLE) or READING_MODE_SINGLE, + ) diff --git a/apps/utilities/migrations/0003_meter_config_and_day_night.py b/apps/utilities/migrations/0003_meter_config_and_day_night.py new file mode 100644 index 0000000..5b48c07 --- /dev/null +++ b/apps/utilities/migrations/0003_meter_config_and_day_night.py @@ -0,0 +1,77 @@ +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('locations', '0012_pharmacy_card_visibility'), + ('utilities', '0002_seed_utility_types'), + ] + + operations = [ + migrations.AlterField( + model_name='utilityreading', + name='value', + field=models.DecimalField( + blank=True, decimal_places=2, max_digits=12, + null=True, verbose_name='Показання', + ), + ), + migrations.AddField( + model_name='utilityreading', + name='value_day', + field=models.DecimalField( + blank=True, decimal_places=2, max_digits=12, + null=True, verbose_name='Показання (день)', + ), + ), + migrations.AddField( + model_name='utilityreading', + name='value_night', + field=models.DecimalField( + blank=True, decimal_places=2, max_digits=12, + null=True, verbose_name='Показання (ніч)', + ), + ), + migrations.CreateModel( + name='UtilityMeterConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('reading_mode', models.CharField( + choices=[('single', 'Звичайний (одне показання)'), ('day_night', 'День / ніч')], + default='single', max_length=20, verbose_name='Режим показань', + )), + ('is_active', models.BooleanField(default=True, verbose_name='Активний')), + ('pharmacy', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='utility_meter_configs', + to='locations.pharmacy', + verbose_name='Аптека', + )), + ('utility_type', models.ForeignKey( + limit_choices_to={'directory_type__code': 'utility_type', 'is_active': True}, + on_delete=django.db.models.deletion.PROTECT, + related_name='utility_meter_configs', + to='core.directory', + verbose_name='Тип лічильника', + )), + ], + options={ + 'verbose_name': 'Налаштування лічильника', + 'verbose_name_plural': 'Налаштування лічильників', + 'ordering': ['pharmacy', 'utility_type__name'], + }, + ), + migrations.AddConstraint( + model_name='utilitymeterconfig', + constraint=models.UniqueConstraint( + fields=('pharmacy', 'utility_type'), + name='unique_utility_meter_config_per_pharmacy', + ), + ), + ] diff --git a/apps/utilities/models.py b/apps/utilities/models.py index fb029e1..605a3b4 100644 --- a/apps/utilities/models.py +++ b/apps/utilities/models.py @@ -7,10 +7,48 @@ from django.utils.translation import gettext_lazy as _ from core.models import BaseModel +class UtilityMeterConfig(BaseModel): + """Як знімаються показання лічильника в конкретній аптеці.""" + + READING_MODE_SINGLE = 'single' + READING_MODE_DAY_NIGHT = 'day_night' + READING_MODE_CHOICES = [ + (READING_MODE_SINGLE, _('Звичайний (одне показання)')), + (READING_MODE_DAY_NIGHT, _('День / ніч')), + ] + + pharmacy = models.ForeignKey( + 'locations.Pharmacy', on_delete=models.CASCADE, + related_name='utility_meter_configs', verbose_name=_('Аптека'), + ) + utility_type = models.ForeignKey( + 'core.Directory', on_delete=models.PROTECT, + limit_choices_to={'directory_type__code': 'utility_type', 'is_active': True}, + related_name='utility_meter_configs', verbose_name=_('Тип лічильника'), + ) + reading_mode = models.CharField( + _('Режим показань'), max_length=20, + choices=READING_MODE_CHOICES, default=READING_MODE_SINGLE, + ) + is_active = models.BooleanField(_('Активний'), default=True) + + class Meta: + verbose_name = _('Налаштування лічильника') + verbose_name_plural = _('Налаштування лічильників') + ordering = ['pharmacy', 'utility_type__name'] + constraints = [ + models.UniqueConstraint( + fields=['pharmacy', 'utility_type'], + name='unique_utility_meter_config_per_pharmacy', + ), + ] + + def __str__(self): + return f'{self.pharmacy} — {self.utility_type.name} ({self.get_reading_mode_display()})' + + class UtilityReading(BaseModel): - """Показання лічильника (світло, вода...), подані аптекою для комунального відділу. - Типи лічильників — довідник core.Directory (directory_type=utility_type), не хардкод, - бо список типів може відрізнятись і розширюватись адміном без релізу коду.""" + """Показання лічильника (світло, вода...), подані аптекою для комунального відділу.""" pharmacy = models.ForeignKey( 'locations.Pharmacy', on_delete=models.CASCADE, @@ -22,7 +60,18 @@ class UtilityReading(BaseModel): related_name='utility_readings', verbose_name=_('Тип лічильника'), ) reading_date = models.DateField(_('Дата зняття показань'), default=timezone.localdate) - value = models.DecimalField(_('Показання'), max_digits=12, decimal_places=2) + value = models.DecimalField( + _('Показання'), max_digits=12, decimal_places=2, + null=True, blank=True, + ) + value_day = models.DecimalField( + _('Показання (день)'), max_digits=12, decimal_places=2, + null=True, blank=True, + ) + value_night = models.DecimalField( + _('Показання (ніч)'), max_digits=12, decimal_places=2, + null=True, blank=True, + ) comment = models.CharField(_('Коментар'), max_length=255, blank=True) submitted_by = models.ForeignKey( @@ -46,17 +95,50 @@ class UtilityReading(BaseModel): ] def __str__(self): + if self.is_day_night: + return f'{self.pharmacy} — {self.utility_type.name}: день {self.value_day} / ніч {self.value_night} ({self.reading_date})' return f'{self.pharmacy} — {self.utility_type.name}: {self.value} ({self.reading_date})' + @property + def is_day_night(self) -> bool: + return self.value_day is not None or self.value_night is not None + + @property + def value_display(self) -> str: + if self.is_day_night: + day = self.value_day if self.value_day is not None else '—' + night = self.value_night if self.value_night is not None else '—' + return f'день {day} / ніч {night}' + return str(self.value if self.value is not None else '—') + def clean(self): super().clean() - if self.value is not None and self.value < 0: - raise ValidationError({'value': _("Показання не може бути від'ємним")}) + from apps.utilities.helpers import get_reading_mode + + mode = get_reading_mode(self.pharmacy_id, self.utility_type_id) + if mode == UtilityMeterConfig.READING_MODE_DAY_NIGHT: + if self.value is not None: + raise ValidationError({'value': _('Для лічильника «день/ніч» використовуйте окремі поля.')}) + if self.value_day is None or self.value_night is None: + raise ValidationError({ + '__all__': [_('Вкажіть показання для денної та нічної зон.')], + }) + for field in ('value_day', 'value_night'): + if getattr(self, field) is not None and getattr(self, field) < 0: + raise ValidationError({field: _("Показання не може бути від'ємним")}) + else: + if self.value is None: + raise ValidationError({'value': _('Вкажіть показання лічильника.')}) + if self.value < 0: + raise ValidationError({'value': _("Показання не може бути від'ємним")}) + if self.value_day is not None or self.value_night is not None: + raise ValidationError({ + '__all__': [_('Для звичайного лічильника використовуйте одне поле показань.')], + }) if self.reading_date and self.reading_date > timezone.localdate(): raise ValidationError({'reading_date': _('Дата не може бути в майбутньому')}) - @property - def previous_reading(self): + def _previous_reading(self): qs = UtilityReading.objects.filter( pharmacy=self.pharmacy, utility_type=self.utility_type, reading_date__lt=self.reading_date, @@ -66,11 +148,41 @@ class UtilityReading(BaseModel): return qs.order_by('-reading_date').first() @property - def consumption(self): - """Витрата з моменту попереднього показання. None — немає з чим порівняти. - Може бути від'ємною (заміна лічильника, помилка вводу) — навмисно не блокується - в clean(), лише позначається попередженням в шаблоні.""" - previous = self.previous_reading + def previous_reading(self): + return self._previous_reading() + + def _delta(self, field: str): + previous = self._previous_reading() if previous is None: return None - return self.value - previous.value + current = getattr(self, field) + prev_val = getattr(previous, field) + if current is None or prev_val is None: + return None + return current - prev_val + + @property + def consumption(self): + """Сумарна витрата (звичайний лічильник або день+ніч).""" + if self.is_day_night: + total = self.consumption_total + return total if total is not None else None + return self._delta('value') + + @property + def consumption_day(self): + return self._delta('value_day') if self.is_day_night else None + + @property + def consumption_night(self): + return self._delta('value_night') if self.is_day_night else None + + @property + def consumption_total(self): + if not self.is_day_night: + return self.consumption + day = self.consumption_day + night = self.consumption_night + if day is None and night is None: + return None + return (day or 0) + (night or 0) diff --git a/apps/utilities/tests.py b/apps/utilities/tests.py new file mode 100644 index 0000000..e56f06e --- /dev/null +++ b/apps/utilities/tests.py @@ -0,0 +1,133 @@ +from datetime import date +from decimal import Decimal + +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.test import TestCase + +from apps.locations.models.pharmacies import Pharmacy +from apps.utilities.helpers import get_reading_mode +from apps.utilities.models import UtilityMeterConfig, UtilityReading +from core.models.directories import Directory, DirectoryType +from core.test_utils import login_2fa +from core.tests_portal_access import make_employee + + +def _utility_type(name='Електроенергія', **extra): + dtype, _ = DirectoryType.objects.get_or_create(code='utility_type', defaults={'name': 'Тип лічильника'}) + defaults = {'directory_type': dtype, 'code': name.lower()[:20], 'name': name, 'is_active': True} + defaults.update(extra) + return Directory.objects.create(**defaults) + + +def _pharmacy(n=1): + org_type, _ = DirectoryType.objects.get_or_create(code='organization', defaults={'name': 'Організація'}) + region_type, _ = DirectoryType.objects.get_or_create(code='location_region', defaults={'name': 'Регион'}) + org = Directory.objects.create(directory_type=org_type, code=f'org{n}', name=f'Org {n}') + region = Directory.objects.create(directory_type=region_type, code=f'reg{n}', name=f'Reg {n}') + return Pharmacy.objects.create(organization=org, location_region=region, pharmacy_number=n, location_type='pharmacy') + + +class UtilityMeterModeTests(TestCase): + def setUp(self): + self.pharmacy = _pharmacy() + self.electricity = _utility_type() + + def test_default_single_mode(self): + self.assertEqual(get_reading_mode(self.pharmacy.pk, self.electricity), UtilityMeterConfig.READING_MODE_SINGLE) + + def test_pharmacy_config_day_night(self): + UtilityMeterConfig.objects.create( + pharmacy=self.pharmacy, + utility_type=self.electricity, + reading_mode=UtilityMeterConfig.READING_MODE_DAY_NIGHT, + ) + self.assertEqual(get_reading_mode(self.pharmacy.pk, self.electricity), UtilityMeterConfig.READING_MODE_DAY_NIGHT) + + def test_single_reading_validation(self): + reading = UtilityReading( + pharmacy=self.pharmacy, + utility_type=self.electricity, + reading_date=date(2026, 9, 1), + value=Decimal('100'), + ) + reading.full_clean() + + def test_day_night_reading_validation(self): + UtilityMeterConfig.objects.create( + pharmacy=self.pharmacy, + utility_type=self.electricity, + reading_mode=UtilityMeterConfig.READING_MODE_DAY_NIGHT, + ) + reading = UtilityReading( + pharmacy=self.pharmacy, + utility_type=self.electricity, + reading_date=date(2026, 7, 1), + value_day=Decimal('50'), + value_night=Decimal('30'), + ) + reading.full_clean() + reading.save() + + reading2 = UtilityReading( + pharmacy=self.pharmacy, + utility_type=self.electricity, + reading_date=date(2026, 8, 1), + value_day=Decimal('60'), + value_night=Decimal('35'), + ) + reading2.full_clean() + self.assertEqual(reading2.consumption_day, Decimal('10')) + self.assertEqual(reading2.consumption_night, Decimal('5')) + self.assertEqual(reading2.consumption_total, Decimal('15')) + + def test_day_night_rejects_single_value(self): + UtilityMeterConfig.objects.create( + pharmacy=self.pharmacy, + utility_type=self.electricity, + reading_mode=UtilityMeterConfig.READING_MODE_DAY_NIGHT, + ) + reading = UtilityReading( + pharmacy=self.pharmacy, + utility_type=self.electricity, + reading_date=date(2026, 9, 1), + value=Decimal('100'), + ) + with self.assertRaises(ValidationError): + reading.full_clean() + + +class UtilityReadingViewTests(TestCase): + def setUp(self): + self.pharmacy = _pharmacy() + self.water = _utility_type('Вода', code='water') + self.electricity = _utility_type() + self.user = make_employee(user_location_type='pharmacy') + self.user.user_pharmacy = self.pharmacy + self.user.save(update_fields=['user_pharmacy']) + 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_post_day_night_reading_via_portal(self): + UtilityMeterConfig.objects.create( + pharmacy=self.pharmacy, + utility_type=self.electricity, + reading_mode=UtilityMeterConfig.READING_MODE_DAY_NIGHT, + ) + login_2fa(self.client, self.user) + response = self.client.post( + '/utilities/', + { + 'utility_type': self.electricity.pk, + 'reading_date': '2026-09-02', + 'value_day': '1200.5', + 'value_night': '800.25', + }, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(response.status_code, 200) + reading = UtilityReading.objects.get(pharmacy=self.pharmacy, utility_type=self.electricity) + self.assertEqual(reading.value_day, Decimal('1200.5')) + self.assertEqual(reading.value_night, Decimal('800.25')) diff --git a/apps/utilities/urls.py b/apps/utilities/urls.py index ae82fd4..0ff08c6 100644 --- a/apps/utilities/urls.py +++ b/apps/utilities/urls.py @@ -5,4 +5,5 @@ from . import views urlpatterns = [ path('utilities/', views.utilities_view, name='utilities'), path('utilities//delete/', views.utility_reading_delete_view, name='utility_reading_delete'), + path('utilities/meter-config//delete/', views.utility_meter_config_delete_view, name='utility_meter_config_delete'), ] diff --git a/apps/utilities/views.py b/apps/utilities/views.py index 8604e68..397c34a 100644 --- a/apps/utilities/views.py +++ b/apps/utilities/views.py @@ -1,53 +1,83 @@ from django.contrib.auth.decorators import login_required from django.core.exceptions import ValidationError -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import get_object_or_404 from django.views.decorators.http import require_POST from apps.locations.models.pharmacies import Pharmacy from core.models.directories import Directory -from core.utils.htmx import render_page -from core.utils.permissions import is_owner_or_staff +from core.portal_access import section_required -from .models import UtilityReading +from .helpers import ( + attach_reading_display, + get_reading_mode, + meter_configs_map, + utility_type_modes_map, +) +from .models import UtilityMeterConfig, UtilityReading +from core.utils.permissions import is_owner_or_staff def can_delete_reading(user, reading) -> bool: return is_owner_or_staff(user, reading, 'submitted_by_id') -@login_required -def utilities_view(request): - """Показання лічильників: staff бачить усі аптеки й фільтри (звіт для - комунального відділу), звичайний співробітник аптеки — тільки свою аптеку - і подає показання лише за неї.""" +def _utilities_context(request, **extra): + """Показання лічильників: staff бачить усі аптеки й фільтри.""" is_staff = request.user.is_staff own_pharmacy = request.user.user_pharmacy form_error = '' + config_message = '' if request.method == 'POST': - pharmacy_id = request.POST.get('pharmacy') if is_staff else (own_pharmacy.pk if own_pharmacy else None) - utility_type_id = request.POST.get('utility_type') - reading_date = request.POST.get('reading_date') - value = request.POST.get('value') - - if not pharmacy_id: - form_error = 'Показання не збережено: у вас не вказана аптека (зверніться до HR).' - elif not (utility_type_id and reading_date and value): - form_error = 'Показання не збережено: заповніть тип лічильника, дату і значення.' + if request.POST.get('form_kind') == 'meter_config': + if not is_staff: + config_message = 'Немає прав на налаштування лічильників.' + else: + pharmacy_id = request.POST.get('config_pharmacy') + utility_type_id = request.POST.get('config_utility_type') + reading_mode = request.POST.get('reading_mode', UtilityMeterConfig.READING_MODE_SINGLE) + if pharmacy_id and utility_type_id and reading_mode in dict(UtilityMeterConfig.READING_MODE_CHOICES): + config, _created = UtilityMeterConfig.objects.update_or_create( + pharmacy_id=pharmacy_id, + utility_type_id=utility_type_id, + defaults={'reading_mode': reading_mode, 'is_active': True}, + ) + config.full_clean() + config.save() + config_message = 'Налаштування лічильника збережено.' + else: + config_message = 'Заповніть аптеку, тип лічильника та режим.' else: - reading = UtilityReading( - pharmacy_id=pharmacy_id, - utility_type_id=utility_type_id, - reading_date=reading_date, - value=value, - comment=request.POST.get('comment', '').strip(), - submitted_by=request.user, - ) - try: - reading.full_clean() - reading.save() - except ValidationError as e: - form_error = ' '.join(m for messages in e.message_dict.values() for m in messages) + pharmacy_id = request.POST.get('pharmacy') if is_staff else (own_pharmacy.pk if own_pharmacy else None) + utility_type_id = request.POST.get('utility_type') + reading_date = request.POST.get('reading_date') + value = request.POST.get('value') + value_day = request.POST.get('value_day') + value_night = request.POST.get('value_night') + + if not pharmacy_id: + form_error = 'Показання не збережено: у вас не вказана аптека (зверніться до HR).' + elif not (utility_type_id and reading_date): + form_error = 'Показання не збережено: заповніть тип лічильника та дату.' + else: + mode = get_reading_mode(pharmacy_id, utility_type_id) + reading = UtilityReading( + pharmacy_id=pharmacy_id, + utility_type_id=utility_type_id, + reading_date=reading_date, + comment=request.POST.get('comment', '').strip(), + submitted_by=request.user, + ) + if mode == UtilityMeterConfig.READING_MODE_DAY_NIGHT: + reading.value_day = value_day or None + reading.value_night = value_night or None + else: + reading.value = value or None + try: + reading.full_clean() + reading.save() + except ValidationError as e: + form_error = ' '.join(m for messages in e.message_dict.values() for m in messages) utility_types = Directory.objects.filter(directory_type__code='utility_type', is_active=True).order_by('name') @@ -71,9 +101,21 @@ def utilities_view(request): pharmacies_for_form = [own_pharmacy] if own_pharmacy else [] readings = list(readings[:200]) + attach_reading_display(readings) for r in readings: r.can_delete = can_delete_reading(request.user, r) + meter_configs = list( + UtilityMeterConfig.objects.select_related('pharmacy', 'pharmacy__organization', 'utility_type') + .filter(is_active=True) + .order_by('pharmacy__organization__name', 'pharmacy__pharmacy_number', 'utility_type__name') + ) + if not is_staff and own_pharmacy: + meter_configs = [c for c in meter_configs if c.pharmacy_id == own_pharmacy.pk] + + import json + from django.core.serializers.json import DjangoJSONEncoder + context = { 'is_staff': is_staff, 'own_pharmacy': own_pharmacy, @@ -81,18 +123,47 @@ def utilities_view(request): 'utility_types': utility_types, 'readings': readings, 'form_error': form_error, + 'config_message': config_message, 'filter_pharmacy': filter_pharmacy, 'filter_type': filter_type, 'all_pharmacies': Pharmacy.objects.filter(is_active=True).select_related('organization').order_by('organization__name', 'pharmacy_number') if is_staff else [], + 'meter_configs': meter_configs, + 'meter_configs_json': json.dumps(meter_configs_map(), cls=DjangoJSONEncoder), + 'utility_type_modes_json': json.dumps(utility_type_modes_map(utility_types), cls=DjangoJSONEncoder), + 'reading_mode_choices': UtilityMeterConfig.READING_MODE_CHOICES, + 'own_pharmacy_id': own_pharmacy.pk if own_pharmacy else '', } - return render_page(request, 'utilities.html', context) + context.update(extra) + return context @login_required +@section_required('utilities') +def utilities_view(request): + from core.accounting_portal import render_accounting_portal + return render_accounting_portal(request, 'utilities') + + +@login_required +@section_required('utilities') @require_POST def utility_reading_delete_view(request, pk): + from core.accounting_portal import render_accounting_portal + reading = get_object_or_404(UtilityReading, pk=pk) - if not can_delete_reading(request.user, reading): - return redirect('utilities') - reading.delete() - return redirect('utilities') + if can_delete_reading(request.user, reading): + reading.delete() + return render_accounting_portal(request, 'utilities') + + +@login_required +@section_required('utilities') +@require_POST +def utility_meter_config_delete_view(request, pk): + from core.accounting_portal import render_accounting_portal + + if not request.user.is_staff: + return render_accounting_portal(request, 'utilities', config_message='Немає прав.') + config = get_object_or_404(UtilityMeterConfig, pk=pk) + config.delete() + return render_accounting_portal(request, 'utilities', config_message='Налаштування видалено.') diff --git a/static/assets/js/utilities.js b/static/assets/js/utilities.js new file mode 100644 index 0000000..ae61429 --- /dev/null +++ b/static/assets/js/utilities.js @@ -0,0 +1,68 @@ +function utilitiesPageRoot() { + return document.getElementById('utilities-page'); +} + +function utilitiesGetPharmacyId() { + var root = utilitiesPageRoot(); + if (!root) return ''; + var select = document.getElementById('utilities-pharmacy-select'); + if (select && select.value) return select.value; + return root.getAttribute('data-own-pharmacy') || ''; +} + +function utilitiesGetReadingMode(pharmacyId, typeId) { + var root = utilitiesPageRoot(); + if (!root || !typeId) return 'single'; + var configs = {}; + var typeModes = {}; + try { + configs = JSON.parse(root.getAttribute('data-meter-configs') || '{}'); + typeModes = JSON.parse(root.getAttribute('data-type-modes') || '{}'); + } catch (e) { + return 'single'; + } + if (pharmacyId) { + var key = pharmacyId + ':' + typeId; + if (configs[key]) return configs[key]; + } + return typeModes[typeId] || 'single'; +} + +function utilitiesFormModeUpdate() { + var typeSelect = document.getElementById('utilities-type-select'); + var singleWrap = document.getElementById('utilities-single-wrap'); + var dayNightWrap = document.getElementById('utilities-daynight-wrap'); + var hint = document.getElementById('utilities-mode-hint'); + if (!typeSelect || !singleWrap || !dayNightWrap) return; + + var pharmacyId = utilitiesGetPharmacyId(); + var typeId = typeSelect.value; + var mode = utilitiesGetReadingMode(pharmacyId, typeId); + var isDayNight = mode === 'day_night'; + + singleWrap.style.display = isDayNight ? 'none' : ''; + dayNightWrap.style.display = isDayNight ? '' : 'none'; + + var valueInput = singleWrap.querySelector('input[name=value]'); + var dayInput = dayNightWrap.querySelector('input[name=value_day]'); + var nightInput = dayNightWrap.querySelector('input[name=value_night]'); + if (valueInput) valueInput.required = !isDayNight; + if (dayInput) dayInput.required = isDayNight; + if (nightInput) nightInput.required = isDayNight; + + if (hint) { + hint.textContent = isDayNight + ? 'Лічильник у режимі «день / ніч» — вкажіть обидва показники.' + : (typeId ? 'Звичайний лічильник — одне показання.' : ''); + } +} + +document.body.addEventListener('htmx:afterSwap', function (evt) { + if (document.getElementById('utilities-page')) { + utilitiesFormModeUpdate(); + } +}); + +if (document.getElementById('utilities-page')) { + utilitiesFormModeUpdate(); +} diff --git a/templates/pages/power-assets.html b/templates/pages/power-assets.html new file mode 100644 index 0000000..76351bd --- /dev/null +++ b/templates/pages/power-assets.html @@ -0,0 +1,358 @@ +{% load i18n %} +
+ +
+
+

{% trans "Энергооборудование" %}

+

+ {% trans "Инверторы и генераторы: объекты, обслуживание и плановое ТО" %} +

+
+ +
+ +
+
+
{% trans "Всего" %}
+
{{ power_counts.total }}
+
+
+
{% trans "Инверторы" %}
+
{{ power_counts.inverter }}
+
+
+
{% trans "Генераторы" %}
+
{{ power_counts.generator }}
+
+
+
{% trans "Просрочено ТО" %}
+
{{ power_counts.overdue }}
+
+
+ + {% if power_form_message %} +
{{ power_form_message }}
+ {% endif %} + {% if power_form_error %} +
{{ power_form_error }}
+ {% endif %} + +
+ +
+ + +
+
+ +
+ +
+
+
+ + +
+ +
+ +
+
+ + + + + + + + + + + + + {% for asset in power_assets_page %} + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
{% trans "Тип / модель" %}{% trans "Объект" %}{% trans "С/н" %}{% trans "кВт" %}{% trans "ТО" %}{% trans "Статус" %}
+ {{ asset.asset_type.name }} +
{{ asset.brand }} {{ asset.model_name }}
+
{{ asset.location }}{{ asset.serial_number|default:"—" }}{{ asset.capacity_kw|default:"—" }} + {% if asset.next_service_at %} + {% if asset.service_overdue %} + {% trans "Просрочено" %} {{ asset.next_service_at|date:"d.m.Y" }} + {% else %} + {% trans "до" %} {{ asset.next_service_at|date:"d.m.Y" }} + {% endif %} + {% elif asset.last_serviced_at %} + {% trans "было" %} {{ asset.last_serviced_at|date:"d.m.Y" }} + {% else %} + — + {% endif %} + {{ asset.status.name }}
+
+ {% csrf_token %} +
+ + +
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
{% trans "Журнал обслуживания" %}
+
+ {% csrf_token %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + {% if asset.service_logs.all %} +
    + {% for log in asset.service_logs.all %} + {% if forloop.counter <= 8 %} +
  • +
    + {{ log.service_date|date:"d.m.Y" }} · {{ log.get_kind_display }} + + {% if log.performed_by_name %}{{ log.performed_by_name }}{% elif log.performed_by %}{{ log.performed_by.get_full_name }}{% endif %} + {% if log.engine_hours %} · {{ log.engine_hours }} {% trans "м/ч" %}{% endif %} + {% if log.next_service_at %} · {% trans "след." %} {{ log.next_service_at|date:"d.m.Y" }}{% endif %} + +
    +
    {{ log.description }}
    +
  • + {% endif %} + {% endfor %} +
+ {% else %} +

{% trans "Записей обслуживания пока нет." %}

+ {% endif %} +
+
{% trans "Пока нет записей — добавьте инвертор или генератор" %}
+
+ {% include "partials/pagination.html" with page=power_assets_page base_url="/accounting/?tab=power" page_param="power_page" size_param="power_per_page" item_label="единиц" %} +
+ + + + + +
diff --git a/templates/pages/rro.html b/templates/pages/rro.html index 63703f8..1ec635e 100644 --- a/templates/pages/rro.html +++ b/templates/pages/rro.html @@ -1,6 +1,7 @@ {% load i18n %} -
+{% if not accounting_embed %}
{% endif %} + {% if not accounting_embed %}

{% trans "РРО / ПРРО" %}

@@ -14,6 +15,13 @@ {% endif %}
+ {% elif can_edit_rro %} +
+ +
+ {% endif %} {% if rro_form_message %}
@@ -21,7 +29,7 @@
{% endif %} -
@@ -219,7 +227,7 @@ {% if rro_form_data_json %}{% endif %} {% endif %} -
+{% if not accounting_embed %}
{% endif %} {% if can_edit_rro %}