it_manager+reports: иерархия договоров, биллинг и месячные отчёты

Иерархия рамочных/дочерних договоров и платежей, интернет-биллинг,
месячные IT-отчёты (импорт, DOCX/PDF, шаблоны выводов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 18:03:44 +03:00
parent 897ffc99c9
commit 0da9c13192
59 changed files with 6861 additions and 267 deletions
+93 -2
View File
@@ -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