fix: restore code-less FNS financial reports
This commit is contained in:
271
src/apps/parsers/clients/fns/line_codes.py
Normal file
271
src/apps/parsers/clients/fns/line_codes.py
Normal 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)
|
||||
@@ -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]:
|
||||
"""Извлекает годы из строки заголовка формы."""
|
||||
|
||||
Reference in New Issue
Block a user