feat(fns): парсер ФНС бухгалтерской отчетности

- Модели FinancialReport и FinancialReportLine
- FNSExcelParser для файлов fin_{id}_{ogrn}.xlsx
- FNSReportService с дедупликацией по хешу файла
- Celery задачи для мониторинга папки (каждые 5 мин)
- API: POST /fns/upload/, GET /fns/reports/
- Django admin интеграция
- 25 unit-тестов
This commit is contained in:
2026-02-01 14:44:19 +01:00
parent eb0d6f2600
commit cd0e21350b
17 changed files with 1537 additions and 10 deletions

View File

@@ -21,6 +21,7 @@ class ParserLoadLog(TimestampMixin, models.Model):
MANUFACTURES = "manufactures", _("Реестр производителей")
INSPECTIONS = "inspections", _("Единый реестр проверок")
PROCUREMENTS = "procurements", _("Государственные закупки")
FNS_REPORTS = "fns_reports", _("Бухгалтерская отчетность ФНС")
batch_id = models.PositiveIntegerField(
_("ID пакета"),
@@ -506,3 +507,153 @@ class ProcurementRecord(TimestampMixin, models.Model):
def __str__(self) -> str:
name = self.purchase_name[:50] if self.purchase_name else ""
return f"{self.purchase_number} - {name}"
class FinancialReport(TimestampMixin, models.Model):
"""
Бухгалтерская отчетность организации из ФНС.
Данные загружаются из Excel файлов, полученных от ФНС.
Имя файла: fin_{external_id}_{ogrn}.xlsx
"""
class Status(models.TextChoices):
PENDING = "pending", _("Ожидает обработки")
PROCESSING = "processing", _("Обрабатывается")
SUCCESS = "success", _("Успешно")
FAILED = "failed", _("Ошибка")
class SourceType(models.TextChoices):
FILE_WATCH = "file_watch", _("Мониторинг папки")
API = "api", _("API загрузка")
external_id = models.CharField(
_("внешний ID"),
max_length=50,
unique=True,
db_index=True,
help_text=_("Уникальный идентификатор из имени файла"),
)
ogrn = models.CharField(
_("ОГРН"),
max_length=15,
db_index=True,
help_text=_("ОГРН организации"),
)
file_name = models.CharField(
_("имя файла"),
max_length=255,
help_text=_("Оригинальное имя загруженного файла"),
)
file_hash = models.CharField(
_("хеш файла"),
max_length=64,
unique=True,
help_text=_("SHA256 хеш файла для дедупликации"),
)
load_batch = models.PositiveIntegerField(
_("ID пакета загрузки"),
db_index=True,
help_text=_("Идентификатор пакета загрузки"),
)
status = models.CharField(
_("статус"),
max_length=20,
choices=Status.choices,
default=Status.PENDING,
db_index=True,
help_text=_("Статус обработки файла"),
)
error_message = models.TextField(
_("сообщение об ошибке"),
blank=True,
help_text=_("Текст ошибки при неудачной обработке"),
)
source = models.CharField(
_("источник"),
max_length=20,
choices=SourceType.choices,
help_text=_("Источник загрузки файла"),
)
class Meta:
db_table = "parsers_financial_report"
verbose_name = _("финансовый отчет")
verbose_name_plural = _("финансовые отчеты")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["ogrn", "status"]),
models.Index(fields=["load_batch", "status"]),
]
def __str__(self) -> str:
return f"{self.external_id} (ОГРН: {self.ogrn}) - {self.status}"
class FinancialReportLine(models.Model):
"""
Строка бухгалтерской отчетности.
Хранит значения по конкретной строке формы отчетности за конкретный год.
Формы: №1 (Баланс), №2 (ОФР), №3 (Изменение капитала),
№4 (Движение денежных средств), №6 (Целевое использование).
"""
report = models.ForeignKey(
FinancialReport,
on_delete=models.CASCADE,
related_name="lines",
verbose_name=_("отчет"),
)
form_code = models.CharField(
_("код формы"),
max_length=10,
db_index=True,
help_text=_("Номер формы отчетности (1, 2, 3, 4, 6)"),
)
line_code = models.CharField(
_("код строки"),
max_length=10,
db_index=True,
help_text=_("Код строки отчетности (например: 1100, 2110)"),
)
line_name = models.CharField(
_("наименование строки"),
max_length=255,
help_text=_("Наименование строки отчетности"),
)
year = models.PositiveSmallIntegerField(
_("год"),
db_index=True,
help_text=_("Отчетный год"),
)
period_start = models.BigIntegerField(
_("на начало периода"),
null=True,
blank=True,
help_text=_("Значение на начало периода (тыс. руб.)"),
)
period_end = models.BigIntegerField(
_("на конец периода"),
null=True,
blank=True,
help_text=_("Значение на конец периода (тыс. руб.)"),
)
class Meta:
db_table = "parsers_financial_report_line"
verbose_name = _("строка финансового отчета")
verbose_name_plural = _("строки финансовых отчетов")
indexes = [
models.Index(fields=["report", "form_code", "line_code"]),
models.Index(fields=["year", "line_code"]),
]
constraints = [
models.UniqueConstraint(
fields=["report", "form_code", "line_code", "year"],
name="unique_report_line_year",
),
]
def __str__(self) -> str:
return f"{self.line_code} ({self.line_name[:30]}) - {self.year}"