тех долг

This commit is contained in:
PotapovaA 2026-10-22 16:58:19 +03:00
parent 63fa528c48
commit 254002f723
9 changed files with 207 additions and 22 deletions

View File

@ -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]

View File

@ -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))

View File

@ -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 сохраняет иерархию в плоском списке."""

View File

@ -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", "Выберите дебетовые и кредитовые счета")

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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:

View File

@ -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")