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