From 1bc98796abf9c31d757dd1302e6ade7f2ea068bb Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Tue, 4 Aug 2026 23:25:31 +0200 Subject: [PATCH] fix: restore code-less FNS financial reports --- README.md | 14 + src/apps/parsers/clients/fns/line_codes.py | 271 ++++++++++++++++++ src/apps/parsers/clients/fns/parser.py | 190 +++++++++--- .../commands/restore_fns_financial_lines.py | 186 ++++++++++++ .../test_restore_fns_financial_lines.py | 95 ++++++ tests/apps/parsers/test_fns_parser.py | 101 +++++++ 6 files changed, 818 insertions(+), 39 deletions(-) create mode 100644 src/apps/parsers/clients/fns/line_codes.py create mode 100644 src/organizations/management/commands/restore_fns_financial_lines.py create mode 100644 tests/apps/organizations/test_restore_fns_financial_lines.py diff --git a/README.md b/README.md index 0d89eb6..3031d26 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,20 @@ Celery-задачи (основные): - успешные: `input/fns/processed/` - ошибки: `input/fns/failed/` +Парсер поддерживает отчеты как с отдельной колонкой `Код`, так и стандартные +файлы, где после наименования строки сразу идут пары `Начало`/`Конец`. Для +восстановления ранее успешно обработанных отчетов без нормализованных +финансовых строк используется идемпотентная команда: + +```bash +# Проверка без записи +uv run python src/manage.py restore_fns_financial_lines --dry-run + +# Восстановление всех пустых отчетов или одной организации +uv run python src/manage.py restore_fns_financial_lines +uv run python src/manage.py restore_fns_financial_lines --ogrn 1187700006273 +``` + ## Тесты - Все тесты проекта хранятся только в `ROOT_DIR/tests`. diff --git a/src/apps/parsers/clients/fns/line_codes.py b/src/apps/parsers/clients/fns/line_codes.py new file mode 100644 index 0000000..20b6891 --- /dev/null +++ b/src/apps/parsers/clients/fns/line_codes.py @@ -0,0 +1,271 @@ +"""Canonical line-code resolution for FNS workbooks without a code column.""" + +from __future__ import annotations + +import re + +_DASH_PATTERN = re.compile(r"[‐‑‒–—−]") + + +def normalize_fns_line_name(value: object) -> str: + """Normalize workbook labels without losing their accounting meaning.""" + normalized = str(value or "").strip().casefold().replace("ё", "е") + normalized = _DASH_PATTERN.sub("-", normalized) + return " ".join(normalized.split()) + + +CODELESS_FNS_LINE_CODES: dict[str, dict[str, str]] = { + "1": { + "баланс": "1600", + "дебиторская задолженность": "1230", + "денежные средства и денежные эквиваленты": "1250", + "добавочный капитал (без накопленной дооценки)": "1350", + "добавочный капитал (без переоценки)": "1350", + "долгосрочные заемные средства": "1410", + "доходные вложения в материальные ценности": "1160", + "доходы будущих периодов": "1530", + "другие долгосрочные обязательства": "1450", + "другие краткосрочные обязательства": "1550", + "запасы": "1210", + "инвестиционная недвижимость": "1160", + "капитал и резервы": "1300", + "краткосрочная кредиторская задолженность": "1520", + "краткосрочные заемные средства": "1510", + "кредиторская задолженность": "1520", + "материальные внеоборотные активы": "1150", + "материальные поисковые активы": "1140", + "накопленная дооценка внеоборотных активов": "1340", + "ндс по приобретенным ценностям": "1220", + "нематериальные активы": "1110", + "нематериальные поисковые активы": "1130", + "нематериальные, финансовые и другие внеоборотные активы": "1170", + "нераспределенная прибыль (непокрытый убыток)": "1370", + "основные средства": "1150", + "отложенные налоговые активы": "1180", + "отложенные налоговые обязательства": "1420", + "переоценка внеоборотных активов": "1340", + "прочие внеоборотные активы": "1190", + "прочие долгосрочные обязательства": "1450", + "прочие краткосрочные обязательства": "1550", + "прочие оборотные активы": "1260", + "резервный капитал": "1360", + "результаты исследований и разработок": "1120", + "собственные акции, выкупленные у акционеров": "1320", + "собственные акции, принадлежащие обществу, задолженность акционеров по оплате акций": "1320", + "уставный капитал": "1310", + "финансовые вложения": "1170", + "финансовые вложения (за исключением денежных эквивалентов)": "1240", + "финансовые и другие оборотные активы": "1230", + }, + "2": { + "базовая прибыль (убыток) на акцию": "2900", + "валовая прибыль (убыток)": "2100", + "выручка": "2110", + "доходы от участия в других организациях": "2310", + "изменение отложенных налоговых активов": "2450", + "изменение отложенных налоговых обязательств": "2430", + "коммерческие расходы": "2210", + "налог на прибыль": "2410", + "налог на прибыль от операций, результат которых не включается в чистую прибыль (убыток)": "2530", + "налоги на прибыль (доходы)": "2410", + "отложенный налог на прибыль": "2412", + "постоянные налоговые обязательства (активы)": "2421", + "прибыль (убыток) до налогообложения": "2300", + "прибыль (убыток) от продаж": "2200", + "проценты к получению": "2320", + "проценты к уплате": "2330", + "прочее": "2460", + "прочие доходы": "2340", + "прочие расходы": "2350", + "разводненная прибыль (убыток) на акцию": "2910", + "расходы по обычной деятельности": "2120", + "результат от переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток)": "2510", + "результат от прочих операций, не включаемый в чистую прибыль (убыток) периода": "2520", + "себестоимость продаж": "2120", + "совокупный финансовый результат периода": "2500", + "текущий налог на прибыль": "2411", + "управленческие расходы": "2220", + "чистая прибыль (убыток)": "2400", + }, + "3": { + "величина капитала": "3300", + "дивиденды": "3327", + "добавочный капитал": "3300", + "дополнительный выпуск акций": "3314", + "доходы на увеличение капитала": "3313", + "изменение добавочного капитала": "3330", + "изменение резервного капитала": "3340", + "нераспределенная прибыль (непокрытый убыток)": "3300", + "расходы на уменьшение капитала": "3323", + "резервный капитал": "3300", + "собственные акции": "3300", + "убыток": "3321", + "увеличение - всего": "3310", + "увеличение номинальной стоимости акций": "3315", + "уменьшение - всего": "3320", + "уменьшение количества акций": "3325", + "уменьшение номинальной стоимости акций": "3324", + "уставный капитал": "3300", + "чистая прибыль": "3311", + "чистые активы": "3600", + }, + "4": { + "арендные, лицензензионные, комиссионные и т.п. платежи": "4112", + "влияние изменений курса валют": "4490", + "возврат займов, продажа долг. ценных бумаг, прав требования": "4213", + "выкуп акций (долей) у собственников (участников)": "4321", + "выпуск акций, увеличение долей участия": "4313", + "выпуск долговых ценных бумаг": "4314", + "денежные вклады собственников (участников)": "4312", + "дивиденды и т.п. платежи по распределению прибыли": "4322", + "дивиденды, проценты по долг. финанс. вложениям и т.п. поступления от долевого участия в др. организациях": "4214", + "налог на прибыль организаций": "4124", + "оплата труда работников": "4122", + "остаток денежных средств и эквивалентов на конец периода": "4500", + "остаток денежных средств и эквивалентов на начало периода": "4450", + "перепродажа финансовых вложений": "4113", + "погашение (выкуп) долг. ценных бумаг, возврат кредитов и займов": "4323", + "получение кредитов и займов": "4311", + "поставщикам (подрядчикам) за сырье, материалы, работы": "4121", + "приобретение акций др. организаций, долей участия": "4222", + "приобретение долг. ценных бумаг, прав требования, предоставление займов": "4223", + "приобретение, создание, модернизация, реконструкция, подготовка к использованию внеоборотных активов": "4221", + "продажа акций др. организаций, долей участия": "4212", + "продажа внеоборотных активов (кроме финанс. вложений)": "4211", + "продажа продукции, товаров, работ и услуг": "4111", + "проценты по долг. обязательствам, вкл. в стоимость инвестиц. актива": "4224", + "проценты по долговым обязательствам": "4123", + "сальдо денежных потоков": "4400", + }, + "6": { + "взносы и иные целевые поступления": "6220", + "вступительные взносы": "6210", + "выплаты, не связанные с оплатой труда": "6322", + "добровольные имущ. взносы и пожертвования": "6230", + "иные мероприятия": "6313", + "на приобретение основных средств и иного имущества": "6330", + "на содержание организации": "6320", + "на целевые мероприятия": "6310", + "оплата труда (включая начисления)": "6321", + "остаток средств на конец периода": "6400", + "остаток средств на начало периода": "6100", + "прибыль от предпринимательской деятельности": "6240", + "прибыль от предпринимательской и иной деятельности": "6240", + "приобретение основных средств, инвентаря и др. имущества": "6330", + "проведение конференций, совещаний, семинаров и т.п.": "6312", + "прочeе": "6350", + "прочие": "6326", + "прочие поступления": "6250", + "расходы на содержание аппарата управления": "6320", + "расходы на целевые мероприятия": "6310", + "ремонт основных средств и др. имущества": "6325", + "служебные командировки и деловые поездки": "6323", + "содержание основных средств и др. имущества (кроме ремонта)": "6324", + "социальная и благотворительная помощь": "6311", + "целевые взносы": "6220", + "членские взносы": "6215", + }, +} + + +_FORM_1_SECTION_CODES: dict[str, dict[str, str]] = { + "внеоборотные активы": {"итого": "1100"}, + "оборотные активы": {"итого": "1200"}, + "капитал и резервы": {"итого": "1300"}, + "долгосрочные обязательства": { + "заемные средства": "1410", + "итого": "1400", + "оценочные обязательства": "1430", + "прочие обязательства": "1450", + }, + "краткосрочные обязательства": { + "заемные средства": "1510", + "итого": "1500", + "оценочные обязательства": "1540", + "прочие обязательства": "1550", + }, +} + +_FORM_4_FLOW_CODES: dict[str, dict[str, str]] = { + "текущих": { + "платежи - всего": "4120", + "поступления - всего": "4110", + "прочие платежи": "4129", + "прочие поступления": "4119", + "сальдо": "4100", + }, + "инвестиционных": { + "платежи - всего": "4220", + "поступления - всего": "4210", + "прочие платежи": "4229", + "прочие поступления": "4219", + "сальдо": "4200", + }, + "финансовых": { + "платежи - всего": "4320", + "поступления - всего": "4310", + "прочие платежи": "4329", + "прочие поступления": "4319", + "сальдо": "4300", + }, +} + + +def _resolve_form_1_code(*, label: str, section: str) -> str | None: + return _FORM_1_SECTION_CODES.get(section, {}).get(label) + + +def _resolve_form_3_code(*, label: str, direction: str) -> str | None: + if label == "переоценка имущества": + return {"увеличение - всего": "3312", "уменьшение - всего": "3322"}.get( + direction + ) + if label == "реорганизация юл": + return {"увеличение - всего": "3316", "уменьшение - всего": "3326"}.get( + direction + ) + return None + + +def _resolve_form_4_code(*, label: str, section: str) -> str | None: + for flow_name, codes in _FORM_4_FLOW_CODES.items(): + if flow_name in section: + return codes.get(label) + return None + + +def _resolve_form_6_code(*, label: str, funds: str) -> str | None: + if label != "итого": + return None + return {"поступило средств": "6200", "использовано средств": "6300"}.get(funds) + + +def resolve_codeless_fns_line_code( + *, + form_code: str, + line_name: object, + section_name: object = "", + change_direction: object = "", + funds_section: object = "", +) -> str | None: + """Resolve a standard accounting code from a code-less FNS row.""" + label = normalize_fns_line_name(line_name) + section = normalize_fns_line_name(section_name) + direction = normalize_fns_line_name(change_direction) + funds = normalize_fns_line_name(funds_section) + + if form_code == "1": + contextual_code = _resolve_form_1_code(label=label, section=section) + elif form_code == "3": + contextual_code = _resolve_form_3_code(label=label, direction=direction) + elif form_code == "4": + contextual_code = _resolve_form_4_code(label=label, section=section) + elif form_code == "6": + contextual_code = _resolve_form_6_code(label=label, funds=funds) + else: + contextual_code = None + + if contextual_code: + return contextual_code + + return CODELESS_FNS_LINE_CODES.get(form_code, {}).get(label) diff --git a/src/apps/parsers/clients/fns/parser.py b/src/apps/parsers/clients/fns/parser.py index c0bf2c6..96c2292 100644 --- a/src/apps/parsers/clients/fns/parser.py +++ b/src/apps/parsers/clients/fns/parser.py @@ -7,6 +7,10 @@ import re from pathlib import Path import openpyxl +from apps.parsers.clients.fns.line_codes import ( + normalize_fns_line_name, + resolve_codeless_fns_line_code, +) from apps.parsers.clients.fns.schemas import ParsedReport, ReportLine logger = logging.getLogger(__name__) @@ -92,13 +96,23 @@ class FNSExcelParser: lines: list[ReportLine] = [] - for sheet_name in workbook.sheetnames: - sheet = workbook[sheet_name] - sheet_lines = cls._parse_sheet(sheet) - lines.extend(sheet_lines) - logger.debug("Лист '%s': извлечено %d строк", sheet_name, len(sheet_lines)) + try: + for sheet_name in workbook.sheetnames: + sheet = workbook[sheet_name] + sheet_lines = cls._parse_sheet(sheet) + lines.extend(sheet_lines) + logger.debug( + "Лист '%s': извлечено %d строк", + sheet_name, + len(sheet_lines), + ) + finally: + workbook.close() - workbook.close() + if not lines: + raise FNSParserError( + f"Файл {file_path.name} не содержит распознанных строк отчетности" + ) logger.info( "Файл %s обработан: %d строк, годы %s, формы %s", @@ -117,6 +131,10 @@ class FNSExcelParser: current_form: str | None = None years: list[int] = [] header_row_found = False + value_column_start: int | None = None + current_section = "" + change_direction = "" + funds_section = "" for row in sheet.iter_rows(values_only=True): if not any(row): @@ -130,51 +148,145 @@ class FNSExcelParser: current_form = form_code years = cls._extract_years_from_row(row) header_row_found = False + value_column_start = None + current_section = "" + change_direction = "" + funds_section = "" logger.debug("Найдена форма %s, годы: %s", form_code, years) break + else: + form_code = None - # Пропускаем заголовочную строку с "Код", "Начало", "Конец" - is_header = len(row) > 1 and row[1] == "Код" - if current_form and not header_row_found and is_header: - header_row_found = True + if form_code is not None: continue - # Парсим строки данных - if current_form and header_row_found and years: - line_name = first_cell + detected_value_start = cls._detect_value_column_start(row) + if current_form and not header_row_found and detected_value_start: + header_row_found = True + value_column_start = detected_value_start + continue + + if not ( + current_form + and header_row_found + and years + and value_column_start is not None + ): + continue + + period_values = [] + for year_idx, year in enumerate(years): + col_start = value_column_start + year_idx * 2 + col_end = col_start + 1 + period_start = cls._parse_value( + row[col_start] if col_start < len(row) else None + ) + period_end = cls._parse_value( + row[col_end] if col_end < len(row) else None + ) + period_values.append((year, period_start, period_end)) + + normalized_line_name = normalize_fns_line_name(first_cell) + has_values = any( + period_start is not None or period_end is not None + for _, period_start, period_end in period_values + ) + if not has_values: + if normalized_line_name and not normalized_line_name.isdigit(): + current_section = normalized_line_name + if current_form == "3": + change_direction = "" + if current_form == "6" and normalized_line_name in { + "поступило средств", + "использовано средств", + }: + funds_section = normalized_line_name + continue + + if not normalized_line_name: + raise FNSParserError( + f"Лист {sheet.title}: строка формы {current_form} " + "содержит значения без наименования" + ) + + if current_form == "3" and normalized_line_name in { + "увеличение - всего", + "уменьшение - всего", + }: + change_direction = normalized_line_name + + row_change_direction = change_direction + if ( + current_form == "3" + and not row_change_direction + and normalized_line_name in {"переоценка имущества", "реорганизация юл"} + ): + row_change_direction = cls._infer_change_direction(period_values) + + if value_column_start == 2: line_code = str(row[1]).strip() if row[1] else "" + else: + line_code = ( + resolve_codeless_fns_line_code( + form_code=current_form, + line_name=normalized_line_name, + section_name=current_section, + change_direction=row_change_direction, + funds_section=funds_section, + ) + or "" + ) - # Пропускаем строки без кода или заголовки секций - if not line_code or not line_code.isdigit(): + if not line_code.isdigit(): + raise FNSParserError( + f"Лист {sheet.title}: не определен код строки " + f"формы {current_form} «{first_cell}»" + ) + + for year, period_start, period_end in period_values: + if period_start is None and period_end is None: continue - - # Извлекаем значения по годам - for year_idx, year in enumerate(years): - col_start = 2 + year_idx * 2 # Начало: 2, 4, 6, 8 - col_end = 3 + year_idx * 2 # Конец: 3, 5, 7, 9 - - period_start = cls._parse_value( - row[col_start] if col_start < len(row) else None + lines.append( + ReportLine( + form_code=current_form, + line_code=line_code, + line_name=first_cell, + year=year, + period_start=period_start, + period_end=period_end, ) - period_end = cls._parse_value( - row[col_end] if col_end < len(row) else None - ) - - # Добавляем строку только если есть хотя бы одно значение - if period_start is not None or period_end is not None: - lines.append( - ReportLine( - form_code=current_form, - line_code=line_code, - line_name=line_name, - year=year, - period_start=period_start, - period_end=period_end, - ) - ) + ) return lines + @staticmethod + def _detect_value_column_start(row: tuple) -> int | None: + """Return the first period-value column for supported FNS layouts.""" + second_cell = normalize_fns_line_name(row[1] if len(row) > 1 else "") + third_cell = normalize_fns_line_name(row[2] if len(row) > 2 else "") + if second_cell == "код": + return 2 + if second_cell == "начало" and third_cell == "конец": + return 1 + return None + + @staticmethod + def _infer_change_direction( + period_values: list[tuple[int, int | None, int | None]], + ) -> str: + """Infer an unlabelled capital change only from an unambiguous sign.""" + values = [ + value + for _, period_start, period_end in period_values + for value in (period_start, period_end) + if value not in (None, 0) + ] + if values and all(value > 0 for value in values): + return "увеличение - всего" + if values and all(value < 0 for value in values): + return "уменьшение - всего" + return "" + @classmethod def _extract_years_from_row(cls, row: tuple) -> list[int]: """Извлекает годы из строки заголовка формы.""" diff --git a/src/organizations/management/commands/restore_fns_financial_lines.py b/src/organizations/management/commands/restore_fns_financial_lines.py new file mode 100644 index 0000000..7cd7f6d --- /dev/null +++ b/src/organizations/management/commands/restore_fns_financial_lines.py @@ -0,0 +1,186 @@ +"""Restore financial lines from already processed FNS workbooks.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from apps.core.management.commands.base import BaseAppCommand +from apps.parsers.clients.fns.parser import FNSExcelParser +from apps.parsers.models import ParserLoadLog +from django.conf import settings +from django.db import transaction +from django.db.models import Count + +from organizations.models import ( + OrganizationSourceFinancialLine, + OrganizationSourceRecord, +) + + +class Command(BaseAppCommand): + """Reparse successful FNS records whose normalized lines are missing.""" + + help = ( + "Восстанавливает отсутствующие строки финансовой отчетности из processed XLSX" + ) + use_transaction = False + + def add_arguments(self, parser) -> None: + super().add_arguments(parser) + parser.add_argument( + "--ogrn", + default=None, + help="Ограничить восстановление одной организацией по ОГРН/ОГРНИП.", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Обработать не более указанного количества отчетов.", + ) + + def execute_command(self, *args, **options) -> str: + records = self._records_to_restore( + ogrn=options.get("ogrn"), + limit=options.get("limit"), + ) + processed_directory = Path(settings.FNS_PROCESSED_DIRECTORY) + summary = { + "selected": len(records), + "parsed": 0, + "restored": 0, + "restored_lines": 0, + "skipped_concurrent": 0, + "missing_files": 0, + "failed": 0, + "dry_run": self.dry_run, + "errors": [], + } + + for record in self.progress_iter( + records, + desc="Восстановление финансовых строк", + total=len(records), + ): + try: + parsed = self._parse_record_file( + record=record, + processed_directory=processed_directory, + ) + except FileNotFoundError as exc: + summary["missing_files"] += 1 + summary["errors"].append( + {"external_id": record.external_id, "error": str(exc)} + ) + self.log_debug(str(exc)) + continue + except Exception as exc: # continue recovery and report exact total + summary["failed"] += 1 + summary["errors"].append( + {"external_id": record.external_id, "error": str(exc)} + ) + self.log_debug(f"{record.external_id}: {exc}") + continue + + summary["parsed"] += 1 + deduplicated_lines = { + (line.form_code, line.line_code, line.year): line + for line in parsed.lines + } + if self.dry_run: + summary["restored"] += 1 + summary["restored_lines"] += len(deduplicated_lines) + continue + + with transaction.atomic(): + locked_record = ( + OrganizationSourceRecord.objects.select_for_update().get( + pk=record.pk + ) + ) + if locked_record.financial_lines.exists(): + summary["skipped_concurrent"] += 1 + continue + + OrganizationSourceFinancialLine.objects.bulk_create( + [ + OrganizationSourceFinancialLine( + source_record=locked_record, + form_code=line.form_code, + line_code=line.line_code, + line_name=line.line_name, + year=line.year, + period_start=line.period_start, + period_end=line.period_end, + ) + for line in deduplicated_lines.values() + ], + batch_size=1000, + ) + payload = dict(locked_record.payload or {}) + payload["lines_count"] = len(parsed.lines) + locked_record.payload = payload + locked_record.save(update_fields=["payload", "updated_at"]) + + summary["restored"] += 1 + summary["restored_lines"] += len(deduplicated_lines) + + rendered = json.dumps(summary, ensure_ascii=False, sort_keys=True) + if summary["failed"] or summary["missing_files"]: + self.log_warning(rendered) + else: + self.log_success(rendered) + return rendered + + @staticmethod + def _records_to_restore( + *, + ogrn: str | None, + limit: int | None, + ) -> list[OrganizationSourceRecord]: + queryset = ( + OrganizationSourceRecord.objects.filter( + source=ParserLoadLog.Source.FNS_REPORTS, + ) + .select_related("extension", "extension__organization") + .annotate(financial_lines_count=Count("financial_lines")) + .filter(financial_lines_count=0) + .order_by("external_id", "uid") + ) + if ogrn: + queryset = queryset.filter( + extension__organization__ogrn=ogrn + ) | queryset.filter(extension__organization__ogrip=ogrn) + queryset = queryset.order_by("external_id", "uid") + if limit is not None: + if limit < 1: + raise ValueError("--limit должен быть положительным числом") + queryset = queryset[:limit] + return list(queryset) + + @staticmethod + def _parse_record_file( + *, + record: OrganizationSourceRecord, + processed_directory: Path, + ): + payload = dict(record.payload or {}) + file_name = str(payload.get("file_name") or record.title or "") + if not file_name or Path(file_name).name != file_name: + raise ValueError("Некорректное имя processed XLSX") + file_path = processed_directory / file_name + if not file_path.is_file(): + raise FileNotFoundError(f"Processed XLSX не найден: {file_name}") + + parsed = FNSExcelParser.parse_file(file_path) + if parsed.external_id != record.external_id: + raise ValueError("external_id файла не совпадает с source record") + expected_ogrn = str( + payload.get("ogrn") + or record.extension.organization.ogrn + or record.extension.organization.ogrip + ) + if parsed.ogrn != expected_ogrn: + raise ValueError("ОГРН файла не совпадает с source record") + return parsed diff --git a/tests/apps/organizations/test_restore_fns_financial_lines.py b/tests/apps/organizations/test_restore_fns_financial_lines.py new file mode 100644 index 0000000..f680b02 --- /dev/null +++ b/tests/apps/organizations/test_restore_fns_financial_lines.py @@ -0,0 +1,95 @@ +"""Tests for restoring normalized FNS financial lines.""" + +from __future__ import annotations + +from pathlib import Path +from tempfile import TemporaryDirectory + +from apps.parsers.models import ParserLoadLog +from django.core.management import call_command +from django.test import TestCase, override_settings +from openpyxl import Workbook +from organizations.models import ( + FinancialIndicatorsExtension, + OrganizationSourceFinancialLine, + OrganizationSourceRecord, +) + +from tests.apps.parsers.organization_helpers import create_directory_organization + + +class RestoreFNSFinancialLinesCommandTests(TestCase): + """Restore only empty reports and keep reruns idempotent.""" + + def test_dry_run_then_restores_codeless_processed_workbook(self): + ogrn = "1187700006273" + external_id = "0130160" + organization = create_directory_organization( + name="Тестовая организация", + inn="7700000001", + kpp="770001001", + okpo="12345678", + ogrn=ogrn, + ) + extension = FinancialIndicatorsExtension.objects.create( + organization=organization, + title="Финансово-экономические показатели", + ) + file_name = f"fin_{external_id}_{ogrn}.xlsx" + record = OrganizationSourceRecord.objects.create( + extension=extension, + source=ParserLoadLog.Source.FNS_REPORTS, + record_type="financial_report", + external_id=external_id, + title=file_name, + status="success", + payload={ + "external_id": external_id, + "file_name": file_name, + "lines_count": 0, + "ogrn": ogrn, + }, + ) + + with TemporaryDirectory() as temp_directory: + workbook = Workbook() + worksheet = workbook.active + worksheet.append(["Форма №1", 2020, None, 2021, None]) + worksheet.append( + ["Бухгалтерский баланс", "Начало", "Конец", "Начало", "Конец"] + ) + worksheet.append(["Баланс", 60_232, 234_841, 234_841, 244_479]) + workbook.save(Path(temp_directory) / file_name) + + with override_settings(FNS_PROCESSED_DIRECTORY=temp_directory): + call_command( + "restore_fns_financial_lines", + "--ogrn", + ogrn, + "--dry-run", + "--silent", + ) + self.assertFalse( + OrganizationSourceFinancialLine.objects.filter( + source_record=record + ).exists() + ) + + call_command( + "restore_fns_financial_lines", + "--ogrn", + ogrn, + "--silent", + ) + call_command( + "restore_fns_financial_lines", + "--ogrn", + ogrn, + "--silent", + ) + + lines = OrganizationSourceFinancialLine.objects.filter(source_record=record) + self.assertEqual(lines.count(), 2) + self.assertEqual(set(lines.values_list("year", flat=True)), {2020, 2021}) + record.refresh_from_db() + self.assertEqual(record.payload["lines_count"], 2) diff --git a/tests/apps/parsers/test_fns_parser.py b/tests/apps/parsers/test_fns_parser.py index b159ba1..c1b740d 100644 --- a/tests/apps/parsers/test_fns_parser.py +++ b/tests/apps/parsers/test_fns_parser.py @@ -2,6 +2,9 @@ Тесты для FNS парсера бухгалтерской отчетности. """ +from pathlib import Path +from tempfile import TemporaryDirectory + from apps.parsers.clients.fns.parser import FNSExcelParser, FNSParserError from apps.parsers.clients.fns.schemas import ParsedReport, ReportLine from apps.parsers.models import FinancialReport @@ -10,6 +13,7 @@ from apps.parsers.services import ( FNSReportService, ) from django.test import TestCase +from openpyxl import Workbook from organizations.models import Organization from tests.apps.parsers.organization_helpers import create_directory_organization @@ -274,6 +278,103 @@ class TestFNSParserParseValue(TestCase): self.assertIsNone(FNSExcelParser._parse_value(fake.word())) +class TestFNSExcelParserWorkbook(TestCase): + """Регрессии поддерживаемых структур бухгалтерских XLSX.""" + + def test_parses_workbook_without_line_code_column(self): + with TemporaryDirectory() as temp_directory: + path = Path(temp_directory) / "fin_0130160_1187700006273.xlsx" + workbook = Workbook() + worksheet = workbook.active + worksheet.append(["Форма №1", 2020, None, 2021, None]) + worksheet.append( + ["Бухгалтерский баланс", "Начало", "Конец", "Начало", "Конец"] + ) + worksheet.append(["Баланс", 60_232, 234_841, 234_841, 244_479]) + worksheet.append(["Внеоборотные активы", None, None, None, None]) + worksheet.append(["Итого", 1_878, 7_071, 7_071, 18_709]) + worksheet.append(["Форма №3", 2020, None, 2021, None]) + worksheet.append( + [ + "Отчет об изменении капитала", + "Начало", + "Конец", + "Начало", + "Конец", + ] + ) + worksheet.append(["Добавочный капитал", None, None, None, None]) + worksheet.append(["Увеличение - всего", 0, 10, 10, 20]) + worksheet.append(["переоценка имущества", 0, 10, 10, 20]) + worksheet.append(["Уменьшение - всего", 0, 5, 5, 6]) + worksheet.append(["реорганизация ЮЛ", 0, 5, 5, 6]) + worksheet.append(["Резервный капитал", None, None, None, None]) + worksheet.append(["переоценка имущества", 0, -5, -5, -6]) + worksheet.append(["Форма №4", 2020, None, 2021, None]) + worksheet.append( + [ + "Отчет о движении денежных средств", + "Начало", + "Конец", + "Начало", + "Конец", + ] + ) + worksheet.append(["Денежные потоки от инвестиционных операций", None, None]) + worksheet.append(["Поступления – всего", 0, 100, 100, 200]) + worksheet.append(["Сальдо", 0, 100, 100, 200]) + worksheet.append(["Форма №6", 2020, None, 2021, None]) + worksheet.append( + [ + "Отчет о целевом использовании полученных средств", + "Начало", + "Конец", + "Начало", + "Конец", + ] + ) + worksheet.append(["Поступило средств", None, None, None, None]) + worksheet.append(["Итого", 0, 10, 10, 20]) + worksheet.append(["Использовано средств", None, None, None, None]) + worksheet.append(["Итого", 0, 5, 5, 6]) + workbook.save(path) + + report = FNSExcelParser.parse_file(path) + + self.assertEqual(report.external_id, "0130160") + self.assertEqual(report.ogrn, "1187700006273") + self.assertEqual(report.years, {2020, 2021}) + self.assertEqual( + {line.line_code for line in report.lines}, + { + "1100", + "1600", + "3310", + "3312", + "3320", + "3322", + "3326", + "4200", + "4210", + "6200", + "6300", + }, + ) + + def test_rejects_codeless_value_row_without_known_accounting_code(self): + with TemporaryDirectory() as temp_directory: + path = Path(temp_directory) / "fin_0000001_1187700006273.xlsx" + workbook = Workbook() + worksheet = workbook.active + worksheet.append(["Форма №1", 2021, None]) + worksheet.append(["Бухгалтерский баланс", "Начало", "Конец"]) + worksheet.append(["Неизвестная строка", 1, 2]) + workbook.save(path) + + with self.assertRaisesMessage(FNSParserError, "не определен код строки"): + FNSExcelParser.parse_file(path) + + class TestFNSReportServiceIntegration(TestCase): """Интеграционные тесты сервиса FNSReportService."""