Merge dev into main for customer release #39

Merged
avm merged 21 commits from feature/customer-release-20260811 into main 2026-08-11 13:30:24 +03:00
6 changed files with 818 additions and 39 deletions
Showing only changes of commit 1bc98796ab - Show all commits

View File

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

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

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

View File

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