0da9c13192
Иерархия рамочных/дочерних договоров и платежей, интернет-биллинг, месячные IT-отчёты (импорт, DOCX/PDF, шаблоны выводов). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
194 lines
8.9 KiB
Python
194 lines
8.9 KiB
Python
from django.conf import settings
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import models
|
||
from django.utils import timezone
|
||
|
||
from core.models.directories import directory_default
|
||
|
||
|
||
class InternetProvider(models.Model):
|
||
name = models.CharField('Название', max_length=200, unique=True)
|
||
phone = models.CharField('Телефон', max_length=50, blank=True)
|
||
email = models.EmailField('Email', blank=True)
|
||
website = models.URLField('Сайт', blank=True)
|
||
support_phone = models.CharField('Телефон техподдержки', max_length=50, blank=True)
|
||
support_email = models.EmailField('Email техподдержки', blank=True)
|
||
notes = models.TextField('Примечания', blank=True)
|
||
is_active = models.BooleanField('Активен', default=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = 'Провайдер'
|
||
verbose_name_plural = 'Провайдеры'
|
||
ordering = ['name']
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
@property
|
||
def active_contracts_count(self):
|
||
return self.contracts.filter(status__code='active').count()
|
||
|
||
|
||
class InternetContract(models.Model):
|
||
provider = models.ForeignKey(
|
||
InternetProvider, on_delete=models.PROTECT,
|
||
related_name='contracts', verbose_name='Провайдер',
|
||
)
|
||
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)
|
||
service_type = models.ForeignKey(
|
||
'core.Directory', on_delete=models.PROTECT,
|
||
limit_choices_to={'directory_type__code': 'contract_service_type', 'is_active': True},
|
||
related_name='contracts_by_service_type', verbose_name='Тип услуги',
|
||
default=directory_default('contract_service_type', 'internet'),
|
||
)
|
||
connection_type = models.ForeignKey(
|
||
'core.Directory', on_delete=models.SET_NULL,
|
||
limit_choices_to={'directory_type__code': 'contract_connection_type', 'is_active': True},
|
||
related_name='contracts_by_connection_type', verbose_name='Технология подключения',
|
||
null=True, blank=True,
|
||
)
|
||
status = models.ForeignKey(
|
||
'core.Directory', on_delete=models.PROTECT,
|
||
limit_choices_to={'directory_type__code': 'contract_status', 'is_active': True},
|
||
related_name='contracts_by_status', verbose_name='Статус',
|
||
default=directory_default('contract_status', 'active'),
|
||
)
|
||
|
||
start_date = models.DateField('Дата начала')
|
||
end_date = models.DateField('Дата окончания', null=True, blank=True)
|
||
|
||
monthly_cost = models.DecimalField('Стоимость в месяц', max_digits=10, decimal_places=2,
|
||
null=True, blank=True)
|
||
bandwidth_down = models.PositiveIntegerField('Скорость вниз (Мбит/с)', null=True, blank=True)
|
||
bandwidth_up = models.PositiveIntegerField('Скорость вверх (Мбит/с)', null=True, blank=True)
|
||
|
||
static_ips = models.TextField('Статические IP', blank=True,
|
||
help_text='По одному на строку')
|
||
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 = ['provider__name', 'contract_number']
|
||
unique_together = [('provider', 'contract_number')]
|
||
|
||
def __str__(self):
|
||
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):
|
||
if not self.end_date or self.status.code != 'active':
|
||
return False
|
||
return (self.end_date - timezone.now().date()).days <= 30
|
||
|
||
@property
|
||
def bandwidth_display(self):
|
||
if self.bandwidth_down and self.bandwidth_up:
|
||
return f'{self.bandwidth_down}↓ / {self.bandwidth_up}↑ Мбит/с'
|
||
if self.bandwidth_down:
|
||
return f'{self.bandwidth_down} Мбит/с'
|
||
return '—'
|
||
|
||
@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
|