feat(parser): шапка ОСВ и схема группировок (§1.1, §1.5)
- header.py: parse_doc_header (организация, счёт, период, data_type, валюта, вариант шапки, предупреждения на русском) и build_group_schema (вариант 1 — метки в text_col, вариант 2 — «субконто: …»); has_inn по ячейке ИНН в строке метки (отдельной или в тексте метки) - periods.yaml: end — конец текста периода («г.», «год»); PeriodRules.end - линтер координат: Literal[1, 2] не считается индексацией ячейки - fixtures-тест на 66 файлах: счёт по имени файла одним правилом без исключений Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
5868b348a2
commit
83dd241a00
@ -12,7 +12,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
|
||||
|---|---|
|
||||
| `osv_headers.yaml` | маркер листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; служебные колонки (ИНН, КПП, код, количество, валюта); строка «Итого»; метки шапки (счёт, период, выводимые данные, валюта, параметры, субконто) |
|
||||
| `osv_groups.yaml` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
|
||||
| `periods.yaml` | текст периода -> `period_quarter` и `is_cumulative` (§1.5); `year` — паттерн года |
|
||||
| `periods.yaml` | текст периода -> `period_quarter` и `is_cumulative` (§1.5); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
|
||||
| `data_types.yaml` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.»; коды валют ISO 4217; синонимы валюты (`руб.` -> `RUB`) |
|
||||
|
||||
## Как писать паттерны
|
||||
|
||||
@ -21,3 +21,7 @@ rules:
|
||||
|
||||
# Год периода: четыре цифры 19xx/20xx, не часть длинного числа (ИНН, номер)
|
||||
year: '(?<!\d)(19|20)\d{2}(?!\d)'
|
||||
|
||||
# Конец текста периода в строке шапки: «за 3 квартал 2024 г. (по подразделениям)» -> «3 квартал 2024 г.»;
|
||||
# без совпадения период идёт до конца строки
|
||||
end: '\bг\.|\bгода?\b'
|
||||
|
||||
305
api/src/parser/header.py
Normal file
305
api/src/parser/header.py
Normal file
@ -0,0 +1,305 @@
|
||||
"""Шапка документа и схема группировок ОСВ (`05-domain-rules.md` §1.1, §1.5; spec «Шапка документа и схема…»).
|
||||
|
||||
Область шапки — строки выше заголовка таблицы. Организация, счёт, период, тип данных и валюта берутся по меткам
|
||||
из `rules/osv_headers.yaml` (`header_markers`), квартал и признак нарастающего итога — по `rules/periods.yaml`,
|
||||
тип группировки — по `rules/osv_groups.yaml`. Ни одна позиция ячейки заранее не известна; отсутствие поля —
|
||||
пустая строка (для полей контракта-строк) или `None` и предупреждение на русском.
|
||||
"""
|
||||
|
||||
import bisect
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from src.contract.common import DataType
|
||||
from src.contract.result import OsvGroup
|
||||
from src.parser.detect import TableHeader
|
||||
from src.parser.grid import Sheet
|
||||
from src.parser.rules import GroupType, Pattern, Rules, first_match, normalize_text
|
||||
|
||||
__all__ = ["DocHeader", "build_group_schema", "parse_doc_header"]
|
||||
|
||||
Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
|
||||
HeaderVariant = Literal[1, 2]
|
||||
|
||||
# Код счёта начинается с цифры и идёт до пробела или запятой: «60», «58.03», «76.07.1»
|
||||
_ACCOUNT_CODE = re.compile(r"\d[^\s,;]*")
|
||||
# Метка отделяется от значения двоеточием («Валюта БУ, НУ: RUB»); перечисление субконто — запятыми
|
||||
_LABEL_SEPARATOR = ":"
|
||||
_LIST_SEPARATOR = ","
|
||||
# Знаки, которыми код счёта может заканчиваться в тексте, но которые кодом не являются
|
||||
_CODE_TRAILING = ".,;:"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocHeader:
|
||||
"""Поля шапки ОСВ (`osvDocument` контракта); `warnings` — что не найдено или не распознано."""
|
||||
|
||||
organization_name: str
|
||||
account_code_raw: str
|
||||
period_text: str
|
||||
period_year: int | None
|
||||
period_quarter: Quarter | None
|
||||
is_cumulative: bool | None
|
||||
data_type: DataType | None
|
||||
currency: str | None
|
||||
header_variant: HeaderVariant
|
||||
warnings: tuple[str, ...]
|
||||
|
||||
|
||||
def _group(group_type: GroupType, name: str | None = None, *, has_inn: bool = False) -> OsvGroup:
|
||||
"""Группировка контракта; необязательные поля без значения опускаются (явный `null` схема не допускает)."""
|
||||
fields: dict[str, Any] = {"type": group_type}
|
||||
if name:
|
||||
fields["name"] = name
|
||||
if has_inn:
|
||||
fields["has_inn"] = True
|
||||
return OsvGroup(**fields)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _HeadCell:
|
||||
row: int
|
||||
col: int
|
||||
text: str
|
||||
|
||||
|
||||
# --- Текст ---
|
||||
|
||||
|
||||
def _source_offset(text: str, normalized_offset: int) -> int:
|
||||
"""Смещение в исходном тексте, с которого начинается хвост нормализованного текста с позиции `normalized_offset`.
|
||||
|
||||
Позиции `Match` относятся к `normalize_text(text)` (нижний регистр, схлопнутые пробелы), а в результат нужен
|
||||
исходный текст («3 квартал 2024 г.», не «3 квартал 2024 г.» в нижнем регистре). Нормализация префикса — префикс
|
||||
нормализации целого, поэтому длина нормализованного префикса монотонна по длине исходного.
|
||||
"""
|
||||
# Пробелы сразу после совпадения (`\s+` в метке) нормализация префикса отбрасывает — не считаем их в позиции
|
||||
target = len(normalize_text(text)[:normalized_offset].rstrip())
|
||||
return bisect.bisect_left(range(len(text) + 1), target, key=lambda size: len(normalize_text(text[:size])))
|
||||
|
||||
|
||||
def _tail(text: str, match: re.Match[str]) -> str:
|
||||
"""Исходный текст после совпадения (позиции `match` — в нормализованном тексте)."""
|
||||
return text[_source_offset(text, match.end()) :].strip()
|
||||
|
||||
|
||||
def _find(patterns: Iterable[Pattern], text: str) -> re.Match[str] | None:
|
||||
for pattern in patterns:
|
||||
match = pattern.search(text)
|
||||
if match is not None:
|
||||
return match
|
||||
return None
|
||||
|
||||
|
||||
def _value_after_colon(tail: str) -> str:
|
||||
"""Значение метки: текст после двоеточия, а без двоеточия — весь хвост («Валюта БУ, НУ: RUB» -> «RUB»)."""
|
||||
_, separator, value = tail.partition(_LABEL_SEPARATOR)
|
||||
return (value if separator else tail).strip()
|
||||
|
||||
|
||||
# --- Область шапки ---
|
||||
|
||||
|
||||
def _head_cells(sheet: Sheet, header: TableHeader) -> list[_HeadCell]:
|
||||
"""Текстовые ячейки выше заголовка таблицы по строкам сверху вниз, слева направо (числа и даты — не текст)."""
|
||||
return [
|
||||
_HeadCell(row, col, cell.text)
|
||||
for row in range(1, header.first_row)
|
||||
for col, cell in sheet.row_cells(row)
|
||||
if cell.kind == "text"
|
||||
]
|
||||
|
||||
|
||||
def _is_marker(cell: _HeadCell, rules: Rules) -> bool:
|
||||
patterns = [
|
||||
*rules.headers.sheet_markers,
|
||||
*(pattern for group in rules.headers.header_markers.values() for pattern in group),
|
||||
]
|
||||
return _find(patterns, cell.text) is not None
|
||||
|
||||
|
||||
def _organization(cells: list[_HeadCell], rules: Rules) -> _HeadCell | None:
|
||||
return next((cell for cell in cells if not _is_marker(cell, rules)), None)
|
||||
|
||||
|
||||
def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None:
|
||||
for cell in cells:
|
||||
match = _find(rules.headers.header_markers["account"], cell.text)
|
||||
if match is None:
|
||||
continue
|
||||
code = _ACCOUNT_CODE.match(_tail(cell.text, match))
|
||||
if code is not None:
|
||||
return code.group(0).rstrip(_CODE_TRAILING)
|
||||
return None
|
||||
|
||||
|
||||
def _period_text(cells: list[_HeadCell], skip: _HeadCell | None, rules: Rules) -> str | None:
|
||||
"""Текст после метки периода («за …», «Период: …») до «г.» включительно или до конца строки."""
|
||||
for cell in cells:
|
||||
if cell is skip:
|
||||
continue
|
||||
match = _find(rules.headers.header_markers["period"], cell.text)
|
||||
if match is None:
|
||||
continue
|
||||
tail = _tail(cell.text, match)
|
||||
end = rules.periods.end.search(tail)
|
||||
if end is not None:
|
||||
tail = tail[: _source_offset(tail, end.end())].strip()
|
||||
if tail:
|
||||
return tail
|
||||
return None
|
||||
|
||||
|
||||
def _labelled_value(sheet: Sheet, cells: list[_HeadCell], patterns: list[Pattern]) -> str | None:
|
||||
"""Значение строки с меткой: после двоеточия в той же ячейке, а если там пусто — в следующей ячейке строки."""
|
||||
for cell in cells:
|
||||
match = _find(patterns, cell.text)
|
||||
if match is None:
|
||||
continue
|
||||
value = _value_after_colon(_tail(cell.text, match))
|
||||
if value:
|
||||
return value
|
||||
neighbours = [other.text for col, other in sheet.row_cells(cell.row) if col > cell.col]
|
||||
return neighbours[0] if neighbours else ""
|
||||
return None
|
||||
|
||||
|
||||
def _subkonto_items(cells: list[_HeadCell], rules: Rules) -> list[str] | None:
|
||||
"""Вариант 2: «Детализация по субсчетам, субконто: Контрагенты, Договоры» -> `["Контрагенты", "Договоры"]`."""
|
||||
for cell in cells:
|
||||
if _find(rules.headers.header_markers["subkonto"], cell.text) is None or _LABEL_SEPARATOR not in cell.text:
|
||||
continue
|
||||
_, _, listing = cell.text.partition(_LABEL_SEPARATOR)
|
||||
items = [item.strip() for item in listing.split(_LIST_SEPARATOR) if item.strip()]
|
||||
if items:
|
||||
return items
|
||||
return None
|
||||
|
||||
|
||||
# --- Шапка документа ---
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Period:
|
||||
year: int | None
|
||||
quarter: Quarter | None
|
||||
is_cumulative: bool | None
|
||||
|
||||
|
||||
def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period:
|
||||
rule = rules.periods.match(text)
|
||||
year = rules.periods.year.search(text)
|
||||
if rule is None:
|
||||
warnings.append(f"период «{text}»: квартал и признак нарастающего итога не определены по словарю периодов")
|
||||
if year is None:
|
||||
warnings.append(f"период «{text}»: год не найден")
|
||||
# ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить
|
||||
# по данным (opening Q3 == opening Q1 -> нарастающая)
|
||||
return _Period(
|
||||
year=None if year is None else int(year.group(0)),
|
||||
quarter=None if rule is None else rule.quarter,
|
||||
is_cumulative=None if rule is None else rule.is_cumulative,
|
||||
)
|
||||
|
||||
|
||||
def _parse_data_type(sheet: Sheet, cells: list[_HeadCell], rules: Rules, warnings: list[str]) -> DataType | None:
|
||||
value = _labelled_value(sheet, cells, rules.headers.header_markers["data_type"])
|
||||
if value is None:
|
||||
warnings.append("тип выводимых данных (БУ/НУ/сумма) в шапке не найден")
|
||||
return None
|
||||
data_type = first_match(rules.data_types.header, value)
|
||||
if data_type is None:
|
||||
warnings.append(f"тип выводимых данных «{value}» не распознан")
|
||||
return data_type
|
||||
|
||||
|
||||
def _parse_currency(sheet: Sheet, cells: list[_HeadCell], rules: Rules, warnings: list[str]) -> str | None:
|
||||
value = _labelled_value(sheet, cells, rules.headers.header_markers["currency"])
|
||||
if value is None:
|
||||
warnings.append("валюта в шапке не найдена")
|
||||
return None
|
||||
code = rules.data_types.currency_code(value)
|
||||
if code is None:
|
||||
warnings.append(f"валюта «{value}» не распознана")
|
||||
return code
|
||||
|
||||
|
||||
def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHeader:
|
||||
"""Шапка документа по области выше заголовка таблицы; что не найдено — `""`/`None` и предупреждение."""
|
||||
cells = _head_cells(sheet, header)
|
||||
warnings: list[str] = []
|
||||
|
||||
organization = _organization(cells, rules)
|
||||
if organization is None:
|
||||
warnings.append("наименование организации в шапке не найдено")
|
||||
account = _account_code(cells, rules)
|
||||
if account is None:
|
||||
warnings.append("код счёта (после «по счету») в шапке не найден")
|
||||
period_text = _period_text(cells, organization, rules)
|
||||
if period_text is None:
|
||||
warnings.append("период (после «за» или «Период:») в шапке не найден")
|
||||
period = _Period(None, None, None)
|
||||
else:
|
||||
period = _parse_period(period_text, rules, warnings)
|
||||
|
||||
data_type = _parse_data_type(sheet, cells, rules, warnings)
|
||||
currency = _parse_currency(sheet, cells, rules, warnings)
|
||||
variant: HeaderVariant = 1 if _subkonto_items(cells, rules) is None else 2
|
||||
return DocHeader(
|
||||
organization_name="" if organization is None else organization.text,
|
||||
account_code_raw=account or "",
|
||||
period_text=period_text or "",
|
||||
period_year=period.year,
|
||||
period_quarter=period.quarter,
|
||||
is_cumulative=period.is_cumulative,
|
||||
data_type=data_type,
|
||||
currency=currency,
|
||||
header_variant=variant,
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
|
||||
|
||||
# --- Схема группировок ---
|
||||
|
||||
|
||||
def _row_has_inn(sheet: Sheet, row: int, rules: Rules) -> bool:
|
||||
"""В строке блока заголовка есть ячейка «ИНН» (отдельная или в тексте метки: «Контрагенты, ИНН»)."""
|
||||
patterns = rules.headers.service_columns["inn"]
|
||||
return any(_find(patterns, cell.text) is not None for _, cell in sheet.row_cells(row))
|
||||
|
||||
|
||||
def _first_group_name(sheet: Sheet, header: TableHeader) -> str | None:
|
||||
if not header.group_label_rows:
|
||||
return None
|
||||
return sheet.text_at(header.group_label_rows[0], header.text_col) or None
|
||||
|
||||
|
||||
def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> list[OsvGroup]:
|
||||
groups: list[OsvGroup] = []
|
||||
for index, row in enumerate(header.group_label_rows):
|
||||
label = sheet.text_at(row, header.text_col)
|
||||
# Первая группировка — всегда ACCOUNT, как бы она ни называлась («Счет», «Подразделение», «Субконто»)
|
||||
group_type: GroupType = "ACCOUNT" if index == 0 else rules.groups.classify(label)
|
||||
groups.append(_group(group_type, label, has_inn=_row_has_inn(sheet, row, rules)))
|
||||
return groups or [_group("ACCOUNT")]
|
||||
|
||||
|
||||
def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> list[OsvGroup]:
|
||||
groups = [_group("ACCOUNT", _first_group_name(sheet, header))]
|
||||
inn_pending = header.inn_col is not None
|
||||
for item in items:
|
||||
group_type = rules.groups.classify(item)
|
||||
has_inn = inn_pending and group_type == "COUNTERPARTY"
|
||||
inn_pending = inn_pending and not has_inn
|
||||
groups.append(_group(group_type, item, has_inn=has_inn))
|
||||
return groups
|
||||
|
||||
|
||||
def build_group_schema(sheet: Sheet, header: TableHeader, rules: Rules) -> tuple[list[OsvGroup], HeaderVariant]:
|
||||
"""Группировки в порядке вложенности (первая — ACCOUNT) и вариант шапки (1 — строки блока, 2 — «субконто: …»)."""
|
||||
items = _subkonto_items(_head_cells(sheet, header), rules)
|
||||
if items is None:
|
||||
return _variant1_groups(sheet, header, rules), 1
|
||||
return _variant2_groups(sheet, header, rules, items), 2
|
||||
@ -165,6 +165,7 @@ class PeriodRule(_Model):
|
||||
class PeriodRules(_Model):
|
||||
rules: list[PeriodRule]
|
||||
year: Pattern
|
||||
end: Pattern
|
||||
|
||||
def match(self, text: str) -> PeriodRule | None:
|
||||
"""Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим."""
|
||||
|
||||
113
api/tests/fixtures/test_header_real.py
vendored
Normal file
113
api/tests/fixtures/test_header_real.py
vendored
Normal file
@ -0,0 +1,113 @@
|
||||
"""Шапка и схема группировок на реальных ОСВ из ../fixtures: общие свойства на всех 66 файлах.
|
||||
|
||||
Фикстуры — регрессия, не оракул: ожидаемые значения выводятся из имени файла и каталога одним общим правилом (без
|
||||
таблицы исключений), а парсер читает только содержимое. Правило имени файла — `expected_account`.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from fixtures_helpers import osv_fixture_files
|
||||
|
||||
from src.contract import FileType
|
||||
from src.parser.detect import SheetKind, classify_sheet, find_table_header
|
||||
from src.parser.grid import Sheet
|
||||
from src.parser.header import build_group_schema, parse_doc_header
|
||||
from src.parser.readers import read_book
|
||||
from src.parser.rules import Rules, load_rules
|
||||
|
||||
# Первое число имени файла — счёт: «ОСВ_58 за 2024», «сч 01», «по счету 62 за 3 квартал»; «583» — три цифры —
|
||||
# счёт и субсчёт без точки («58», субсчёт «3» -> «58.03»)
|
||||
ACCOUNT_IN_NAME = re.compile(r"\d{2,3}(?:\.\d{1,2})?")
|
||||
SUBACCOUNT_WITHOUT_DOT = 3 # цифр в коде «счёт + субсчёт» без точки
|
||||
ACCOUNT_DIGITS = 2 # цифр в корне счёта
|
||||
SUBACCOUNT_DIGITS = 2 # цифр в записи субсчёта: «58.03»
|
||||
PERIOD_DIR = re.compile(r"^(?P<year>\d{4})-(?P<kind>FY|Q[1-4])$")
|
||||
EXPECTED_FILES = 66
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rules() -> Rules:
|
||||
return load_rules()
|
||||
|
||||
|
||||
def expected_account(path: Path) -> str:
|
||||
"""Код счёта из имени файла: первое число; три цифры без точки — счёт и субсчёт («583» -> «58.03»)."""
|
||||
found = ACCOUNT_IN_NAME.search(path.stem)
|
||||
assert found is not None, path.name
|
||||
code = found.group(0)
|
||||
if "." not in code and len(code) == SUBACCOUNT_WITHOUT_DOT:
|
||||
return f"{code[:ACCOUNT_DIGITS]}.{code[ACCOUNT_DIGITS:].zfill(SUBACCOUNT_DIGITS)}"
|
||||
return code
|
||||
|
||||
|
||||
def _osv_sheet(path: Path, rules: Rules) -> Sheet:
|
||||
sheets, _ = read_book(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
|
||||
osv = [sheet for sheet in sheets if classify_sheet(sheet, rules) is SheetKind.OSV]
|
||||
assert len(osv) == 1
|
||||
return osv[0]
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_every_real_osv_header_and_group_schema(osv_file: Path, rules: Rules) -> None:
|
||||
sheet = _osv_sheet(osv_file, rules)
|
||||
table = find_table_header(sheet, rules)
|
||||
|
||||
doc = parse_doc_header(sheet, table, rules)
|
||||
groups, variant = build_group_schema(sheet, table, rules)
|
||||
|
||||
assert doc.organization_name
|
||||
assert doc.account_code_raw == expected_account(osv_file)
|
||||
assert doc.period_text
|
||||
assert doc.period_quarter is not None
|
||||
assert doc.period_year is not None
|
||||
assert doc.is_cumulative is not None
|
||||
assert doc.data_type == "BU"
|
||||
assert doc.currency == "RUB"
|
||||
assert doc.header_variant == variant == 1
|
||||
assert doc.warnings == ()
|
||||
assert groups[0].type == "ACCOUNT"
|
||||
assert len(groups) == len(table.group_label_rows)
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_real_osv_period_matches_its_directory(osv_file: Path, rules: Rules) -> None:
|
||||
sheet = _osv_sheet(osv_file, rules)
|
||||
doc = parse_doc_header(sheet, find_table_header(sheet, rules), rules)
|
||||
|
||||
directory = PERIOD_DIR.fullmatch(osv_file.parent.name)
|
||||
assert directory is not None, osv_file.parent.name
|
||||
year, kind = directory.group("year"), directory.group("kind")
|
||||
assert doc.period_year == int(year)
|
||||
if kind == "FY":
|
||||
assert (doc.period_quarter, doc.is_cumulative) == ("Q4", True)
|
||||
else:
|
||||
assert doc.period_quarter == kind
|
||||
if kind == "Q3":
|
||||
assert doc.is_cumulative is False # ASSUMPTION(OQ-02): квартальная ведомость, не нарастающая
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_group_schemas_of_known_files(fixtures_dir: Path, rules: Rules) -> None:
|
||||
schastl_62 = fixtures_dir / "osv/schastl/2024-Q3/Оборотносальдовая ведомость по счету 62 за 3 квартал 2024 г..xls"
|
||||
sheet = _osv_sheet(schastl_62, rules)
|
||||
groups, variant = build_group_schema(sheet, find_table_header(sheet, rules), rules)
|
||||
|
||||
assert variant == 1
|
||||
assert [(g.type, g.has_inn) for g in groups] == [("ACCOUNT", None), ("DEPARTMENT", None), ("COUNTERPARTY", True)]
|
||||
doc = parse_doc_header(sheet, find_table_header(sheet, rules), rules)
|
||||
assert doc.period_text == "3 квартал 2024 г."
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_all_66_real_files_are_covered(fixtures_dir: Path) -> None:
|
||||
assert len(osv_fixture_files(fixtures_dir)) == EXPECTED_FILES
|
||||
|
||||
|
||||
def test_expected_account_rule() -> None:
|
||||
assert expected_account(Path("сч 583_3 кв 2024.xlsx")) == "58.03"
|
||||
assert expected_account(Path("ОСВ_01_ОС по субсчетам.xlsx")) == "01"
|
||||
assert expected_account(Path("ОСВ_58 за 2024.xls")) == "58"
|
||||
assert expected_account(Path("сч 66_3 кв 2024.xlsx")) == "66"
|
||||
378
api/tests/unit/parser/test_header.py
Normal file
378
api/tests/unit/parser/test_header.py
Normal file
@ -0,0 +1,378 @@
|
||||
"""Шапка документа и схема группировок (spec «Шапка документа и схема группировок», `05-domain-rules.md` §1.1, §1.5).
|
||||
|
||||
Сетки собираются `gridbuilder`; шапка и заголовок таблицы находятся по содержимому, позиции в сетках произвольные.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from gridbuilder import M, at, sheet_from_rows
|
||||
|
||||
from src.parser.detect import TableHeader, find_table_header
|
||||
from src.parser.grid import Sheet
|
||||
from src.parser.header import DocHeader, build_group_schema, parse_doc_header
|
||||
from src.parser.rules import Rules, load_rules
|
||||
|
||||
OPENING = "Сальдо на начало периода"
|
||||
TURNOVER = "Обороты за период"
|
||||
CLOSING = "Сальдо на конец периода"
|
||||
MISSING_FIELDS_WARNINGS = 5 # организация, счёт, период, тип данных, валюта
|
||||
VARIANT_2 = 2
|
||||
DATA_ROW = at({1: "60", 5: 100, 8: 20, 10: 30, 12: 40, 15: 50, 17: 60})
|
||||
SUBHEAD = ["Дебет", None, None, "Кредит", None, "Дебет", "Кредит", "Дебет", None, "Кредит"]
|
||||
GROUPS_ROW = [M("Счет", 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rules() -> Rules:
|
||||
return load_rules()
|
||||
|
||||
|
||||
def _table(*label_rows: list[object]) -> list[list[object]]:
|
||||
"""Заголовок таблицы варианта 1: строка групп, подзаголовки сумм в строке первой метки, остальные метки ниже."""
|
||||
first, *rest = label_rows
|
||||
return [
|
||||
GROUPS_ROW,
|
||||
[*first, *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[len(first) :]],
|
||||
*rest,
|
||||
DATA_ROW,
|
||||
]
|
||||
|
||||
|
||||
def _parse(rows: list[list[object]], rules: Rules) -> tuple[Sheet, TableHeader, DocHeader]:
|
||||
sheet = sheet_from_rows(rows)
|
||||
header = find_table_header(sheet, rules)
|
||||
return sheet, header, parse_doc_header(sheet, header, rules)
|
||||
|
||||
|
||||
def _variant1(
|
||||
rules: Rules, *, org: object = 'АО "Ромашка"', title: str, params: object = "Валюта БУ, НУ: RUB"
|
||||
) -> DocHeader:
|
||||
rows: list[list[object]] = [
|
||||
[org],
|
||||
[title],
|
||||
[],
|
||||
["Выводимые данные: БУ (данные бухгалтерского учета)"],
|
||||
[],
|
||||
["Параметры:", None, params],
|
||||
[],
|
||||
*_table(["Контрагенты"]),
|
||||
]
|
||||
return _parse(rows, rules)[2]
|
||||
|
||||
|
||||
def _title(period: str, account: str = "60") -> str:
|
||||
return f"Оборотно-сальдовая ведомость по счету {account} за {period}"
|
||||
|
||||
|
||||
# --- Вариант 1: организация, счёт, период, тип данных, валюта ---
|
||||
|
||||
|
||||
def test_variant1_header_fields(rules: Rules) -> None:
|
||||
header = _variant1(rules, title=_title("3 квартал 2024 г."), params="Валюта БУ, НУ: руб.")
|
||||
|
||||
assert header == DocHeader(
|
||||
organization_name='АО "Ромашка"',
|
||||
account_code_raw="60",
|
||||
period_text="3 квартал 2024 г.",
|
||||
period_year=2024,
|
||||
period_quarter="Q3",
|
||||
is_cumulative=False,
|
||||
data_type="BU",
|
||||
currency="RUB",
|
||||
header_variant=1,
|
||||
warnings=(),
|
||||
)
|
||||
|
||||
|
||||
def test_account_with_subaccount_is_kept_as_in_file(rules: Rules) -> None:
|
||||
header = _variant1(rules, title=_title("3 квартал 2024 г.", "58.03"))
|
||||
|
||||
assert header.account_code_raw == "58.03"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("currency", ["RUB", "rub", "руб.", "Руб", "рублей"])
|
||||
def test_currency_spellings_give_rub(rules: Rules, currency: str) -> None:
|
||||
header = _variant1(rules, title=_title("2024 г."), params=f"Валюта БУ, НУ: {currency}")
|
||||
|
||||
assert header.currency == "RUB"
|
||||
assert not any("алют" in warning for warning in header.warnings)
|
||||
|
||||
|
||||
def test_other_currency_code_is_kept(rules: Rules) -> None:
|
||||
header = _variant1(rules, title=_title("2024 г."), params="Валюта БУ, НУ: USD")
|
||||
|
||||
assert header.currency == "USD"
|
||||
|
||||
|
||||
def test_currency_value_in_neighbour_cell(rules: Rules) -> None:
|
||||
rows: list[list[object]] = [
|
||||
["Организация"],
|
||||
[_title("2024 г.")],
|
||||
["Параметры:", None, "Валюта БУ, НУ:", None, "RUB"],
|
||||
*_table(["Контрагенты"]),
|
||||
]
|
||||
|
||||
assert _parse(rows, rules)[2].currency == "RUB"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("БУ (данные бухгалтерского учета)", "BU"),
|
||||
("НУ (данные налогового учета)", "NU"),
|
||||
("сумма", "SUM"),
|
||||
],
|
||||
)
|
||||
def test_data_type_values(rules: Rules, text: str, expected: str) -> None:
|
||||
rows: list[list[object]] = [
|
||||
["Организация"],
|
||||
[_title("2024 г.")],
|
||||
[f"Выводимые данные: {text}"],
|
||||
*_table(["Контрагенты"]),
|
||||
]
|
||||
|
||||
assert _parse(rows, rules)[2].data_type == expected
|
||||
|
||||
|
||||
# --- Период: все строки таблицы §1.5 ---
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("period", "quarter", "cumulative"),
|
||||
[
|
||||
("1 квартал 2025 г.", "Q1", True),
|
||||
("I кв. 2025 г.", "Q1", True),
|
||||
("3 месяца 2025 г.", "Q1", True),
|
||||
("январь-март 2025 г.", "Q1", True),
|
||||
("01.01.2025 - 31.03.2025", "Q1", True),
|
||||
("2 квартал 2025 г.", "Q2", False),
|
||||
("II кв. 2025 г.", "Q2", False),
|
||||
("полугодие 2025 г.", "Q2", True),
|
||||
("1 полугодие 2025 г.", "Q2", True),
|
||||
("6 месяцев 2025 г.", "Q2", True),
|
||||
("январь-июнь 2025 г.", "Q2", True),
|
||||
("3 квартал 2025 г.", "Q3", False),
|
||||
("III кв. 2025 г.", "Q3", False),
|
||||
("9 месяцев 2025 г.", "Q3", True),
|
||||
("январь-сентябрь 2025 г.", "Q3", True),
|
||||
("4 квартал 2025 г.", "Q4", False),
|
||||
("IV кв. 2025 г.", "Q4", False),
|
||||
("2025 год", "Q4", True),
|
||||
("2025 г.", "Q4", True),
|
||||
("12 месяцев 2025 г.", "Q4", True),
|
||||
("январь-декабрь 2025 г.", "Q4", True),
|
||||
("2025 год (годовая)", "Q4", True),
|
||||
],
|
||||
)
|
||||
def test_period_table_of_domain_rules(rules: Rules, period: str, quarter: str, cumulative: bool) -> None:
|
||||
header = _variant1(rules, title=_title(period))
|
||||
|
||||
assert (header.period_quarter, header.is_cumulative, header.period_year) == (quarter, cumulative, 2025)
|
||||
assert header.warnings == ()
|
||||
|
||||
|
||||
def test_period_text_ends_at_year_marker(rules: Rules) -> None:
|
||||
header = _variant1(rules, title=_title("1 квартал 2025 г. (по подразделениям)"))
|
||||
|
||||
assert header.period_text == "1 квартал 2025 г."
|
||||
|
||||
|
||||
def test_period_without_year_marker_runs_to_line_end(rules: Rules) -> None:
|
||||
header = _variant1(rules, title=_title("с 01.01.2025 по 31.03.2025"))
|
||||
|
||||
assert header.period_text == "с 01.01.2025 по 31.03.2025"
|
||||
assert header.period_quarter == "Q1"
|
||||
|
||||
|
||||
def test_unrecognised_period_text_gives_none_and_warning(rules: Rules) -> None:
|
||||
header = _variant1(rules, title=_title("неделю"))
|
||||
|
||||
assert header.period_text == "неделю"
|
||||
assert (header.period_quarter, header.is_cumulative, header.period_year) == (None, None, None)
|
||||
assert any("период" in warning for warning in header.warnings)
|
||||
|
||||
|
||||
# --- Отсутствующие поля шапки ---
|
||||
|
||||
|
||||
def test_missing_organization_gives_empty_string_and_warning(rules: Rules) -> None:
|
||||
rows: list[list[object]] = [[_title("2024 г.")], ["Выводимые данные: БУ"], *_table(["Контрагенты"])]
|
||||
|
||||
header = _parse(rows, rules)[2]
|
||||
|
||||
assert header.organization_name == ""
|
||||
assert any("организац" in warning for warning in header.warnings)
|
||||
assert header.account_code_raw == "60"
|
||||
|
||||
|
||||
def test_empty_head_area_gives_empty_strings_none_and_warnings(rules: Rules) -> None:
|
||||
header = _parse(_table(["Контрагенты"]), rules)[2]
|
||||
|
||||
assert header.organization_name == ""
|
||||
assert header.account_code_raw == ""
|
||||
assert header.period_text == ""
|
||||
assert (header.period_year, header.period_quarter, header.is_cumulative) == (None, None, None)
|
||||
assert (header.data_type, header.currency, header.header_variant) == (None, None, 1)
|
||||
assert len(header.warnings) == MISSING_FIELDS_WARNINGS
|
||||
assert all(warning for warning in header.warnings)
|
||||
|
||||
|
||||
def test_numeric_and_date_cells_in_head_do_not_break_parsing(rules: Rules) -> None:
|
||||
rows: list[list[object]] = [
|
||||
[45658], # xlsb: даты остаются числами
|
||||
["Организация", None, 45659],
|
||||
[_title("2024 г.")],
|
||||
*_table(["Контрагенты"]),
|
||||
]
|
||||
|
||||
header = _parse(rows, rules)[2]
|
||||
|
||||
assert header.organization_name == "Организация"
|
||||
assert header.period_quarter == "Q4"
|
||||
|
||||
|
||||
def test_organization_is_first_text_cell_that_is_not_a_marker(rules: Rules) -> None:
|
||||
rows: list[list[object]] = [
|
||||
[],
|
||||
["Оборотно-сальдовая ведомость"],
|
||||
["Организация Б"],
|
||||
["Организация В"],
|
||||
[_title("2024 г.")],
|
||||
*_table(["Контрагенты"]),
|
||||
]
|
||||
|
||||
assert _parse(rows, rules)[2].organization_name == "Организация Б"
|
||||
|
||||
|
||||
# --- Схема группировок ---
|
||||
|
||||
|
||||
def test_group_schema_variant1_account_counterparty_contract(rules: Rules) -> None:
|
||||
sheet, header, doc = _parse(
|
||||
[["Организация"], [_title("2024 г.")], *_table(["Контрагенты"], ["Договоры"])],
|
||||
rules,
|
||||
)
|
||||
|
||||
groups, variant = build_group_schema(sheet, header, rules)
|
||||
|
||||
assert variant == doc.header_variant == 1
|
||||
assert [(g.type, g.name, g.has_inn) for g in groups] == [
|
||||
("ACCOUNT", "Счет", None),
|
||||
("COUNTERPARTY", "Контрагенты", None),
|
||||
("CONTRACT", "Договоры", None),
|
||||
]
|
||||
|
||||
|
||||
def test_group_schema_inn_in_separate_cell_of_counterparty_row(rules: Rules) -> None:
|
||||
rows: list[list[object]] = [
|
||||
["Организация"],
|
||||
[_title("2024 г.")],
|
||||
*_table(["Подразделение"], [M("Контрагенты", 2), "ИНН"]),
|
||||
]
|
||||
sheet, header, _ = _parse(rows, rules)
|
||||
|
||||
groups, _ = build_group_schema(sheet, header, rules)
|
||||
|
||||
assert [(g.type, g.has_inn) for g in groups] == [("ACCOUNT", None), ("DEPARTMENT", None), ("COUNTERPARTY", True)]
|
||||
|
||||
|
||||
def test_group_schema_inn_in_the_label_cell(rules: Rules) -> None:
|
||||
rows: list[list[object]] = [["Организация"], [_title("2024 г.")], *_table(["Контрагенты, ИНН"])]
|
||||
sheet, header, _ = _parse(rows, rules)
|
||||
|
||||
groups, _ = build_group_schema(sheet, header, rules)
|
||||
|
||||
assert [(g.type, g.name, g.has_inn) for g in groups] == [
|
||||
("ACCOUNT", "Счет", None),
|
||||
("COUNTERPARTY", "Контрагенты, ИНН", True),
|
||||
]
|
||||
|
||||
|
||||
def test_first_group_is_account_whatever_its_text_says(rules: Rules) -> None:
|
||||
rows: list[list[object]] = [
|
||||
["Организация"],
|
||||
[_title("2024 г.")],
|
||||
[M("Подразделение", 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)],
|
||||
["Контрагенты", *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[1:]],
|
||||
DATA_ROW,
|
||||
]
|
||||
sheet, header, _ = _parse(rows, rules)
|
||||
|
||||
groups, _ = build_group_schema(sheet, header, rules)
|
||||
|
||||
assert [(g.type, g.name) for g in groups] == [("ACCOUNT", "Подразделение"), ("COUNTERPARTY", "Контрагенты")]
|
||||
|
||||
|
||||
def test_unknown_group_text_is_other(rules: Rules) -> None:
|
||||
rows: list[list[object]] = [["Организация"], [_title("2024 г.")], *_table(["Основные средства"])]
|
||||
sheet, header, _ = _parse(rows, rules)
|
||||
|
||||
groups, _ = build_group_schema(sheet, header, rules)
|
||||
|
||||
assert [g.type for g in groups] == ["ACCOUNT", "OTHER"]
|
||||
|
||||
|
||||
def test_without_inn_cell_has_inn_stays_unset(rules: Rules) -> None:
|
||||
rows: list[list[object]] = [["Организация"], [_title("2024 г.")], *_table(["Контрагенты"], ["Договоры"])]
|
||||
sheet, header, _ = _parse(rows, rules)
|
||||
|
||||
groups, _ = build_group_schema(sheet, header, rules)
|
||||
|
||||
assert all(g.has_inn is None for g in groups)
|
||||
|
||||
|
||||
# --- Вариант 2 ---
|
||||
|
||||
|
||||
def _variant2_rows(detail: str = "Детализация по субсчетам, субконто: Контрагенты, Договоры") -> list[list[object]]:
|
||||
return [
|
||||
["ООО Вторая"],
|
||||
["Оборотно-сальдовая ведомость по счету 60"],
|
||||
["Период: 1 квартал 2026 г."],
|
||||
[detail],
|
||||
["Выводимые данные: сумма"],
|
||||
[M("Субконто", 4), *GROUPS_ROW[1:]],
|
||||
["", *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[1:]],
|
||||
DATA_ROW,
|
||||
]
|
||||
|
||||
|
||||
def test_variant2_header(rules: Rules) -> None:
|
||||
sheet, header, doc = _parse(_variant2_rows(), rules)
|
||||
|
||||
groups, variant = build_group_schema(sheet, header, rules)
|
||||
|
||||
assert variant == doc.header_variant == VARIANT_2
|
||||
assert [(g.type, g.name) for g in groups] == [
|
||||
("ACCOUNT", "Субконто"),
|
||||
("COUNTERPARTY", "Контрагенты"),
|
||||
("CONTRACT", "Договоры"),
|
||||
]
|
||||
assert doc.organization_name == "ООО Вторая"
|
||||
assert doc.account_code_raw == "60"
|
||||
assert (doc.period_text, doc.period_year, doc.period_quarter, doc.is_cumulative) == (
|
||||
"1 квартал 2026 г.",
|
||||
2026,
|
||||
"Q1",
|
||||
True,
|
||||
)
|
||||
assert doc.data_type == "SUM"
|
||||
assert doc.currency is None
|
||||
assert any("алют" in warning for warning in doc.warnings)
|
||||
|
||||
|
||||
def test_variant2_single_item(rules: Rules) -> None:
|
||||
sheet, header, _ = _parse(_variant2_rows("Детализация по субсчетам, субконто: Контрагенты"), rules)
|
||||
|
||||
groups, variant = build_group_schema(sheet, header, rules)
|
||||
|
||||
assert variant == VARIANT_2
|
||||
assert [g.type for g in groups] == ["ACCOUNT", "COUNTERPARTY"]
|
||||
|
||||
|
||||
def test_variant2_detail_row_is_not_taken_for_organization(rules: Rules) -> None:
|
||||
rows = _variant2_rows()
|
||||
rows[0] = []
|
||||
|
||||
doc = _parse(rows, rules)[2]
|
||||
|
||||
assert doc.organization_name == ""
|
||||
@ -134,6 +134,7 @@ def _violations_in_tree(path: Path, tree: ast.AST, allowed_strings: frozenset[st
|
||||
yield Violation(path, node.lineno, "сравнение номера строки/колонки с числовой константой")
|
||||
elif (
|
||||
isinstance(node, ast.Subscript)
|
||||
and not _is_literal_subscript(node) # Literal[1, 2] — значения типа контракта, не индекс ячейки
|
||||
and isinstance(node.slice, ast.Tuple)
|
||||
and len(node.slice.elts) == 2 # noqa: PLR2004 - пара (строка, колонка)
|
||||
and all(_is_int_constant(element) for element in node.slice.elts)
|
||||
@ -305,3 +306,9 @@ def test_grid_py_and_readers_are_excluded_but_other_files_are_not(tmp_path: Path
|
||||
"classify.py",
|
||||
"nested/grid.py",
|
||||
]
|
||||
|
||||
|
||||
def test_int_literal_type_is_not_a_cell_subscript(tmp_path: Path) -> None:
|
||||
root = _tree(tmp_path, {"detect.py": "from typing import Literal\nVariant = Literal[1, 2]\n"})
|
||||
|
||||
assert find_violations(root) == []
|
||||
|
||||
@ -244,9 +244,9 @@ def build_group_schema(sheet: Sheet, header: TableHeader, rules: Rules) -> tuple
|
||||
```
|
||||
`parse_doc_header`: область — строки 1..header.first_row−1; организация — первая непустая текстовая ячейка, не матчащая ни один `header_markers` и `sheet_markers`; счёт — текст после `header_markers.account` до пробела/«за»; период — после `header_markers.period` до «г.»/конца строки (для варианта 2 — строка «Период: …»); отсутствие → `""` + предупреждение; `data_type` по `data_types.header`; валюта по `header_markers.currency` → код (`RUB` для «RUB», «руб», «рубл»); год — `periods.year`; квартал/нарастание — первый матч `periods.rules` (`# ASSUMPTION(OQ-02)`). `build_group_schema`: вариант 1 — тексты `text_col` в `group_label_rows` сверху вниз → первая всегда `ACCOUNT` (даже если текст «Счет»), остальные по `osv_groups`; `has_inn` — в той же строке есть ячейка `service_columns.inn`; вариант 2 — если в области шапки есть строка с `header_markers.subkonto`: `ACCOUNT` + элементы через запятую после двоеточия.
|
||||
|
||||
- [ ] **Step 1: Тесты.** Юнит: вариант 1 с «Счет/Контрагенты/Договоры», с «Подразделение/Контрагенты ИНН» → `has_inn` у COUNTERPARTY; вариант 2; шапка без организации → `""` + warning; счёт «58.03»; все строки таблицы §1.5 через `parse_doc_header` на мини-сетке; валюта «руб.» и «RUB» → `RUB`. `test_header_real.py` (`fixtures`): для всех 66 `account_code_raw` совпадает с кодом из имени файла (регулярка `(\d{2,3}(?:\.\d{1,2})?)` по имени; для `сч 583_` ожидать `58.03`, для `ОСВ_01_ОС` — `01`; таблица исключений в тесте запрещена — вместо неё нормализация имени каталога/файла одной регуляркой, 583 → `58.03` по правилу «три цифры = счёт+субсчёт»), `period_quarter` не `None`, для каталога `2024-FY` `is_cumulative is True`, для `*-Q3` — `False`; `organization_name` непустой; `group_schema[0].type == "ACCOUNT"`.
|
||||
- [ ] **Step 2–4:** падает → реализация → тесты, `make check`.
|
||||
- [ ] **Step 5: Commit** `feat(parser): шапка ОСВ и схема группировок (§1.1, §1.5)`.
|
||||
- [x] **Step 1: Тесты.** Юнит: вариант 1 с «Счет/Контрагенты/Договоры», с «Подразделение/Контрагенты ИНН» → `has_inn` у COUNTERPARTY; вариант 2; шапка без организации → `""` + warning; счёт «58.03»; все строки таблицы §1.5 через `parse_doc_header` на мини-сетке; валюта «руб.» и «RUB» → `RUB`. `test_header_real.py` (`fixtures`): для всех 66 `account_code_raw` совпадает с кодом из имени файла (регулярка `(\d{2,3}(?:\.\d{1,2})?)` по имени; для `сч 583_` ожидать `58.03`, для `ОСВ_01_ОС` — `01`; таблица исключений в тесте запрещена — вместо неё нормализация имени каталога/файла одной регуляркой, 583 → `58.03` по правилу «три цифры = счёт+субсчёт»), `period_quarter` не `None`, для каталога `2024-FY` `is_cumulative is True`, для `*-Q3` — `False`; `organization_name` непустой; `group_schema[0].type == "ACCOUNT"`.
|
||||
- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
|
||||
- [x] **Step 5: Commit** `feat(parser): шапка ОСВ и схема группировок (§1.1, §1.5)`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
группой, свёрнутое сальдо, отсутствие «начала», `right_edge`, `text_col`, `inn_col` по заголовку и по доле 10/12 цифр,
|
||||
`extra`-колонки). Unit-тесты на сетках: заголовок 1/2/3/4 строки, merged, без «начала», свёрнутое сальдо, КПП между
|
||||
ИНН и суммами, мусор правее. `fixtures`-тест: `columns` для РБ `E/H/J/L/O/Q`, для Счастливочной xls `G/J/K/N/Q/S` + `inn=E`.
|
||||
- [ ] 2.2 `header.py`: шапка документа (организация, счёт, период, data_type, валюта), `group_schema` вариант 1 и 2, `has_inn`,
|
||||
- [x] 2.2 `header.py`: шапка документа (организация, счёт, период, data_type, валюта), `group_schema` вариант 1 и 2, `has_inn`,
|
||||
период → `period_year`, `period_quarter`, `is_cumulative` по `periods.yaml` (`ASSUMPTION(OQ-02)`). Unit-тесты: обе шапки,
|
||||
отсутствие полей → `null` + warning, субсчёт 58.03, все строки таблицы §1.5. `fixtures`-тест: у всех 66 файлов
|
||||
`account_code_raw` совпадает с кодом в имени каталога/файла по регулярке и период распознан.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user