From b5f0b9d77372f479b58eda74adb4287feac4bd3b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:48:46 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D0=B7=D0=B0=D0=BF=D0=B0?= =?UTF-8?q?=D1=81=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80=D0=B8=D0=B7=D0=BD=D0=B0?= =?UTF-8?q?=D0=BA=20=D0=BB=D0=B8=D1=81=D1=82=D0=B0=20=D0=BF=D0=BE=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D1=83=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20(#81,=20AURORA-1717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Лист без названия ведомости, не опознанный как форма, — ОСВ, если в первых 30 строках полный заголовок: три группы, у каждой Дт и Кт рядом. Проверяется раньше слабого продолжения формы (R-12). Co-Authored-By: Claude Opus 5.5 --- api/rules/osv_headers.yaml | 2 + api/src/parser/book.py | 10 ++- api/src/parser/detect.py | 39 ++++++++++- api/src/parser/statements/adapter.py | 15 ++++- api/tests/synthetic/test_synthetic_files.py | 9 +-- .../unit/parser/statements/test_adapter.py | 55 ++++++++++++++++ api/tests/unit/parser/test_detect.py | 64 +++++++++++++++++++ .../specs/osv-parsing/spec.md | 22 +++++-- 8 files changed, 196 insertions(+), 20 deletions(-) diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index 01a158f..a0ac2a7 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -30,6 +30,7 @@ groups: - 'остат\w*(?=\s+по\s+(дебету|кредиту)\s+на\s+начало)' # «Остаток по дебету на начало» (КТЗ, ЧАЗ) - 'сальдо\s+начальное' # «Сальдо НачальноеДТВВ» (SAP, НМЖК) - 'сальдо\s+вх\b' # «Сальдо вх Дт» (SAP, Виола) + - '^начало(?=\s*(дт|кт)\b)' # «НачалоДт» (Navision без названия ведомости, Айсберри) turnover: - '\bоборот(ы)?\b' - '^оборот(ы)?(?=\s*(дт|кт))' # «ОборотДт» (Navision), «ОборотыДтВВ» (SAP, НМЖК) @@ -41,6 +42,7 @@ groups: - 'остат\w*(?=\s+по\s+(дебету|кредиту)\s+на\s+конец)' # «Остаток по кредиту на конец» (КТЗ, ЧАЗ) - 'сальдо\s+конечное' # «Сальдо КонечноеКТВВ» (SAP, НМЖК) - 'сальдо\s+исх\b' # «Сальдо исх Кт» (SAP, Виола) + - '^конец(?=\s*(дт|кт)\b)' # «КонецКт» (Navision без названия ведомости, Айсберри) # Подзаголовки сумм под группой («Дебет»/«Кредит», «Дт»/«Кт», «Д-т»/«К-т») или сторона в остатке ячейки группы: # ячейка (остаток) начинается с синонима diff --git a/api/src/parser/book.py b/api/src/parser/book.py index becfe08..14f3be3 100644 --- a/api/src/parser/book.py +++ b/api/src/parser/book.py @@ -35,7 +35,7 @@ from src.contract import ( ) from src.parser import ParseOutcome from src.parser.deadline import Deadline -from src.parser.detect import is_osv_sheet +from src.parser.detect import has_osv_table, is_osv_sheet from src.parser.errors import InvalidDocumentError, ParserError, UnsupportedFormatError from src.parser.osv import OsvParser, data_rows from src.parser.osv_warnings import OSV_XLSB_NO_OUTLINE, OsvNote @@ -143,7 +143,13 @@ class BookParser: if read.no_codepage: warnings.append(_file_warning(make_warning(WarningCode.XLS_NO_CODEPAGE, NO_CODEPAGE_WARNING, rules))) sheets = read.sheets - plan = plan_sheets(sheets, lambda sheet: is_osv_sheet(sheet, self._rules), rules, deadline) + plan = plan_sheets( + sheets, + lambda sheet: is_osv_sheet(sheet, self._rules), + rules, + deadline, + osv_by_table=lambda sheet: has_osv_table(sheet, self._rules), + ) osv_names = {sheet.name for sheet in plan.osv} other_names = {sheet.name for sheet in plan.other} reader_notes = _reader_notes(read) diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index fade3e9..1570cff 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -30,6 +30,7 @@ __all__ = [ "classify_sheet", "find_table_header", "first_data_row", + "has_osv_table", "inn_from_cell", "is_header_repeat", "is_osv_sheet", @@ -166,13 +167,35 @@ def is_osv_sheet(sheet: Sheet, rules: Rules) -> bool: def classify_sheet(sheet: Sheet, rules: Rules, statement_rules: StatementRules | None = None) -> SheetKind: """ОСВ — если в первых строках есть признак ведомости; иначе форма ББ/ОФР, её продолжение или прочее — по словарям - пакета `arm_statements` (`statements.forms`; `None` — словари пакета по умолчанию).""" + пакета `arm_statements` (`statements.forms`; `None` — словари пакета по умолчанию); «прочее» с полным заголовком + таблицы ОСВ — ОСВ (`has_osv_table`).""" if is_osv_sheet(sheet, rules): return SheetKind.OSV # цикл импорта: forms → cells → number_format → numbers → detect (INN_LENGTHS, inn_from_cell) from src.parser.statements.forms import classify_statement_sheet # noqa: PLC0415 - return _STATEMENT_KINDS[classify_statement_sheet(sheet, statement_rules or default_rules())] + kind = _STATEMENT_KINDS[classify_statement_sheet(sheet, statement_rules or default_rules())] + if kind is SheetKind.OTHER and has_osv_table(sheet, rules): + return SheetKind.OSV + return kind + + +def has_osv_table(sheet: Sheet, rules: Rules) -> bool: + """Запасной признак листа ОСВ без названия ведомости (#81, design D1): в первых `SHEET_MARKER_ROWS` строках есть + строка со всеми тремя группами сумм, и у каждой группы — колонки «Дебет» и «Кредит» (свёрнутое сальдо не годится, + R-3), между которыми в строках заголовка нет других подписей (R-11: у отчёта 1С «Обороты счета» между «Оборот Дт» и + «Оборот Кт» — колонки корр. счетов, это не ОСВ). Колонки ищутся тем же разбором, что у обычного заголовка (R-6).""" + for row in range(1, min(sheet.n_rows, SHEET_MARKER_ROWS) + 1): + cells = _group_cells(sheet, row, rules) + if {cell.group for cell in cells} != set(GROUP_ORDER): + continue + try: + groups = [_columns_of_group(sheet, row, cells, group, rules) for group in GROUP_ORDER] + except InvalidFormatError: + continue + if all(_sides_side_by_side(sheet, row, columns) for columns in groups): + return True + return False # --- Заголовок таблицы --- @@ -311,6 +334,18 @@ def _check_sides(sheet: Sheet, group: SumGroup, debit: _SumCell | None, credit: return _GroupColumns(debit, credit, None) +def _sides_side_by_side(sheet: Sheet, group_row: int, columns: _GroupColumns) -> bool: + """У группы найдены «Дебет» и «Кредит», и между их колонками в строках заголовка (от строки групп до нижней строки + подзаголовков) нет других непустых ячеек.""" + if columns.debit is None or columns.credit is None: + return False + left, right = sorted((columns.debit, columns.credit), key=lambda cell: cell.col) + bottom = max(left.bottom, right.bottom) + return not any( + left.right < col < right.col for row in range(group_row, bottom + 1) for col, _ in _texts(sheet, row) + ) + + def _is_label_row(sheet: Sheet, row: int, sums: range, rules: Rules) -> bool: """Строка-метка блока заголовка: левее сумм — текст группировки, «ИНН» или «Субконто», в колонках сумм — пусто. diff --git a/api/src/parser/statements/adapter.py b/api/src/parser/statements/adapter.py index 3998273..d819054 100644 --- a/api/src/parser/statements/adapter.py +++ b/api/src/parser/statements/adapter.py @@ -54,10 +54,16 @@ class SheetPlan: def plan_sheets( - sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules, deadline: Deadline + sheets: Sequence[Sheet], + is_osv: Callable[[Sheet], bool], + rules: StatementRules, + deadline: Deadline, + osv_by_table: Callable[[Sheet], bool] | None = None, ) -> SheetPlan: - """Листы по порядку книги: ОСВ; форма; продолжение (в том числе слабое — лист из 1–2 кодов сразу после листа - формы или продолжения); прочее. Лист ОСВ или «прочее» прерывает «сразу после».""" + """Листы по порядку книги: ОСВ; форма; продолжение; ОСВ по заголовку таблицы (`osv_by_table`, запасной признак + #81: только для листа, который не форма и не продолжение по признакам пакета, — раньше слабого продолжения, R-12); + слабое продолжение — лист из 1–2 кодов сразу после листа формы или продолжения; прочее. Лист ОСВ или «прочее» + прерывает «сразу после».""" osv: list[Sheet] = [] statements: list[tuple[Sheet, SheetKind]] = [] other: list[Sheet] = [] @@ -75,6 +81,9 @@ def plan_sheets( last_type = blocks[-1].type_hint if blocks else None elif kind == "continuation": statements.append((sheet, SheetKind.STATEMENT_CONTINUATION)) + elif osv_by_table is not None and osv_by_table(sheet): + osv.append(sheet) + last_type = None elif last_type is not None and is_weak_continuation(sheet, rules, last_type): statements.append((sheet, SheetKind.STATEMENT_CONTINUATION)) else: diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index f1641f2..7afe5ed 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -68,8 +68,6 @@ S55 = "s55_not_osv_titles.xlsx" S56 = "s56_almost_osv_tables.xlsx" S57 = "s57_form_mentions_osv.xlsx" OTHER_SYSTEMS_FILES = (S48, S49, S50, S51, S52, S53, S54, S55, S56, S57) -PENDING_FALLBACK = "tp#81 Task 3: запасной признак листа по заголовку таблицы" -PENDING_81 = {S51: PENDING_FALLBACK, S52: PENDING_FALLBACK, S54: PENDING_FALLBACK} S48_SHEETS = ("ОВ", "ОСВ сокр", "Оборотный баланс", "Навижн", "Главная книга", "Верхний регистр", "Вторая колонка") TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ @@ -312,10 +310,8 @@ def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None: assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}" -def test_header_status_and_hierarchy(synthetic_file: Path, request: pytest.FixtureRequest) -> None: +def test_header_status_and_hierarchy(synthetic_file: Path) -> None: """Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту.""" - if synthetic_file.name in PENDING_81: - request.applymarker(pytest.mark.xfail(strict=True, reason=PENDING_81[synthetic_file.name])) expect = EXPECTED[synthetic_file.name] outcome = parse_path(synthetic_file) @@ -900,7 +896,6 @@ def test_s50_sap_in_out_and_glued_side(fixtures_dir: Path) -> None: assert by_text(glued)["ООО Дтвв-Сервис"].group_type == "COUNTERPARTY" -@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK) def test_s51_untitled_sheet_is_osv_by_table(fixtures_dir: Path) -> None: """Navision без названия ведомости: ОСВ по полному заголовку таблицы (запасной признак).""" outcome = parse_named(fixtures_dir, S51) @@ -911,7 +906,6 @@ def test_s51_untitled_sheet_is_osv_by_table(fixtures_dir: Path) -> None: assert_done_with_sums(doc, ALFA, ALFA_SUMS) -@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK) def test_s52_form_stays_form_navision_is_osv_reference_is_other(fixtures_dir: Path) -> None: """Форма ББ — свой документ (лист Navision не присоединяется к ней слабым продолжением, R-12), Navision — ОСВ, «Справка» — прочий лист.""" @@ -934,7 +928,6 @@ def test_s53_xls_balance_by_side(fixtures_dir: Path) -> None: assert_done_with_sums(second, "ООО Покупатель-1", BUYER_SUMS) -@pytest.mark.xfail(strict=True, reason=PENDING_FALLBACK) def test_s54_xlsb_untitled_navision(fixtures_dir: Path) -> None: """s51 в xlsb: ОСВ по запасному признаку, те же суммы.""" outcome = parse_named(fixtures_dir, S54) diff --git a/api/tests/unit/parser/statements/test_adapter.py b/api/tests/unit/parser/statements/test_adapter.py index 0f5b82b..ddf29d5 100644 --- a/api/tests/unit/parser/statements/test_adapter.py +++ b/api/tests/unit/parser/statements/test_adapter.py @@ -146,6 +146,61 @@ def test_plan_sheets_weak_continuation_only_after_form(rules: StatementRules) -> assert [s.name for s in plan.other] == ["Примечания", "2400"] +class _CountingTable: + """`osv_by_table` для `plan_sheets`: лист с именем из `osv` — ОСВ по таблице; запоминает, для каких листов звали.""" + + def __init__(self, *osv: str) -> None: + self.osv = set(osv) + self.asked: list[str] = [] + + def __call__(self, sheet: Sheet) -> bool: + self.asked.append(sheet.name) + return sheet.name in self.osv + + +def _no_osv(sheet: Sheet) -> bool: + return False + + +def test_plan_sheets_table_fallback_only_for_other(rules: StatementRules) -> None: + """#81, D1: запасной признак не спрашивается у листа формы; лист с таблицей ОСВ — ОСВ, «Справка» — прочее.""" + form = _form("Бухгалтерский баланс", BALANCE_CODES, "ББ") + table = sheet_from_rows([["НачалоДт"]], name="ОВ") + reference = sheet_from_rows([["Справка"], ["Аренда", 100]], name="Справка") + by_table = _CountingTable("ОВ") + + plan = plan_sheets([form, table, reference], _no_osv, rules, NO_DEADLINE, osv_by_table=by_table) + + assert plan.osv == (table,) + assert [(s.name, k) for s, k in plan.statements] == [("ББ", STATEMENT)] + assert plan.other == (reference,) + assert by_table.asked == ["ОВ", "Справка"] + + +def test_plan_sheets_table_fallback_before_weak_continuation(rules: StatementRules) -> None: + """R-12: лист с таблицей ОСВ после формы — ОСВ, а не слабое продолжение; без таблицы — продолжение, как было + (Review Focus 4).""" + ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР") + weak = sheet_from_rows([["Чистая прибыль", "2400", 10, 8]], name="2400") + + plan = plan_sheets([ofr, weak], _no_osv, rules, NO_DEADLINE, osv_by_table=_CountingTable()) + assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT), ("2400", CONTINUATION)] + assert plan.osv == () + + plan = plan_sheets([ofr, weak], _no_osv, rules, NO_DEADLINE, osv_by_table=_CountingTable("2400")) + assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT)] + assert plan.osv == (weak,) + + +def test_plan_sheets_without_table_fallback_is_unchanged(rules: StatementRules) -> None: + """Без `osv_by_table` — прежнее поведение: «прочее» остаётся прочим.""" + reference = sheet_from_rows([["Справка"], ["Аренда", 100]], name="Справка") + + plan = plan_sheets([reference], _no_osv, rules, NO_DEADLINE) + + assert (plan.osv, plan.statements, plan.other) == ((), (), (reference,)) + + def test_is_osv_sheet_by_marker() -> None: rules = load_rules() osv = sheet_from_rows([["Оборотно-сальдовая ведомость по счету 60"]]) diff --git a/api/tests/unit/parser/test_detect.py b/api/tests/unit/parser/test_detect.py index a62706c..89f547c 100644 --- a/api/tests/unit/parser/test_detect.py +++ b/api/tests/unit/parser/test_detect.py @@ -17,6 +17,7 @@ from src.parser.detect import ( classify_sheet, find_table_header, first_data_row, + has_osv_table, inn_from_cell, is_header_repeat, ) @@ -110,6 +111,69 @@ def test_osv_marker_far_below_the_head_is_not_searched(rules: Rules) -> None: assert classify_sheet(sheet, rules) is SheetKind.OTHER +# --- Запасной признак листа ОСВ по заголовку таблицы (#81, design D1, R-2, R-3, R-11) --- + +NAVISION_HEADER = ["ФинСчет", "ИсточникНазвание", "НачалоДт", "НачалоКт", "ОборотДт", "ОборотКт", "КонецДт", "КонецКт"] +NAVISION_ROW = at({1: "60", 3: 10, 5: 5, 7: 15}) + + +def test_has_osv_table_navision_split_sides(rules: Rules) -> None: + """Navision без названия: все три группы, у каждой Дт и Кт в ячейках группы — ОСВ.""" + assert has_osv_table(sheet_from_rows([NAVISION_HEADER, NAVISION_ROW]), rules) is True + + +def test_has_osv_table_with_subheaders(rules: Rules) -> None: + """Обычный заголовок: группы над «Дебет | Кредит».""" + sheet = sheet_from_rows( + [ + ["Счет", OPENING, None, TURNOVER, None, CLOSING], + [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + at({1: "60", 2: 10, 7: 10}), + ] + ) + + assert has_osv_table(sheet, rules) is True + + +def test_has_osv_table_requires_opening(rules: Rules) -> None: + header = ["Счет", "ОборотДт", "ОборотКт", "КонецДт", "КонецКт"] + + assert has_osv_table(sheet_from_rows([header, at({1: "60", 2: 5})]), rules) is False + + +def test_has_osv_table_rejects_collapsed(rules: Rules) -> None: + header = ["Счет", "Сальдо на начало", "Оборот Дт", "Оборот Кт", "Сальдо на конец Дт", "Сальдо на конец Кт"] + + assert has_osv_table(sheet_from_rows([header, at({1: "60", 2: 100, 3: 10})]), rules) is False + + +def test_has_osv_table_rejects_disclosure(rules: Rules) -> None: + """Расшифровка с остатками: «Поступило/Выбыло» — не обороты (Review Focus 1).""" + header = ["Наименование", "Остаток на начало", "Поступило", "Выбыло", "Остаток на конец"] + + assert has_osv_table(sheet_from_rows([header, ["Материалы", 100, 50, 30, 120]]), rules) is False + + +def test_has_osv_table_rejects_columns_between_sides(rules: Rules) -> None: + """1С «Обороты счета»: между «Оборот Дт» и «Оборот Кт» — колонки корр. счетов (R-1, R-11).""" + header = ["Счет", "Начальное сальдо Дт", "Начальное сальдо Кт", "Оборот Дт", "51", "68", "Оборот Кт"] + header += ["Конечное сальдо Дт", "Конечное сальдо Кт"] + + assert has_osv_table(sheet_from_rows([header, at({1: "60", 2: 100, 4: 50})]), rules) is False + + +@pytest.mark.parametrize(("blank_rows", "expected"), [(29, True), (30, False)]) +def test_has_osv_table_only_first_rows(rules: Rules, blank_rows: int, expected: bool) -> None: + """Строка групп ищется в первых `SHEET_MARKER_ROWS` (30) строках (R-2): 30-я — да, 31-я — нет.""" + sheet = sheet_from_rows([*([[]] * blank_rows), NAVISION_HEADER, NAVISION_ROW]) + + assert has_osv_table(sheet, rules) is expected + + +def test_classify_sheet_falls_back_to_table(rules: Rules) -> None: + assert classify_sheet(sheet_from_rows([NAVISION_HEADER, NAVISION_ROW]), rules) is SheetKind.OSV + + # --- find_table_header: варианты заголовка --- diff --git a/openspec/changes/osv-other-systems/specs/osv-parsing/spec.md b/openspec/changes/osv-other-systems/specs/osv-parsing/spec.md index 644a002..6f59b85 100644 --- a/openspec/changes/osv-other-systems/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-other-systems/specs/osv-parsing/spec.md @@ -6,11 +6,13 @@ дефисов). Признаки ОСВ — названия ведомости разных учётных систем («Оборотно-сальдовая ведомость», «Оборотная ведомость», «ОСВ по сч.», «Оборотный баланс», «Фин. Обороты», «Отчет по Главной книге»). Лист с признаками формы ББ/ОФР или её продолжения уходит в конвейер форм (спека `statement-parsing`). Лист, не опознанный ни как ОСВ по признаку, ни -как форма или её продолжение, SHALL считаться листом ОСВ по запасному признаку, если в первых `SHEET_MARKER_ROWS` -строках есть строка групп сумм со всеми тремя группами («начало», «обороты», «конец») и у каждой группы найдены обе -стороны — «Дебет» и «Кредит»; свёрнутое сальдо (одна колонка в группе) запасного признака не даёт. Запасной признак -MUST проверяться только после классификации форм и MUST NOT отнимать у конвейера форм лист формы, её продолжения или -расшифровки. Прочие листы SHALL пропускаться с предупреждением файла `SHEET_OTHER` (`severity = info`, +как форма или её продолжение по признакам форм, SHALL считаться листом ОСВ по запасному признаку, если в первых +`SHEET_MARKER_ROWS` строках есть строка групп сумм со всеми тремя группами («начало», «обороты», «конец»), у каждой +группы найдены обе стороны — «Дебет» и «Кредит», и между колонками «Дебет» и «Кредит» группы в строках заголовка нет +других подписей; свёрнутое сальдо (одна колонка в группе) запасного признака не даёт. Запасной признак MUST +проверяться только после классификации форм и MUST NOT отнимать у конвейера форм лист формы, её продолжения или +расшифровки; слабое продолжение (лист из 1–2 кодов сразу после формы) признаком формы не считается — лист с полной +таблицей ОСВ после формы — ОСВ. Прочие листы SHALL пропускаться с предупреждением файла `SHEET_OTHER` (`severity = info`, `locator = sheet:<имя>`) в `metrics.warnings`. Книга без единого листа ОСВ и без форм → `status = error`, `error.code = INVALID_DOCUMENT` («в книге нет листов ОСВ или форм»). Несколько листов ОСВ → несколько `documents` с `locator = sheet:<имя>`. Имя листа MUST NOT использоваться для поиска. @@ -44,6 +46,16 @@ MUST проверяться только после классификации - **THEN** вид листа расшифровки тот же, что без запасного признака (форма или продолжение конвейера форм либо `SHEET_OTHER`), документа ОСВ из него нет +#### Scenario: Лист ОСВ без названия после формы — не слабое продолжение +- **WHEN** разбирается книга: форма ББ, за ней лист Navision без названия ведомости с полным заголовком таблицы, за ним + посторонний лист +- **THEN** форма — свой документ, лист Navision — `osvDocument`, посторонний лист — `SHEET_OTHER` + +#### Scenario: Отчёт «Обороты счета» не ОСВ +- **WHEN** лист без названия ведомости содержит все три группы со сторонами, но между «Оборот Дт» и «Оборот Кт» стоят + колонки корреспондирующих счетов +- **THEN** лист пропускается с `SHEET_OTHER` + #### Scenario: Неполная таблица не даёт запасного признака - **WHEN** лист без названия ведомости содержит группы «обороты» и «конец», но без группы «начало» или со свёрнутым сальдо (одна колонка в группе)