feat(consolidations): тип ЮЛ и счета ОСВ к периоду версии у участника периметра (#42)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 17:51:45 +04:00
parent 678cc4566f
commit d782637bc1
5 changed files with 150 additions and 4 deletions

View File

@ -14,6 +14,7 @@ from sqlalchemy import Select, case, exists, false, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.checklist import EntityFilesState
from src.domain.osv_mapping import account_sort_key
from src.domain.policy import VisibilityScope, company_groups_visible
from src.models.access import AppUser
from src.models.accounting import ChartOfAccount
@ -350,6 +351,36 @@ async def unconfirmed_documents(session: AsyncSession, entity_ids: Collection[uu
return list((await session.execute(stmt)).scalars())
async def osv_accounts(
session: AsyncSession, entity_ids: Collection[uuid.UUID], year: int, quarters: Collection[PeriodQuarter]
) -> dict[uuid.UUID, list[str]]:
"""Счета ОСВ, загруженные к периоду версии (колонка «ОСВ» панели [34]; front-api-gaps D-3): уникальные
`osv_account_code` документов `OSV` ЮЛ за год `year` и кварталы `quarters`; документ не `ERROR`, файл не
`SUPERSEDED`/`ERROR`, квартал и код счёта заданы; статус подтверждения и привязка к версии не важны. Порядок —
натуральный (`account_sort_key`); ЮЛ без документов в словаре нет."""
if not entity_ids:
return {}
stmt = (
select(Document.legal_entity_id, Document.osv_account_code)
.join(StoredFile, StoredFile.id == Document.stored_file_id)
.where(
Document.legal_entity_id.in_(list(entity_ids)),
Document.document_type == DocumentType.OSV,
Document.period_year == year,
Document.period_quarter.in_(list(quarters)),
Document.status != DocumentStatus.ERROR,
StoredFile.status.not_in([FileStatus.SUPERSEDED, FileStatus.ERROR]),
Document.osv_account_code.is_not(None),
Document.osv_account_code != "",
)
.distinct()
)
accounts: dict[uuid.UUID, list[str]] = defaultdict(list)
for entity_id, code in await session.execute(stmt):
accounts[entity_id].append(code)
return {entity_id: sorted(codes, key=account_sort_key) for entity_id, codes in accounts.items()}
def _summary_entries(version_id: uuid.UUID) -> Select[tuple[OsvEntry]]:
"""Строки свода версии: контрагентские (`COUNTERPARTY`) строки BU/SUM ОСВ-документов, привязанных к версии."""
return (

View File

@ -20,6 +20,7 @@ from src.models.enums import (
ConsolidationStatus,
CounterpartyMatchKind,
DocumentType,
EntityType,
PeriodQuarter,
)
@ -110,13 +111,16 @@ class ChecklistCellOut(BaseModel):
class MemberOut(BaseModel):
"""ЮЛ периметра версии; `checklist` — ячейки в порядке правил подхода (как `rules[]` чек-листа)."""
"""ЮЛ периметра версии (front-api-gaps D-2, D-3): тип ЮЛ, самоописывающие ячейки чек-листа в порядке правил подхода
и счета ОСВ, загруженные к периоду версии (`osv_accounts` — «что загружено», готовность — в ячейках)."""
id: uuid.UUID
name: str
inn: str
entity_type: EntityType
is_head_entity: bool
checklist: list[ChecklistCellOut]
osv_accounts: list[str]
class HistoryOut(BaseModel):

View File

@ -19,7 +19,7 @@ from src.api.dependencies.audit import RequestAudit
from src.common.clock import utc_now
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
from src.domain import checklist as checklist_domain
from src.domain.consolidation_periods import reporting_date
from src.domain.consolidation_periods import quarters_through, reporting_date
from src.domain.policy import FINISHED_STATUSES, VisibilityScope
from src.models.access import AppUser
from src.models.consolidation import (
@ -379,7 +379,8 @@ class ConsolidationService:
async def _card(self, version: ConsolidationVersion, consolidation: Consolidation) -> VersionCard:
"""Карточка версии: версия, консолидация, ГК, инициатор, периметр, история, активная блокировка.
Чек-лист у членов — те же ячейки, что у `GET …/checklist`, в порядке правил подхода.
Чек-лист у членов — те же ячейки, что у `GET …/checklist`, в порядке правил подхода; у членов — тип ЮЛ и счета
ОСВ к периоду версии (front-api-gaps D-3).
"""
group = await directories_repo.get_company_group(self._session, self._scope, consolidation.company_group_id)
initiator = await self._session.get(AppUser, version.initiator_id)
@ -391,6 +392,12 @@ class ConsolidationService:
history = await repo.history(self._session, version.id)
brief = self._brief(version, locks.get(version.id), len(members))
rules, cells = await self._checklist_cells(version, consolidation, [entity for _, entity in members])
accounts = await repo.osv_accounts(
self._session,
[entity.id for _, entity in members],
consolidation.year,
quarters_through(consolidation.quarter),
)
return VersionCard(
**brief.model_dump(),
consolidation_id=consolidation.id,
@ -408,10 +415,12 @@ class ConsolidationService:
id=entity.id,
name=entity.name,
inn=entity.inn,
entity_type=entity.entity_type,
is_head_entity=member.is_head_entity,
checklist=[
self._cell_out(rule, cell) for rule, cell in zip(rules, cells[position][1], strict=True)
],
osv_accounts=accounts.get(entity.id, []),
)
for position, (member, entity) in enumerate(members)
],

View File

@ -20,15 +20,27 @@ from src.models.consolidation import (
ConsolidationVersion,
)
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup, RelatedGroupMember
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter, RelatedGroupKind
from src.models.enums import (
AuditAction,
AuditSection,
ConsolidationStatus,
DocumentStatus,
EntityType,
FileStatus,
PeriodQuarter,
RecognitionStatus,
RelatedGroupKind,
)
from tests.conftest import AuthClientFactory
from tests.factories import (
make_company_group,
make_consolidation,
make_document,
make_legal_entity,
make_lock,
make_membership,
make_related_group,
make_stored_file,
make_version,
)
@ -560,3 +572,87 @@ async def test_version_card_requires_consolidation_view(auth_client: AuthClientF
client = await auth_client("admin@armka.local") # у роли ADMIN права CONSOLIDATION_VIEW нет
response = await client.get(f"{VERSIONS}/{versions['q3v1'].id}")
assert response.status_code == HTTP_FORBIDDEN
async def _osv( # noqa: PLR0913 - параметры документа сценария
session: AsyncSession,
entity: LegalEntity,
account: str,
year: int,
quarter: PeriodQuarter,
*,
status: DocumentStatus = DocumentStatus.RECOGNIZED,
file_status: FileStatus = FileStatus.PROCESSED,
) -> None:
"""ОСВ-документ ЮЛ в отдельном файле (на файл — одна ОСВ по счёту)."""
file, job = await make_stored_file(session, entity=entity, status=file_status, job_status=RecognitionStatus.done)
await make_document(session, file=file, job=job, account=account, year=year, quarter=quarter, status=status)
async def _card_world(
session: AsyncSession, quarter: PeriodQuarter = PeriodQuarter.Q3
) -> tuple[ConsolidationVersion, LegalEntity, LegalEntity]:
"""Версия 2024 `quarter` ГК Ростовского филиала: ЮЛ «ООО А» и ИП «ИП Б»."""
group = await make_company_group(session, name="ГК Карточка", office=await _office(session, ROSTOV))
company = await make_legal_entity(session, name="ООО А", group=group)
person = await make_legal_entity(session, name="ИП Б", group=group, entity_type=EntityType.ИП, inn="770100000012")
_, version = await make_consolidation(
session,
group=group,
year=2024,
quarter=quarter,
members=[company, person],
initiator=await _user(session, KA_RF),
)
return version, company, person
@pytest.mark.integration
async def test_card_member_entity_type(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, company, person = await _card_world(seeded)
client = await auth_client(KA_RF)
card = (await client.get(f"{VERSIONS}/{version.id}")).json()
types = {member["id"]: member["entity_type"] for member in card["members"]}
assert types == {str(company.id): "ЮЛ", str(person.id): "ИП"}
@pytest.mark.integration
async def test_card_member_osv_accounts_loaded_to_version_period(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""D-3 (вариант B): счета действующих ОСВ за год версии с кварталом ≤ версии, в любом статусе, кроме ERROR."""
version, company, person = await _card_world(seeded)
q1, q2, q3, q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4
await _osv(seeded, company, "60", 2024, q1) # RECOGNIZED
await _osv(seeded, company, "60", 2024, q2) # тот же счёт ещё раз — один раз в списке
await _osv(seeded, company, "51", 2024, q3, status=DocumentStatus.CONFIRMED)
await _osv(seeded, company, "58.03", 2024, q3, status=DocumentStatus.NEEDS_CHECK)
await _osv(seeded, company, "62", 2024, q4) # позже квартала версии
await _osv(seeded, company, "01", 2023, q3) # прошлый год
await _osv(seeded, company, "76", 2024, q3, file_status=FileStatus.SUPERSEDED) # заменённый файл
await _osv(seeded, company, "90", 2024, q3, status=DocumentStatus.ERROR) # документ в ошибке
await _osv(seeded, company, "10", 2024, q3, file_status=FileStatus.ERROR) # файл в ошибке
client = await auth_client(KA_RF)
card = (await client.get(f"{VERSIONS}/{version.id}")).json()
accounts = {member["id"]: member["osv_accounts"] for member in card["members"]}
assert accounts == {str(company.id): ["51", "58.03", "60"], str(person.id): []}
@pytest.mark.integration
async def test_card_member_osv_accounts_q1_version_ignores_later_quarters(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Review Focus: версия за Q1 — документы Q2 того же года не входят."""
version, company, _ = await _card_world(seeded, PeriodQuarter.Q1)
await _osv(seeded, company, "60", 2024, PeriodQuarter.Q1)
await _osv(seeded, company, "62", 2024, PeriodQuarter.Q2)
client = await auth_client(KA_RF)
card = (await client.get(f"{VERSIONS}/{version.id}")).json()
member = next(member for member in card["members"] if member["id"] == str(company.id))
assert member["osv_accounts"] == ["60"]

View File

@ -257,3 +257,9 @@ def test_checklist_cell_declares_its_rule(schema: dict[str, Any]) -> None:
"""front-api-gaps D-2: ячейка чек-листа самоописывающая."""
cell = schema["components"]["schemas"]["ChecklistCellOut"]
assert {"document_type", "account", "status", "missing_quarters", "document_ids"} <= set(cell["required"])
def test_version_member_declares_type_and_osv_accounts(schema: dict[str, Any]) -> None:
"""front-api-gaps D-3: участник периметра — тип ЮЛ и счета ОСВ к периоду версии."""
member = schema["components"]["schemas"]["MemberOut"]
assert {"entity_type", "osv_accounts", "checklist"} <= set(member["required"])