Хвосты ревью Task 3: формулировка направления листа в spec/design (T3.1), тесты противоречия сведений и приоритета подсказки (T3.2), assert на glue.ok (T3.4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
478 lines
19 KiB
Python
478 lines
19 KiB
Python
"""Строки ИНН рядом с контрагентом (spec «Строки ИНН рядом с контрагентом», change `osv-inn-rows-layouts`, #35).
|
||
|
||
Тесты модуля `inn_rows` на деревьях, собранных вручную: строка листа — (уровень, подпись, шесть сумм, ИНН колонки).
|
||
Подпись — колонка 1, колонка ИНН — 2, суммы — 3…8; уровни — как у стратегии outline, родители — `hierarchy.parents`.
|
||
"""
|
||
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
from gridbuilder import sheet_from_rows
|
||
|
||
from src.contract.result import OsvGroup
|
||
from src.parser.classify import ACCOUNT_RE
|
||
from src.parser.header import InnHint
|
||
from src.parser.hierarchy import BodyRow, Levels, parents
|
||
from src.parser.inn_rows import InnGlue, glue_inn_rows
|
||
from src.parser.rules import Rules, load_rules
|
||
|
||
Sums = tuple[object, object, object, object, object, object]
|
||
NO: Sums = (None, None, None, None, None, None)
|
||
A: Sums = (None, None, 100, 40, 60, None)
|
||
B: Sums = (10, None, None, 30, None, 20)
|
||
INN_A = "7701234560"
|
||
INN_B = "7707654321"
|
||
INN_12 = "500100732259"
|
||
BAD = "7701234567" # контрольная сумма не сходится
|
||
LABEL_COL = 1
|
||
INN_COL = 2
|
||
SCHEMA = [OsvGroup(type="ACCOUNT", name="Счет"), OsvGroup(type="COUNTERPARTY", name="Контрагенты")]
|
||
SCHEMA_WITH_CONTRACTS = [*SCHEMA, OsvGroup(type="CONTRACT", name="Договоры")]
|
||
BELOW = InnHint("Контрагенты.ИНН", "below")
|
||
ABOVE = InnHint("Контрагенты.ИНН", "above")
|
||
|
||
Row = tuple[int, object, Sums, object]
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def rules() -> Rules:
|
||
return load_rules()
|
||
|
||
|
||
def r(level: int, label: object, sums: Sums = NO, inn: object = None) -> Row:
|
||
return level, label, sums, inn
|
||
|
||
|
||
def run(rules: Rules, *rows: Row, hint: InnHint | None = None, schema: list[OsvGroup] | None = None) -> InnGlue:
|
||
sheet = sheet_from_rows([[label, inn, *sums] for _, label, sums, inn in rows])
|
||
body: list[BodyRow] = []
|
||
for number, (level, _, sums, _) in enumerate(rows, start=1):
|
||
text = sheet.text_at(number, LABEL_COL)
|
||
account = ACCOUNT_RE.match(text)
|
||
body.append(
|
||
BodyRow(
|
||
row=number,
|
||
text=text,
|
||
is_account=account is not None,
|
||
account_rank=None if account is None else account.group(1).count(".") + 1,
|
||
bold=False,
|
||
indent=0,
|
||
outline=level,
|
||
sums=tuple(None if value is None else Decimal(str(value)) for value in sums),
|
||
label_col=LABEL_COL,
|
||
)
|
||
)
|
||
by_row = {item.row: level for item, (level, *_) in zip(body, rows, strict=True)}
|
||
levels = Levels(by_row=by_row, parent=parents(body, by_row), strategy="outline")
|
||
return glue_inn_rows(body, levels, None, sheet, inn_col=INN_COL, schema=schema or SCHEMA, hint=hint, rules=rules)
|
||
|
||
|
||
def texts(glue: InnGlue) -> list[str]:
|
||
return [item.text for item in glue.rows]
|
||
|
||
|
||
def has_warning(glue: InnGlue, *parts: str) -> bool:
|
||
return any(all(part in warning for part in parts) for warning in glue.warnings)
|
||
|
||
|
||
# --- ИНН под наименованием, в дереве ---
|
||
|
||
|
||
def test_inn_below_in_tree_with_sums_is_glued(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A))
|
||
|
||
assert texts(glue) == ["60", "ООО Альфа"]
|
||
assert glue.inns == {2: INN_A}
|
||
assert (glue.unpaired, glue.ok) == (frozenset(), True)
|
||
assert has_warning(glue, "строка 3", INN_A, "отнесён к контрагенту в строке 2")
|
||
|
||
|
||
def test_inn_below_in_tree_without_sums_is_glued(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_12))
|
||
|
||
assert glue.inns == {2: INN_12}
|
||
assert glue.ok
|
||
|
||
|
||
def test_children_of_inn_row_move_up_to_counterparty(rules: Rules) -> None:
|
||
glue = run(
|
||
rules,
|
||
r(0, "60", A),
|
||
r(1, "ООО Альфа", A),
|
||
r(2, INN_A, A),
|
||
r(3, "Договор № 1", A),
|
||
schema=SCHEMA_WITH_CONTRACTS,
|
||
)
|
||
|
||
assert texts(glue) == ["60", "ООО Альфа", "Договор № 1"]
|
||
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 2)
|
||
|
||
|
||
def test_inn_with_contracts_under_counterparty_belongs_to_parent(rules: Rules) -> None:
|
||
# Без подсказки: родитель по схеме — контрагент, значит ИНН его, а дети строки ИНН — договоры
|
||
glue = run(
|
||
rules,
|
||
r(0, "60", A),
|
||
r(1, "ООО Альфа", A),
|
||
r(2, INN_A, A),
|
||
r(3, "Договор № 1", A),
|
||
schema=SCHEMA_WITH_CONTRACTS,
|
||
)
|
||
|
||
assert glue.inns == {2: INN_A}
|
||
assert texts(glue) == ["60", "ООО Альфа", "Договор № 1"]
|
||
|
||
|
||
def test_inn_between_department_and_name_is_above(rules: Rules) -> None:
|
||
# Родитель по схеме — подразделение: строка ИНН стоит над наименованием
|
||
schema = [
|
||
OsvGroup(type="ACCOUNT", name="Счет"),
|
||
OsvGroup(type="DEPARTMENT", name="Подразделения"),
|
||
OsvGroup(type="COUNTERPARTY", name="Контрагенты"),
|
||
]
|
||
glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, INN_A, A), r(3, "ООО Альфа", A), schema=schema)
|
||
|
||
assert glue.inns == {4: INN_A}
|
||
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 2)
|
||
|
||
|
||
def test_two_inn_rows_under_one_name_stay_unpaired(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A), r(2, INN_B))
|
||
|
||
assert glue.unpaired == frozenset({3, 4})
|
||
assert not glue.ok
|
||
assert has_warning(glue, "строка 3", "несколько строк ИНН")
|
||
|
||
|
||
def test_inn_row_with_other_sums_stays_unpaired(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, B))
|
||
|
||
assert glue.unpaired == frozenset({3})
|
||
assert glue.inns == {}
|
||
assert has_warning(glue, "строка 3", "суммы не равны")
|
||
|
||
|
||
# --- ИНН над наименованием, в дереве ---
|
||
|
||
|
||
def test_inn_above_in_tree_with_sums_is_glued_and_name_moves_up(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A))
|
||
|
||
assert texts(glue) == ["60", "ООО Альфа"]
|
||
assert glue.inns == {3: INN_A}
|
||
assert (glue.levels.by_row[3], glue.levels.parent[3]) == (1, 1)
|
||
|
||
|
||
def test_inn_above_in_tree_without_sums_is_glued(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, INN_A), r(2, "ООО Альфа", A))
|
||
|
||
assert glue.inns == {3: INN_A}
|
||
assert glue.ok
|
||
|
||
|
||
def test_inn_above_with_two_children_stays_unpaired(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, INN_A), r(2, "ООО Альфа", A), r(2, "ООО Бета", B))
|
||
|
||
assert glue.unpaired == frozenset({2})
|
||
assert texts(glue) == ["60", INN_A, "ООО Альфа", "ООО Бета"]
|
||
assert has_warning(glue, "строка 2", "несколько")
|
||
|
||
|
||
def test_no_extra_level_keeps_inn_named_counterparty(rules: Rules) -> None:
|
||
# Схема «Контрагенты, Договоры», глубже схемы строк нет: «7701234560» — контрагент с одним договором, не строка ИНН
|
||
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "Договор № 1", A), schema=SCHEMA_WITH_CONTRACTS)
|
||
|
||
assert texts(glue) == ["60", INN_A, "Договор № 1"]
|
||
assert (glue.inns, glue.unpaired, glue.warnings, glue.ok) == ({}, frozenset(), [], True)
|
||
|
||
|
||
def test_extra_level_allows_inn_above_with_contract_schema(rules: Rules) -> None:
|
||
glue = run(
|
||
rules,
|
||
r(0, "60", A),
|
||
r(1, INN_A, A),
|
||
r(2, "ООО Альфа", A),
|
||
r(3, "Договор № 1", A),
|
||
schema=SCHEMA_WITH_CONTRACTS,
|
||
)
|
||
|
||
assert glue.inns == {3: INN_A}
|
||
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 3)
|
||
|
||
|
||
# --- Распознавание и контрольная сумма ---
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("label", "inn"),
|
||
[
|
||
(Decimal(7701234560), INN_A),
|
||
(123456788, "0123456788"), # 9 цифр числом (#34)
|
||
("ИНН 7701234560", INN_A),
|
||
("ИНН: 7701234560 / 770101001", INN_A),
|
||
(INN_12, INN_12),
|
||
],
|
||
)
|
||
def test_inn_row_forms(rules: Rules, label: object, inn: str) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, label, A))
|
||
|
||
assert glue.inns == {2: inn}
|
||
|
||
|
||
def test_number_with_lost_leading_zero_is_restored(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Адыгея", A), r(2, 105012345, A), hint=BELOW)
|
||
|
||
assert glue.inns == {2: "0105012345"}
|
||
|
||
|
||
def test_bad_checksum_without_hint_is_lookalike(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, BAD, A))
|
||
|
||
assert texts(glue) == ["60", "ООО Альфа", BAD]
|
||
assert (glue.inns, glue.unpaired, glue.ok) == ({}, frozenset(), False)
|
||
assert has_warning(glue, "строка 3", "похожа на ИНН", "контрольная сумма не сходится")
|
||
|
||
|
||
def test_lone_number_named_row_without_names_around_is_silent(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, BAD, A))
|
||
|
||
assert (glue.warnings, glue.ok) == ([], True)
|
||
|
||
|
||
def test_hint_skips_checksum(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, BAD, A), hint=BELOW)
|
||
|
||
assert glue.inns == {2: BAD}
|
||
assert has_warning(glue, f"ИНН {BAD} («Контрагенты.ИНН») отнесён к контрагенту в строке 2")
|
||
|
||
|
||
def test_hint_below_empty_label_is_glued_without_inn(rules: Rules) -> None:
|
||
# R-1 #32: физлицо — 1С выводит строку реквизита с пустой ячейкой
|
||
glue = run(rules, r(0, "60", A), r(1, "Иванов Иван", A), r(2, None, A), hint=BELOW)
|
||
|
||
assert texts(glue) == ["60", "Иванов Иван"]
|
||
assert (glue.inns, glue.ok) == ({}, True)
|
||
assert has_warning(
|
||
glue, "строка 3: ИНН пуст («Контрагенты.ИНН») — строка отнесена к контрагенту в строке 2 без ИНН"
|
||
)
|
||
|
||
|
||
def test_hint_below_unrecognised_attribute_stays_unpaired(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Нерезидент", A), r(2, "нерез.", A), hint=BELOW)
|
||
|
||
assert glue.unpaired == frozenset({3})
|
||
assert not glue.ok
|
||
assert has_warning(glue, "строка 3", "не распознан")
|
||
|
||
|
||
def test_hint_below_real_name_with_own_inn_row_is_not_an_attribute(rules: Rules) -> None:
|
||
# R-1 T2.1: единственный ребёнок подразделения с теми же суммами — наименование, у него своя строка ИНН
|
||
schema = [
|
||
OsvGroup(type="ACCOUNT", name="Счет"),
|
||
OsvGroup(type="DEPARTMENT", name="Подразделения"),
|
||
OsvGroup(type="COUNTERPARTY", name="Контрагенты"),
|
||
]
|
||
glue = run(
|
||
rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, "ООО Альфа", A), r(3, INN_A, A), hint=BELOW, schema=schema
|
||
)
|
||
|
||
assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"]
|
||
assert (glue.inns, glue.unpaired, glue.ok) == ({3: INN_A}, frozenset(), True)
|
||
|
||
|
||
def test_childless_inn_next_to_counterparty_under_department_is_not_paired_with_department(rules: Rules) -> None:
|
||
# R-2 T2.2 / T2.9: родитель-подразделение — не пара; ИНН без детей уходит соседу-контрагенту (блок «над»)
|
||
schema = [
|
||
OsvGroup(type="ACCOUNT", name="Счет"),
|
||
OsvGroup(type="DEPARTMENT", name="Подразделения"),
|
||
OsvGroup(type="COUNTERPARTY", name="Контрагенты"),
|
||
]
|
||
glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, INN_A), r(2, "ООО Альфа", A), schema=schema)
|
||
|
||
assert glue.inns == {4: INN_A}
|
||
assert glue.unpaired == frozenset()
|
||
assert glue.ok
|
||
assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"]
|
||
|
||
|
||
def test_hint_above_disables_no_extra_level_guard(rules: Rules) -> None:
|
||
# R-3 T2.3: подсказка «над» — свидетельство лишнего уровня; защита работает только без подсказки
|
||
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=ABOVE, schema=SCHEMA_WITH_CONTRACTS)
|
||
|
||
assert texts(glue) == ["60", "ООО Альфа"]
|
||
assert (glue.inns, glue.unpaired) == ({3: INN_A}, frozenset())
|
||
|
||
|
||
def test_column_inn_wins_over_inn_row(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_B), r(2, INN_A, A))
|
||
|
||
assert texts(glue) == ["60", "ООО Альфа"]
|
||
assert glue.inns == {}
|
||
assert not glue.ok
|
||
assert has_warning(glue, "строка 3", "расходится", INN_B)
|
||
|
||
|
||
def test_same_inn_in_column_and_row_is_fine(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_A), r(2, INN_A, A))
|
||
|
||
assert (glue.inns, glue.ok) == ({2: INN_A}, True)
|
||
|
||
|
||
# --- Не в дереве: соседи того же родителя ---
|
||
|
||
|
||
def test_inn_above_sibling_without_sums(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B), r(1, "ООО Бета", B))
|
||
|
||
assert texts(glue) == ["60", "ООО Альфа", "ООО Бета"]
|
||
assert glue.inns == {3: INN_A, 5: INN_B}
|
||
assert glue.ok
|
||
|
||
|
||
def test_inn_below_sibling_without_sums_goes_to_own_counterparty(rules: Rules) -> None:
|
||
# Дефект main: ИНН первого контрагента доставался второму
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B), r(1, INN_B))
|
||
|
||
assert glue.inns == {2: INN_A, 4: INN_B}
|
||
assert glue.ok
|
||
|
||
|
||
def test_inn_above_sibling_with_sums(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Бета", B))
|
||
|
||
assert glue.inns == {3: INN_A, 5: INN_B}
|
||
|
||
|
||
def test_inn_below_sibling_with_sums(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), r(1, INN_B, B))
|
||
|
||
assert glue.inns == {2: INN_A, 4: INN_B}
|
||
|
||
|
||
def test_sums_match_no_neighbour(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Гамма", A))
|
||
|
||
assert glue.unpaired == frozenset({3})
|
||
assert has_warning(glue, "строка 3", "суммы не совпали")
|
||
|
||
|
||
def test_both_neighbours_match_sums_need_direction(rules: Rules) -> None:
|
||
rows = (r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", A))
|
||
|
||
assert run(rules, *rows).unpaired == frozenset({3})
|
||
assert run(rules, *rows, hint=ABOVE).inns == {4: INN_A}
|
||
|
||
|
||
def test_ambiguous_block_leaves_inn_unpaired(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B))
|
||
|
||
assert glue.unpaired == frozenset({3})
|
||
assert not glue.ok
|
||
assert has_warning(glue, "строка 3", "направление")
|
||
|
||
|
||
def test_direction_from_other_block(rules: Rules) -> None:
|
||
glue = run(
|
||
rules,
|
||
r(0, "60", A),
|
||
r(1, "60.01", A),
|
||
r(2, "ООО Альфа", A),
|
||
r(2, INN_A),
|
||
r(2, "Иванов Иван", B), # блок 60.01 «имя, ИНН, имя» направление не даёт
|
||
r(1, "60.02", B),
|
||
r(2, "ООО Гамма", B),
|
||
r(2, INN_B), # блок 60.02 кончается строкой ИНН — ИНН под наименованием
|
||
)
|
||
|
||
assert glue.inns == {3: INN_A, 7: INN_B}
|
||
|
||
|
||
def test_direction_from_sum_pair(rules: Rules) -> None:
|
||
glue = run(
|
||
rules,
|
||
r(0, "60", A),
|
||
r(1, "ООО Альфа", A),
|
||
r(1, INN_A, A),
|
||
r(1, "ООО Бета", B),
|
||
r(1, INN_B),
|
||
r(1, "Иванов Иван", B),
|
||
)
|
||
|
||
assert glue.inns == {2: INN_A, 4: INN_B}
|
||
|
||
|
||
def test_contradicting_blocks_leave_inn_unpaired(rules: Rules) -> None:
|
||
glue = run(
|
||
rules,
|
||
r(0, "60", A),
|
||
r(1, "60.01", A),
|
||
r(2, INN_A),
|
||
r(2, "ООО Альфа", A), # начинается строкой ИНН — над
|
||
r(1, "60.02", B),
|
||
r(2, "ООО Бета", B),
|
||
r(2, INN_B), # кончается строкой ИНН — под
|
||
)
|
||
|
||
assert glue.unpaired == frozenset({3, 7})
|
||
|
||
|
||
def test_broken_block_leaves_inn_unpaired(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B))
|
||
|
||
assert glue.unpaired == frozenset({2, 4})
|
||
assert has_warning(glue, "строка 2", "начинается и заканчивается")
|
||
|
||
|
||
def test_hint_direction_wins_over_blocks(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, BAD), r(1, "ООО Бета", B), hint=ABOVE)
|
||
|
||
assert glue.inns == {4: BAD}
|
||
|
||
|
||
def test_sum_pair_below_and_block_above_contradict_each_other(rules: Rules) -> None:
|
||
# T3.2: пара по суммам говорит «под», однозначный блок (начинается строкой ИНН) — «над»: направление неизвестно
|
||
glue = run(
|
||
rules,
|
||
r(0, "60", A),
|
||
r(1, "60.01", A),
|
||
r(2, "ООО Альфа", A),
|
||
r(2, INN_A, A), # суммы совпали только с предыдущим — «под»
|
||
r(2, "ООО Бета", B),
|
||
r(1, "60.02", B),
|
||
r(2, INN_B), # блок 60.02 начинается строкой ИНН — «над»
|
||
r(2, "ООО Гамма", B),
|
||
)
|
||
|
||
assert glue.unpaired == frozenset({7})
|
||
assert not glue.ok
|
||
assert has_warning(glue, "строка 7", "направление")
|
||
|
||
|
||
def test_hint_overrides_opposite_sum_pair_direction(rules: Rules) -> None:
|
||
# T3.2: пара по суммам даёт «под»; подсказка шапки «над» перекрывает это сведение для строки ИНН без сумм
|
||
rows = (
|
||
r(0, "60", A),
|
||
r(1, "ООО Альфа", A),
|
||
r(1, INN_A, A), # пара по суммам с предыдущим — «под»
|
||
r(1, "ООО Бета", B),
|
||
r(1, INN_B),
|
||
r(1, "ООО Гамма", B),
|
||
)
|
||
|
||
assert run(rules, *rows).inns == {2: INN_A, 4: INN_B}
|
||
assert run(rules, *rows, hint=ABOVE).inns == {2: INN_A, 6: INN_B}
|
||
|
||
|
||
def test_two_inn_rows_claim_one_name(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, A))
|
||
|
||
assert glue.unpaired == frozenset({2, 4})
|
||
assert has_warning(glue, "претендуют")
|
||
|
||
|
||
def test_inn_without_name_neighbour_in_direction(rules: Rules) -> None:
|
||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), hint=ABOVE)
|
||
|
||
assert glue.unpaired == frozenset({3})
|
||
assert has_warning(glue, "строка 3", "нет наименования")
|