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:
parent
678cc4566f
commit
d782637bc1
@ -14,6 +14,7 @@ from sqlalchemy import Select, case, exists, false, func, or_, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.domain.checklist import EntityFilesState
|
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.domain.policy import VisibilityScope, company_groups_visible
|
||||||
from src.models.access import AppUser
|
from src.models.access import AppUser
|
||||||
from src.models.accounting import ChartOfAccount
|
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())
|
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]]:
|
def _summary_entries(version_id: uuid.UUID) -> Select[tuple[OsvEntry]]:
|
||||||
"""Строки свода версии: контрагентские (`COUNTERPARTY`) строки BU/SUM ОСВ-документов, привязанных к версии."""
|
"""Строки свода версии: контрагентские (`COUNTERPARTY`) строки BU/SUM ОСВ-документов, привязанных к версии."""
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from src.models.enums import (
|
|||||||
ConsolidationStatus,
|
ConsolidationStatus,
|
||||||
CounterpartyMatchKind,
|
CounterpartyMatchKind,
|
||||||
DocumentType,
|
DocumentType,
|
||||||
|
EntityType,
|
||||||
PeriodQuarter,
|
PeriodQuarter,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -110,13 +111,16 @@ class ChecklistCellOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MemberOut(BaseModel):
|
class MemberOut(BaseModel):
|
||||||
"""ЮЛ периметра версии; `checklist` — ячейки в порядке правил подхода (как `rules[]` чек-листа)."""
|
"""ЮЛ периметра версии (front-api-gaps D-2, D-3): тип ЮЛ, самоописывающие ячейки чек-листа в порядке правил подхода
|
||||||
|
и счета ОСВ, загруженные к периоду версии (`osv_accounts` — «что загружено», готовность — в ячейках)."""
|
||||||
|
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
inn: str
|
inn: str
|
||||||
|
entity_type: EntityType
|
||||||
is_head_entity: bool
|
is_head_entity: bool
|
||||||
checklist: list[ChecklistCellOut]
|
checklist: list[ChecklistCellOut]
|
||||||
|
osv_accounts: list[str]
|
||||||
|
|
||||||
|
|
||||||
class HistoryOut(BaseModel):
|
class HistoryOut(BaseModel):
|
||||||
|
|||||||
@ -19,7 +19,7 @@ from src.api.dependencies.audit import RequestAudit
|
|||||||
from src.common.clock import utc_now
|
from src.common.clock import utc_now
|
||||||
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
|
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
|
||||||
from src.domain import checklist as checklist_domain
|
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.domain.policy import FINISHED_STATUSES, VisibilityScope
|
||||||
from src.models.access import AppUser
|
from src.models.access import AppUser
|
||||||
from src.models.consolidation import (
|
from src.models.consolidation import (
|
||||||
@ -379,7 +379,8 @@ class ConsolidationService:
|
|||||||
async def _card(self, version: ConsolidationVersion, consolidation: Consolidation) -> VersionCard:
|
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)
|
group = await directories_repo.get_company_group(self._session, self._scope, consolidation.company_group_id)
|
||||||
initiator = await self._session.get(AppUser, version.initiator_id)
|
initiator = await self._session.get(AppUser, version.initiator_id)
|
||||||
@ -391,6 +392,12 @@ class ConsolidationService:
|
|||||||
history = await repo.history(self._session, version.id)
|
history = await repo.history(self._session, version.id)
|
||||||
brief = self._brief(version, locks.get(version.id), len(members))
|
brief = self._brief(version, locks.get(version.id), len(members))
|
||||||
rules, cells = await self._checklist_cells(version, consolidation, [entity for _, entity in 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(
|
return VersionCard(
|
||||||
**brief.model_dump(),
|
**brief.model_dump(),
|
||||||
consolidation_id=consolidation.id,
|
consolidation_id=consolidation.id,
|
||||||
@ -408,10 +415,12 @@ class ConsolidationService:
|
|||||||
id=entity.id,
|
id=entity.id,
|
||||||
name=entity.name,
|
name=entity.name,
|
||||||
inn=entity.inn,
|
inn=entity.inn,
|
||||||
|
entity_type=entity.entity_type,
|
||||||
is_head_entity=member.is_head_entity,
|
is_head_entity=member.is_head_entity,
|
||||||
checklist=[
|
checklist=[
|
||||||
self._cell_out(rule, cell) for rule, cell in zip(rules, cells[position][1], strict=True)
|
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)
|
for position, (member, entity) in enumerate(members)
|
||||||
],
|
],
|
||||||
|
|||||||
@ -20,15 +20,27 @@ from src.models.consolidation import (
|
|||||||
ConsolidationVersion,
|
ConsolidationVersion,
|
||||||
)
|
)
|
||||||
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup, RelatedGroupMember
|
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.conftest import AuthClientFactory
|
||||||
from tests.factories import (
|
from tests.factories import (
|
||||||
make_company_group,
|
make_company_group,
|
||||||
make_consolidation,
|
make_consolidation,
|
||||||
|
make_document,
|
||||||
make_legal_entity,
|
make_legal_entity,
|
||||||
make_lock,
|
make_lock,
|
||||||
make_membership,
|
make_membership,
|
||||||
make_related_group,
|
make_related_group,
|
||||||
|
make_stored_file,
|
||||||
make_version,
|
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 нет
|
client = await auth_client("admin@armka.local") # у роли ADMIN права CONSOLIDATION_VIEW нет
|
||||||
response = await client.get(f"{VERSIONS}/{versions['q3v1'].id}")
|
response = await client.get(f"{VERSIONS}/{versions['q3v1'].id}")
|
||||||
assert response.status_code == HTTP_FORBIDDEN
|
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"]
|
||||||
|
|||||||
@ -257,3 +257,9 @@ def test_checklist_cell_declares_its_rule(schema: dict[str, Any]) -> None:
|
|||||||
"""front-api-gaps D-2: ячейка чек-листа самоописывающая."""
|
"""front-api-gaps D-2: ячейка чек-листа самоописывающая."""
|
||||||
cell = schema["components"]["schemas"]["ChecklistCellOut"]
|
cell = schema["components"]["schemas"]["ChecklistCellOut"]
|
||||||
assert {"document_type", "account", "status", "missing_quarters", "document_ids"} <= set(cell["required"])
|
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"])
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user