From 8485b2c53ec4b1e467210116044e0b7477330a44 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 18:52:59 +0400 Subject: [PATCH] =?UTF-8?q?=D0=93=D1=83=D0=B4=D0=B2=D0=B8=D0=BB=D0=BB:=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=D1=83=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1: сохранённая доля неактивной строки нормализуется (numeric(7,4) читается как 80.1200) и не даёт INVALID_SHARE при null в пакете; экспорт помечает строку «Нет вложений» (сценарий спеки); design/tasks/docstring без устаревшего «построчного» PUT, R-7/R-8. Co-Authored-By: Claude Opus 5.5 --- .../api/endpoints/consolidations/goodwill.py | 2 +- api/src/services/goodwill.py | 3 +- api/src/services/goodwill_export.py | 7 +++- api/tests/test_api_goodwill_mutations.py | 39 ++++++++++++++++++- api/tests/test_goodwill_export.py | 2 +- openspec/changes/goodwill-table/design.md | 11 ++++-- openspec/changes/goodwill-table/tasks.md | 4 +- 7 files changed, 57 insertions(+), 11 deletions(-) diff --git a/api/src/api/endpoints/consolidations/goodwill.py b/api/src/api/endpoints/consolidations/goodwill.py index 4a53a8a..93dfdda 100644 --- a/api/src/api/endpoints/consolidations/goodwill.py +++ b/api/src/api/endpoints/consolidations/goodwill.py @@ -1,4 +1,4 @@ -"""Таблица гудвилла, построчное сохранение, переключатель и XLSX.""" +"""Таблица гудвилла, пакетное сохранение, переключатель и XLSX (§8 «Гудвилл»).""" from typing import Annotated from uuid import UUID diff --git a/api/src/services/goodwill.py b/api/src/services/goodwill.py index 7cfef8d..bd6301b 100644 --- a/api/src/services/goodwill.py +++ b/api/src/services/goodwill.py @@ -283,7 +283,8 @@ def _decision(row: GoodwillRowOut, item: GoodwillRowKeyIn, key: tuple[UUID, UUID if not row.has_investments: # DOC-259025 §10 п. 9: отсутствующий ввод не затирает сохранённые данные неактивной пары. if share is None and row.ownership_share is not None: - share = str(row.ownership_share) + # numeric(7,4) читается с масштабом 4 («80.1200»): без хвостовых нулей для проверки двух знаков + share = format(row.ownership_share.normalize(), "f") if comment is None: comment = row.comment try: diff --git a/api/src/services/goodwill_export.py b/api/src/services/goodwill_export.py index 44ebc12..a03fad2 100644 --- a/api/src/services/goodwill_export.py +++ b/api/src/services/goodwill_export.py @@ -19,6 +19,7 @@ COLUMNS = { "goodwill_amount": "Гудвилл (+/-)", "comment": "Комментарий", } +NO_INVESTMENTS = "Нет вложений" MONEY = ("financial_investment_amount", "participation_amount", "charter_capital_amount", "goodwill_amount") @@ -43,7 +44,9 @@ def build_workbook(table: GoodwillTableOut, sign: GoodwillSign = "all") -> bytes row = sheet.max_row + 1 for col, key in enumerate(keys, 1): value = values.get(key) - if key in MONEY or key == "ownership_share": + if isinstance(value, str) and key in MONEY: + _put_text(sheet, row, col, value) + elif key in MONEY or key == "ownership_share": _put_money(sheet, row, col, value) else: _put_text(sheet, row, col, value) @@ -66,6 +69,8 @@ def build_workbook(table: GoodwillTableOut, sign: GoodwillSign = "all") -> bytes ) for item in visible: values = {key: getattr(item, key) for key in MONEY} + if not item.has_investments: + values["financial_investment_amount"] = NO_INVESTMENTS values.update( name=item.subsidiary_legal_entity.name, include_in_kfo="☑" if item.include_in_kfo else "☐", diff --git a/api/tests/test_api_goodwill_mutations.py b/api/tests/test_api_goodwill_mutations.py index 85e5638..7747041 100644 --- a/api/tests/test_api_goodwill_mutations.py +++ b/api/tests/test_api_goodwill_mutations.py @@ -2,6 +2,7 @@ from decimal import Decimal from http import HTTPStatus +from uuid import uuid4 import pytest from sqlalchemy import func, select @@ -13,8 +14,9 @@ from src.models.enums import AuditAction from src.models.kfo import Goodwill, KfoResult from src.models.osv import OsvEntry from src.models.statements import FinancialStatementValue -from src.schemas.goodwill import GoodwillRowIn -from src.services.goodwill import validate_decision +from src.schemas.consolidations import RefOut +from src.schemas.goodwill import GoodwillRowIn, GoodwillRowKeyIn, GoodwillRowOut +from src.services.goodwill import _decision, validate_decision from tests.test_api_consolidated_osv import KA_RF from tests.test_api_goodwill import batch, goodwill_world @@ -228,3 +230,36 @@ async def test_new_row_with_defaults_is_not_created(db_session, auth_client, fix assert response.json()["rows"][0]["include_in_kfo"] is False assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0 assert await _goodwill_audit_count(db_session) == 0 + + +def test_inactive_row_keeps_stored_share_with_db_scale(): + """Колонка numeric(7,4) читается как Decimal("80.1200"): null в пакете не должен давать INVALID_SHARE.""" + head, sub = uuid4(), uuid4() + row = GoodwillRowOut( + head_legal_entity=RefOut(id=head, name="ГО"), + subsidiary_legal_entity=RefOut(id=sub, name="ДО"), + ownership_share=Decimal("80.1200"), + financial_investment_amount=None, + charter_capital_amount=None, + additional_capital_amount=None, + participation_amount=None, + goodwill_amount=None, + include_in_kfo=True, + comment="Было", + has_investments=False, + warnings=[], + updated_by=None, + updated_at=None, + ) + item = GoodwillRowKeyIn( + head_legal_entity_id=head, + subsidiary_legal_entity_id=sub, + ownership_share=None, + include_in_kfo=True, + comment=None, + ) + decision = _decision(row, item, (head, sub)) + assert decision.ownership_share == Decimal("80.12") + assert decision.comment == "Было" + row100 = row.model_copy(update={"ownership_share": Decimal("100.0000")}) + assert _decision(row100, item, (head, sub)).ownership_share == Decimal(100) diff --git a/api/tests/test_goodwill_export.py b/api/tests/test_goodwill_export.py index 72def3f..1e9d0a6 100644 --- a/api/tests/test_goodwill_export.py +++ b/api/tests/test_goodwill_export.py @@ -73,7 +73,7 @@ async def test_export_matches_server_table(db_session, auth_client, fixtures_dir ) response = await client.get(base + "/goodwill/export") sheet = load_workbook(BytesIO(response.content)).active - assert sheet.cell(3, 4).value is None + assert sheet.cell(3, 4).value == "Нет вложений" assert sheet.cell(3, 7).value is None assert sheet.cell(5, 4).value == 0 assert sheet.cell(5, 7).value == 0 diff --git a/openspec/changes/goodwill-table/design.md b/openspec/changes/goodwill-table/design.md index 2ae7676..a0c2035 100644 --- a/openspec/changes/goodwill-table/design.md +++ b/openspec/changes/goodwill-table/design.md @@ -26,7 +26,7 @@ ### 3. Ручной ввод и отсутствие вложений -Ключ — (version_id, head_id, subsidiary_id), существующий UNIQUE. GET объединяет найденные пары с goodwill и ничего не пишет. Впервые запись создаётся при PUT строки; не материализуем все строки при чтении или смене переключателя. Это обычное сохранение по PUT из MD §3.3; GET формирует ответ без записи. +Ключ — (version_id, head_id, subsidiary_id), существующий UNIQUE. GET объединяет найденные пары с goodwill и ничего не пишет. Впервые запись создаётся при сохранении строки пакетом (`PUT …/goodwill`, R-2); не материализуем все строки при чтении или смене переключателя; GET формирует ответ без записи. has_investments — вычисляемый DTO-флаг, не колонка. По прямому решению пользователя для «Нет вложений» все денежные колонки ответа равны null; сохранённый ввод и последний денежный снимок в БД не удаляются. Строка исключается из итогов и применения в КФО. Собственной ошибки GOODWILL_PAIR_INACTIVE нет. Отсутствующие доля и комментарий не затирают сохранённый ввод неактивной пары (PDF §10). @@ -34,11 +34,11 @@ has_investments — вычисляемый DTO-флаг, не колонка. П Decimal, P.quantize(0.01, ROUND_HALF_UP), затем G=ФВ−P. Ограничение numeric(18,2) проверять до записи, переполнение отклонять 422 MONEY_OUT_OF_RANGE без частичного сохранения; float не использовать. Значения в API — рубли с двумя знаками, ownership_share — проценты с двумя (R-1). Максимум два дробных знака во входе, без автоматического округления; запятая и точка допустимы по PDF §5.2; null допускается для снятой галочки. P/G рассчитываются только у действующей отмеченной строки при наличии доли и капитала (PDF §6); итоги P/G учитывают только выбранные действующие строки. Для первоначальной строки include_in_kfo=false, доля/comment/updated_* = null. -GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениями spec: has_investments, warnings[] из {code, message}; charter_capital_amount и additional_capital_amount тоже nullable, чтобы отсутствие ББ не маскировать нулём. totals — поля MD; в этом этапе без отдельного API групповых итогов: они выводимы из всех строк. При goodwill_enabled=false потенциальные G видны, goodwill_in_kfo_amount=0.00. +GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениями spec: has_investments, warnings[] из {code, message}; charter_capital_amount и additional_capital_amount тоже nullable, чтобы отсутствие ББ не маскировать нулём. totals — итог по листу, groups — итоги «Всего» по каждой ГО; оба считает бэк (R-4). При goodwill_enabled=false потенциальные G видны, goodwill_in_kfo_amount=0.00. ### 5. Транзакции и безопасность -Маршруты гудвилла используют зависимости по образцу API корректировок. Service использует текущие policy и lock_editable_version. PUT строки под блокировкой версии повторно загружает актуальные источники, проверяет пару и ввод, upsert существующей записи, записывает GOODWILL_EDIT before/after в той же транзакции. Вычисляемые суммы из тела не принимаются. PUT настроек не трогает строки; GOODWILL_EDIT имеет object_type=consolidation_version и changed_field=goodwill_enabled, строки — object_type=goodwill. Новые enum не нужны. PUT …/goodwill принимает набор строк и сохраняет его одной транзакцией (R-2): сначала проверяются все строки, потом пишутся изменённые. Отдельная оптимистическая ревизия не добавляется: существующая блокировка версии сериализует операции, последнее сохранение владельца побеждает. +Маршруты гудвилла используют зависимости по образцу API корректировок. Service использует текущие policy и lock_editable_version. PUT …/goodwill под блокировкой версии загружает актуальные источники, проверяет пары и ввод всех строк, затем upsert изменённых записей и GOODWILL_EDIT before/after по каждой в той же транзакции. Вычисляемые суммы из тела не принимаются. PUT настроек не трогает строки; GOODWILL_EDIT имеет object_type=consolidation_version и changed_field=goodwill_enabled, строки — object_type=goodwill. Новые enum не нужны. Отдельная оптимистическая ревизия не добавляется: существующая блокировка версии сериализует операции, последнее сохранение владельца побеждает. ### 6. Граница КФО @@ -85,3 +85,8 @@ GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениям - **R-6 (2026-11-06). Пометка результата КФО устаревшим при смене переключателя или строк не делается — в AURORA-1546.** Обоснование: результата КФО (`kfo_result`) ещё не строится, помечать нечего. Цена ошибки: в 1546 добавить сброс в `save_rows` и `settings`. +- **R-7 (2026-11-06, финальное ревью I-1). Сохранённая доля неактивной строки нормализуется** (`80.1200` → `80.12`) + перед проверкой ввода: колонка `numeric(7,4)` читается с масштабом 4, а ввод ограничен двумя знаками (R-1). Цена ошибки: + если в БД окажется доля с 3–4 значащими знаками (данные до R-1), пакет с этой неактивной строкой даст 422 `INVALID_SHARE`. +- **R-8 (2026-11-06, финальное ревью). В XLSX у строки «Нет вложений» в колонке ФВ — текст «Нет вложений»** (сценарий спеки + «Выгрузка таблицы»). Цена ошибки: нет. diff --git a/openspec/changes/goodwill-table/tasks.md b/openspec/changes/goodwill-table/tasks.md index 27d3c1c..f8ead7a 100644 --- a/openspec/changes/goodwill-table/tasks.md +++ b/openspec/changes/goodwill-table/tasks.md @@ -5,7 +5,7 @@ ## 1. Источники и контракт расчёта - [x] 1.1 arm-core: добавить тесты на реальных исходных данных с загрузкой неизменённых реальных fixtures счёта 58.01 «Приманыческий» 2025 Q3 и 58.03 РБ 2025 Q1 / 2024 Q3; проверить реальные счета, контрагентов и суммы, зафиксировать положительный набор 58.01 и отрицательный 58.03. Не подменять отсутствующий реальный источник синтетической суммой. -- [x] 1.2 arm-core: добавить доменные тесты гудвилла и кейсы состояний на реальных исходных строках: направление, неиспользование is_head_entity, исчезновение/возврат пары, ноль/null, четыре знака, Decimal-округление, итоги, сумма долей >100. Проверка — воспроизводимый набор ожидаемых расчётов до реализации. +- [x] 1.2 arm-core: добавить доменные тесты гудвилла и кейсы состояний на реальных исходных строках: направление, неиспользование is_head_entity, исчезновение/возврат пары, ноль/null, четыре знака (позже — два, R-1), Decimal-округление, итоги, сумма долей >100. Проверка — воспроизводимый набор ожидаемых расчётов до реализации. - [x] 1.3 arm-core: реализовать доменный модуль расчёта гудвилла: пары по дереву счетов, объединение сохранённого ввода, формулы и предупреждения; проверить тесты 1.1–1.2, отсутствие ORM/float и записи вычисляемых пар при чтении. ## 2. Загрузка и API чтения @@ -18,7 +18,7 @@ ## 3. Сохранение и переключатель - [x] 3.1 arm-core: добавить тесты изменения данных гудвилла для создания/повторного сохранения, снятия выбора, доли null/0/100/лишней точности, COMMENT_REQUIRED, неактивной пары, переполнения денег и отката аудита вместе с записью; добавить проверки статусов/блокировок и двух сериализованных сохранений. -- [x] 3.2 arm-core: реализовать PUT строки в сервисе и репозитории гудвилла через consolidation_guard; запись goodwill и GOODWILL_EDIT атомарна. Проверка — тесты 3.1, одна запись на естественный ключ и неизменность остальных строк. +- [x] 3.2 arm-core (позже заменено пакетным PUT, R-2): реализовать PUT строки в сервисе и репозитории гудвилла через consolidation_guard; запись goodwill и GOODWILL_EDIT атомарна. Проверка — тесты 3.1, одна запись на естественный ключ и неизменность остальных строк. - [x] 3.3 arm-core: реализовать PUT goodwill-settings, добавить тесты изменения/повтора значения, сохранности ручного ввода и нулевого вклада при выключении; проверить единое событие GOODWILL_EDIT только при изменении переключателя и отсутствие изменений KfoResult. - [x] 3.4 arm-core: обновить описательный контракт API и OpenAPI-контракт для PUT, ошибок и null доли снятой строки; проверить примеры запросов/ответов контрактными тестами и одинаковую семантику MD/OpenAPI.