Гудвилл: правки по финальному ревью ветки

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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-06 18:52:59 +04:00
parent 3bfcc2e2aa
commit 8485b2c53e
7 changed files with 57 additions and 11 deletions

View File

@ -1,4 +1,4 @@
"""Таблица гудвилла, построчное сохранение, переключатель и XLSX."""
"""Таблица гудвилла, пакетное сохранение, переключатель и XLSX (§8 «Гудвилл»)."""
from typing import Annotated
from uuid import UUID

View File

@ -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:

View File

@ -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 "☐",

View File

@ -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)

View File

@ -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

View File

@ -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 у строки «Нет вложений» в колонке ФВ — текст «Нет вложений»** (сценарий спеки
«Выгрузка таблицы»). Цена ошибки: нет.

View File

@ -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.