From 6f52360c494d807b845e73783340358d2afac45a Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Tue, 27 Oct 2026 17:44:39 +0300 Subject: [PATCH 1/4] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=D1=89=D0=B8=D0=B9=D1=85=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9?= =?UTF-8?q?=20=D0=B2=20EliminationRuleOut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/schemas/consolidations.py | 2 ++ api/src/services/elimination_rules.py | 2 ++ api/tests/test_api_intercompany_matrix.py | 4 +++- openspec/specs/consolidations-api/spec.md | 3 ++- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 0215801..6b602cd 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -322,6 +322,8 @@ class EliminationRuleOut(BaseModel): rule_no: int version_no: int statement_type: StatementType + article_1_line: str + article_2_line: str side_1: Literal["D", "K"] measure: Literal["closing", "turnover"] company_1: IntercompanyMatrixRuleSideOut diff --git a/api/src/services/elimination_rules.py b/api/src/services/elimination_rules.py index b5a00c8..2b57aa5 100644 --- a/api/src/services/elimination_rules.py +++ b/api/src/services/elimination_rules.py @@ -39,6 +39,8 @@ class EliminationRuleService: "rule_no": rule.rule_no, "version_no": rule.version_no, "statement_type": rule.statement_type, + "article_1_line": rule.article_1_line, + "article_2_line": rule.article_2_line, "side_1": rule.side_1, "measure": rule.measure, "company_1": IntercompanyMatrixRuleSideOut(accounts=[], excluded=[]), diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index a21275e..c732c74 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -234,7 +234,9 @@ async def test_preset_uses_fixed_rules_version(auth_client: AuthClientFactory, s assert listed.json()["is_version_fixed"] is True assert listed.json()["version_no"] == rule.version_no assert "as_of" not in listed.json() - assert str(rule.id) in {item["id"] for item in listed.json()["rules"]} + listed_rule = next(item for item in listed.json()["rules"] if item["id"] == str(rule.id)) + assert listed_rule["article_1_line"] == rule.article_1_line + assert listed_rule["article_2_line"] == rule.article_2_line pairs = await client.get( f"/consolidations/versions/{world.version.id}/elimination-pairs", params={ diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index 989b5d1..2ba2b83 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -294,7 +294,8 @@ policy) SHALL возвращать XLSX с теми же статьями, ко SHALL возвращать объект `{version_no, is_version_fixed, rules}`: `version_no` — `consolidation_version.elimination_rules_version` (с DBML v5.23 задан у каждой версии), `is_version_fixed = true` (поле оставлено для совместимости клиента), `rules` — правила этой версии; правила других версий, в том числе более новых, MUST NOT попадать в ответ. Каждое правило SHALL -содержать `id`, `rule_no`, `version_no`, `statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), +содержать `id`, `rule_no`, `version_no`, `statement_type`, `article_1_line`, `article_2_line`, `side_1` (`D`/`K`), +`measure` (`closing`/`turnover`), `company_1`, `company_2`; каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии счетов. Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`. -- 2.47.2 From 4b98ff2a24ef5d1e4cd1d0be5ed0eb19dce10a60 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Tue, 27 Oct 2026 18:07:21 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=D0=B5=20?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B2=20=D1=88=D0=B0?= =?UTF-8?q?=D1=85=D0=BC=D0=B0=D1=82=D0=BA=D0=B5=20=D0=B2=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=B6=D0=BD=D0=BE=D0=BC=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/schemas/consolidations.py | 10 ++++++++++ api/tests/test_api_intercompany_matrix.py | 4 ++++ api/tests/test_schemas_consolidations.py | 16 ++++++++++++++++ openspec/specs/consolidations-api/spec.md | 4 +++- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 6b602cd..90c9d03 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -498,6 +498,11 @@ class IntercompanyResultAmountOut(BaseModel): company_id: uuid.UUID amount: Decimal + @field_validator("amount") + @classmethod + def _two_decimals(cls, value: Decimal) -> Decimal: + return value.quantize(CENT, rounding=ROUND_HALF_UP) + class IntercompanyResultArticleOut(BaseModel): """Статья ВГО и её суммы по компаниям и группе.""" @@ -507,6 +512,11 @@ class IntercompanyResultArticleOut(BaseModel): amounts: list[IntercompanyResultAmountOut] group_total: Decimal + @field_validator("group_total") + @classmethod + def _two_decimals(cls, value: Decimal) -> Decimal: + return value.quantize(CENT, rounding=ROUND_HALF_UP) + class IntercompanyResultTableOut(BaseModel): """Фиксированная результирующая таблица ВГО выбранной версии.""" diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index c732c74..d20672a 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -690,6 +690,10 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory, export = await _request(client, world.version.id, "result-table/export", world.selection) assert result.status_code == export.status_code == OK data = result.json() + receivables = next(article for article in data["articles"] if article["code"] == "receivables") + assert sorted(item["amount"] for item in receivables["amounts"]) == ["0.00", "100.25"] + assert receivables["group_total"] == "100.25" + assert all(article["group_total"] == "0.00" for article in data["articles"] if article != receivables) sheet = load_workbook(BytesIO(export.content))["Результирующая таблица"] assert [sheet.cell(7, col).value for col in range(2, len(data["companies"]) + 2)] == [ company["name"] for company in data["companies"] diff --git a/api/tests/test_schemas_consolidations.py b/api/tests/test_schemas_consolidations.py index e6249bf..dac5fab 100644 --- a/api/tests/test_schemas_consolidations.py +++ b/api/tests/test_schemas_consolidations.py @@ -115,6 +115,22 @@ def test_consolidated_row_money_has_two_decimals() -> None: assert (dumped["opening_debit"], dumped["turnover_debit"], dumped["closing_debit"]) == ("0.00", "30.00", "7.00") +def test_result_table_money_has_two_decimals_with_half_up_rounding() -> None: + company_id = uuid.uuid4() + article = schemas.IntercompanyResultArticleOut( + code="receivables", + name="Дебиторская задолженность", + amounts=[ + schemas.IntercompanyResultAmountOut(company_id=company_id, amount=Decimal("0")), + schemas.IntercompanyResultAmountOut(company_id=uuid.uuid4(), amount=Decimal("1.005")), + ], + group_total=Decimal("1.005"), + ) + dumped = article.model_dump(mode="json") + assert [item["amount"] for item in dumped["amounts"]] == ["0.00", "1.01"] + assert dumped["group_total"] == "1.01" + + def test_entity_files_state_is_frozen() -> None: state = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.ERROR) with pytest.raises(dataclasses.FrozenInstanceError): diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index 2ba2b83..7bf1ada 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -258,11 +258,13 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account` Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения, долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям. +`amount` и `group_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP` +(например, `"0.00"`). Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов. #### Scenario: Компания без ОСВ - **WHEN** в периметре есть компания без подходящих строк ОСВ -- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0"` +- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0.00"` #### Scenario: Нет обязательного счёта - **WHEN** в плане счетов отсутствует счёт 58.03 -- 2.47.2 From b3260ab288362c0a7c81fba23a8ea44b95e3ab79 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Tue, 27 Oct 2026 18:13:36 +0300 Subject: [PATCH 3/4] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE=D0=BA=D1=81=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/repositories/elimination_rules.py | 6 +++--- api/src/services/intercompany_matrix.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/src/repositories/elimination_rules.py b/api/src/repositories/elimination_rules.py index 3d060ce..16b4b95 100644 --- a/api/src/repositories/elimination_rules.py +++ b/api/src/repositories/elimination_rules.py @@ -28,7 +28,7 @@ async def list_rules_with_accounts( async def latest_effective_version_no(session: AsyncSession, as_of: date) -> int | None: - """Максимальный номер версии правил, действующей на дату создания""" + """Максимальный номер версии правил §4, действующей на дату создания.""" return await session.scalar( select(func.max(EliminationRule.version_no)).where( EliminationRule.valid_from <= as_of, @@ -43,7 +43,7 @@ def is_valid_for_version(rule: EliminationRule | None, version_no: int) -> bool: async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]: - """Все правила заданной версии, включая ББ и ОФР, в порядке типа отчётности и номера.""" + """Все правила §4 заданной версии, включая ББ и ОФР, в порядке типа отчётности и номера.""" stmt = ( select(EliminationRule) .where(EliminationRule.version_no == version_no) @@ -55,7 +55,7 @@ async def list_rules(session: AsyncSession, version_no: int) -> list[Elimination async def list_rule_accounts( session: AsyncSession, rule_ids: Collection[uuid.UUID] ) -> list[tuple[EliminationRuleAccount, str]]: - """Счета правил с кодами из плана счетов; `company_no` задаёт сторону, `is_excluded` — исключение.""" + """Счета правил §4 с кодами из плана счетов; `company_no` задаёт сторону, `is_excluded` — исключение.""" if not rule_ids: return [] stmt = ( diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index a7d7aff..8e70e49 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -198,7 +198,7 @@ def calculate_matrix( member_companies: Sequence[IntercompanyMatrixCompanyOut], selection: MatrixSelection, ) -> IntercompanyMatrixOut: - """Суммы по парам компаний; + """Суммы по парам компаний для шахматки (§6). Сторона 1: дебет ОСВ компании A по контрагенту Б. Сторона 2: кредит ОСВ компании Б по контрагенту A. @@ -318,7 +318,7 @@ def calculate_result_table( member_companies: Sequence[IntercompanyMatrixCompanyOut], accounts: Sequence[ChartOfAccount], ) -> IntercompanyResultTableOut: - """Фиксированные статьи ВГО: сальдо по всем контрагентам периметра для каждого ЮЛ.""" + """Фиксированные статьи ВГО: сальдо по всем контрагентам периметра для каждого ЮЛ (§6).""" by_id, children = _make_account_tree(accounts) by_code = {account.code: account.id for account in accounts} required_codes = {code for article in RESULT_ARTICLES for code in (*article.account_codes, *article.excluded_codes)} -- 2.47.2 From 29cead170c02bb0afdd3a3e605f7091e47e70da1 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Tue, 27 Oct 2026 19:14:26 +0300 Subject: [PATCH 4/4] =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D1=83=D0=B5=D0=BC=D1=8B=D0=B5=20=D1=84=D1=83=D0=BD=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/repositories/elimination_rules.py | 16 ---------------- api/src/services/intercompany_matrix.py | 9 --------- 2 files changed, 25 deletions(-) diff --git a/api/src/repositories/elimination_rules.py b/api/src/repositories/elimination_rules.py index 16b4b95..7087e45 100644 --- a/api/src/repositories/elimination_rules.py +++ b/api/src/repositories/elimination_rules.py @@ -1,7 +1,6 @@ """Чтение версионированных правил элиминации и счетов их сторон.""" import uuid -from collections.abc import Collection from datetime import date from sqlalchemy import func, select @@ -52,21 +51,6 @@ async def list_rules(session: AsyncSession, version_no: int) -> list[Elimination return list((await session.scalars(stmt)).all()) -async def list_rule_accounts( - session: AsyncSession, rule_ids: Collection[uuid.UUID] -) -> list[tuple[EliminationRuleAccount, str]]: - """Счета правил §4 с кодами из плана счетов; `company_no` задаёт сторону, `is_excluded` — исключение.""" - if not rule_ids: - return [] - stmt = ( - select(EliminationRuleAccount, ChartOfAccount.code) - .join(ChartOfAccount, ChartOfAccount.id == EliminationRuleAccount.account_id) - .where(EliminationRuleAccount.rule_id.in_(rule_ids)) - .order_by(EliminationRuleAccount.rule_id, EliminationRuleAccount.company_no, ChartOfAccount.code) - ) - return [(account, code) for account, code in (await session.execute(stmt)).all()] - - async def rule_with_account( session: AsyncSession, rule_id: uuid.UUID ) -> tuple[EliminationRule | None, list[EliminationRuleAccount]]: diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 8e70e49..1ddf739 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -609,15 +609,6 @@ class IntercompanyMatrixService: items = calculate_rule_total_items(osv_result.consolidated.rows, rules, list(osv_result.members)) return EliminationTotalsOut(version_no=version.elimination_rules_version, items=items) - async def build( - self, - version_id: uuid.UUID, - selection: MatrixSelection, - ) -> IntercompanyMatrixOut: - """Счета принимает готовыми; выбор режима ALL/MANUAL выполняется вызывающим кодом.""" - result = await self._osv_service.build_with_members(version_id) - return self._calculate(result.consolidated.rows, result.members, selection) - def _calculate( self, rows: Iterable[ConsolidatedRow], members: dict[uuid.UUID, Entity], selection: MatrixSelection ) -> IntercompanyMatrixOut: -- 2.47.2