497 lines
23 KiB
Python
497 lines
23 KiB
Python
"""Расчёт шахматки по готовой сводной ОСВ и выбранным наборам счетов."""
|
||
|
||
import uuid
|
||
from collections import defaultdict
|
||
from collections.abc import Iterable, Sequence
|
||
from dataclasses import dataclass
|
||
from decimal import Decimal
|
||
from typing import Literal
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from src.common.exceptions import DomainValidationError, NotFoundError
|
||
from src.domain.consolidated_osv import ConsolidatedRow, Entity
|
||
from src.domain.policy import VisibilityScope
|
||
from src.models.accounting import ChartOfAccount, EliminationRule
|
||
from src.models.consolidation import Consolidation, ConsolidationVersion
|
||
from src.models.directories import CompanyGroup
|
||
from src.models.enums import AccountKind, PeriodQuarter, StatementType
|
||
from src.repositories import directories as directory_repo
|
||
from src.repositories import elimination_rules as elimination_rules_repo
|
||
from src.schemas.consolidations import (
|
||
IntercompanyMatrixCellOut,
|
||
IntercompanyMatrixCompanyOut,
|
||
IntercompanyMatrixOut,
|
||
IntercompanyResultAmountOut,
|
||
IntercompanyResultArticleOut,
|
||
IntercompanyResultTableOut,
|
||
UnpairedOsvOut,
|
||
)
|
||
from src.services.consolidated_osv import ConsolidatedOsvService, row_out
|
||
|
||
Measure = Literal["closing", "turnover"]
|
||
Side = Literal["D", "K"]
|
||
ZERO = Decimal("0")
|
||
VARIANCE_THRESHOLD_PERCENT = Decimal("5")
|
||
DEBIT_KINDS = frozenset({AccountKind.A, AccountKind.AP})
|
||
CREDIT_KINDS = frozenset({AccountKind.P, AccountKind.AP})
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ResultArticleSpec:
|
||
code: str
|
||
name: str
|
||
side: Side
|
||
account_codes: tuple[str, ...]
|
||
excluded_codes: tuple[str, ...] = ()
|
||
|
||
|
||
RESULT_ARTICLES = (
|
||
ResultArticleSpec(
|
||
"receivables",
|
||
"Дебиторская задолженность",
|
||
"D",
|
||
("60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"),
|
||
("76.07.5", "76.07.9"),
|
||
),
|
||
ResultArticleSpec(
|
||
"payables",
|
||
"Кредиторская задолженность",
|
||
"K",
|
||
("60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"),
|
||
("76.07.1",),
|
||
),
|
||
ResultArticleSpec("financial_investments", "Финансовые вложения", "D", ("58.01", "58.03")),
|
||
ResultArticleSpec("long_term_loans", "Долгосрочные кредиты и займы", "K", ("67", "67.03")),
|
||
ResultArticleSpec("short_term_loans", "Краткосрочные кредиты и займы", "K", ("66", "66.03", "66.04")),
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MatrixSelection:
|
||
"""Счета дебетовой и кредитовой сторон и показатель выбранного режима."""
|
||
|
||
company_1_account_ids: frozenset[uuid.UUID]
|
||
company_2_account_ids: frozenset[uuid.UUID]
|
||
measure: Measure
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MatrixBuildResult:
|
||
"""Матрица и данные свода, использованные при её построении."""
|
||
|
||
matrix: IntercompanyMatrixOut
|
||
version: ConsolidationVersion
|
||
consolidation: Consolidation
|
||
accounts: list[ChartOfAccount]
|
||
selection: MatrixSelection
|
||
rows: tuple[ConsolidatedRow, ...]
|
||
rule: EliminationRule | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MatrixExportInfo:
|
||
group_name: str
|
||
year: int
|
||
quarter: PeriodQuarter
|
||
version_no: int
|
||
mode: Literal["ALL", "MANUAL", "PRESET"]
|
||
debit_account_codes: tuple[str, ...]
|
||
credit_account_codes: tuple[str, ...]
|
||
measure: Measure = "closing"
|
||
rule_id: uuid.UUID | None = None
|
||
rule_no: int | None = None
|
||
rule_version_no: int | None = None
|
||
|
||
@property
|
||
def filename(self) -> str:
|
||
preset = f"_preset-{self.rule_id}" if self.rule_id is not None else ""
|
||
return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}{preset}.xlsx"
|
||
|
||
|
||
def _get_amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal:
|
||
if measure == "closing":
|
||
value = row.closing_debit if side == "D" else row.closing_credit
|
||
else:
|
||
value = row.turnover_debit if side == "D" else row.turnover_credit
|
||
return value if value is not None else ZERO
|
||
|
||
|
||
def calculate_matrix(
|
||
rows: Iterable[ConsolidatedRow],
|
||
member_companies: Sequence[IntercompanyMatrixCompanyOut],
|
||
selection: MatrixSelection,
|
||
) -> IntercompanyMatrixOut:
|
||
"""Суммы по парам компаний;
|
||
|
||
Сторона 1: дебет ОСВ компании A по контрагенту Б.
|
||
Сторона 2: кредит ОСВ компании Б по контрагенту A.
|
||
"""
|
||
if selection.measure not in ("closing", "turnover"):
|
||
raise ValueError(f"неизвестный показатель правила: {selection.measure}")
|
||
|
||
company_info = tuple({company.id: company for company in member_companies}.values())
|
||
company_ids = tuple(company.id for company in company_info)
|
||
members = set(company_ids)
|
||
amounts_1: dict[tuple[uuid.UUID, uuid.UUID], dict] = defaultdict(lambda: {"amount": ZERO, "entry_osv_ids": set()})
|
||
amounts_2: dict[tuple[uuid.UUID, uuid.UUID], dict] = defaultdict(lambda: {"amount": ZERO, "entry_osv_ids": set()})
|
||
skipped_entries: dict[tuple[uuid.UUID, uuid.UUID], int] = defaultdict(int)
|
||
|
||
for row in rows:
|
||
owner = row.legal_entity_id
|
||
counterparty = row.counterparty_id
|
||
if owner not in members or counterparty not in members or owner == counterparty:
|
||
continue
|
||
pair = (owner, counterparty)
|
||
if row.account_id is None:
|
||
skipped_entries[pair] += len(row.entry_ids)
|
||
continue
|
||
if row.account_id in selection.company_1_account_ids:
|
||
item = amounts_1[pair]
|
||
item["amount"] += _get_amount(row, selection.measure, "D")
|
||
item["entry_osv_ids"].update(row.entry_ids)
|
||
if row.account_id in selection.company_2_account_ids:
|
||
item = amounts_2[pair]
|
||
item["amount"] += _get_amount(row, selection.measure, "K")
|
||
item["entry_osv_ids"].update(row.entry_ids)
|
||
|
||
cells: list[IntercompanyMatrixCellOut] = []
|
||
for company_a in company_ids:
|
||
for company_b in company_ids:
|
||
if company_a == company_b:
|
||
continue
|
||
item_a = amounts_1.get((company_a, company_b))
|
||
item_b = amounts_2.get((company_b, company_a))
|
||
|
||
amount_a = item_a["amount"] if item_a is not None else ZERO
|
||
amount_b = item_b["amount"] if item_b is not None else ZERO
|
||
|
||
variance_amount = abs(amount_a - amount_b)
|
||
base = min(amount_a, amount_b)
|
||
variance_percent = None if base == ZERO else variance_amount / base * 100
|
||
exceeds_threshold = variance_percent is not None and variance_percent > VARIANCE_THRESHOLD_PERCENT
|
||
debit_ids = item_a["entry_osv_ids"] if item_a is not None else ()
|
||
credit_ids = item_b["entry_osv_ids"] if item_b is not None else ()
|
||
cells.append(
|
||
IntercompanyMatrixCellOut(
|
||
company_a_id=company_a,
|
||
company_b_id=company_b,
|
||
amount_a=amount_a,
|
||
amount_b=amount_b,
|
||
variance_amount=variance_amount,
|
||
variance_percent=variance_percent,
|
||
exceeds_threshold=exceeds_threshold,
|
||
one_sided=bool(debit_ids) != bool(credit_ids),
|
||
skipped_osv_entry_count=skipped_entries[(company_a, company_b)]
|
||
+ skipped_entries[(company_b, company_a)],
|
||
osv_entry_ids_debit=debit_ids,
|
||
osv_entry_ids_credit=credit_ids,
|
||
)
|
||
)
|
||
return IntercompanyMatrixOut(companies=list(company_info), cells=cells)
|
||
|
||
|
||
def get_unpaired_rows(rows: Iterable[ConsolidatedRow], matrix: IntercompanyMatrixOut) -> list[ConsolidatedRow]:
|
||
"""Строки свода, попавшие только на одну сторону пары компаний в шахматке."""
|
||
entry_ids = {
|
||
entry_id
|
||
for cell in matrix.cells
|
||
if cell.one_sided
|
||
for entry_id in (*cell.osv_entry_ids_debit, *cell.osv_entry_ids_credit)
|
||
}
|
||
return [row for row in rows if entry_ids.intersection(row.entry_ids)]
|
||
|
||
|
||
def _make_account_tree(
|
||
accounts: Sequence[ChartOfAccount],
|
||
) -> tuple[dict[uuid.UUID, ChartOfAccount], dict[uuid.UUID, list[uuid.UUID]]]:
|
||
by_id = {account.id: account for account in accounts}
|
||
children: dict[uuid.UUID, list[uuid.UUID]] = defaultdict(list)
|
||
for account in accounts:
|
||
if account.parent_id is not None:
|
||
children[account.parent_id].append(account.id)
|
||
return by_id, children
|
||
|
||
|
||
def _expand(roots: Iterable[uuid.UUID], children: dict[uuid.UUID, list[uuid.UUID]]) -> set[uuid.UUID]:
|
||
"""Выбранные счета и все их потомки."""
|
||
result: set[uuid.UUID] = set()
|
||
pending = list(roots)
|
||
while pending:
|
||
account_id = pending.pop()
|
||
if account_id not in result:
|
||
result.add(account_id)
|
||
pending.extend(children.get(account_id, ()))
|
||
return result
|
||
|
||
|
||
def _get_kinds(side: Side) -> frozenset[AccountKind]:
|
||
return DEBIT_KINDS if side == "D" else CREDIT_KINDS
|
||
|
||
|
||
def _select_accounts(
|
||
included: Iterable[uuid.UUID],
|
||
excluded: Iterable[uuid.UUID],
|
||
side: Side,
|
||
by_id: dict[uuid.UUID, ChartOfAccount],
|
||
children: dict[uuid.UUID, list[uuid.UUID]],
|
||
) -> frozenset[uuid.UUID]:
|
||
roots = set(included)
|
||
missing = roots - by_id.keys()
|
||
if missing:
|
||
missing_ids = ", ".join(map(str, sorted(missing)))
|
||
raise DomainValidationError("ACCOUNT_NOT_FOUND", f"Выбранного счёта нет ({missing_ids})")
|
||
if any(by_id[account_id].kind not in _get_kinds(side) for account_id in roots):
|
||
raise DomainValidationError("ACCOUNT_KIND_MISMATCH", "Сторона счёта не соответствует направлению расчёта")
|
||
selected = _expand(roots, children) - _expand(excluded, children)
|
||
return frozenset(account_id for account_id in selected if by_id[account_id].kind in _get_kinds(side))
|
||
|
||
|
||
def calculate_result_table(
|
||
rows: Iterable[ConsolidatedRow],
|
||
member_companies: Sequence[IntercompanyMatrixCompanyOut],
|
||
accounts: Sequence[ChartOfAccount],
|
||
) -> IntercompanyResultTableOut:
|
||
"""Фиксированные статьи ВГО: сальдо по всем контрагентам периметра для каждого ЮЛ."""
|
||
by_id, children = _make_account_tree(accounts)
|
||
by_code = {account.code: account.id for account in accounts}
|
||
required_codes = {code for article in RESULT_ARTICLES for code in (*article.account_codes, *article.excluded_codes)}
|
||
missing = sorted(required_codes - by_code.keys())
|
||
if missing:
|
||
raise DomainValidationError("RESULT_ACCOUNTS_MISSING", f"В плане счетов нет счетов: {', '.join(missing)}")
|
||
|
||
selected = {
|
||
article.code: _select_accounts(
|
||
(by_code[code] for code in article.account_codes),
|
||
(by_code[code] for code in article.excluded_codes),
|
||
article.side,
|
||
by_id,
|
||
children,
|
||
)
|
||
for article in RESULT_ARTICLES
|
||
}
|
||
companies = tuple({company.id: company for company in member_companies}.values())
|
||
member_ids = {company.id for company in companies}
|
||
amounts: dict[str, dict[uuid.UUID, Decimal]] = {
|
||
article.code: defaultdict(lambda: ZERO) for article in RESULT_ARTICLES
|
||
}
|
||
for row in rows:
|
||
if (
|
||
row.legal_entity_id not in member_ids
|
||
or row.counterparty_id not in member_ids
|
||
or row.legal_entity_id == row.counterparty_id
|
||
or row.account_id is None
|
||
):
|
||
continue
|
||
for article in RESULT_ARTICLES:
|
||
if row.account_id in selected[article.code]:
|
||
amounts[article.code][row.legal_entity_id] += _get_amount(row, "closing", article.side)
|
||
|
||
articles: list[IntercompanyResultArticleOut] = []
|
||
for article in RESULT_ARTICLES:
|
||
company_amounts = [
|
||
IntercompanyResultAmountOut(company_id=company.id, amount=amounts[article.code].get(company.id, ZERO))
|
||
for company in companies
|
||
]
|
||
articles.append(
|
||
IntercompanyResultArticleOut(
|
||
code=article.code,
|
||
name=article.name,
|
||
amounts=company_amounts,
|
||
group_total=sum((item.amount for item in company_amounts), ZERO),
|
||
)
|
||
)
|
||
return IntercompanyResultTableOut(companies=list(companies), articles=articles)
|
||
|
||
|
||
class IntercompanyMatrixService:
|
||
"""Получает свод и периметр версии, затем вызывает чистый расчёт матрицы."""
|
||
|
||
def __init__(self, session: AsyncSession, scope: VisibilityScope, service_osv: ConsolidatedOsvService) -> None:
|
||
self._session = session
|
||
self._scope = scope
|
||
self._osv_service = service_osv
|
||
|
||
async def build(
|
||
self,
|
||
version_id: uuid.UUID,
|
||
selection: MatrixSelection,
|
||
) -> IntercompanyMatrixOut:
|
||
"""Счета принимает готовыми; выбор режима ALL/MANUAL выполняется вызывающим кодом."""
|
||
result = await self._osv_service.build_with_members(version_id)
|
||
return self._calculate(result.consolidated.rows, result.members, selection)
|
||
|
||
def _calculate(
|
||
self, rows: Iterable[ConsolidatedRow], members: dict[uuid.UUID, Entity], selection: MatrixSelection
|
||
) -> IntercompanyMatrixOut:
|
||
"""Общий вызов расчёта с участниками из уже построенного свода."""
|
||
companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in members.values()]
|
||
return calculate_matrix(rows, companies, selection)
|
||
|
||
async def build_result_table(self, version_id: uuid.UUID) -> IntercompanyResultTableOut:
|
||
"""Статичная таблица по пяти статьям"""
|
||
result = await self._osv_service.build_with_members(version_id)
|
||
accounts = await directory_repo.list_accounts(self._session)
|
||
companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in result.members.values()]
|
||
return calculate_result_table(result.consolidated.rows, companies, accounts)
|
||
|
||
async def build_accounts(
|
||
self,
|
||
version_id: uuid.UUID,
|
||
*,
|
||
mode: Literal["ALL", "MANUAL", "PRESET"],
|
||
debit_account_ids: Sequence[uuid.UUID] = (),
|
||
credit_account_ids: Sequence[uuid.UUID] = (),
|
||
rule_id: uuid.UUID | None = None,
|
||
) -> IntercompanyMatrixOut:
|
||
"""Построить шахматку по выбранному режиму.
|
||
|
||
ALL включает все счета соответствующей стороны; MANUAL — выбранные счета и их потомков;
|
||
PRESET берёт счета и показатель из правила с идентификатором `rule_id`.
|
||
"""
|
||
result = await self._build_accounts(
|
||
version_id,
|
||
mode=mode,
|
||
debit_account_ids=debit_account_ids,
|
||
credit_account_ids=credit_account_ids,
|
||
rule_id=rule_id,
|
||
)
|
||
return result.matrix
|
||
|
||
async def build_export(
|
||
self,
|
||
version_id: uuid.UUID,
|
||
*,
|
||
mode: Literal["ALL", "MANUAL", "PRESET"],
|
||
debit_account_ids: Sequence[uuid.UUID] = (),
|
||
credit_account_ids: Sequence[uuid.UUID] = (),
|
||
rule_id: uuid.UUID | None = None,
|
||
) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]:
|
||
"""Матрица и реквизиты книги из одного расчёта свода."""
|
||
result = await self._build_accounts(
|
||
version_id,
|
||
mode=mode,
|
||
debit_account_ids=debit_account_ids,
|
||
credit_account_ids=credit_account_ids,
|
||
rule_id=rule_id,
|
||
)
|
||
group_name = (
|
||
await self._session.execute(
|
||
select(CompanyGroup.name).where(CompanyGroup.id == result.consolidation.company_group_id)
|
||
)
|
||
).scalar_one()
|
||
by_id = {account.id: account.code for account in result.accounts}
|
||
if mode == "PRESET":
|
||
debit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_1_account_ids))
|
||
credit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_2_account_ids))
|
||
else:
|
||
debit_codes = tuple(by_id[account_id] for account_id in debit_account_ids)
|
||
credit_codes = tuple(by_id[account_id] for account_id in credit_account_ids)
|
||
info = MatrixExportInfo(
|
||
group_name=group_name,
|
||
year=result.consolidation.year,
|
||
quarter=result.consolidation.quarter,
|
||
version_no=result.version.version_no,
|
||
mode=mode,
|
||
debit_account_codes=debit_codes,
|
||
credit_account_codes=credit_codes,
|
||
measure=result.selection.measure,
|
||
rule_id=result.rule.id if result.rule is not None else None,
|
||
rule_no=result.rule.rule_no if result.rule is not None else None,
|
||
rule_version_no=result.rule.version_no if result.rule is not None else None,
|
||
)
|
||
return result.matrix, info
|
||
|
||
async def get_unpaired_osv(self, version_id: uuid.UUID, rule_id: uuid.UUID) -> UnpairedOsvOut:
|
||
"""Все строки свода без встречной стороны в шахматке заданного пресета."""
|
||
result = await self._build_accounts(
|
||
version_id, mode="PRESET", debit_account_ids=(), credit_account_ids=(), rule_id=rule_id
|
||
)
|
||
unmatched = get_unpaired_rows(result.rows, result.matrix)
|
||
return UnpairedOsvOut(items=[row_out(row) for row in unmatched], total=len(unmatched))
|
||
|
||
async def _resolve_preset_selection(
|
||
self,
|
||
rule_id: uuid.UUID | None,
|
||
version: ConsolidationVersion,
|
||
by_id: dict[uuid.UUID, ChartOfAccount],
|
||
children: dict[uuid.UUID, list[uuid.UUID]],
|
||
) -> tuple[MatrixSelection, EliminationRule]:
|
||
"""Выбрать счета и показатель пресета для версии консолидации."""
|
||
if rule_id is None:
|
||
raise DomainValidationError("RULE_REQUIRED", "Выберите правило-пресет")
|
||
rule, rule_accounts = await elimination_rules_repo.rule_with_account(self._session, rule_id)
|
||
if rule is None or not elimination_rules_repo.is_valid_for_version(rule, version.elimination_rules_version):
|
||
raise NotFoundError("правило-пресет не найдено для версии консолидации")
|
||
if version.elimination_rules_version is None:
|
||
open_version_no = await elimination_rules_repo.current_open_version_no(self._session)
|
||
if rule.version_no != open_version_no:
|
||
raise NotFoundError("правило-пресет не найдено для версии консолидации")
|
||
if rule.statement_type != StatementType.balance:
|
||
raise DomainValidationError("INVALID_RULE", "Шахматка PRESET поддерживает только правила ББ")
|
||
if rule.side_1 not in ("D", "K") or rule.measure not in ("closing", "turnover"):
|
||
raise DomainValidationError("INVALID_RULE", "Неверное направление или показатель правила-пресета")
|
||
|
||
sides: dict[int, tuple[list[uuid.UUID], list[uuid.UUID]]] = {
|
||
1: ([], []),
|
||
2: ([], []),
|
||
}
|
||
for rule_account in rule_accounts:
|
||
if rule_account.company_no not in sides:
|
||
raise DomainValidationError("INVALID_RULE", "Неверная сторона счёта в правиле-пресете")
|
||
included, excluded = sides[rule_account.company_no]
|
||
(excluded if rule_account.is_excluded else included).append(rule_account.account_id)
|
||
debit_side = 1 if rule.side_1 == "D" else 2
|
||
credit_side = 2 if debit_side == 1 else 1
|
||
debit_ids, debit_excluded = sides[debit_side]
|
||
credit_ids, credit_excluded = sides[credit_side]
|
||
if not debit_ids or not credit_ids:
|
||
raise DomainValidationError("ACCOUNTS_REQUIRED", "В правиле-пресете нужны счета обеих сторон")
|
||
debit = _select_accounts(debit_ids, debit_excluded, "D", by_id, children)
|
||
credit = _select_accounts(credit_ids, credit_excluded, "K", by_id, children)
|
||
measure: Measure = "closing" if rule.measure == "closing" else "turnover"
|
||
|
||
return MatrixSelection(debit, credit, measure), rule
|
||
|
||
async def _build_accounts(
|
||
self,
|
||
version_id: uuid.UUID,
|
||
*,
|
||
mode: Literal["ALL", "MANUAL", "PRESET"],
|
||
debit_account_ids: Sequence[uuid.UUID],
|
||
credit_account_ids: Sequence[uuid.UUID],
|
||
rule_id: uuid.UUID | None = None,
|
||
) -> MatrixBuildResult:
|
||
osv_result = await self._osv_service.build_with_members(version_id)
|
||
rows = osv_result.consolidated.rows
|
||
accounts = await directory_repo.list_accounts(self._session)
|
||
by_id, children = _make_account_tree(accounts)
|
||
rule: EliminationRule | None = None
|
||
if mode == "ALL":
|
||
if debit_account_ids or credit_account_ids:
|
||
raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для режима ALL не нужно указывать счета")
|
||
debit = frozenset(account.id for account in accounts if account.kind in DEBIT_KINDS)
|
||
credit = frozenset(account.id for account in accounts if account.kind in CREDIT_KINDS)
|
||
selection = MatrixSelection(debit, credit, "closing")
|
||
elif mode == "MANUAL":
|
||
if not debit_account_ids or not credit_account_ids:
|
||
raise DomainValidationError("ACCOUNTS_REQUIRED", "Выберите дебетовые и кредитовые счета")
|
||
debit = _select_accounts(debit_account_ids, (), "D", by_id, children)
|
||
credit = _select_accounts(credit_account_ids, (), "K", by_id, children)
|
||
selection = MatrixSelection(debit, credit, "closing")
|
||
elif mode == "PRESET":
|
||
selection, rule = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children)
|
||
else:
|
||
raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов")
|
||
matrix = self._calculate(rows, osv_result.members, selection)
|
||
return MatrixBuildResult(
|
||
matrix=matrix,
|
||
version=osv_result.version,
|
||
consolidation=osv_result.consolidation,
|
||
accounts=accounts,
|
||
selection=selection,
|
||
rows=rows,
|
||
rule=rule,
|
||
)
|