Merge dev into main for customer release #39
14
README.md
14
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`.
|
||||
|
||||
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]:
|
||||
"""Извлекает годы из строки заголовка формы."""
|
||||
|
||||
@@ -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
|
||||
95
tests/apps/organizations/test_restore_fns_financial_lines.py
Normal file
95
tests/apps/organizations/test_restore_fns_financial_lines.py
Normal file
@@ -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)
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user