fix(osv): «Организация» не вид субконто, валюта не контрагент, тождество вторых итогов (#59)

I-1 (R-17): синоним COUNTERPARTY только «Организации» (^организации\b).
I-2 (R-18): правило расчётных счетов не применяется к строкам, известным словарю как не-контрагенты (валюта, «В валюте»).
I-4 (R-19): тождество у второй итоговой строки пропускается только без оборотов и при наличии другой «Итого».
M-1, M-5, M-6, M-7 (вымышленное ФИО ИП в синтетике), M-9.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 13:14:32 +04:00
parent 835083ca4d
commit a6cd375306
13 changed files with 114 additions and 29 deletions

View File

@ -4,8 +4,9 @@ mapping:
- ['^(счета?|субконто):?$', ACCOUNT]
- ['^подразделени', DEPARTMENT]
- ['^контрагент', COUNTERPARTY]
# вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10)
- ['^организаци', COUNTERPARTY]
# вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10, R-17); единственное число
# «Организация» — собственная организация учёта (измерение шапки), контрагентом не является
- ['^организации\b', COUNTERPARTY]
- ['^договор', CONTRACT]
# Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы
# (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных)
@ -13,7 +14,8 @@ mapping:
- ['^нематериальн\w*\s+актив', OTHER]
- ['^номенклатур', OTHER]
- ['^стать(и|я)\b', OTHER]
- ['^валют', OTHER]
# строки валюты («Валюта USD», «В валюте») — не контрагенты и по плану счетов (R-18)
- ['^(в\s+)?валют', OTHER]
# Расчётные счета, у которых аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван
# (ОСВ 1С по счёту 60 без строки «Контрагенты»; AURORA-1661, OQ-158). Счёт сопоставляется по коду и субсчетам

View File

@ -79,6 +79,11 @@ def _is_secondary_total(item: ClassifiedRow, secondary: Sequence[Pattern]) -> bo
return item.group_type == "TOTAL" and any(p.search(item.body.text) for p in secondary)
def _has_turnovers(item: ClassifiedRow) -> bool:
"""В строке есть хотя бы одно значение оборотов за период (дебет или кредит; пусто = нет, 0 — значение)."""
return any(value is not None for value in item.body.sums[_TURNOVER])
def balance_identity(
rows: Sequence[ClassifiedRow],
exempt: OsvCheckRules | None = None,
@ -86,13 +91,17 @@ def balance_identity(
) -> list[int]:
"""Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого».
Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторые итоговые строки
(`secondary_totals`, «Итого развернутое») не проверяются: в 1С они несут только развёрнутые сальдо, без оборотов
(#59, R-16); основная «Итого» проверяется.
Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторая итоговая строка
(`secondary_totals`, «Итого развернутое») пропускается, только если у неё нет оборотов (обе колонки пусты) и она не
единственная TOTAL листа: «Итого развернутое» перед «Итого» несёт одни развёрнутые сальдо (#59, R-19). С оборотами
или единственная — проверяется как обычная строка.
"""
violations: list[int] = []
totals = sum(1 for item in rows if item.group_type == "TOTAL")
for item in rows:
if _no_analytic_balance(item, exempt) or _is_secondary_total(item, secondary_totals):
if _no_analytic_balance(item, exempt):
continue
if _is_secondary_total(item, secondary_totals) and totals > 1 and not _has_turnovers(item):
continue
sums = _values(item)
expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER])
@ -196,8 +205,8 @@ def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence
Нет «Итого» — `False` (R-20); нет корня ACCOUNT, но есть строки уровня 0 — «Итого» сверяется с их суммой (плоский
список под счётом шапки, #59, R-5); нет ни того ни другого — `True`, только если все суммы «Итого» пусты или
нулевые (R-13). Сумма
уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме.
нулевые (R-13). Сумма уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов,
«Итого» равно их сумме.
"""
total = _total(rows, secondary_totals)
if total is None:
@ -250,8 +259,8 @@ def run_checks(
"""Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось).
`exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью.
`secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они, и тождество
строки на них не проверяется (#59, R-16).
`secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они; тождество на них
пропускается только без оборотов, когда есть другая «Итого» (#59, R-19).
"""
identity = balance_identity(rows, exempt, secondary_totals)
parents = parent_sums(rows, exempt)

View File

@ -83,7 +83,7 @@ class ClassifiedRow:
data_type: DataType | None
extra: dict[str, str | None] | None
indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке
# COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-9), а не по схеме группировок
# COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-6), а не по схеме группировок
counterparty_by_account: bool = False
@ -427,12 +427,15 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
position = inherited.depth + 1
group_type, name = _schema_group(context.schema, position)
branch = _Branch(code, position)
# Схема из одного ACCOUNT, первый уровень под расчётным счётом без названия вида — контрагент по плану счетов (R-9)
# Схема из одного ACCOUNT, первый уровень под расчётным счётом без названия вида — контрагент по плану счетов
# (решение владельца №3, R-6); строки, которые словарь групп явно знает как не-контрагентов (валюта), — нет (R-18)
known = context.rules.groups.known(item.text)
by_account = (
group_type == "OTHER"
and name is None
and position == 1
and position >= len(context.schema)
and (known is None or known == "COUNTERPARTY")
and context.rules.groups.is_counterparty_account(code)
)
if by_account:

View File

@ -113,7 +113,7 @@ def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHea
def _by_account_warnings(rows: Sequence[ClassifiedRow]) -> list[str]:
"""По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-9), в порядке листа."""
"""По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-6), в порядке листа."""
codes = dict.fromkeys(item.account_code_raw or "" for item in rows if item.counterparty_by_account)
return [
f"вид субконто в шапке не назван: строки аналитики счёта {code} приняты за контрагентов по плану счетов — "

View File

@ -201,7 +201,7 @@ def _is_under_account(account_code: str | None, roots: Iterable[str]) -> bool:
class OsvGroupRules(_Model):
mapping: list[tuple[Pattern, GroupType]]
default: GroupType = "OTHER"
# Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-9)
# Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-6)
counterparty_accounts: list[str] = []
@model_validator(mode="after")

View File

@ -964,7 +964,7 @@ def _s43_s44(account: str, root: str, kids: tuple[str, str]) -> list[SheetSpec]:
def _s43() -> list[SheetSpec]:
"""Плоская форма (#59): счёт 60, контрагенты без вида аналитики в шапке (подстроки с отступом)."""
return _s43_s44("60", "60.01", ("ИП Знаменских А.И.", 'ООО "ЭСТЕТ ОМСК"'))
return _s43_s44("60", "60.01", ("ИП Иванов А.А.", 'ООО "ЭСТЕТ ОМСК"'))
def _s44() -> list[SheetSpec]:

View File

@ -26,7 +26,7 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None:
assert left.read_bytes() == right.read_bytes(), left.name
def test_generated_set_is_s01_to_s19_with_one_xlsb(tmp_path: Path) -> None:
def test_generated_set_is_s01_to_s32_and_s40_to_s44_with_one_xlsb(tmp_path: Path) -> None:
names = [path.name for path in generate_all(tmp_path)]
assert names == list(FILE_NAMES)

View File

@ -258,7 +258,7 @@ def test_total_check_uses_main_total_when_secondary_comes_first() -> None:
SECONDARY = [Pattern.compile(r"^итого\s+развернут")]
# «Итого развернутое» в 1С несёт только развёрнутые сальдо, без оборотов: тождество строки на ней не выполняется
# «Итого развернутое» перед «Итого» несёт только развёрнутые сальдо, без оборотов: тождество на ней не выполняется
SECONDARY_ROW: Sums = ("100", "10", None, None, "120", "5")
@ -266,7 +266,7 @@ def _secondary(number: int, sums: Sums) -> ClassifiedRow:
return row(number, sums, group_type="TOTAL", text="Итого развернутое")
def test_balance_identity_skips_secondary_total_without_turnovers() -> None:
def test_balance_identity_skips_secondary_total_without_turnovers_when_not_the_only_total() -> None:
rows = [*tree(), _secondary(11, SECONDARY_ROW)]
assert balance_identity(rows, secondary_totals=SECONDARY) == []
@ -275,6 +275,30 @@ def test_balance_identity_skips_secondary_total_without_turnovers() -> None:
assert not any(note.code == "OSV_BALANCE_IDENTITY" for note in notes)
def test_balance_identity_checks_secondary_total_with_turnovers() -> None:
"""R-19: «Итого развернутое» после «Итого» несёт обороты — тождество проверяется, сломанное даёт нарушение."""
broken: Sums = ("100", None, "50", "30", "121", None)
rows = [*tree(), _secondary(11, broken)]
assert balance_identity(rows, secondary_totals=SECONDARY) == [11]
checks, notes = run_checks(rows, secondary_totals=SECONDARY)
assert checks.balance_identity_violations == [11]
assert any(note.code == "OSV_BALANCE_IDENTITY" for note in notes)
def test_balance_identity_accepts_secondary_total_with_turnovers_that_balance() -> None:
rows = [*tree(), _secondary(11, ROOT)]
assert balance_identity(rows, secondary_totals=SECONDARY) == []
def test_balance_identity_checks_only_total_even_if_secondary_without_turnovers() -> None:
"""Единственная итоговая строка проверяется, даже если она вторая и без оборотов."""
rows = [account(7, ROOT), _secondary(8, SECONDARY_ROW)]
assert balance_identity(rows, secondary_totals=SECONDARY) == [8]
def test_balance_identity_still_checks_main_total_with_secondary_present() -> None:
broken: Sums = ("100", None, "50", "30", "121", None)
rows = [account(7, ROOT), total(8, broken), _secondary(9, SECONDARY_ROW)]

View File

@ -1134,7 +1134,7 @@ def test_secondary_total_before_main_total_is_in_total_block(rules: Rules) -> No
def test_only_secondary_total_is_the_total(rules: Rules) -> None:
"""Review Focus 3: без обычной «Итого» «Итого развернутое» — единственная итоговая строка."""
"""Без обычной «Итого» «Итого развернутое» — единственная итоговая строка."""
sheet = osv(r(0, "60", S100), r(None, "Итого развернутое", S100))
parsed = parse(sheet, rules)
@ -1144,7 +1144,7 @@ def test_only_secondary_total_is_the_total(rules: Rules) -> None:
assert secondary.group_type == "TOTAL"
# --- Контрагенты расчётных счетов без названия вида (#59, R-9) ---
# --- Контрагенты расчётных счетов без названия вида (#59, R-6) ---
def _subkonto_sheet(*rows: list[object], label: str | None = None) -> Sheet:
@ -1157,23 +1157,23 @@ def _subkonto_sheet(*rows: list[object], label: str | None = None) -> Sheet:
def test_unnamed_first_level_on_settlement_account_is_counterparty(rules: Rules) -> None:
sheet = _subkonto_sheet(
[L(0), "60.01", *S100],
[L(1), "ИП Знаменских А.И.", *S100],
[L(1), "ИП Иванов А.А.", *S100],
[L(0), "Итого", *S100],
)
parsed = parse(sheet, rules)
row = parsed.one("ИП Знаменских А.И.")
row = parsed.one("ИП Иванов А.А.")
assert row.group_type == "COUNTERPARTY"
assert row.group_name is None
assert row.counterparty_name == "ИП Знаменских А.И."
assert row.counterparty_name == "ИП Иванов А.А."
assert row.counterparty_by_account is True
def test_unnamed_second_level_stays_other(rules: Rules) -> None:
sheet = _subkonto_sheet(
[L(0), "60.01", *S100],
[L(1), "ИП Знаменских А.И.", *S100],
[L(1), "ИП Иванов А.А.", *S100],
[L(2), "Договор № 1", *S100],
[L(0), "Итого", *S100],
)
@ -1213,3 +1213,21 @@ def test_non_settlement_account_stays_other(rules: Rules) -> None:
row = parsed.one("Мука пшеничная")
assert row.group_type == "OTHER"
assert row.counterparty_by_account is False
@pytest.mark.parametrize("text", ["Валюта", "Валюта USD", "Валюта CNY", "Валюта руб", "В валюте"])
def test_currency_row_under_settlement_account_is_not_counterparty(rules: Rules, text: str) -> None:
"""R-18: строка валюты под 76.55 — не контрагент по плану счетов, а контрагент рядом остаётся контрагентом."""
sheet = _subkonto_sheet(
[L(0), "76.55", *S100],
[L(1), "ООО Ромашка", *S100],
[L(1), text, *S100],
[L(0), "Итого", *S100],
)
parsed = parse(sheet, rules)
row = parsed.one(text)
assert row.group_type == "OTHER"
assert row.counterparty_by_account is False
assert parsed.one("ООО Ромашка").group_type == "COUNTERPARTY"

View File

@ -645,6 +645,11 @@ def test_organizations_kind_row_is_counterparty(rules: Rules) -> None:
assert _flat_schema(rules, "Организации") == [("ACCOUNT", "Субконто"), ("COUNTERPARTY", "Организации")]
def test_single_organization_kind_row_is_not_counterparty(rules: Rules) -> None:
"""R-17: «Организация» — собственная организация учёта (варианты 1/2 шапки), схема остаётся [ACCOUNT]."""
assert _flat_schema(rules, "Организация") == [("ACCOUNT", "Субконто")]
def test_currency_row_between_title_and_period_is_not_added_to_schema(rules: Rules) -> None:
assert _flat_schema(rules, "Валюта БУ, НУ: RUB") == [("ACCOUNT", "Субконто")]

View File

@ -486,3 +486,15 @@ def test_header_account_not_used_with_account_rows() -> None:
assert levels.strategy == "account_pattern"
assert strategy_header_account(rows) is None
def test_header_account_goes_before_sums_for_adjacent_rows_with_equal_sums() -> None:
"""Две соседние строки с равными суммами при счёте шапки — плоский список, а не вложенность по суммам."""
same = money("100.00", None, "50.00", None, "150.00", None)
rows = numbered(("ООО Ромашка", {"sums": same}), ("ООО Лютик", {"sums": same}))
levels = resolve_levels(rows, None, never_expires(), header_account=True)
assert levels.strategy == "header_account"
assert levels_list(rows, levels.by_row) == [0, 0]
assert set(levels.parent.values()) == {None}

View File

@ -86,7 +86,7 @@ def test_flat_list_s41(rules: Rules) -> None:
def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Rules) -> None:
"""Review Focus 4: счёт в шапке, строк-счетов нет, outline есть — `outline`, но строки наследуют счёт шапки."""
"""Счёт в шапке, строк-счетов нет, outline есть — `outline`, но строки наследуют счёт шапки."""
rows: list[list[object]] = [
["ООО Зерновая"],
["Оборотно-сальдовая ведомость по счету 60"],
@ -106,7 +106,7 @@ def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Ru
assert [item.account_code_raw for item in data] == ["60", "60"]
# --- Контрагенты расчётных счетов без названия вида (Task 5, R-9, R-10) ---
# --- Контрагенты расчётных счетов без названия вида (Task 5, R-6, R-10, R-17) ---
BY_ACCOUNT_WARNING = (
"вид субконто в шапке не назван: строки аналитики счёта 60.01 приняты за контрагентов по плану счетов — проверьте"
@ -129,7 +129,7 @@ def test_s43_counterparties_with_warning(synthetic_dir: Path) -> None:
document, messages, status = _parse_synthetic(synthetic_dir, "s43_subkonto_unnamed_60.xlsx")
kids = {item.row_value: item for item in _data_rows(document) if item.row_value != "60.01"}
assert set(kids) == {"ИП Знаменских А.И.", 'ООО "ЭСТЕТ ОМСК"'}
assert set(kids) == {"ИП Иванов А.А.", 'ООО "ЭСТЕТ ОМСК"'}
assert {item.group_type for item in kids.values()} == {"COUNTERPARTY"}
notes = [note for note in document.warnings if note.code == "OSV_ROWS"]
assert [note.message for note in notes] == [BY_ACCOUNT_WARNING]

View File

@ -373,6 +373,18 @@ def test_known_group_is_none_for_default(rules: Rules) -> None:
assert rules.groups.known("Номенклатура") == "OTHER"
def test_single_organization_is_own_organization_not_counterparty(rules: Rules) -> None:
"""R-17: «Организация» (единственное число) — собственная организация учёта, не вид субконто «Организации»."""
assert rules.groups.known("Организация") is None
assert rules.groups.classify("Организация") == "OTHER"
@pytest.mark.parametrize("text", ["Валюта", "Валюта USD", "В валюте", "в валюте CNY"])
def test_currency_rows_are_known_other_groups(rules: Rules, text: str) -> None:
"""R-18: строки валюты словарь групп знает как OTHER — по плану счетов они контрагентами не становятся."""
assert rules.groups.known(text) == "OTHER"
def test_account_marker_ends_right_before_the_account_code(rules: Rules) -> None:
text = "Оборотно-сальдовая ведомость по счёту 58.03 за 3 квартал 2024 г."
match = next(m for p in rules.headers.header_markers["account"] if (m := p.search(text)))
@ -708,7 +720,7 @@ def test_indicator_service_column(rules: Rules, title: str) -> None:
assert any(pattern.search(title) for pattern in rules.headers.service_columns["indicator"])
# --- Расчётные счета с контрагентами без названия вида (osv_groups.yaml, #59, R-9) ---
# --- Расчётные счета с контрагентами без названия вида (osv_groups.yaml, #59, R-6) ---
@pytest.mark.parametrize(