From d782637bc1d0d209aaea8b02f182349f5b108b4e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 17:51:45 +0400 Subject: [PATCH] =?UTF-8?q?feat(consolidations):=20=D1=82=D0=B8=D0=BF=20?= =?UTF-8?q?=D0=AE=D0=9B=20=D0=B8=20=D1=81=D1=87=D0=B5=D1=82=D0=B0=20=D0=9E?= =?UTF-8?q?=D0=A1=D0=92=20=D0=BA=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20=D1=83=20=D1=83?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B0=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D0=BC=D0=B5=D1=82=D1=80=D0=B0=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/repositories/consolidations.py | 31 ++++++++ api/src/schemas/consolidations.py | 6 +- api/src/services/consolidations.py | 13 +++- api/tests/test_api_consolidations.py | 98 +++++++++++++++++++++++++- api/tests/test_openapi_routes.py | 6 ++ 5 files changed, 150 insertions(+), 4 deletions(-) diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index fb2a4f3..4f4801b 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -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 ( diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 188f991..705f10b 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -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): diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index 4406591..d94ad88 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -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) ], diff --git a/api/tests/test_api_consolidations.py b/api/tests/test_api_consolidations.py index 7d8c7f5..68239d6 100644 --- a/api/tests/test_api_consolidations.py +++ b/api/tests/test_api_consolidations.py @@ -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"] diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 690586d..0ce684e 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -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"])