fix: restore code-less FNS financial reports
Some checks failed
CI/CD Pipeline / Quality Gate (push) Failing after 39s
CI/CD Pipeline / Build and Push Images (push) Has been skipped
CI/CD Pipeline / Internal Notify (push) Successful in 0s
CI/CD Pipeline / Deploy Dev via Compose (push) Has been skipped

This commit is contained in:
2026-08-04 23:25:31 +02:00
parent ef92cc4610
commit 1bc98796ab
6 changed files with 818 additions and 39 deletions

View File

@@ -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)

View File

@@ -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]:
"""Извлекает годы из строки заголовка формы."""

View File

@@ -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