arm-core/api/tests/test_api_corrections.py

309 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""API таблицы и экспорта: реальные суммы ОСВ, права, видимость и отсутствие записи на GET."""
import datetime
import uuid
from decimal import Decimal
from io import BytesIO
from pathlib import Path
import pytest
from fastapi import FastAPI
from openpyxl import load_workbook
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.consolidations import get_correction_source
from src.api.endpoints.consolidations.corrections import _export_filename
from src.models.accounting import ChartOfAccount, EliminationRule
from src.models.audit_ex import AuditEvent
from src.models.consolidation import ConsolidationDocument
from src.models.enums import AuditAction, ConsolidationStatus, CorrectionStatus, PeriodQuarter, StatementType
from src.models.kfo import Correction
from src.models.osv import OsvEntry
from src.repositories.corrections import vgo_assignment
from src.schemas.corrections import CorrectionAggregateOut
from src.services.correction_source import CorrectionSourceTable
from tests.conftest import AuthClientFactory
from tests.factories import make_confirmed_osv
from tests.test_api_consolidated_osv import FOREIGN, KA_RF, _world
from tests.test_intercompany_matrix_golden import _source
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
HTTP_OK, HTTP_NOT_FOUND, HTTP_FORBIDDEN = 200, 404, 403
TRADE_RULE_NO = 6
class MutableCorrectionSource:
def __init__(self) -> None:
self.tables: dict[StatementType, CorrectionSourceTable] = {}
async def table(self, version_id, statement_type):
del version_id
table = self.tables.get(statement_type, CorrectionSourceTable())
return CorrectionSourceTable(
rules=[rule.model_copy(deep=True) for rule in table.rules],
aggregates=[aggregate.model_copy(deep=True) for aggregate in table.aggregates],
)
@pytest.fixture
def correction_source(app: FastAPI):
source = MutableCorrectionSource()
app.dependency_overrides[get_correction_source] = lambda: source
yield source
app.dependency_overrides.pop(get_correction_source, None)
async def configure_trade_source(
source: MutableCorrectionSource, session: AsyncSession, world, fixtures_dir: Path
) -> None:
rule = (
await session.scalars(
select(EliminationRule).where(
EliminationRule.rule_no == TRADE_RULE_NO,
EliminationRule.statement_type == StatementType.balance,
EliminationRule.version_no == 1,
)
)
).one()
amount = Decimal(_source(fixtures_dir, "schastl", "62", 21)["closing_debit"])
def entry(owner, other, account, side, entry_id):
return dict(
entry_ids=[uuid.UUID(int=entry_id)],
legal_entity_id=owner.id,
counterparty_id=other.id,
account_code=account,
side=side,
amount=amount,
)
aggregate = CorrectionAggregateOut.model_validate(
dict(
rule_id=rule.id,
rule_no=rule.rule_no,
company_1=dict(id=world.a.id, name=world.a.name),
company_2=dict(id=world.b.id, name=world.b.name),
side_1=dict(
article_line="1230",
accounts=["62.01"],
amount=amount,
entries=[entry(world.a, world.b, "62.01", "D", 1)],
),
side_2=dict(
article_line="1520",
accounts=["60.01"],
amount=amount,
entries=[entry(world.b, world.a, "60.01", "K", 2)],
),
deviation_amount=0,
deviation_percent=0,
exceeds_threshold=False,
status="AUTO",
eliminated_amount=amount,
correction=None,
)
)
source.tables[StatementType.balance] = CorrectionSourceTable(aggregates=[aggregate])
async def real_trade(session: AsyncSession, fixtures_dir: Path):
world = await _world(session)
await session.execute(
delete(ConsolidationDocument).where(ConsolidationDocument.consolidation_version_id == world.version.id)
)
for owner, other, folder, root, number, side in (
(world.a, world.b, "schastl", "62", 21, "closing_debit"),
(world.b, world.a, "rb", "60", 409, "closing_credit"),
):
row = _source(fixtures_dir, folder, root, number)
account = (
await session.scalars(select(ChartOfAccount).where(ChartOfAccount.code == row["account_code_raw"]))
).one()
document = await make_confirmed_osv(
session,
entity=owner,
account=account.code,
year=2024,
quarter=PeriodQuarter.Q3,
is_cumulative=True,
rows=[
dict(
name=other.name,
inn=other.inn,
account_id=account.id,
counterparty_id=other.id,
**{side: Decimal(row[side])},
)
],
)
session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id))
await session.flush()
return world
async def test_read_and_export_real_trade(
auth_client: AuthClientFactory,
seeded: AsyncSession,
fixtures_dir: Path,
correction_source: MutableCorrectionSource,
) -> None:
world = await real_trade(seeded, fixtures_dir)
await configure_trade_source(correction_source, seeded, world, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/consolidations/versions/{world.version.id}/corrections"
response = await client.get(url)
assert response.status_code == HTTP_OK, response.text
body = response.json()
assert len(body["aggregates"]) == 1
aggregate = body["aggregates"][0]
assert aggregate["rule_no"] == 6 # noqa: PLR2004 - правило реальной торговой пары
assert aggregate["side_1"]["amount"] == aggregate["side_2"]["amount"] == "106311116.46"
assert aggregate["eliminated_amount"] == "106311116.46"
assert aggregate["correction"] is None
assert body["read_only_reason"] == "LOCK_REQUIRED"
assert await seeded.scalar(select(func.count()).select_from(Correction)) == 0
assert (
await seeded.scalar(select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))
== 0
)
exported = await client.get(url + "/export")
assert exported.status_code == HTTP_OK, exported.text
assert "attachment" in exported.headers["content-disposition"]
assert "spreadsheetml" in exported.headers["content-type"]
workbook = load_workbook(BytesIO(exported.content), data_only=True)
try:
assert Decimal(str(workbook["Корректировки"]["E2"].value)) == Decimal("106311116.46")
finally:
workbook.close()
audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
assert audit.consolidation_version_id == world.version.id
assert audit.after_value["report"] == "corrections"
income = await client.get(url, params={"statement_type": "income"})
assert income.status_code == HTTP_OK
assert income.json()["aggregates"] == []
async def test_ready_assignments_build_correction_aggregate(
auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path
) -> None:
world = await real_trade(seeded, fixtures_dir)
rule = (
await seeded.scalars(
select(EliminationRule).where(
EliminationRule.rule_no == TRADE_RULE_NO,
EliminationRule.statement_type == StatementType.balance,
EliminationRule.version_no == world.version.elimination_rules_version,
)
)
).one()
entries = list(
(
await seeded.scalars(
select(OsvEntry)
.join(ConsolidationDocument, ConsolidationDocument.document_id == OsvEntry.document_id)
.where(ConsolidationDocument.consolidation_version_id == world.version.id)
)
).all()
)
await seeded.execute(
vgo_assignment.insert(),
[
dict(
id=uuid.uuid4(),
consolidation_version_id=world.version.id,
statement_type=StatementType.balance.value,
osv_entry_id=entry.id,
rule_id=rule.id,
action="ASSIGN",
is_exclude=False,
)
for entry in entries
],
)
await seeded.flush()
response = await (await auth_client(KA_RF)).get(f"/consolidations/versions/{world.version.id}/corrections")
assert response.status_code == HTTP_OK, response.text
body = response.json()
assert len(body["aggregates"]) == 1
aggregate = body["aggregates"][0]
assert aggregate["rule_no"] == TRADE_RULE_NO
assert aggregate["side_1"]["amount"] == aggregate["side_2"]["amount"] == "106311116.46"
assert [entry["account_code"] for entry in aggregate["side_1"]["entries"]] == ["62.01"]
assert [entry["account_code"] for entry in aggregate["side_2"]["entries"]] == ["60.01"]
async def test_table_read_only_visibility_and_unknown_version(
auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path
) -> None:
world = await real_trade(seeded, fixtures_dir)
world.version.status = ConsolidationStatus.VERIFIED
await seeded.flush()
client = await auth_client(KA_RF)
url = f"/consolidations/versions/{world.version.id}/corrections"
response = await client.get(url)
assert response.status_code == HTTP_OK
assert response.json()["read_only_reason"] == "VERSION_NOT_EDITABLE"
assert response.json()["aggregates"] == []
missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/corrections")
foreign = await (await auth_client(FOREIGN)).get(url)
assert missing.status_code == foreign.status_code == HTTP_NOT_FOUND
assert missing.json() == foreign.json()
async def test_view_permission_is_required_before_visibility(auth_client: AuthClientFactory) -> None:
client = await auth_client("admin@armka.local")
for suffix in ("corrections", "corrections/export"):
response = await client.get(f"/consolidations/versions/{uuid.uuid4()}/{suffix}")
assert response.status_code == HTTP_FORBIDDEN
def test_corrections_export_has_human_readable_filename() -> None:
assert _export_filename("ГК Русский бисквит", 2024, "Q3") == ("Корректировки_ГК Русский бисквит_2024_Q3.xlsx")
async def test_existing_draft_keeps_original_amounts(
auth_client: AuthClientFactory,
seeded: AsyncSession,
fixtures_dir: Path,
correction_source: MutableCorrectionSource,
) -> None:
world = await real_trade(seeded, fixtures_dir)
await configure_trade_source(correction_source, seeded, world, fixtures_dir)
rule = (
await seeded.scalars(
select(EliminationRule).where(
EliminationRule.rule_no == TRADE_RULE_NO,
EliminationRule.statement_type == StatementType.balance,
EliminationRule.version_no == 1,
)
)
).one()
now = datetime.datetime.now(datetime.UTC)
seeded.add(
Correction(
consolidation_version_id=world.version.id,
statement_type=StatementType.balance,
rule_id=rule.id,
legal_entity_id=world.a.id,
counterparty_id=world.b.id,
debit_line_code="1230",
debit_adjustment=Decimal("42"),
comment="Черновик",
status=CorrectionStatus.DRAFT,
version_no=1,
created_at=now,
updated_at=now,
)
)
await seeded.flush()
response = await (await auth_client(KA_RF)).get(f"/consolidations/versions/{world.version.id}/corrections")
assert response.status_code == HTTP_OK, response.text
aggregate = response.json()["aggregates"][0]
assert aggregate["side_1"]["amount"] == "106311116.46"
assert aggregate["correction"]["debit"] == [{"line_code": "1230", "amount": "42.00"}]
assert aggregate["correction"]["balance"]["remainder_1"] == "106311074.46"
assert response.json()["totals"]["correction_debit"] == "0.00"