it_manager+reports: иерархия договоров, биллинг и месячные отчёты
Иерархия рамочных/дочерних договоров и платежей, интернет-биллинг, месячные IT-отчёты (импорт, DOCX/PDF, шаблоны выводов). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user