16bf2cf1c7
Конфиг счётчиков и день/ночь-тарифы, учёт энергосистем (генераторы, инверторы), доступ к разделу РРО/ПРРО. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
7.8 KiB
Python
189 lines
7.8 KiB
Python
from django.conf import settings
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
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):
|
|
"""Показання лічильника (світло, вода...), подані аптекою для комунального відділу."""
|
|
|
|
pharmacy = models.ForeignKey(
|
|
'locations.Pharmacy', on_delete=models.CASCADE,
|
|
related_name='utility_readings', 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_readings', verbose_name=_('Тип лічильника'),
|
|
)
|
|
reading_date = models.DateField(_('Дата зняття показань'), default=timezone.localdate)
|
|
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(
|
|
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
|
null=True, blank=True,
|
|
related_name='utility_readings_submitted', verbose_name=_('Хто подав'),
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _('Показання лічильника')
|
|
verbose_name_plural = _('Показання лічильників')
|
|
ordering = ['-reading_date', '-created_at']
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=['pharmacy', 'utility_type', 'reading_date'],
|
|
name='unique_reading_per_pharmacy_type_date',
|
|
),
|
|
]
|
|
indexes = [
|
|
models.Index(fields=['pharmacy', 'utility_type', '-reading_date']),
|
|
]
|
|
|
|
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()
|
|
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': _('Дата не може бути в майбутньому')})
|
|
|
|
def _previous_reading(self):
|
|
qs = UtilityReading.objects.filter(
|
|
pharmacy=self.pharmacy, utility_type=self.utility_type,
|
|
reading_date__lt=self.reading_date,
|
|
)
|
|
if self.pk:
|
|
qs = qs.exclude(pk=self.pk)
|
|
return qs.order_by('-reading_date').first()
|
|
|
|
@property
|
|
def previous_reading(self):
|
|
return self._previous_reading()
|
|
|
|
def _delta(self, field: str):
|
|
previous = self._previous_reading()
|
|
if previous is None:
|
|
return None
|
|
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)
|