309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""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"
|