diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 92b53e5..ff9566e 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -4,16 +4,17 @@ 1. тождество сальдо по каждой строке: `opening_debit − opening_credit + turnover_debit − turnover_credit = closing_debit − closing_credit`; -2. по каждому родителю: суммы родителя = Σ детей того же `data_type`; строки CURRENCY/QUANTITY («Вал.», «Кол.») - в суммирование не входят и сами родителями не проверяются. Обороты сверяются по колонкам. Сальдо (начало, конец) — - по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто - `дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех - реальных файлах, где у счёта есть субсчета с разными сторонами сальдо); +2. по каждому родителю: суммы родителя = Σ детей того же `data_type`; строки-показатели (`indicator`: «Вал.», «Кол.», + НУ и прочие метки колонки «Показатели») в суммирование не входят и сами родителями не проверяются. + Обороты сверяются по колонкам. Сальдо (начало, конец) — по колонкам (развёрнутое сальдо) либо, если у родителя + сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым + сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с + разными сторонами сальдо); 3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2); корня нет (пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, - результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы. + результат `false`. Строки-показатели (валюта, количество, НУ) в сумму уровня 0 не входят: это другие единицы. Счета без сальдо по аналитике (91, 99; список — `rules/osv_checks.yaml`, OQ-132): у них строки аналитики несут только обороты, сальдо закрыто на субсчёт закрытия (91.09). Для строк такого счёта тождество п. 1 не проверяется; в п. 2 @@ -45,8 +46,6 @@ TOLERANCE = Decimal("0.01") # Сколько номеров строк показывать в тексте предупреждения; полный список — в `checks` WARNING_ROWS_SHOWN = 20 _ZERO = Decimal(0) -# Строки-показатели: суммы в других единицах (валюта, количество), в рублёвые суммы родителя не входят -_INDICATOR_DATA_TYPES = frozenset({"CURRENCY", "QUANTITY"}) _ROOT_LEVEL = 0 # Позиции сумм в `BodyRow.sums` (порядок `classify.SUM_FIELDS`): дебет и кредит каждой группы рядом _OPENING, _TURNOVER, _CLOSING = slice(0, 2), slice(2, 4), slice(4, 6) @@ -68,7 +67,7 @@ def _differs(left: Sequence[Decimal], right: Sequence[Decimal]) -> bool: def _is_indicator(item: ClassifiedRow) -> bool: - return item.data_type in _INDICATOR_DATA_TYPES + return item.indicator def _no_analytic_balance(item: ClassifiedRow, exempt: OsvCheckRules | None) -> bool: @@ -114,7 +113,7 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No или по нетто при свёрнутом сальдо родителя (см. описание модуля). Дерево — по `parent` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются. - Строки CURRENCY/QUANTITY не суммируются и не проверяются как родители; родитель без учитываемых детей не + Строки-показатели не суммируются и не проверяются как родители; родитель без учитываемых детей не проверяется. Родитель счёта без сальдо по аналитике (`exempt`, OQ-132) без дочерних субсчетов (ACCOUNT) сверяется только по @@ -147,8 +146,8 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: - """Основная «Итого» листа — первая строка TOTAL (вторая итоговая строка идёт за ней и в сверку не входит).""" - return next((item for item in rows if item.group_type == "TOTAL"), None) + """Основная «Итого» листа — первая строка TOTAL без признака показателя (следующие за ней в сверку не входят).""" + return next((item for item in rows if item.group_type == "TOTAL" and not item.indicator), None) def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 6d47ab1..a2f80e2 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -83,6 +83,7 @@ class ClassifiedRow: counterparty_inn: str | None data_type: DataType | None extra: dict[str, str | None] | None + indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке # --- Строки таблицы --- @@ -368,6 +369,7 @@ class _Kind: data_type: DataType | None counterparty_name: str | None = None counterparty_inn: str | None = None + indicator: bool = False @dataclass(frozen=True) @@ -391,31 +393,31 @@ def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType, def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple[_Kind, _Branch]: """Тип строки (кроме TOTAL) по признаку счёта, словарю строк-показателей и позиции в схеме; контекст для детей.""" - # ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть - doc_data_type = context.doc_data_type + code = inherited.account_code_raw + if item.indicator: + # Строка-показатель (§1.3): не уровень группировки, а вид сумм родителя; позицию в схеме не занимает + return _Kind("OTHER", None, code, item.label_data_type, indicator=True), inherited + # ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть; метка строки («БУ») важнее шапки + own = item.label_data_type or context.doc_data_type account = ACCOUNT_RE.match(item.text) if item.is_account else None if account is not None: code = account.group(1) _, name = _schema_group(context.schema, _ACCOUNT_POSITION) - return _Kind("ACCOUNT", name, code, doc_data_type), _Branch(code, 0) - code = inherited.account_code_raw - data_type = row_data_type(item.text, context.rules, context.currency_codes) - if data_type is not None: - return _Kind("OTHER", None, code, data_type), inherited + return _Kind("ACCOUNT", name, code, own), _Branch(code, 0) if item.row in context.unpaired: # Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте - return _Kind("OTHER", None, code, doc_data_type), inherited + return _Kind("OTHER", None, code, own), inherited position = inherited.depth + 1 group_type, name = _schema_group(context.schema, position) branch = _Branch(code, position) if group_type != "COUNTERPARTY": - return _Kind(group_type, name, code, doc_data_type), branch + return _Kind(group_type, name, code, own), branch inn_col = context.header.inn_col column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules) # ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100) counterparty, in_name = split_inn_suffix(item.text, context.rules) inn = column or context.inns.get(item.row) or in_name - return _Kind(group_type, name, code, doc_data_type, counterparty_name=counterparty, counterparty_inn=inn), branch + return _Kind(group_type, name, code, own, counterparty_name=counterparty, counterparty_inn=inn), branch def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно @@ -436,8 +438,11 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta - «Итого» (`total_row`) и вторые итоговые строки после неё («Итого развернутое») — TOTAL, уровень 0 без родителя; в `rows` остаются (# ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро); - счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки; - - строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень - группировки, а вид сумм родителя; позицию в схеме она не занимает; + - строка-показатель (`BodyRow.indicator`: метка колонки «Показатели» не «БУ», «Вал.», «Кол.», код валюты, §1.3) — + OTHER, `indicator=True`, `data_type` метки (НУ/CURRENCY/QUANTITY/`None`), `account_code_raw` ветки родителя, + без контрагента: это не уровень группировки, а вид сумм родителя; позицию в схеме не занимает; итоговая + строка-показатель — TOTAL с тем же признаком; + - вид данных строки — метка (`label_data_type`), иначе `doc_data_type` (умолчание БУ подставляет `osv.py`); - прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой — OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН); - строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`. @@ -452,7 +457,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta if total_row is not None and item.row >= total_row: # Всё от основной «Итого» и ниже — итоговые строки (подвал `collect_body_rows` уже отрезал) # ASSUMPTION(OQ-01): строка «Итого» остаётся в rows; хранить ли её, решает ядро - kind, branch = _Kind("TOTAL", None, None, doc_data_type), _ROOT_BRANCH + kind = _Kind("TOTAL", None, None, item.label_data_type or doc_data_type, indicator=item.indicator) + branch = _ROOT_BRANCH level, parent = 0, None else: parent = levels.parent[item.row] @@ -472,6 +478,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta counterparty_inn=kind.counterparty_inn, data_type=kind.data_type, extra=_extra(sheet, item.row, extra_keys), + indicator=kind.indicator, ) ) return result diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index 74515a9..69ec6c4 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -11,6 +11,7 @@ from collections.abc import Iterable, Sequence from dataclasses import dataclass, replace from src.contract import OsvDocument, ResultWarning +from src.contract.common import DataType from src.contract.result import DocumentStatus, OsvChecks, OsvGroup, OsvHeader, OsvRow from src.parser.checks import run_checks from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows @@ -37,6 +38,8 @@ __all__ = ["OsvParser", "SheetResult", "data_rows"] LOCATOR_PREFIX = "sheet:" INN_COLUMN_KEY = "inn" NO_DATA_ROWS_WARNING = "нет строк данных" +DEFAULT_DATA_TYPE: DataType = "BU" +DEFAULT_DATA_TYPE_WARNING = "вид данных в шапке не указан — строки без метки приняты как БУ" def _columns(header: TableHeader) -> dict[str, str]: @@ -194,13 +197,18 @@ class OsvParser: rules=rules, ) step = "классификация строк" + data_type = doc.data_type or DEFAULT_DATA_TYPE + # Вид данных шапки не указан: строки без метки приняты как БУ — предупреждение, если умолчание сработало + defaulted = doc.data_type is None and any( + not item.indicator and item.label_data_type is None for item in body + ) classified = classify_rows( glue.rows, glue.levels, schema.groups, header, sheet, - doc.data_type, + data_type, total_row, rules, inns=glue.inns, @@ -215,6 +223,7 @@ class OsvParser: notes = [ hierarchy_note(levels.strategy), *notes_of(OSV_HEADER, doc.warnings), + *([OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING)] if defaulted else []), *notes_of(OSV_ROWS, body_warnings), *notes_of(OSV_INN_ROWS, glue.warnings), ] diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index b0f335e..f86a32c 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -26,6 +26,7 @@ def row( # noqa: PLR0913 - строка теста описывается по data_type: DataType | None = "BU", text: str = "строка", code: str | None = "60", + indicator: bool = False, ) -> ClassifiedRow: body = BodyRow( row=number, @@ -48,6 +49,7 @@ def row( # noqa: PLR0913 - строка теста описывается по counterparty_inn=None, data_type=data_type, extra=None, + indicator=indicator, ) @@ -198,8 +200,24 @@ def test_currency_and_quantity_children_are_excluded() -> None: rows = [ account(7, ROOT), row(8, ROOT, level=1, parent=7), - row(9, ("1", None, "2", None, "3", None), level=2, parent=8, group_type="OTHER", data_type="CURRENCY"), - row(10, ("7", None, None, None, "7", None), level=2, parent=8, group_type="OTHER", data_type="QUANTITY"), + row( + 9, + ("1", None, "2", None, "3", None), + level=2, + parent=8, + group_type="OTHER", + data_type="CURRENCY", + indicator=True, + ), + row( + 10, + ("7", None, None, None, "7", None), + level=2, + parent=8, + group_type="OTHER", + data_type="QUANTITY", + indicator=True, + ), total(11, ROOT), ] @@ -299,7 +317,9 @@ def test_total_that_equals_neither_first_root_nor_sum_of_roots_mismatches() -> N def test_sum_of_roots_ignores_indicator_rows_and_total_itself() -> None: rows = multi_root_tree() - rows.insert(2, row(20, ("5", None, None, None, "5", None), group_type="OTHER", data_type="CURRENCY")) + rows.insert( + 2, row(20, ("5", None, None, None, "5", None), group_type="OTHER", data_type="CURRENCY", indicator=True) + ) assert total_matches_root(rows) is True @@ -464,3 +484,41 @@ def test_run_checks_without_violations_on_account_91_gives_clean_status() -> Non assert checks.parent_sum_violations == [] assert checks.total_row_matches_root is True assert warnings == [] + + +# --- Признак строки-показателя (#58) --- + + +def test_indicator_nu_child_is_excluded_from_parent_sum() -> None: + """Строка-показатель НУ под родителем БУ в сумму детей не входит, хотя её data_type не CURRENCY/QUANTITY.""" + rows = [ + account(7, ("10", None, None, None, "10", None)), + row(8, ("10", None, None, None, "10", None), level=1, parent=7), + row( + 9, ("5", None, None, None, "5", None), level=1, parent=7, group_type="OTHER", data_type="NU", indicator=True + ), + ] + + assert parent_sums(rows) == [] + + +def test_document_of_nu_rows_without_indicator_is_checked_as_usual() -> None: + """«Выводимые данные: НУ» (s12): все строки НУ без признака — проверки идут как обычно.""" + rows = [ + row(7, ("10", None, None, None, "10", None), group_type="ACCOUNT", data_type="NU"), + row(8, ("4", None, None, None, "4", None), level=1, parent=7, data_type="NU"), + row(9, ("5", None, None, None, "5", None), level=1, parent=7, data_type="NU"), + ] + + assert parent_sums(rows) == [7] + + +def test_total_skips_leading_indicator_total() -> None: + """Первая итоговая строка без признака — основная «Итого», даже если перед ней стоит итог-показатель.""" + rows = [ + *tree(), + row(12, ("1", None, None, None, "1", None), group_type="TOTAL", data_type="NU", indicator=True), + ] + rows.insert(0, row(2, ("9", None, None, None, "9", None), group_type="TOTAL", data_type="NU", indicator=True)) + + assert total_matches_root(rows) is True diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index cef06c3..75eba01 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -1074,3 +1074,40 @@ def test_indicator_column_rows_are_labelled_indicators(rules: Rules) -> None: assert [by_row[row].label_data_type for row in (4, 5, 6, 7, 8)] == ["BU", "BU", "NU", "BU", "NU"] assert by_row[5].text == "ООО Поставщик" assert not by_row[6].is_account + + +def test_indicator_rows_and_totals_are_classified_by_flag(rules: Rules) -> None: + parsed = parse(_indicator_sheet(), rules, None) + by_row = {item.body.row: item for item in parsed.rows} + + nu = by_row[6] + assert (nu.group_type, nu.data_type, nu.indicator) == ("OTHER", "NU", True) + assert (nu.account_code_raw, nu.counterparty_name, nu.counterparty_inn) == ("60", None, None) + nu_total = by_row[8] + assert (nu_total.group_type, nu_total.data_type, nu_total.indicator) == ("TOTAL", "NU", True) + assert by_row[7].indicator is False + assert by_row[7].data_type == "BU" + + +def test_label_data_type_wins_over_document_data_type(rules: Rules) -> None: + parsed = parse(_indicator_sheet(), rules, "NU") + + assert {item.body.row: item.data_type for item in parsed.rows if not item.indicator} == { + 4: "BU", + 5: "BU", + 7: "BU", + } + + +def test_row_without_label_takes_document_data_type(rules: Rules) -> None: + """Строка без метки получает вид данных шапки; умолчание БУ подставляет `osv.py` до вызова классификатора.""" + sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик", S100)) + + assert {item.body.text: item.data_type for item in parse(sheet, rules, "BU").rows} == { + "60": "BU", + "ООО Поставщик": "BU", + } + assert {item.body.text: item.data_type for item in parse(sheet, rules, "NU").rows} == { + "60": "NU", + "ООО Поставщик": "NU", + }