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

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 typing import Annotated
from uuid import UUID 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: if not row.has_investments:
# DOC-259025 §10 п. 9: отсутствующий ввод не затирает сохранённые данные неактивной пары. # DOC-259025 §10 п. 9: отсутствующий ввод не затирает сохранённые данные неактивной пары.
if share is None and row.ownership_share is not None: 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: if comment is None:
comment = row.comment comment = row.comment
try: try:

View File

@ -19,6 +19,7 @@ COLUMNS = {
"goodwill_amount": "Гудвилл (+/-)", "goodwill_amount": "Гудвилл (+/-)",
"comment": "Комментарий", "comment": "Комментарий",
} }
NO_INVESTMENTS = "Нет вложений"
MONEY = ("financial_investment_amount", "participation_amount", "charter_capital_amount", "goodwill_amount") 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 row = sheet.max_row + 1
for col, key in enumerate(keys, 1): for col, key in enumerate(keys, 1):
value = values.get(key) 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) _put_money(sheet, row, col, value)
else: else:
_put_text(sheet, row, col, value) _put_text(sheet, row, col, value)
@ -66,6 +69,8 @@ def build_workbook(table: GoodwillTableOut, sign: GoodwillSign = "all") -> bytes
) )
for item in visible: for item in visible:
values = {key: getattr(item, key) for key in MONEY} values = {key: getattr(item, key) for key in MONEY}
if not item.has_investments:
values["financial_investment_amount"] = NO_INVESTMENTS
values.update( values.update(
name=item.subsidiary_legal_entity.name, name=item.subsidiary_legal_entity.name,
include_in_kfo="☑" if item.include_in_kfo else "☐", include_in_kfo="☑" if item.include_in_kfo else "☐",

View File

@ -2,6 +2,7 @@
from decimal import Decimal from decimal import Decimal
from http import HTTPStatus from http import HTTPStatus
from uuid import uuid4
import pytest import pytest
from sqlalchemy import func, select 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.kfo import Goodwill, KfoResult
from src.models.osv import OsvEntry from src.models.osv import OsvEntry
from src.models.statements import FinancialStatementValue from src.models.statements import FinancialStatementValue
from src.schemas.goodwill import GoodwillRowIn from src.schemas.consolidations import RefOut
from src.services.goodwill import validate_decision 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_consolidated_osv import KA_RF
from tests.test_api_goodwill import batch, goodwill_world 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 response.json()["rows"][0]["include_in_kfo"] is False
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0 assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
assert await _goodwill_audit_count(db_session) == 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") response = await client.get(base + "/goodwill/export")
sheet = load_workbook(BytesIO(response.content)).active 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(3, 7).value is None
assert sheet.cell(5, 4).value == 0 assert sheet.cell(5, 4).value == 0
assert sheet.cell(5, 7).value == 0 assert sheet.cell(5, 7).value == 0

View File

@ -26,7 +26,7 @@
### 3. Ручной ввод и отсутствие вложений ### 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). 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. 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. Транзакции и безопасность ### 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. Граница КФО ### 6. Граница КФО
@ -85,3 +85,8 @@ GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениям
- **R-6 (2026-11-06). Пометка результата КФО устаревшим при смене переключателя или строк не делается — в AURORA-1546.** - **R-6 (2026-11-06). Пометка результата КФО устаревшим при смене переключателя или строк не делается — в AURORA-1546.**
Обоснование: результата КФО (`kfo_result`) ещё не строится, помечать нечего. Цена ошибки: в 1546 добавить сброс Обоснование: результата КФО (`kfo_result`) ещё не строится, помечать нечего. Цена ошибки: в 1546 добавить сброс
в `save_rows` и `settings`. в `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. Источники и контракт расчёта ## 1. Источники и контракт расчёта
- [x] 1.1 arm-core: добавить тесты на реальных исходных данных с загрузкой неизменённых реальных fixtures счёта 58.01 «Приманыческий» 2025 Q3 и 58.03 РБ 2025 Q1 / 2024 Q3; проверить реальные счета, контрагентов и суммы, зафиксировать положительный набор 58.01 и отрицательный 58.03. Не подменять отсутствующий реальный источник синтетической суммой. - [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 и записи вычисляемых пар при чтении. - [x] 1.3 arm-core: реализовать доменный модуль расчёта гудвилла: пары по дереву счетов, объединение сохранённого ввода, формулы и предупреждения; проверить тесты 1.1–1.2, отсутствие ORM/float и записи вычисляемых пар при чтении.
## 2. Загрузка и API чтения ## 2. Загрузка и API чтения
@ -18,7 +18,7 @@
## 3. Сохранение и переключатель ## 3. Сохранение и переключатель
- [x] 3.1 arm-core: добавить тесты изменения данных гудвилла для создания/повторного сохранения, снятия выбора, доли null/0/100/лишней точности, COMMENT_REQUIRED, неактивной пары, переполнения денег и отката аудита вместе с записью; добавить проверки статусов/блокировок и двух сериализованных сохранений. - [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.3 arm-core: реализовать PUT goodwill-settings, добавить тесты изменения/повтора значения, сохранности ручного ввода и нулевого вклада при выключении; проверить единое событие GOODWILL_EDIT только при изменении переключателя и отсутствие изменений KfoResult.
- [x] 3.4 arm-core: обновить описательный контракт API и OpenAPI-контракт для PUT, ошибок и null доли снятой строки; проверить примеры запросов/ответов контрактными тестами и одинаковую семантику MD/OpenAPI. - [x] 3.4 arm-core: обновить описательный контракт API и OpenAPI-контракт для PUT, ошибок и null доли снятой строки; проверить примеры запросов/ответов контрактными тестами и одинаковую семантику MD/OpenAPI.