{ "request_id": "req-T8", "task_id": "T8", "title": "ISO 4217-aware форматирование денег (per-currency decimals)", "status": "ready_for_review", "created_at": "2026-10-09T12:00:00Z", "goal": "Исправить наивное форматирование денег (захардкоженные :,.2f) — ввести per-currency разрядность по ISO 4217, единый форматтер, корректное отображение JPY (0 знаков), BHD/KWD/OMR (3 знака) и остальных валют из CURRENCY_SYMBOLS.", "decisions": { "rounding_mode": { "value": "ROUND_HALF_UP", "rationale": "Совместимо с T3 и интуитивно (0.5 → 1). ROUND_HALF_EVEN (banker's rounding) — для банковской отчётности; для персональной финмодели overkill. Зафиксировать в format_money() как единственное место округления при отображении." }, "thousands_separator": { "value": ",", "rationale": "Сохраняем текущее поведение (:,.2f). Locale-чувствительное форматирование (ru: пробел, en: запятая) — отдельная задача T9+, не входит в T8." }, "unknown_currency_layout": { "value": "suffix", "example": "1,234.56 CHF", "rationale": "Символ валюты-как-префикс работает только если валюта в CURRENCY_SYMBOLS. Для неизвестных (CHF, TRY, AED) — суффикс через пробел. Код валюты (USD, JPY) тоже суффиксом при отсутствии символа, чтобы не путать с уже известными." }, "internal_precision": { "value": "сохранить Decimal-арифметику без изменений", "rationale": "T8 только про display. _q() в forecast.py и quantize('0.01') в convert() оставить как есть. Их влияние на точность прогноза и кросс-валютной конвертации — отдельный T9 (если будет нужен). Это решение изолирует blast radius." } }, "scope": { "in_scope": [ "Добавить CURRENCY_DECIMALS в domain/currency.py (RUB/USD/EUR/GBP/CNY/JPY/KZT/UAH = 2 кроме JPY=0; BHD/KWD/OMR = 3 как примеры)", "Реализовать CurrencyConverter.format_money(amount: Decimal, currency: str) -> str", "Заменить все 14 литералов :,.2f в infrastructure/cli/ на вызов format_money()", "Переработать 2 i18n-шаблона с {amount:.2f} — передавать уже отформатированную строку", "Расширить tests/test_currency.py кейсами: JPY (0 знаков), BHD (3 знака), USD (2 знака), неизвестная валюта (suffix)" ], "out_of_scope": [ "Изменение _q() в application/forecast.py (точность хранения)", "Изменение quantize() в CurrencyConverter.convert() (точность кросс-валютной конвертации)", "Locale-чувствительное форматирование (ru: пробел как разделитель)", "Поддержка криптовалют, не-ISO 4217 кодов", "Изменение ExcelRepository (формат ячеек в .xlsx)" ] }, "files_to_change": [ "domain/currency.py (новые CURRENCY_DECIMALS, format_money)", "infrastructure/cli/services.py (2 места format_money)", "infrastructure/cli/commands/info.py (5 мест format_money)", "infrastructure/cli/commands/config_cmd.py (5 мест format_money)", "infrastructure/cli/i18n.py (2 шаблона, .format(amount_str=...) вместо .format(amount=...))", "infrastructure/cli/commands/config_cmd.py (callers i18n-шаблонов — передавать amount_str)", "tests/test_currency.py (новые кейсы format_money)" ], "acceptance_criteria": [ "format_money(Decimal('1234.567'), 'JPY') == '¥1,235' (без десятичных)", "format_money(Decimal('1234.567'), 'USD') == '$1,234.57' (2 знака, HALF_UP)", "format_money(Decimal('1.234'), 'BHD') == 'BHD 1.234' (3 знака, suffix для неизвестного)", "format_money(Decimal('1.234'), 'CHF') == 'CHF 1.23' (2 знака default, suffix)", "format_money(Decimal('-1200.00'), 'RUB') == '-₽1,200.00' (отрицательные работают)", "format_money(Decimal('0'), 'JPY') == '¥0'", "Ни одного литерала :,.2f для денег в infrastructure/cli/ (можно оставить в unit-тестах format_money и в не-денежных format-строках)", "i18n-шаблоны cmd.config.transaction_add.ok и cmd.config.recurring_add.ok больше не содержат {amount:.2f}", "Все 67 существующих тестов проходят (без изменений кода тестов, кроме test_currency.py)", "Минимум 5 новых тестов в test_currency.py (по одному на JPY/BHD/USD/CHF/негативный кейс)" ], "verification": { "tests_passed_target": "67 существующих + 5 новых = 72/72", "lsp_clean": true, "cf_info_with_jpy_account": "показывает целые йены, без .00", "cf_config_transaction_add_ru": "ok-message содержит корректно отформатированную сумму (e.g. '1 200,50 ₽' или '$1,200.00' в зависимости от локали счёта)", "no_remaining_hardcoded_decimals": "grep -rn ':\\.2f' infrastructure/cli/ | grep -v test | grep -v 'not money' не находит денежных литералов" }, "fulfills_ac": [ "format_money() — единственная точка форматирования денег в domain-слое", "ISO 4217-корректное отображение для JPY (0 знаков) и 3-значных валют", "CURRENCY_DECIMALS покрывает все валюты из CURRENCY_SYMBOLS плюс BHD/KWD/OMR", "Все денежные литералы в CLI заменены на format_money()", "Тесты JPY/BHD/USD/CHF/негативный кейс", "pytest 72/72" ], "execution_hints": { "preferred_diff_approach": "Сначала ввести CURRENCY_DECIMALS + format_money() в domain/, написать тесты, проверить зелёные. Потом по одному менять callers (services.py → info.py → config_cmd.py → i18n.py), после каждого — pytest.", "risk_areas": [ "i18n-шаблоны: format() с kwargs — если забыть передать новый amount_str, KeyError в рантайме. Защита: f'format ... {amount_str}' с дефолтом через .get()", "config_cmd.py: 5 мест с разным контекстом (account, transaction, recurring, asset, liability) — все нужно заменить консистентно", "Negative amounts: f-string в Python не даёт ведущий минус по-другому, но Decimal('−1200') в format() может дать неожиданные скобки. Тест обязателен." ], "rollback": "Если T8 ломает > 2 тестов из существующих 67 — откатить, переоценить scope. Скорее всего, проблема в callers, не в format_money()." } }