From 254002f72308cbbeeccf4bacdd87ae6999843b59 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Thu, 22 Oct 2026 16:58:19 +0300 Subject: [PATCH] =?UTF-8?q?=D1=82=D0=B5=D1=85=20=D0=B4=D0=BE=D0=BB=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/api/endpoints/directories/accounts.py | 18 ++++-- api/src/repositories/directories.py | 19 +++++- api/src/schemas/directories.py | 8 +++ api/src/services/intercompany_matrix.py | 12 ++-- .../services/intercompany_matrix_export.py | 2 +- api/tests/test_api_accounts.py | 54 ++++++++++++++++- api/tests/test_api_intercompany_matrix.py | 24 +++++++- api/tests/test_intercompany_matrix.py | 59 ++++++++++++++++--- api/tests/test_intercompany_matrix_export.py | 33 +++++++++++ 9 files changed, 207 insertions(+), 22 deletions(-) diff --git a/api/src/api/endpoints/directories/accounts.py b/api/src/api/endpoints/directories/accounts.py index eb0b912..410aa18 100644 --- a/api/src/api/endpoints/directories/accounts.py +++ b/api/src/api/endpoints/directories/accounts.py @@ -1,11 +1,13 @@ """Плоский список счетов плана счетов для выбора статьи расходов.""" -from fastapi import APIRouter, Depends +from typing import Annotated + +from fastapi import APIRouter, Depends, Query from src.api.dependencies.auth import require from src.api.dependencies.db import SessionDep from src.repositories import directories as repo -from src.schemas.directories import AccountOut +from src.schemas.directories import AccountFilter, AccountOut router = APIRouter() @@ -16,6 +18,12 @@ router = APIRouter() dependencies=[Depends(require("DIRECTORY_VIEW"))], summary="Список счетов плана счетов", ) -async def list_accounts(session: SessionDep) -> list[AccountOut]: - """Все счета одним списком""" - return [AccountOut.model_validate(account) for account in await repo.list_accounts(session)] +async def list_accounts( + session: SessionDep, + filters: Annotated[AccountFilter, Query()], +) -> list[AccountOut]: + """Счета одним списком с необязательными фильтрами.""" + accounts = await repo.list_accounts( + session, kind=filters.kind, row_type=filters.row_type, is_off_balance=filters.is_off_balance + ) + return [AccountOut.model_validate(account) for account in accounts] diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index a745220..817500b 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -27,15 +27,28 @@ from src.models.directories import ( RelatedGroup, RelatedGroupMember, ) -from src.models.enums import FileStatus, RelatedGroupKind +from src.models.enums import AccountKind, AccountRowType, FileStatus, RelatedGroupKind from src.models.files import Document, StoredFile LIKE_ESCAPE = "\\" ALIAS_LOCK_PREFIX = "legal-entity-alias" # пространство ключей advisory-блокировки алиасов -async def list_accounts(session: AsyncSession) -> list[ChartOfAccount]: - accounts = (await session.scalars(select(ChartOfAccount))).all() +async def list_accounts( + session: AsyncSession, + *, + kind: list[AccountKind] | None = None, + row_type: AccountRowType | None = None, + is_off_balance: bool | None = None, +) -> list[ChartOfAccount]: + stmt = select(ChartOfAccount) + if kind is not None: + stmt = stmt.where(ChartOfAccount.kind.in_(kind)) + if row_type is not None: + stmt = stmt.where(ChartOfAccount.row_type == row_type) + if is_off_balance is not None: + stmt = stmt.where(ChartOfAccount.is_off_balance.is_(is_off_balance)) + accounts = (await session.scalars(stmt)).all() return sorted(accounts, key=lambda account: (account_sort_key(account.code), account.code)) diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index 39636dd..8885d00 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -38,6 +38,14 @@ class RegionalOfficeOut(RegionalOfficeBrief): is_active: bool +class AccountFilter(BaseModel): + """Необязательные фильтры списка счетов.""" + + kind: list[AccountKind] | None = None + row_type: AccountRowType | None = None + is_off_balance: bool | None = None + + class AccountOut(BaseModel): """Одна строка плана счетов; parent_id сохраняет иерархию в плоском списке.""" diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index bbc0014..56c41fd 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -185,7 +185,7 @@ def calculate_matrix( variance_amount=variance_amount, variance_percent=variance_percent, exceeds_threshold=exceeds_threshold, - one_sided=bool(debit_ids) != bool(credit_ids), + one_sided=min(amount_a, amount_b) == 0 and variance_amount > 0, skipped_osv_entry_count=skipped_entries[(company_a, company_b)] + skipped_entries[(company_b, company_a)], osv_entry_ids_debit=debit_ids, @@ -360,7 +360,7 @@ class IntercompanyMatrixService: debit_account_ids: Sequence[uuid.UUID] = (), credit_account_ids: Sequence[uuid.UUID] = (), ) -> IntercompanyMatrixOut: - """ALL использует все счета нужного вида; MANUAL раскрывает выбранные счета и потомков.""" + """ALL использует балансовые счета нужного вида; MANUAL раскрывает выбранные счета и потомков.""" matrix, _, _, _ = await self._build_accounts( version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids ) @@ -404,8 +404,12 @@ class IntercompanyMatrixService: 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) + debit = frozenset( + account.id for account in accounts if not account.is_off_balance and account.kind in DEBIT_KINDS + ) + credit = frozenset( + account.id for account in accounts if not account.is_off_balance and account.kind in CREDIT_KINDS + ) elif mode == "MANUAL": if not debit_account_ids or not credit_account_ids: raise DomainValidationError("ACCOUNTS_REQUIRED", "Выберите дебетовые и кредитовые счета") diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index 5e91e55..4a262c3 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -120,7 +120,7 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) if cell is None: continue _write_values(sheet, start_row, column, cell) - if cell.exceeds_threshold: + if cell.exceeds_threshold or cell.one_sided: for offset in range(len(METRICS)): marked = sheet.cell(start_row + offset, column) marked.fill = THRESHOLD_FILL diff --git a/api/tests/test_api_accounts.py b/api/tests/test_api_accounts.py index 54c8497..cb5bca5 100644 --- a/api/tests/test_api_accounts.py +++ b/api/tests/test_api_accounts.py @@ -4,9 +4,14 @@ import uuid from types import SimpleNamespace from unittest.mock import AsyncMock +import pytest + +from src.api.endpoints.directories import accounts as accounts_endpoint from src.api.endpoints.directories.accounts import list_accounts +from src.app import app from src.models.enums import AccountKind, AccountRowType from src.repositories.directories import list_accounts as query_accounts +from src.schemas.directories import AccountFilter async def test_accounts_are_flat_and_naturally_sorted() -> None: @@ -43,10 +48,57 @@ async def test_accounts_are_flat_and_naturally_sorted() -> None: session = SimpleNamespace(scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: rows))) assert [item.code for item in await query_accounts(session)] == ["60", "60.2", "60.10"] - response = await list_accounts(session) + response = await list_accounts(session, AccountFilter()) assert [item.code for item in response] == ["60", "60.2", "60.10"] assert response[0].parent_id is None assert response[2].parent_id == root_id assert response[2].id == sub_id assert [item.model_dump(mode="json")["is_debit"] for item in response] == [True, False, True] assert [item.model_dump(mode="json")["is_credit"] for item in response] == [True, True, False] + + +@pytest.mark.parametrize( + ("filters", "expected_columns"), + [ + ({}, set()), + ({"kind": [AccountKind.A]}, {"kind"}), + ({"kind": [AccountKind.A, AccountKind.AP]}, {"kind"}), + ({"row_type": AccountRowType.analytic}, {"row_type"}), + ({"is_off_balance": False}, {"is_off_balance"}), + ( + {"kind": [AccountKind.AP, AccountKind.P], "row_type": AccountRowType.account, "is_off_balance": True}, + {"kind", "row_type", "is_off_balance"}, + ), + ], +) +async def test_account_filters_apply_only_when_supplied(filters: dict, expected_columns: set[str]) -> None: + session = SimpleNamespace(scalars=AsyncMock(return_value=SimpleNamespace(all=list))) + + assert await query_accounts(session, **filters) == [] + statement = session.scalars.await_args.args[0] + where_clause = str(statement.whereclause) if statement.whereclause is not None else "" + for column in ("kind", "row_type", "is_off_balance"): + assert (f"chart_of_account.{column} " in where_clause) == (column in expected_columns) + if "kind" in filters: + assert "chart_of_account.kind IN" in where_clause + assert statement.compile().params["kind_1"] == filters["kind"] + + +async def test_account_endpoint_forwards_all_filters(monkeypatch: pytest.MonkeyPatch) -> None: + query = AsyncMock(return_value=[]) + monkeypatch.setattr(accounts_endpoint.repo, "list_accounts", query) + session = SimpleNamespace() + + filters = AccountFilter(kind=[AccountKind.AP, AccountKind.P], row_type=AccountRowType.account, is_off_balance=False) + assert await list_accounts(session, filters) == [] + query.assert_awaited_once_with( + session, kind=[AccountKind.AP, AccountKind.P], row_type=AccountRowType.account, is_off_balance=False + ) + + +def test_account_filters_are_optional_query_parameters() -> None: + parameters = app.openapi()["paths"]["/api/v1/directories/accounts"]["get"]["parameters"] + assert {item["name"] for item in parameters} == {"kind", "row_type", "is_off_balance"} + assert all(item["in"] == "query" and item["required"] is False for item in parameters) + kind = next(item for item in parameters if item["name"] == "kind") + assert kind["schema"]["anyOf"][0]["type"] == "array" diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 950e7a7..1948a48 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -138,6 +138,28 @@ async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seed assert cell["skipped_osv_entry_count"] == 0 +async def test_all_excludes_off_balance_accounts_but_manual_can_select_them( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + world = await _world(seeded) + assert world.accounts["001"].is_off_balance is True + await _source(seeded, world, credit=False, amount="100", account="001") + client = await auth_client(KA_RF) + + all_response = await _request(client, world.version.id, "all", world.selection) + assert all_response.status_code == OK, all_response.text + assert all(Decimal(cell["amount_a"]) == 0 for cell in all_response.json()["cells"]) + + manual_selection = { + "debit_account_ids": [str(world.accounts["001"].id)], + "credit_account_ids": [str(world.accounts["60.01"].id)], + } + manual_response = await _request(client, world.version.id, "manual", manual_selection) + assert manual_response.status_code == OK, manual_response.text + cell = next(c for c in manual_response.json()["cells"] if c["company_a_id"] == str(world.members[0].id)) + assert Decimal(cell["amount_a"]) == Decimal("100") + + async def test_unmapped_account_source_is_visible_in_cell_count( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: @@ -170,7 +192,7 @@ async def test_pair_without_counterpart( cell = next(c for c in response.json()["cells"] if c["company_a_id"] == str(world.members[0].id)) assert Decimal(cell["amount_a"]) == (Decimal(0) if credit else Decimal(amount)) assert Decimal(cell["amount_b"]) == (Decimal(amount) if credit else Decimal(0)) - assert cell["one_sided"] is True + assert cell["one_sided"] is (Decimal(amount) != 0) assert cell["variance_percent"] is None assert cell["exceeds_threshold"] is False assert bool(cell["osv_entry_ids_credit"]) is credit diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 971a135..dcd6227 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -3,6 +3,7 @@ import uuid from decimal import Decimal from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -84,7 +85,50 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: ] -def test_one_sided_reflects_osv_sources_even_when_amount_is_zero() -> None: +async def test_all_excludes_off_balance_accounts_only(monkeypatch: pytest.MonkeyPatch) -> None: + company_a, company_b = uuid.uuid4(), uuid.uuid4() + off_balance = SimpleNamespace(id=uuid.uuid4(), kind=AccountKind.A, parent_id=None, is_off_balance=True) + off_balance_credit = SimpleNamespace(id=uuid.uuid4(), kind=AccountKind.P, parent_id=None, is_off_balance=True) + debit = SimpleNamespace(id=uuid.uuid4(), kind=AccountKind.A, parent_id=None, is_off_balance=False) + credit = SimpleNamespace(id=uuid.uuid4(), kind=AccountKind.P, parent_id=None, is_off_balance=False) + accounts = [off_balance, off_balance_credit, debit, credit] + monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=accounts)) + rows = [ + _row(company_a, company_b, off_balance.id, debit="100"), + _row(company_a, company_b, debit.id, debit="30"), + _row(company_b, company_a, off_balance_credit.id, credit="40"), + _row(company_b, company_a, credit.id, credit="20"), + ] + members = { + company_a: SimpleNamespace(id=company_a, name="Компания А"), + company_b: SimpleNamespace(id=company_b, name="Компания Б"), + } + osv_service = SimpleNamespace( + build_with_members=AsyncMock(return_value=(SimpleNamespace(rows=rows), None, None, members)) + ) + service = matrix.IntercompanyMatrixService(SimpleNamespace(), None, osv_service) + + all_matrix = await service.build_accounts(uuid.uuid4(), mode="ALL") + manual_matrix = await service.build_accounts( + uuid.uuid4(), mode="MANUAL", debit_account_ids=[off_balance.id], credit_account_ids=[off_balance_credit.id] + ) + assert all_matrix.cells[0].amount_a == Decimal("30") + assert all_matrix.cells[0].amount_b == Decimal("20") + assert manual_matrix.cells[0].amount_a == Decimal("100") + assert manual_matrix.cells[0].amount_b == Decimal("40") + + +@pytest.mark.parametrize( + ("debit_amount", "credit_amount", "expected"), + [ + ("100000000", "0", True), + ("0", "100000000", True), + ("0", "0", False), + ], +) +def test_one_sided_uses_amounts_even_with_zero_balance_rows( + debit_amount: str, credit_amount: str, expected: bool +) -> None: company_a, company_b = uuid.uuid4(), uuid.uuid4() debit_account, credit_account = uuid.uuid4(), uuid.uuid4() companies = [ @@ -92,15 +136,16 @@ def test_one_sided_reflects_osv_sources_even_when_amount_is_zero() -> None: IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"), ] selection = matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing") - debit = _row(company_a, company_b, debit_account) - credit = _row(company_b, company_a, credit_account) + debit = _row(company_a, company_b, debit_account, debit=debit_amount) + credit = _row(company_b, company_a, credit_account, credit=credit_amount) debit.entry_ids = (uuid.uuid4(),) credit.entry_ids = (uuid.uuid4(),) - for rows, expected in (([], False), ([debit], True), ([credit], True), ([debit, credit], False)): - cell = matrix.calculate_matrix(rows, companies, selection).cells[0] - assert cell.one_sided is expected - assert cell.model_dump(mode="json")["one_sided"] is expected + cell = matrix.calculate_matrix([debit, credit], companies, selection).cells[0] + assert cell.one_sided is expected + assert cell.exceeds_threshold is False + assert cell.variance_percent is None + assert cell.model_dump(mode="json")["one_sided"] is expected def test_unmapped_account_entries_are_counted_for_both_directions_of_pair() -> None: diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index 9b43dbd..09fd0bd 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -142,6 +142,39 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold ) +@pytest.mark.parametrize(("amount", "highlighted"), [("100000000", True), ("0", False)]) +def test_xlsx_highlights_one_sided_amount_but_not_two_zero_balances(amount: str, highlighted: bool) -> None: + company_a = UUID("11111111-1111-1111-1111-111111111111") + company_b = UUID("22222222-2222-2222-2222-222222222222") + matrix = IntercompanyMatrixOut( + companies=[ + IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"), + IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"), + ], + cells=[ + IntercompanyMatrixCellOut( + company_a_id=company_a, + company_b_id=company_b, + amount_a=amount, + amount_b="0", + variance_amount=amount, + variance_percent=None if highlighted else "0", + exceeds_threshold=False, + one_sided=highlighted, + skipped_osv_entry_count=0, + osv_entry_ids_debit=[], + osv_entry_ids_credit=[], + ) + ], + ) + + sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, _info(mode="ALL")))).active + assert sheet is not None + assert all( + (sheet.cell(row, 4).fill.fgColor.rgb == THRESHOLD_FILL.fgColor.rgb) is highlighted for row in range(11, 15) + ) + + async def test_both_export_routes_use_matrix_calculation() -> None: version_id = UUID("11111111-1111-1111-1111-111111111111") account_id = UUID("22222222-2222-2222-2222-222222222222")