From 31fb404b472035f6874df8399ec94cf3f93c258f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:47:50 +0400 Subject: [PATCH 01/28] =?UTF-8?q?=D0=94=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20=C2=AB=D0=A2=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D0=B0=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=C2=BB=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenSpec change corrections-table-ui: proposal, design, спека corrections-table-ui и дельта intercompany-matrix-ui (вкладка доступна). AURORA-1541. Co-Authored-By: Claude Opus 5.5 --- .../corrections-table-ui/.openspec.yaml | 2 + .../changes/corrections-table-ui/design.md | 181 +++++++++++++++++ .../changes/corrections-table-ui/proposal.md | 61 ++++++ .../specs/corrections-table-ui/spec.md | 184 ++++++++++++++++++ .../specs/intercompany-matrix-ui/spec.md | 34 ++++ 5 files changed, 462 insertions(+) create mode 100644 openspec/changes/corrections-table-ui/.openspec.yaml create mode 100644 openspec/changes/corrections-table-ui/design.md create mode 100644 openspec/changes/corrections-table-ui/proposal.md create mode 100644 openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md create mode 100644 openspec/changes/corrections-table-ui/specs/intercompany-matrix-ui/spec.md diff --git a/openspec/changes/corrections-table-ui/.openspec.yaml b/openspec/changes/corrections-table-ui/.openspec.yaml new file mode 100644 index 0000000..1bb6456 --- /dev/null +++ b/openspec/changes/corrections-table-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-28 diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md new file mode 100644 index 0000000..e6a62f2 --- /dev/null +++ b/openspec/changes/corrections-table-ui/design.md @@ -0,0 +1,181 @@ +# Дизайн: экран «Таблица корректировки» (AURORA-1541, arm-core #206) + +## Зачем + +Кредитный аналитик на шаге консолидации видит, где внутригрупповые обороты двух компаний не сошлись, разбирается, из каких +строк ОСВ сложилась каждая сторона, переносит строки между правилами и вносит корректировку — сколько и из каких статей ББ +исключить. Без экрана корректировки ВГО делаются только вне системы, и тестирование корректировок (AURORA-1542, с 03.11) +начать нельзя. + +## Что считается готовым (слова владельца, 28.10.2026) + +- К сроку 02.11 — **рабочий экран на настоящем API**, не демо на моках. Если бэкенд (AURORA-1540) или аналитика не успевают — + мы сами помогаем ребятам доделать. +- Моки нужны, чтобы фронт не стоял, пока нет API, а не как результат. + +**Объём плана — только `web/`.** Бэкенд (DBML, `vgo_assignment`, разнесение, эндпоинты, аудит) — AURORA-1540 Цыганкова; +помогаем ему на ревью его PR, а не задачами этого плана. Последняя задача плана — подключение к настоящему API, когда 1540 +слит: флаг `REACT_APP_CORRECTIONS_MOCK` выключается, если эндпоинты есть в `main`, вкладка открывается на стенде на +реальной версии и проходит сценарии спеки; расхождения ответа с контрактом правятся в той ветке, где ошибка (фейк или +бэкенд). Сверяем фейк с реальным ответом мы (владелец / этот агент) при подключении. + +## Источники и их вес + +1. Аналитика Лысенко — DOC-272613, ответы 1–7 (AURORA-1395, 27.10), DOC-274230. **Приоритетна над нашими правилами и + черновиками** (решение владельца 28.10). +2. Дизайн от 23.10.2026 — `ДКБ_таблица_корректировок_23_10_26_1.html` (вложение к DOC-272613; в корне workspace). Как лежат + строки в дизайне — визуализация; логика — по аналитике. +3. ТЗ 6.8 / Прил. 9 — ниже аналитики, иногда игнорируется. +4. Контракт API — `../docs/dev/api-contract-corrections.md` и `.openapi.yaml` (docs #183, вложение в AURORA-1540). + +Открытые вопросы аналитику (комментарий `[design-23-10-check]` в AURORA-1395, п. 1–12 + 3 по ТЗ; в AURORA-1396 — ОФР) не +блокируют работу: зависящие места сделаны так, чтобы ответ менял поведение точечно. + +## Цели + +- Вкладка «Таблица корректировки» страницы отчёта версии, по виду — как дизайн 23.10. +- Таблица агрегатов с раскрытием до строк ОСВ, нераспределённые строки, итоги, фильтры, пресеты по правилам, единицы, + настройка колонок, экспорт, полный экран. +- Окно «Корректировка операции» агрегата: строки сторон, распределение по статьям, контроль, комментарий, сохранение. +- Решения по строкам: флажок «Учитывать в КФО», отнести / перенести / убрать / вернуть к автоматике. +- Отмена корректировки агрегата. +- Только чтение, когда сервер так говорит (`editable = false`). + +## Не цели + +- Бэкенд и DBML (AURORA-1540), правила §8 в `05-domain-rules.md`. +- ОФР (нет в дизайне; AURORA-1396), импорт xlsx (ТЗ 6.8, ждёт ответа). +- Корректировка строки ОСВ и нераспределённой строки (аналитика: только агрегат; ждёт п. 2). +- Переход из шахматки с фокусом на паре (AURORA-1653), учёт корректировок в шахматке (AURORA-1643). + +## Анти-цели (провал, даже если «работает») + +- Фронт сам считает разнесение строк, отклонения, порог 5 % или статусы агрегата — это делает сервер; фронт только + показывает, форматирует и суммирует видимые строки для «Итого». +- Деньги через `number`/`float` с потерей копеек. Суммы — строки контракта, сложение — точное десятичное. +- Экран, в котором можно что-то нажать и молча потерять изменения окна корректировки. +- Кнопки правки у версии, которую сервер считает нередактируемой. + +## Ограничения + +- `web/` arm-core: React 18 + TS strict, antd 6, react-router 7, biome (140, одинарные кавычки), vitest. +- Деньги в API — рубли строками с двумя знаками; показ в руб. / тыс. руб. и 0–2 знака — как в шахматке. +- Состояние экрана — локальные хуки (стор Redux пуст по соглашению шаблона). +- Пользователь слаб во фронте — решения в PR объясняются простыми словами. +- Каждый экран сверяется с дизайном в Playwright и проходится руками до сдачи. + +## Как устроено + +### Данные + +- `GET …/corrections?statement_type=balance` — одна загрузка всей таблицы; фильтры, сортировка, «Итого» — на клиенте. +- Правки: `PUT …/aggregates/{rule}/{c1}/{c2}` (сохранить), `POST …/revoke`, `POST …/allocations` (решение по строке, ответ — + вся таблица), `GET …/export`. +- Клиент — `web/src/services/api/corrections.ts` поверх `api/client.ts`. При `REACT_APP_CORRECTIONS_MOCK=true` те же функции + отвечают из фейка контракта в памяти (данные — из дизайна 23.10, с проверками контракта: обязательный комментарий, + `expected_version_no`, применимость правила). Флаг по умолчанию выключен; в production-сборке фейк не используется. + +### Таблица + +Колонки как в дизайне: «Учитывать в КФО»; Дебет — ЮЛ, Строка баланса, Счёт, Сумма; Кредит — то же; Контроль — +Отклонение %, Отклонение сумма; Корректировка — Строка баланса, Сумма (Дт), Строка баланса, Сумма (Кт), Комментарий; +колонка действий. + +- **Строка агрегата.** Суммы сторон, счета (`accounts`), отклонение. Процент и сумма отклонения красные при + `exceeds_threshold`. Корректировка — статьи и суммы `correction.debit` / `credit` (несколько статей стороны — по строке на + статью в ячейке), комментарий. Действия: «Корректировать» (окно), меню «…» — «Отменить корректировку» (если есть). + `composition_changed` — подсветка строки и значок с подсказкой «Состав изменился после корректировки». + `correction.status = DRAFT` — пометка «черновик» у корректировки. +- **Раскрытие.** Под агрегатом — его строки ОСВ (`side_1.entries`, затем `side_2.entries`): строка стороны 1 заполняет блок + «Дебет», стороны 2 — «Кредит», остальные ячейки пустые (DOC-272613 п. 3.4; пары строк из дизайна не строим). У строки — + флажок «Учитывать в КФО» и меню: «Перенести в правило…» (правила — `candidate_rule_ids` строки; фронт сам не сверяет + счета с правилами), «Убрать из правила», «Вернуть к автоматике» (если `decision = ASSIGN`). +- **Флажок агрегата** «Учитывать в КФО» — только показ: «Да» — все строки учитываются, «Частично» — часть исключена, «Нет» — + все исключены. Сам признак ставится на строке (DOC-272613; ждёт п. 4). +- **Нераспределённые строки** — после агрегатов, фон приглушён. Владелец — на своей стороне (дебетовая строка — в «Дебет», + кредитовая — в «Кредит»), на другой стороне — только ЮЛ контрагента. Строка баланса не показывается (DOC-272613 п. 4.3). + Флажок «Учитывать в КФО», меню «Отнести к правилу…» (список — `candidate_rule_ids`), «Вернуть к автоматике» для + `reason = UNASSIGNED`. Кнопки «Корректировать» нет (ждёт п. 2). +- **«Итого»** — суммы сторон, отклонения и корректировок по видимым агрегатам, точным сложением строк; процент «Итого» не + показываем (смысл не задан). +- Подписи «Отнести/Перенести в правило…» показывают правила как пресеты: «Правило N: <статья 1> — <статья 2>». + +### Панель + +- «Отображение»: «Общий» / «По пресету»; при «По пресету» — выбор правила (подпись с номером — снимает одинаковые подписи + правил 5 и 8 из дизайна, п. 12); в таблице только агрегаты этого правила и те нераспределённые строки, у которых это + правило есть в `candidate_rule_ids` (их можно отнести к нему). +- «Тип записи»: «Все» / «Пары счетов» (агрегаты) / «Нераспределенные записи». +- Фильтры «ЮЛ» и «Счёт» действуют и на нераспределённые строки; «Строка баланса» и «Отклонение» — только на агрегаты, при + заданном одном из них нераспределённые строки скрываются (у них нет статьи и отклонения). +- Составная статья правила (`"1450/1550"`, `"1310/1350"`, правила 1 и 2) показывается как «1450 / 1550»; в фильтре + «Строка баланса» это два кода; в окне корректировки подставляется первый код, аналитик может выбрать второй. +- Единицы и знаки — тот же компонент, что в шахматке. +- Фильтры в заголовках: ЮЛ (подстрока, обе стороны), Строка баланса (список), Счёт (начало кода), Отклонение % + («Все» / «≥ 5 %» / «< 5 %», по `exceeds_threshold`); «Сбросить фильтры» — когда что-то задано. +- «Настройка таблицы» — `GenericTable/ColumnSettings` (видимость групп и колонок); «Экспорт» — файл сервера; «На весь экран» — + как в шахматке. + +### Окно «Корректировка операции» + +По дизайну: шапка сторон (компания, статья правила), блоки «Исходные данные ОСВ» и «Распределение корректировки» для +каждой стороны, итоги, комментарий, «Отменить» / «Сохранить корректировки». + +- **Исходные данные ОСВ** — строки стороны агрегата. «×» у строки — «Убрать из правила» (`UNASSIGN`), «Добавить строку ОСВ» — + выбор из нераспределённых строк, которые подходят под эту сторону: та же пара компаний (владелец и контрагент), сторона + строки = сторона правила, правило агрегата есть в `candidate_rule_ids` (ответ 3(а)). Обе операции идут на сервер сразу + (`POST …/allocations`), окно перечитывает агрегат. +- **Распределение** — статьи стороны: строка баланса (код статьи) и сумма; «Добавить строку» — дочерняя статья. Без + корректировки окно предлагает по стороне одну статью правила с суммой стороны (как в дизайне). +- **Контроль** — суммы исходных строк сторон и суммы распределения сторон, разница и процент к меньшему (как в таблице; + 79,77 % из дизайна — ошибка дизайна, п. 7). Расхождение не запрещает сохранение (ждёт п. 8). +- **Сохранить** — `PUT` с `status = APPLIED`, `expected_version_no` из ответа; ошибки сервера показываются в окне по коду + (`COMMENT_REQUIRED` — у поля комментария, `INVALID_LINE_CODE` — у статьи, `CORRECTION_VERSION_CONFLICT` — «Корректировку уже + изменили, таблица обновлена», окно перечитывает данные). +- **Черновик окна в `localStorage`** (решение владельца 28.10.2026 вместо блокировки переходов): статьи распределения и + комментарий сохраняются при каждой правке; ключ — пользователь + версия + агрегат (`rule_id`, компании), в записи — время + и `version_no` корректировки, от которой начаты правки. Переход «Назад», закрытие вкладки, переход по ссылке правки не + теряют. При открытии окна этого агрегата черновик подставляется с плашкой «Восстановлены несохранённые правки от ЧЧ:ММ» и + кнопкой «Сбросить»; если `version_no` на сервере уже другой — плашка «Корректировку изменили после ваших правок» + (аналитик решает: оставить или сбросить). Черновик удаляется после успешного сохранения и после «Отменить» с + подтверждением «Есть несохранённые изменения. Закрыть без сохранения?». Действия со строками в черновик не входят (они уже + на сервере). Доступ к хранилищу — в `try/catch`: недоступно (приватный режим) — работаем без черновика. + +### Только чтение + +`editable = false` — «Корректировать», флажки и меню решений неактивны; подсказка по `read_only_reason`: +`VERSION_NOT_EDITABLE` — «Версия закрыта для изменений», `LOCK_REQUIRED` — «Возьмите консолидацию в работу», +`LOCKED_BY_OTHER` — «Консолидацию редактирует другой пользователь». Окно корректировки открывается на просмотр. + +### Состояния + +Загрузка — индикатор на месте таблицы; ошибка — уведомление с текстом сервера и «Повторить»; пусто — «Внутригрупповых +остатков между компаниями периметра нет»; фильтры ничего не нашли — «Нет записей для выбранных фильтров» (текст дизайна). + +## Что оставлено исполнителю + +Разбиение на компоненты и хуки, имена файлов внутри `widgets/CorrectionTable/`, способ раскрытия (antd `expandable` или +собственные строки), вид меню действий, точная раскладка окна в пределах дизайна, состав данных фейка. + +## Decisions made without discussion + +| Дата | Решение | Почему | Цена ошибки | +|---|---|---|---| +| 28.10.2026 | Контракт API пишем сами (docs #183), markdown + OpenAPI; корректировка в контракте — только на агрегат | API ещё нет; аналитика приоритетна | при ответе «можно на строку» (п. 2) — новый эндпоинт и кнопки на строках | +| 28.10.2026 | Раскрытие агрегата — строки ОСВ под своей стороной, не пары из дизайна | DOC-272613 п. 3.4; пары нигде не хранятся | ответ по п. 3 «пары нужны» — доработка раскрытия и контракта | +| 28.10.2026 | «Учитывать в КФО» ставится на строке; у агрегата — производное «Да / Частично / Нет» | DOC-272613 (EXCLUDE на `osv_entry`) | ответ по п. 4 «на агрегат» — поменять флажок агрегата на действие | +| 28.10.2026 | Действия со строками в окне корректировки идут на сервер сразу, статьи и комментарий — по «Сохранить» | разнесение и корректировка — разные записи (`vgo_assignment` / `correction`); аналитик видит новые суммы до ввода корректировки | отмена окна не откатывает перенос строк — аналитику нужно вернуть строку вручную | +| 28.10.2026 | Сохранение сразу применяет (`APPLIED`), черновик не показываем | в дизайне одна кнопка | ответ по п. 9 «нужен черновик» — вторая кнопка | +| 28.10.2026 | «Отменить корректировку» — в меню агрегата, с комментарием | `REVOKED` в аналитике есть, в дизайне нет места | ответ по п. 9 — перенести кнопку | +| 28.10.2026 | Процент в окне — к меньшему, как в таблице | аналитика; в дизайне две формулы | — | +| 28.10.2026 | Без корректировки окно предлагает суммы сторон целиком | так в дизайне; в аналитике подсказка не описана | ответ по п. 8 — поменять подсказку на min | +| 28.10.2026 | Фильтр «Отклонение» — по `exceeds_threshold` (≥ 5 % / < 5 %), а не диапазон чисел | порог — главный вопрос аналитика; диапазон в дизайне не описан | доработка фильтра | +| 28.10.2026 | «Итого» без процента | смысл процента по итогам не задан | добавить колонку | +| 28.10.2026 | Фейк контракта под флагом `REACT_APP_CORRECTIONS_MOCK`, данные из дизайна | фронт не ждёт бэкенд; тот же код ходит в настоящий API | фейк расходится с бэкендом — ловится при подключении API и ревью AURORA-1540 | +| 28.10.2026 | Только `statement_type = balance`, переключателя ББ/ОФР нет | в дизайне его нет; ОФР ждёт AURORA-1396 | добавить переключатель | +| 28.10.2026 | Ключ черновика — пользователь + версия + агрегат; черновик без срока жизни, удаляется сохранением или «Отменить» | на общем компьютере у разных пользователей разные черновики | старые черновики копятся в `localStorage` — объём мал | +| 28.10.2026 | План — только `web/`, бэкенду помогаем на ревью 1540; последняя задача — подключение к API и снятие флага | слова владельца «поможем ребятам на ревью» | если 1540 не успеет к 02.11, экран сдаётся на фейке | +| 28.10.2026 | В контракт добавлен `candidate_rule_ids` у строки агрегата | иначе фронту пришлось бы сверять счета с правилами — это разнесение, анти-цель | правка контракта до начала 1540 — дёшево | +| 28.10.2026 | `expected_version_no = null`, когда действующей корректировки нет (и после отзыва) | иначе после отзыва фронту неоткуда взять номер | — | +| 28.10.2026 | Составная статья правил 1 и 2 — в окне первый код, можно выбрать второй | `line_code` — один код; особый расчёт правил 1 и 2 (DOC-274230) — на сервере | при ответе «нужно распределение по обеим» — аналитик добавляет вторую статью руками | +| 28.10.2026 | «По пресету» показывает и подходящие нераспределённые строки; «Строка баланса» и «Отклонение» скрывают нераспределённые | пресет — рабочее место по правилу, строки для отнесения нужны рядом | поменять функцию фильтров | diff --git a/openspec/changes/corrections-table-ui/proposal.md b/openspec/changes/corrections-table-ui/proposal.md new file mode 100644 index 0000000..5f21d30 --- /dev/null +++ b/openspec/changes/corrections-table-ui/proposal.md @@ -0,0 +1,61 @@ +## Why + +AURORA-1541: вкладка «Таблица корректировки» страницы отчёта версии — заглушка «Будет в следующих срезах». Аналитику нужен +экран, где видно расхождения ВГО по правилам и парам компаний, строки ОСВ, из которых они сложились, нераспределённые +строки, и где вносится корректировка агрегата. Срок задачи — 02.11.2026, к 03.11 начинается тестирование корректировок +(AURORA-1542). + +Основа — аналитика Лысенко (DOC-272613 «Корректировка данных агрегатом», ответы на вопросы 1–7 от 27.10.2026 в AURORA-1395, +DOC-274230 «Правила пар счетов») и дизайн от 23.10.2026 (вложение к DOC-272613). Где дизайн расходится с аналитикой, +действует аналитика (решение владельца 28.10.2026); вопросы по расхождениям — комментарий `[design-23-10-check]` в +AURORA-1395 и в AURORA-1396. + +## What Changes + +Срез 5 (`09-delivery-plan.md`, «ВГО: автоматическое сопоставление, шахматка, корректировки»). Репозиторий — arm-core, +только `web/`. Бэкенд — AURORA-1540 (Цыганков) по контракту `../docs/dev/api-contract-corrections.md` + +`api-contract-corrections.openapi.yaml` (docs #183); пока API нет, экран работает на встроенном фейке контракта под флагом +`REACT_APP_CORRECTIONS_MOCK`. + +- Вкладка «Таблица корректировки» (`…/versions/{id}/correction`) становится доступной. +- Таблица агрегатов «правило × компания 1 × компания 2»: блоки Дебет / Кредит / Контроль / Корректировка, раскрытие до строк + ОСВ под своей стороной, нераспределённые строки после агрегатов, строка «Итого» по видимым строкам. +- Панель: «Отображение» (Общий / По пресету + выбор правила), «Тип записи», единицы и знаки, «Сбросить фильтры», + «Настройка таблицы», «Экспорт», «На весь экран»; фильтры в заголовках колонок. +- Окно «Корректировка операции» агрегата: исходные строки ОСВ сторон (убрать, добавить подходящую нераспределённую), + распределение по статьям каждой стороны, контроль распределения, комментарий, «Сохранить корректировки». +- Решения по строке: «Учитывать в КФО» (флажок строки), «Отнести к правилу» / «Перенести в правило», «Убрать из правила», + «Вернуть к автоматике»; «Отменить корректировку» агрегата с комментарием. +- Режим только чтения по `editable` / `read_only_reason` ответа. +- Новая спека `corrections-table-ui`; дельта `intercompany-matrix-ui` (вкладка «Таблица корректировки» доступна). + +## Non-goals + +- Бэкенд, DBML (`correction` как есть в v5.23; `vgo_assignment` — новая версия DBML в рамках AURORA-1540). +- ОФР: в дизайне его нет, вопрос в AURORA-1396; экран запрашивает только `statement_type = balance`. +- Импорт xlsx (вопрос по ТЗ 6.8 в `[design-23-10-check]`). +- Корректировка строки ОСВ и нераспределённой строки — по аналитике корректируется только агрегат (ждёт п. 2). +- Переход из ячейки шахматки в таблицу с фокусом на паре — AURORA-1653. +- Учёт корректировок в шахматке — AURORA-1643. + +## Capabilities + +### New Capabilities + +- `corrections-table-ui`: экран «Таблица корректировки» версии консолидации. + +### Modified Capabilities + +- `intercompany-matrix-ui`: MODIFIED «Вкладки страницы отчёта по версии» — «Таблица корректировки» доступна. + +## Impact + +- `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx` — вкладка `correction` без `disabled`. +- `web/src/layouts/ConsolidationReportPage.tsx` — содержимое вкладки `correction`. +- Новое: `web/src/widgets/CorrectionTable/` (таблица, панель, окно корректировки, чистые функции + тесты), + `web/src/services/api/corrections.ts`, типы контракта в `web/src/types/api.ts` (или `types/corrections.ts`), фейк контракта. +- `web/src/conf/config.ts` — флаг `REACT_APP_CORRECTIONS_MOCK`. +- Таблицы DBML v5.23: `correction`, `elimination_rule`, `osv_entry` — только читаются через API; новых полей фронт не вводит. +- Правила: `05-domain-rules.md` §8 (корректировки) в нынешнем виде описывает только запись корректировки; разнесение строк и + `vgo_assignment` в §8 нет — ASSUMPTION: до обновления §8 источник — DOC-272613 и ответы 1–7 (обновление §8 и DBML — + в рамках AURORA-1540 / AURORA-1751). diff --git a/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md b/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md new file mode 100644 index 0000000..15dee52 --- /dev/null +++ b/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md @@ -0,0 +1,184 @@ +## ADDED Requirements + +### Requirement: Загрузка таблицы корректировок +Вкладка `correction` страницы отчёта версии SHALL загружать таблицу одним запросом +`GET /consolidations/versions/{id}/corrections?statement_type=balance` (контракт `../docs/dev/api-contract-corrections.md`) +и показывать её целиком: строки агрегатов (`aggregates[]`) в порядке ответа, затем нераспределённые строки +(`unallocated[]`), внизу строку «Итого». Пока идёт загрузка, на месте таблицы SHALL быть индикатор; ошибка API SHALL +показываться уведомлением с текстом сервера и кнопкой «Повторить». Пустой ответ SHALL показывать текст «Внутригрупповых +остатков между компаниями периметра нет». Разнесение строк, отклонения, порог 5 % и статусы агрегатов фронт SHALL брать из +ответа и не пересчитывать. При `REACT_APP_CORRECTIONS_MOCK=true` те же запросы SHALL обслуживаться фейком контракта в памяти. + +#### Scenario: Открытие вкладки +- **WHEN** аналитик открывает `…/versions/{id}/correction` +- **THEN** выполняется один запрос таблицы, показываются агрегаты, после них нераспределённые строки и строка «Итого» + +#### Scenario: Ошибка загрузки +- **WHEN** сервер вернул 500 +- **THEN** показано уведомление с текстом ошибки и кнопка «Повторить», повторный запрос выполняется по нажатию + +### Requirement: Колонки и строка агрегата +Таблица SHALL иметь колонки «Учитывать в КФО»; группу «Дебет» — «ЮЛ», «Строка баланса», «Счёт», «Сумма»; группу «Кредит» — +те же четыре; группу «Контроль» — «Отклонение, %», «Отклонение, сумма»; группу «Корректировка» — «Строка баланса», «Сумма» +(Дт), «Строка баланса», «Сумма» (Кт), «Комментарий»; колонку действий. Строка агрегата SHALL показывать: компании +`company_1` / `company_2`, статьи `side_1.article_line` / `side_2.article_line`, счета `accounts` через запятую, суммы сторон, +`deviation_percent` (пусто — «—») и `deviation_amount`; при `exceeds_threshold = true` процент и сумма отклонения SHALL быть +красными. Блок «Корректировка» SHALL показывать статьи и суммы `correction.debit` / `correction.credit` (по строке на статью) +и `correction.comment`; без корректировки — «—». Корректировка в статусе `DRAFT` SHALL помечаться «черновик». Агрегат с +`composition_changed = true` SHALL подсвечиваться и иметь значок с подсказкой «Состав изменился после корректировки». + +#### Scenario: Расхождение выше порога +- **WHEN** у агрегата «ЭПТ — ИЛС» `amount 1000000.00 / 202320.00`, `deviation_percent = "394.27"`, `exceeds_threshold = true`, + выбраны «тыс. руб.» и 2 знака +- **THEN** в строке видно `1 000,00`, `202,32`, красные `394,27%` и `797,68` + +#### Scenario: Корректировка по двум статьям стороны +- **WHEN** у корректировки агрегата `credit = [{1520, 500000.00}, {1550, 500000.00}]` +- **THEN** в ячейках «Строка баланса» и «Сумма» стороны Кт видно две строки: `1520` / `500,00` и `1550` / `500,00` + +### Requirement: Раскрытие агрегата до строк ОСВ +Строка агрегата SHALL раскрываться стрелкой слева. Под агрегатом SHALL показываться его строки: сначала `side_1.entries`, +затем `side_2.entries`; строка стороны 1 заполняет в блоке «Дебет» «Счёт» и «Сумма», строки стороны 2 — то же в блоке +«Кредит»; остальные ячейки пустые. У каждой строки SHALL быть флажок «Учитывать в КФО» (`excluded = false` — отмечен) и меню +действий: «Перенести в правило…» (правила из `candidate_rule_ids` строки; пустой список — пункт неактивен), «Убрать из +правила», «Вернуть к автоматике» (только при `decision = ASSIGN`). Строка с +`excluded = true` SHALL показываться приглушённо. + +#### Scenario: Агрегат из четырёх строк с каждой стороны +- **WHEN** аналитик раскрывает агрегат «ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД" — БЕКОВСКИЙ САХАРНЫЙ КОМБИНАТ ООО» правила 6 +- **THEN** под ним видны строки 62.01, 60.02, 62.03, 76.01 в блоке «Дебет» и строки 76.07, 60.01, 60.03, 62.02 в блоке + «Кредит», у каждой — флажок и меню + +### Requirement: Нераспределённые строки +После агрегатов SHALL показываться нераспределённые строки (`unallocated[]`) на приглушённом фоне: владелец (`owner`) — в +блоке своей стороны (`side = D` — «Дебет», `K` — «Кредит») со счётом и суммой; в блоке другой стороны — только ЮЛ +`counterparty`; «Строка баланса» обеих сторон, «Контроль» и «Корректировка» — «—». У строки SHALL быть флажок «Учитывать в КФО» +и меню «Отнести к правилу…» (правила из `candidate_rule_ids`) и «Вернуть к автоматике» (только при `reason = UNASSIGNED`). +Кнопки «Корректировать» у нераспределённой строки SHALL NOT быть. + +#### Scenario: Кредитовая строка без пары +- **WHEN** в `unallocated` строка владельца «ООО "БСК"» по 76.05 `side = K` на «ПМП ООО» на 142 878,00 +- **THEN** «ООО "БСК"», `76.05` и `142 878,00` — в блоке «Кредит», «ПМП ООО» — в «ЮЛ» блока «Дебет», меню предлагает правила из + `candidate_rule_ids` + +### Requirement: Флажок «Учитывать в КФО» +Флажок строки ОСВ (в раскрытии и у нераспределённой строки) SHALL отправлять `POST …/corrections/allocations` с +`action = EXCLUDE` (снят) или `INCLUDE` (поставлен) и заменять таблицу ответом. Флажок строки агрегата SHALL быть только +показом: «Да» — все строки агрегата учитываются, «Частично» — часть исключена, «Нет» — все исключены. + +#### Scenario: Исключить строку из КФО +- **WHEN** в раскрытом агрегате аналитик снимает флажок у строки 62.03 +- **THEN** уходит `{action: "EXCLUDE", entry_ids: [...]}`, таблица обновляется ответом, у агрегата флажок «Частично» + +### Requirement: Отнести, перенести и убрать строку +«Отнести к правилу…» и «Перенести в правило…» SHALL открывать выбор правила с подписями «Правило N: <статья 1> — <статья 2>» +и отправлять `action = ASSIGN` с `rule_id`; «Убрать из правила» — `action = UNASSIGN`; «Вернуть к автоматике» — +`action = RESET`. Ответ (вся таблица) SHALL заменять показанную таблицу; ошибка 422 `RULE_NOT_APPLICABLE` SHALL показываться +уведомлением с текстом сервера. + +#### Scenario: Перенос строки в другое правило +- **WHEN** аналитик выбирает у строки «Перенести в правило…» и «Правило 7: 1230 — 1410» +- **THEN** уходит `{action: "ASSIGN", rule_id: <правило 7>, entry_ids: [...]}`, строка видна в агрегате правила 7 + +### Requirement: Окно «Корректировка операции» +Кнопка «Корректировать» строки агрегата SHALL открывать окно «Корректировка операции» с двумя сторонами: компания и статья +правила стороны; блок «Исходные данные ОСВ» (счёт, строка баланса, сумма строк стороны; «×» у строки — `UNASSIGN`; +«Добавить строку ОСВ» — выбор из нераспределённых строк той же пары компаний, той же стороны, у которых правило агрегата есть +в `candidate_rule_ids`, — `ASSIGN`); блок «Распределение корректировки» (статьи: строка баланса и сумма; «Добавить строку» — +ещё статья стороны; «×» — удалить статью). Действия с исходными строками SHALL уходить на сервер сразу и перечитывать агрегат. +Без корректировки окно SHALL предлагать по каждой стороне одну статью правила с суммой стороны; у составной статьи +(«1450/1550») — первый код, второй доступен для выбора. Под блоками SHALL быть итоги +сторон и разница с процентом к меньшему (красным, если процент ≥ 5 или одна сторона пуста), поле «Комментарий к +корректировке», кнопки «Отменить» и «Сохранить корректировки». + +#### Scenario: Окно агрегата без корректировки +- **WHEN** аналитик нажимает «Корректировать» у агрегата «Пензенский Мир Продуктов — Регион-Сахар» правила 3 (29 742 550,00 / + 29 242 550,00) +- **THEN** в распределении стороны Дт — статья `1170` с суммой `29 742 550,00`, стороны Кт — `1410` с `29 242 550,00`, разница + `500 000,00 (1,71%)` + +#### Scenario: Добавить строку ОСВ +- **WHEN** в окне агрегата пары «Пензенский Мир Продуктов — Бековский сахарный комбинат» правила 6 аналитик нажимает + «Добавить строку ОСВ» на стороне Кт +- **THEN** список содержит только нераспределённые кредитовые строки «Бековский сахарный комбинат ООО» на «Пензенский Мир + Продуктов», подходящие под правило 6; выбор строки отправляет `ASSIGN` и обновляет суммы стороны + +### Requirement: Сохранение корректировки +«Сохранить корректировки» SHALL отправлять `PUT …/corrections/aggregates/{rule_id}/{company_1_id}/{company_2_id}` со статьями +обеих сторон (первая статья стороны — основная), комментарием, `status = APPLIED` и `expected_version_no` из ответа (`null` — +действующей корректировки нет, в том числе после отзыва) и после ответа закрывать окно и обновлять агрегат в таблице. Ошибки SHALL показываться в окне: `COMMENT_REQUIRED` — у +поля комментария, `INVALID_LINE_CODE` — у статьи `detail.line_code`, `CORRECTION_VERSION_CONFLICT` — сообщением «Корректировку +уже изменили, данные обновлены» с перечитыванием таблицы, остальные — текстом сервера. Закрытие окна с несохранёнными +статьями или комментарием SHALL спрашивать «Есть несохранённые изменения. Закрыть без сохранения?». + +#### Scenario: Сохранить без комментария +- **WHEN** аналитик сохраняет корректировку с пустым комментарием и сервер отвечает 422 `COMMENT_REQUIRED` +- **THEN** окно остаётся открытым, у поля комментария — текст ошибки + +#### Scenario: Закрыть с несохранённым +- **WHEN** аналитик изменил сумму статьи и нажал «Отменить» +- **THEN** появляется вопрос «Есть несохранённые изменения. Закрыть без сохранения?»; «Остаться» оставляет окно с правками + +### Requirement: Черновик окна корректировки +Статьи распределения и комментарий открытого окна SHALL сохраняться в `localStorage` при каждой правке под ключом +«пользователь + версия + агрегат» вместе со временем и `version_no` корректировки, от которой начаты правки. При открытии окна +агрегата с черновиком окно SHALL подставлять черновик и показывать плашку «Восстановлены несохранённые правки от ЧЧ:ММ» с +кнопкой «Сбросить»; если `version_no` действующей корректировки отличается от сохранённого в черновике — плашку +«Корректировку изменили после ваших правок». Черновик SHALL удаляться после успешного сохранения, после «Сбросить» и после +«Отменить» с подтверждением. Недоступное хранилище SHALL NOT ломать окно — оно работает без черновика. + +#### Scenario: Ушёл со страницы и вернулся +- **WHEN** аналитик изменил сумму статьи в окне агрегата, нажал «Назад» в браузере, потом снова открыл вкладку и окно этого + агрегата +- **THEN** в окне его сумма и плашка «Восстановлены несохранённые правки от …» + +#### Scenario: Корректировку изменили, пока аналитик отсутствовал +- **WHEN** в черновике `version_no = 2`, а в ответе таблицы у корректировки агрегата `version_no = 3` +- **THEN** окно подставляет черновик и показывает плашку «Корректировку изменили после ваших правок» с кнопкой «Сбросить» + +### Requirement: Отмена корректировки +У агрегата с корректировкой меню действий SHALL содержать «Отменить корректировку»: окно с обязательным комментарием, +`POST …/revoke`, после ответа агрегат обновляется и блок «Корректировка» показывает «—». + +#### Scenario: Отмена корректировки +- **WHEN** аналитик выбирает «Отменить корректировку» у агрегата «Сатурн — Архол» и вводит комментарий +- **THEN** уходит `POST …/revoke {comment}`, у агрегата в блоке «Корректировка» — «—» + +### Requirement: Панель и фильтры +Над таблицей SHALL быть: «Отображение» — «Общий» / «По пресету»; при «По пресету» — поле «Правило» с правилами `rules[]` +(подпись «Правило N: <статья 1> — <статья 2>», составная статья — «1450 / 1550») и в таблице только агрегаты выбранного +правила и нераспределённые строки, у которых оно есть в `candidate_rule_ids`; «Тип записи» — «Все», «Пары +счетов» (только агрегаты), «Нераспределенные записи» (только нераспределённые); единицы и знаки — как в шахматке; «Сбросить +фильтры» — когда задан любой фильтр; справа — «Настройка таблицы», «Экспорт», «На весь экран». В заголовках SHALL быть +фильтры: «ЮЛ» (подстрока имени, по обеим сторонам), «Строка баланса» (выбор кода), «Счёт» (начало кода, по счетам строки), +«Отклонение, %» («Все» / «≥ 5 %» / «< 5 %» по `exceeds_threshold`). Фильтры «ЮЛ» и «Счёт» SHALL действовать и на +нераспределённые строки; при заданном фильтре «Строка баланса» или «Отклонение, %» нераспределённые строки SHALL скрываться. +Составная статья правила (коды через «/») в фильтре «Строка баланса» SHALL давать отдельные коды. Фильтры ничего не нашли — +текст «Нет записей для выбранных фильтров». Строка «Итого» SHALL суммировать видимые агрегаты точным сложением: суммы сторон, отклонение, суммы +корректировок Дт и Кт; процент в «Итого» — «—». + +#### Scenario: Пресет правила 3 +- **WHEN** выбраны «По пресету» и «Правило 3: 1170 — 1410» +- **THEN** в таблице только агрегаты правила 3, «Итого» — по ним + +#### Scenario: Только нераспределённые +- **WHEN** «Тип записи» — «Нераспределенные записи» +- **THEN** агрегатов и «Итого» по агрегатам нет, видны только нераспределённые строки + +### Requirement: Экспорт +«Экспорт» SHALL скачивать файл `GET …/corrections/export?statement_type=balance` с именем из ответа сервера; ошибка — +уведомлением с текстом сервера. + +#### Scenario: Выгрузка +- **WHEN** аналитик нажимает «Экспорт» +- **THEN** браузер сохраняет xlsx, кнопка на время запроса показывает загрузку + +### Requirement: Только чтение +При `editable = false` кнопки «Корректировать» SHALL открывать окно только на просмотр, флажки «Учитывать в КФО» и пункты меню +решений и отмены SHALL быть неактивны с подсказкой по `read_only_reason`: `VERSION_NOT_EDITABLE` — «Версия закрыта для +изменений», `LOCK_REQUIRED` — «Возьмите консолидацию в работу», `LOCKED_BY_OTHER` — «Консолидацию редактирует другой +пользователь». Ответы 409 правок версии SHALL показываться уведомлением с текстом сервера и перечитыванием таблицы. + +#### Scenario: Верифицированная версия +- **WHEN** сервер вернул `editable = false`, `read_only_reason = "VERSION_NOT_EDITABLE"` +- **THEN** флажки неактивны, в окне корректировки нет «Сохранить корректировки», подсказка — «Версия закрыта для изменений» diff --git a/openspec/changes/corrections-table-ui/specs/intercompany-matrix-ui/spec.md b/openspec/changes/corrections-table-ui/specs/intercompany-matrix-ui/spec.md new file mode 100644 index 0000000..217f9c0 --- /dev/null +++ b/openspec/changes/corrections-table-ui/specs/intercompany-matrix-ui/spec.md @@ -0,0 +1,34 @@ +## MODIFIED Requirements + +### Requirement: Вкладки страницы отчёта по версии +Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз +и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`: +`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка», `correction` — «Таблица корректировки» (спека `corrections-table-ui`). +Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки +(навигация, не локальное состояние) заменой текущей записи истории браузера, без новой записи: кнопка «Назад» в шапке и +кнопка «Назад» браузера ведут туда, откуда открыт отчёт, а не по переключениям вкладок (AURORA-1851). Активная вкладка +подсвечена зелёным и выделена жирным. Неизвестный ключ вкладки SHALL +заменяться на `osv` без новой записи в истории браузера. Вкладки «Гудвилл», «КФО», «КФО Общий», +«Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». При смене `version_id` всё +состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново. + +#### Scenario: Переход на шахматку +- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка» +- **THEN** адрес становится `…/versions/{id}/chess`, шапка версии остаётся, под вкладками показана шахматка + +#### Scenario: Переход на таблицу корректировки +- **WHEN** открыта шахматка версии по адресу `…/versions/{id}/chess` и нажата вкладка «Таблица корректировки» +- **THEN** адрес становится `…/versions/{id}/correction`, шапка версии остаётся, под вкладками показана таблица корректировок + +#### Scenario: Прежний адрес сводной ОСВ +- **WHEN** открыт адрес `…/versions/{id}/osv` +- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна + +#### Scenario: Неизвестная вкладка +- **WHEN** открыт адрес `…/versions/{id}/abc` +- **THEN** адрес заменяется на `…/versions/{id}/osv`, показана сводная ОСВ; кнопка «Назад» браузера не возвращает на `…/abc` + +#### Scenario: Назад после переключения вкладок +- **WHEN** аналитик открыл отчёт версии кнопкой «К отчётности» в списке консолидаций, переключился «Шахматка» → «ОСВ» → + «Шахматка» и нажал «Назад» в шапке +- **THEN** одним нажатием открыт список консолидаций ГК -- 2.47.2 From 59bbf484cfd41b069cdb7be92191d7bbdffa6b4a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:53:45 +0400 Subject: [PATCH 02/28] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-28-corrections-table-ui.md | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-28-corrections-table-ui.md diff --git a/docs/superpowers/plans/2026-10-28-corrections-table-ui.md b/docs/superpowers/plans/2026-10-28-corrections-table-ui.md new file mode 100644 index 0000000..8bc1599 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-corrections-table-ui.md @@ -0,0 +1,338 @@ +# Таблица корректировки (UI) — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** вкладка «Таблица корректировки» страницы отчёта версии: таблица агрегатов ВГО со строками ОСВ, нераспределённые +строки, решения по строкам, окно корректировки агрегата, экспорт — по дизайну 23.10 и аналитике Лысенко. + +**Architecture:** только фронт `web/`. Данные — по контракту `../docs/dev/api-contract-corrections.md` (+ `.openapi.yaml`); +пока бэкенда (AURORA-1540) нет, клиент `services/api/corrections.ts` при `REACT_APP_CORRECTIONS_MOCK=true` отвечает из фейка +в памяти. Вся логика (строки таблицы, фильтры, итоги, окно, черновик) — чистые функции с тестами vitest; компоненты тонкие и +проверяются в браузере против дизайна. + +**Tech Stack:** React 18, TS strict, antd 6, react-router 7, vitest 3, biome (+ ESLint/Prettier `npm run lint`). + +**Spec:** `openspec/changes/corrections-table-ui/` — `design.md` (главное), `proposal.md`, `specs/corrections-table-ui/spec.md`, +дельта `specs/intercompany-matrix-ui/spec.md`. Контракт — `/Users/andrey.epifancev/projects/awp-ca/.wt/corrections-ui/docs/dev/api-contract-corrections.md`. +Дизайн — `/Users/andrey.epifancev/projects/awp-ca/ДКБ_таблица_корректировок_23_10_26_1.html` (SPA; открывать в браузере). + +**Ветка:** `feat/206-corrections-ui`, worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/corrections-ui/arm-core`, issue +RSHB/arm-core#206, Jira AURORA-1541. + +## Global Constraints + +- Только `web/`; бэкенд, DBML, `api/` не трогать. +- Деньги — строки контракта в рублях; показ — `formatScaledAmount` (`utils/money.ts`) через `useNumberDisplay`; сложение — + только `sumAmounts`; никаких `Number(...)`/`parseFloat` над суммами. +- Фронт не считает разнесение, отклонения, порог и статусы — берёт из ответа (`exceeds_threshold`, `deviation_*`, `status`, + `candidate_rule_ids`). Исключение — живой контроль в окне (суммы несохранённых статей) и «Итого» видимых строк. +- Только `statement_type = 'balance'`. +- Тексты (дословно): вкладка «Таблица корректировки»; «Учитывать в КФО»; группы «Дебет», «Кредит», «Контроль», + «Корректировка»; «Отклонение, %», «Отклонение, сумма»; «Корректировать»; «Корректировка операции»; «Исходные данные ОСВ»; + «Добавить строку ОСВ»; «Распределение корректировки»; «Добавить строку»; «Комментарий к корректировке»; «Отменить»; + «Сохранить корректировки»; «Отображение» — «Общий» / «По пресету»; «Правило»; «Тип записи» — «Все» / «Пары счетов» / + «Нераспределенные записи»; «Сбросить фильтры»; «Настройка таблицы»; «Экспорт»; «На весь экран»; «Итого»; + «Внутригрупповых остатков между компаниями периметра нет»; «Нет записей для выбранных фильтров»; «Состав изменился после + корректировки»; «черновик»; «Перенести в правило…»; «Отнести к правилу…»; «Убрать из правила»; «Вернуть к автоматике»; + «Отменить корректировку»; «Да» / «Частично» / «Нет»; «Восстановлены несохранённые правки от ЧЧ:ММ»; «Сбросить»; + «Корректировку изменили после ваших правок»; «Есть несохранённые изменения. Закрыть без сохранения?»; «Остаться»; + «Корректировку уже изменили, данные обновлены»; подсказки только чтения: `VERSION_NOT_EDITABLE` — «Версия закрыта для + изменений», `LOCK_REQUIRED` — «Возьмите консолидацию в работу», `LOCKED_BY_OTHER` — «Консолидацию редактирует другой + пользователь». +- Подпись правила: `Правило N: <статья 1> — <статья 2>`, составная статья `"1450/1550"` показывается `1450 / 1550`. +- Порог для красного в окне (живой контроль): процент к меньшей стороне `≥ 5` или одна сторона 0 при ненулевой другой. +- `localStorage`/`sessionStorage` — только в `try/catch`; недоступно — работа без черновика. +- Стиль: табы, одинарные кавычки, ширина 140 (biome); комментарии в коде — по-русски; файлы виджета — в + `web/src/widgets/CorrectionTable/`. +- Проверки перед коммитом задачи: из `web/` — `npm test`, `npx tsc --noEmit -p .`, `npx biome check src`; в Task 7 — ещё + `npm run build` и `make web-check` из корня репозитория. + +## Review Focus + +- Ответ на устаревший запрос (аналитик быстро щёлкнул два флажка) перетирает свежую таблицу — `useCorrectionTable` применяет + только ответ последнего запроса (счётчик запросов, как `usePagedRows`); тест в Task 4 на функцию выбора ответа. +- Сумма статьи «1 000 000,5» / «1000000,567» / «-5» / пусто в окне: принимается только `parseAmountInput`-валидное ≥ 0 с ≤ 2 + знаками, иначе поле с ошибкой и «Сохранить» неактивна — тест в Task 3 (`validateLines`). +- Черновик из `localStorage` от другой формы данных (старый формат, мусор) не ломает окно — `loadDraft` возвращает `null` — + тест в Task 3. +- Агрегат исчез из ответа после переноса строк, пока окно открыто (все строки убраны) — окно показывает «—» по сторонам и + не падает; `findAggregate` возвращает `null` — тест в Task 2. +- Фильтр «ЮЛ» с латиницей/регистром/кавычками («ооо "бск"») находит «ООО "БСК"» — сравнение без регистра — тест в Task 2. + +--- + +### Task 1: Типы контракта, клиент API, флаг фейка + +**Files:** +- Create: `web/src/types/corrections.ts` +- Create: `web/src/services/api/corrections.ts` +- Modify: `web/src/conf/config.ts` +- Test: `web/src/services/api/corrections.test.ts` + +**Interfaces:** +- Produces (`types/corrections.ts`) — типы 1:1 с `components.schemas` OpenAPI: `StatementType`, `Side = 'D' | 'K'`, + `Ref`, `CorrectionTableOut`, `CorrectionRuleOut`, `CorrectionAggregateOut`, `AggregateSideOut`, `CorrectionEntryOut`, + `UnallocatedEntryOut`, `CorrectionOut`, `CorrectionLineOut`, `CorrectionBalanceOut`, `CorrectionTotalsOut`, + `CorrectionLineIn`, `CorrectionIn`, `CorrectionRevokeIn`, `AllocationAction = 'ASSIGN' | 'UNASSIGN' | 'EXCLUDE' | 'INCLUDE' | 'RESET'`, + `AllocationIn`, `ReadOnlyReason`, `AggregateKey = { rule_id: string; company_1_id: string; company_2_id: string }`. +- Produces (`services/api/corrections.ts`): + `getCorrectionTable(versionId: string): Promise`; + `saveCorrection(versionId: string, key: AggregateKey, body: CorrectionIn): Promise`; + `revokeCorrection(versionId: string, key: AggregateKey, body: CorrectionRevokeIn): Promise`; + `allocate(versionId: string, body: AllocationIn): Promise`; + `exportCorrectionTable(versionId: string): Promise<{ blob: Blob; filename: string | null }>` (форма ответа — как у + `apiFetchBlob`; если она другая — вернуть то, что даёт `apiFetchBlob`, и записать в Interfaces); + `correctionsPath(versionId: string, key?: AggregateKey): string`. + При `Config.CORRECTIONS_MOCK` все функции делегируют в `correctionsFake` (Task 2); флаг читается в рантайме, фейк + остаётся в бандле — это допустимо (данные фейка — небольшие, без персональных данных). +- Produces (`conf/config.ts`): `Config.CORRECTIONS_MOCK: boolean` = `environment.REACT_APP_CORRECTIONS_MOCK === 'true'`. + +- [ ] **Step 1: node_modules в worktree.** `ln -s /Users/andrey.epifancev/projects/awp-ca/arm-core/web/node_modules web/node_modules` + (тот же `package-lock.json`, что у `main`; в git не попадает). Проверка: `cd web && npx vitest --version` печатает `3.1.4`. +- [ ] **Step 2: Failing test** `corrections.test.ts`: + `correctionsPath('v1')` → `'/consolidations/versions/v1/corrections'`; + `correctionsPath('v1', {rule_id: 'r', company_1_id: 'a', company_2_id: 'b'})` → `'/consolidations/versions/v1/corrections/aggregates/r/a/b'`. +- [ ] **Step 3: Run** `cd web && npx vitest run src/services/api/corrections.test.ts` — FAIL (нет модуля). +- [ ] **Step 4: Implement** типы, `correctionsPath`, функции поверх `api.get/put/post` и `apiFetchBlob` + (`?statement_type=balance` в GET и export), флаг в `Config`. До Task 2 ветка фейка — `throw new Error('фейк в Task 2')`. +- [ ] **Step 5: Run** тест — PASS; `npx tsc --noEmit -p .` и `npx biome check src` — чисто. +- [ ] **Step 6: Commit** `feat(web): типы и клиент API таблицы корректировок (#206)`. + +### Task 2: Фейк контракта и модель таблицы + +**Files:** +- Create: `web/src/widgets/CorrectionTable/mockData.ts` — данные из дизайна 23.10 +- Create: `web/src/services/api/correctionsFake.ts`, Test: `web/src/services/api/correctionsFake.test.ts` +- Create: `web/src/widgets/CorrectionTable/tableModel.ts`, Test: `web/src/widgets/CorrectionTable/tableModel.test.ts` +- Modify: `web/src/services/api/corrections.ts` (ветка фейка) + +**Interfaces:** +- Consumes: типы Task 1. +- Produces (`correctionsFake.ts`): объект `correctionsFake` с теми же сигнатурами, что функции Task 1, состояние в памяти + модуля, `resetCorrectionsFake(): void` для тестов. Правила фейка: агрегат пересчитывается из своих строк (суммы сторон + `sumAmounts` по не-`excluded`, `deviation_amount`, `deviation_percent` к меньшей с двумя знаками, `exceeds_threshold` при + `≥ 5` или одна сторона 0), `composition_changed = true` при изменении состава агрегата с корректировкой; `ASSIGN` — + только в правило из `candidate_rule_ids`, иначе `ApiError` 422 `RULE_NOT_APPLICABLE`; пустой комментарий — 422 + `COMMENT_REQUIRED`; `expected_version_no` не совпал — 409 `CORRECTION_VERSION_CONFLICT`; `revoke` без корректировки — 409 + `CORRECTION_NOT_APPLIED`; `editable = true`. Деление для процента — BigInt-копейки с округлением половины вверх. + `mockData.ts`: 8 правил (как `api/seed/core/elimination_rules_v1.yaml`, статьи `1150`/`1450/1550`, `1170`/`1310/1350`, + `1170`/`1410`, `1240`/`1510`, `1230`/`1510`, `1230`/`1520`, `1230`/`1410`, `1230`/`1510`), агрегаты и нераспределённые строки + из дизайна (суммы в рублях: «ЭПТ — ИЛС» 1000000.00 / 202320.00 с корректировкой 1230 / 1520 и комментарием «Требуется + сверка с контрагентом»; «ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД" — БЕКОВСКИЙ САХАРНЫЙ КОМБИНАТ ООО» правила 6 — четыре строки с + каждой стороны; правило 3 — «Пензенский Мир Продуктов — Регион-Сахар» 29742550.00 / 29242550.00; нераспределённые — не + меньше пяти, включая кредитовую 76.05 «ООО "БСК"» → «ПМП ООО» 142878.00 с `candidate_rule_ids` правил 6 и 7). +- Produces (`tableModel.ts`): + ```ts + type TableRow = + | { kind: 'aggregate'; key: string; aggregate: CorrectionAggregateOut; include: 'Да' | 'Частично' | 'Нет' } + | { kind: 'entry'; key: string; parentKey: string; side: 1 | 2; entry: CorrectionEntryOut } + | { kind: 'unallocated'; key: string; entry: UnallocatedEntryOut }; + type DisplayMode = { mode: 'all' } | { mode: 'preset'; ruleId: string }; + type RecordType = 'all' | 'posting' | 'unallocated'; + type DeviationFilter = 'all' | 'over' | 'under'; + type TableFilters = { display: DisplayMode; recordType: RecordType; entity: string; line: string | null; account: string; deviation: DeviationFilter }; + const EMPTY_FILTERS: TableFilters; + aggregateKey(a: CorrectionAggregateOut): string // `${rule_id}|${company_1.id}|${company_2.id}` + toAggregateKey(a: CorrectionAggregateOut): AggregateKey + includeState(a: CorrectionAggregateOut): 'Да' | 'Частично' | 'Нет' + ruleLabel(r: CorrectionRuleOut): string // «Правило 6: 1230 — 1520», «Правило 1: 1150 — 1450 / 1550» + articleCodes(line: string): string[] // '1450/1550' → ['1450','1550'] + lineOptions(table: CorrectionTableOut): string[] // отдельные коды статей агрегатов, по возрастанию + filterTable(table: CorrectionTableOut, f: TableFilters): { aggregates: CorrectionAggregateOut[]; unallocated: UnallocatedEntryOut[] } + buildRows(view: ReturnType, expanded: ReadonlySet): TableRow[] + visibleTotals(aggregates: CorrectionAggregateOut[]): { amount_1: string | null; amount_2: string | null; deviation_amount: string | null; correction_debit: string | null; correction_credit: string | null } + isFiltered(f: TableFilters): boolean + findAggregate(table: CorrectionTableOut, key: string): CorrectionAggregateOut | null + ``` + Правила `filterTable` — design.md «Панель»: пресет — агрегаты правила + нераспределённые с этим правилом в + `candidate_rule_ids`; `recordType`; `entity` — подстрока без регистра в именах обеих сторон (у нераспределённых — `owner`, + `counterparty`); `account` — начало кода любого счёта строки (`accounts` сторон / `account_code`); `line` и `deviation` — + только агрегаты (по `articleCodes` статей сторон и по `exceeds_threshold`), при любом из них нераспределённые скрываются. + +- [ ] **Step 1: Failing tests `tableModel.test.ts`** (на `mockData`): + `ruleLabel` правила 1 = `'Правило 1: 1150 — 1450 / 1550'`; `articleCodes('1310/1350')` = `['1310','1350']`; + `includeState` — все не исключены → `'Да'`, одна из восьми исключена → `'Частично'`, все → `'Нет'`; + пресет правила 3 → только агрегаты правила 3 и нераспределённые, где правило 3 в `candidate_rule_ids`; + `recordType: 'unallocated'` → `aggregates = []`; + `entity: 'ооо "бск"'` → агрегаты и нераспределённые «ООО "БСК"» (Review Focus); + `deviation: 'over'` → только `exceeds_threshold`, `unallocated = []`; + `buildRows` с раскрытым агрегатом правила 6 «Бековский завод — комбинат» → после агрегата 4 строки `side: 1`, затем 4 `side: 2`; + `visibleTotals` по агрегатам «ЭПТ — ИЛС» и правила 3 → `amount_1 = '30742550.00'`; + `findAggregate(table, 'нет')` → `null` (Review Focus). +- [ ] **Step 2: Failing tests `correctionsFake.test.ts`**: `getCorrectionTable` после `resetCorrectionsFake` отдаёт агрегат + «ЭПТ — ИЛС» с `deviation_percent = '394.27'`, `exceeds_threshold = true`; `allocate EXCLUDE` строки 62.03 агрегата правила 6 + уменьшает `side_1.amount` на её сумму; `allocate ASSIGN` в правило не из `candidate_rule_ids` — `ApiError` с + `code = 'RULE_NOT_APPLICABLE'`; `saveCorrection` с `comment: ' '` — `COMMENT_REQUIRED`; повторный `saveCorrection` с + `expected_version_no: null`, когда корректировка есть, — `CORRECTION_VERSION_CONFLICT`; `revokeCorrection` → `correction = null`, + затем `saveCorrection` с `expected_version_no: null` проходит. +- [ ] **Step 3: Run** `cd web && npx vitest run src/widgets/CorrectionTable src/services/api/correctionsFake.test.ts` — FAIL. +- [ ] **Step 4: Implement** `mockData.ts`, `correctionsFake.ts`, `tableModel.ts`; в `corrections.ts` ветка флага вызывает + `correctionsFake`. `ApiError` создавать так же, как это делает `api/client.ts` (посмотреть конструктор). +- [ ] **Step 5: Run** тесты — PASS; tsc и biome — чисто. +- [ ] **Step 6: Commit** `feat(web): фейк контракта корректировок и модель таблицы (#206)`. + +### Task 3: Модель окна корректировки и черновик + +**Files:** +- Create: `web/src/widgets/CorrectionTable/editorModel.ts`, Test: `web/src/widgets/CorrectionTable/editorModel.test.ts` +- Create: `web/src/widgets/CorrectionTable/draftStorage.ts`, Test: `web/src/widgets/CorrectionTable/draftStorage.test.ts` + +**Interfaces:** +- Consumes: типы Task 1, `articleCodes`, `aggregateKey` (Task 2), `parseAmountInput`, `sumAmounts` (`utils/money.ts`). +- Produces (`editorModel.ts`): + ```ts + type EditLine = { line_code: string; amount: string }; // amount — как ввёл пользователь + type EditorState = { debit: EditLine[]; credit: EditLine[]; comment: string }; + initialEditorState(a: CorrectionAggregateOut): EditorState // есть корректировка — её статьи (суммы как в API); + // нет — по стороне одна статья: первый код articleCodes(article_line), сумма стороны + validateLines(s: EditorState): { ok: true; body: Pick } | { ok: false; errors: Record } + // ключ ошибки `debit.0.amount`; пустая сторона ок, обе пустые — ошибка «Добавьте статью»; + // код — 4 цифры; сумма — parseAmountInput, ≥ 0, ≤ 2 знаков + liveControl(a: CorrectionAggregateOut, s: EditorState): { sourceDiff: Diff; allocationDiff: Diff } + type Diff = { left: string | null; right: string | null; difference: string | null; percent: string | null; red: boolean } + // percent к меньшей стороне, 2 знака; red — percent ≥ 5 или одна сторона 0 при ненулевой другой + isDirty(initial: EditorState, current: EditorState): boolean + pickerCandidates(table: CorrectionTableOut, a: CorrectionAggregateOut, side: 1 | 2): UnallocatedEntryOut[] + // та же пара: side 1 — owner = company_1, counterparty = company_2, entry.side = rule.side_1; + // side 2 — наоборот и противоположная сторона; правило агрегата в candidate_rule_ids; не excluded + toCorrectionIn(body: …, expectedVersionNo: number | null): CorrectionIn // status 'APPLIED' + ``` +- Produces (`draftStorage.ts`): + ```ts + type Draft = { state: EditorState; savedAt: string; baseVersionNo: number | null }; + draftKey(userId: string, versionId: string, aggregateKey: string): string // 'armka:correction-draft:::' + saveDraft(key: string, draft: Draft, storage?: Storage | null): void + loadDraft(key: string, storage?: Storage | null): Draft | null // мусор/старый формат/исключение — null + clearDraft(key: string, storage?: Storage | null): void + isStale(draft: Draft, a: CorrectionAggregateOut): boolean // baseVersionNo ≠ (a.correction?.version_no ?? null) + ``` + `storage` по умолчанию — `window.localStorage`, доступ в `try/catch`. + +- [ ] **Step 1: Failing tests `editorModel.test.ts`**: `initialEditorState` агрегата правила 3 без корректировки → + `debit = [{line_code: '1170', amount: '29742550.00'}]`, `credit = [{line_code: '1410', amount: '29242550.00'}]`; + агрегат правила 1 без корректировки → кредит `line_code = '1450'`; + `validateLines` с суммами `'1 000 000,5'`, `'1000000,567'`, `'-5'`, `''` → ошибки у `amount` (Review Focus), с `'500000,5'` → + `'500000.50'` в `body`; `liveControl` агрегата «ЭПТ — ИЛС» с исходным состоянием → `sourceDiff.percent = '394.27'`, + `red = true`; при Дт 1000000.00 и Кт 1000000.00 распределения → `allocationDiff.difference = '0.00'`, `red = false`; + `pickerCandidates` для агрегата «Пензенский Мир Продуктов — Бековский сахарный комбинат» правила 6, сторона 2 → только + кредитовые нераспределённые строки комбината на ПМП с правилом 6. +- [ ] **Step 2: Failing tests `draftStorage.test.ts`** (`storage` — простая заглушка `Map` с `getItem/setItem/removeItem`): + `saveDraft` → `loadDraft` возвращает то же; `setItem` строки `'{"x":1}'` → `loadDraft` = `null` (Review Focus); заглушка, + бросающая на `getItem`, → `null` без исключения; `isStale` при `baseVersionNo = 2` и `correction.version_no = 3` → `true`. +- [ ] **Step 3: Run** `cd web && npx vitest run src/widgets/CorrectionTable` — FAIL. +- [ ] **Step 4: Implement** оба модуля. +- [ ] **Step 5: Run** — PASS; tsc и biome — чисто. +- [ ] **Step 6: Commit** `feat(web): модель окна корректировки и черновик (#206)`. + +### Task 4: Вкладка и таблица только на чтение + +**Files:** +- Create: `web/src/widgets/CorrectionTable/useCorrectionTable.ts`, Test: `web/src/widgets/CorrectionTable/useCorrectionTable.test.ts` +- Create: `web/src/widgets/CorrectionTable/CorrectionTable.tsx` (таблица), `CorrectionToolbar.tsx` (панель), + `CorrectionContent.tsx` (вкладка: загрузка, состояния, панель + таблица) +- Modify: `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx` (у `correction` убрать `disabled`/`tooltip`) +- Modify: `web/src/layouts/ConsolidationReportPage.tsx` (`TAB_COMPONENTS.correction`, `isValidTab` с `correction`) + +**Interfaces:** +- Consumes: Task 1–2 (`getCorrectionTable`, `filterTable`, `buildRows`, `visibleTotals`, `ruleLabel`, `lineOptions`, + `isFiltered`, `EMPTY_FILTERS`), `useNumberDisplay`, `ColumnSettings`/`useVisibleColumns` (`components/GenericTable`), + полноэкранный режим — как `toggleFullscreen` в `ConsolidatedChessContent.tsx`. +- Produces: + `latestOnly(): { next(): number; isLatest(id: number): boolean }` (в `useCorrectionTable.ts`, чистая, тестируется); + `useCorrectionTable(versionId: string): { table: CorrectionTableOut | null; loading: boolean; error: string | null; reload(): void; replace(t: CorrectionTableOut): void; replaceAggregate(a: CorrectionAggregateOut): void }` + — применяет только ответ последнего запроса (Review Focus); + `CorrectionContent({ versionId }: { versionId: string })`; + `CorrectionTable` props: `{ rows: TableRow[]; totals; format; expanded; onToggle(key); rules; editable; readOnlyHint: string | null; actions: RowActions }`, + где `RowActions` — колбэки Task 5–6 (`onCorrect(a)`, `onRevoke(a)`, `onAllocate(body)`), в этой задаче — заглушки. + +- [ ] **Step 1: Failing test** `useCorrectionTable.test.ts`: `latestOnly` — после `next()` = 1 и `next()` = 2 `isLatest(1) = false`, + `isLatest(2) = true`. +- [ ] **Step 2: Run** — FAIL. +- [ ] **Step 3: Implement** хук, вкладку и таблицу: колонки и тексты — Global Constraints и spec «Колонки и строка агрегата», + «Раскрытие», «Нераспределённые строки», «Панель и фильтры»; суммы — `format` из `useNumberDisplay` (по умолчанию «тыс. руб.», + 2 знака, как в дизайне); красное — при `exceeds_threshold`; `composition_changed` — фон строки и значок с подсказкой; + `DRAFT` — метка «черновик»; «Итого» — `visibleTotals`, процент «—»; флажки и меню пока неактивны. Экспорт — `exportCorrectionTable` + + `saveBlob(blob, filename ?? xlsxFilename('Таблица_корректировки', …))`. Вкладка доступна, адрес `…/correction`. +- [ ] **Step 4: Run** тест — PASS; `npx tsc --noEmit -p .`, `npx biome check src` — чисто. +- [ ] **Step 5: Проверка в браузере** (фейк): `cd web && REACT_APP_CORRECTIONS_MOCK=true npm run start`, бэкенд ядра для + карточки версии — как в `../docs/dev/qa-guide.md`; открыть `…/versions//correction`; сверить с дизайном (открыть + `ДКБ_таблица_корректировок_23_10_26_1.html` рядом): колонки, раскрытие агрегата правила 6, нераспределённые строки, пресет + правила 3, фильтры, «Итого», единицы. Снимки — в `/private/tmp/claude-501/` (scratchpad сессии), в репозиторий не класть. +- [ ] **Step 6: Commit** `feat(web): вкладка «Таблица корректировки» — таблица и панель (#206)`. + +### Task 5: Решения по строкам, отмена корректировки, только чтение + +**Files:** +- Create: `web/src/widgets/CorrectionTable/RowActions.tsx` (флажок «Учитывать в КФО», меню строки, выбор правила) +- Create: `web/src/widgets/CorrectionTable/RevokeModal.tsx` +- Create: `web/src/widgets/CorrectionTable/readOnly.ts`, Test: `web/src/widgets/CorrectionTable/readOnly.test.ts` +- Modify: `CorrectionTable.tsx`, `CorrectionContent.tsx` + +**Interfaces:** +- Consumes: `allocate`, `revokeCorrection` (Task 1), `replace`/`replaceAggregate`/`reload` (Task 4), `ruleLabel`. +- Produces: `readOnlyHint(reason: ReadOnlyReason | null): string | null` (тексты — Global Constraints); + `allocationMenu(row: TableRow, rules: CorrectionRuleOut[]): { key: AllocationAction | 'MOVE'; label: string; disabled: boolean }[]` + (тестируется: у строки агрегата с `decision = 'AUTO'` нет «Вернуть к автоматике»; пустой `candidate_rule_ids` — «Перенести в + правило…» неактивен; у нераспределённой `reason = 'UNASSIGNED'` есть «Вернуть к автоматике»). + +- [ ] **Step 1: Failing tests** `readOnly.test.ts`: `readOnlyHint('LOCK_REQUIRED')` = «Возьмите консолидацию в работу», `null` → `null`; + три случая `allocationMenu` выше. +- [ ] **Step 2: Run** — FAIL. +- [ ] **Step 3: Implement**: флажок строки → `allocate({statement_type: 'balance', entry_ids, action: checked ? 'INCLUDE' : 'EXCLUDE'})`, + ответ — `replace`; меню → `ASSIGN` (выбор правила из `candidate_rule_ids`, подписи `ruleLabel`), `UNASSIGN`, `RESET`; + «Отменить корректировку» в меню агрегата → `RevokeModal` (комментарий обязателен, пустой — «Сохранить» неактивна) → + `revokeCorrection` → `replaceAggregate`. Ошибки API — уведомление с `ApiError.message`; 409 правок версии — ещё `reload()`. + `editable = false` — всё неактивно с подсказкой `readOnlyHint`. +- [ ] **Step 4: Run** тесты — PASS; tsc, biome — чисто. +- [ ] **Step 5: Проверка в браузере** (фейк): исключить строку 62.03 → у агрегата «Частично», сумма стороны меньше; + «Отнести к правилу…» у кредитовой 76.05 «ООО "БСК"» → строка в агрегате; «Отменить корректировку» у «Сатурн — Архол». + Флаг фейка `editable = false` (временно в тесте руками) — подсказки. +- [ ] **Step 6: Commit** `feat(web): решения по строкам и отмена корректировки (#206)`. + +### Task 6: Окно «Корректировка операции» + +**Files:** +- Create: `web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx` +- Modify: `CorrectionContent.tsx` (открытие окна из «Корректировать») + +**Interfaces:** +- Consumes: Task 3 целиком, `allocate`, `saveCorrection`, `findAggregate`, `toAggregateKey`, `useCurrentUser().id`, + `replace`/`replaceAggregate`/`reload`. +- Produces: `CorrectionEditorModal({ versionId, table, aggregateKey, editable, onClose, onTable, onAggregate })`. + +- [ ] **Step 1: Implement** по spec «Окно…», «Сохранение…», «Черновик окна…»: состояние — `initialEditorState` или черновик + (`loadDraft(draftKey(...))`, плашки «Восстановлены несохранённые правки от ЧЧ:ММ» / «Корректировку изменили после ваших + правок» по `isStale`, кнопка «Сбросить»); `saveDraft` при каждой правке; «×» исходной строки и «Добавить строку ОСВ» + (`pickerCandidates`) — `allocate` сразу, таблица — `onTable`, агрегат окна — `findAggregate` (нет — по сторонам «—», Review + Focus); контроль — `liveControl`; «Сохранить корректировки» — `validateLines` → `saveCorrection(toCorrectionIn(body, + aggregate.correction?.version_no ?? null))` → `clearDraft`, `onAggregate`, закрыть; ошибки: `COMMENT_REQUIRED` — у + комментария, `INVALID_LINE_CODE` — у статьи `detail.line_code`, `CORRECTION_VERSION_CONFLICT` — сообщение «Корректировку уже + изменили, данные обновлены» + `reload()`; «Отменить» при `isDirty` — `Modal.confirm` «Есть несохранённые изменения. Закрыть + без сохранения?» («Остаться» / закрыть с `clearDraft`). `editable = false` — всё только на просмотр, без «Сохранить + корректировки». +- [ ] **Step 2: Run** `npx tsc --noEmit -p .`, `npx biome check src`, `npm test` — чисто. +- [ ] **Step 3: Проверка в браузере** (фейк): окно «ЭПТ — ИЛС» (суммы, 394,27 %, красное); правило 3 без корректировки — + подстановка 1170 / 1410; добавить статью 1550 на стороне Кт, сохранить без комментария → ошибка у поля; с комментарием → + таблица показывает две статьи; правка суммы → «Назад» в браузере → вернуться → плашка восстановления; «Отменить» → + вопрос. Сверка вида окна с дизайном (`editor0.png`, `editor-unalloc.png` — снимки в + `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/d22b6541-bb55-46fc-a4fe-8bba06387616/scratchpad/mockup-1023/`). +- [ ] **Step 4: Commit** `feat(web): окно «Корректировка операции» с черновиком (#206)`. + +### Task 7: Сверка с дизайном, проверки, документация + +**Files:** +- Modify: `web/README.md` (раздел про флаг `REACT_APP_CORRECTIONS_MOCK`), `web/ARCHITECTURE.md` (виджет `CorrectionTable`) +- Modify: `openspec/changes/corrections-table-ui/tasks.md` (создать по факту выполненного, все пункты отмечены) + +- [ ] **Step 1:** полный проход сценариев spec `corrections-table-ui` в браузере на фейке, сверка каждой части экрана с + дизайном в Playwright (оба окна рядом, одинаковая ширина 1920); расхождения — исправить или записать в отчёт с причиной. +- [ ] **Step 2:** из корня репозитория `make web-check` — PASS; из `web/` — `npm test`, `npm run lint` — PASS. +- [ ] **Step 3:** `openspec validate corrections-table-ui --strict` — valid. +- [ ] **Step 4: Commit** `docs(web): таблица корректировок — README, ARCHITECTURE, tasks (#206)`. + +### Task 8 (после слияния AURORA-1540): подключение к API + +Выполняется отдельно, когда эндпоинты 1540 в `main`. Влить `main` в ветку; запустить без флага на стенде +(`../docs/dev/qa-guide.md`) на реальной версии; пройти сценарии spec; расхождения ответа с контрактом — правка в фейке и +типах или замечание в PR 1540; `make check-full`; PR в `main` (до этого PR не открывать — фронт без API не сливаем, если +владелец не решит иначе). -- 2.47.2 From 8eefa3ba027fc2ac8f84d44070047d8f7e176295 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 20:57:14 +0400 Subject: [PATCH 03/28] =?UTF-8?q?feat(web):=20=D1=82=D0=B8=D0=BF=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=20API=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20=D0=BA=D0=BE=D1=80=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Типы 1:1 с components.schemas контракта корректировок, клиент API (таблица, сохранение, отзыв, разнесение, экспорт), флаг REACT_APP_CORRECTIONS_MOCK в Config. Ветка фейка пока бросает ошибку (фейк — в следующей задаче). Строка R-1 в design.md. Co-Authored-By: Claude Opus 5.5 --- .../changes/corrections-table-ui/design.md | 1 + web/src/conf/config.ts | 3 + web/src/services/api/corrections.test.ts | 13 ++ web/src/services/api/corrections.ts | 49 +++++ web/src/types/corrections.ts | 176 ++++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 web/src/services/api/corrections.test.ts create mode 100644 web/src/services/api/corrections.ts create mode 100644 web/src/types/corrections.ts diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index e6a62f2..f225f3d 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -179,3 +179,4 @@ | 28.10.2026 | `expected_version_no = null`, когда действующей корректировки нет (и после отзыва) | иначе после отзыва фронту неоткуда взять номер | — | | 28.10.2026 | Составная статья правил 1 и 2 — в окне первый код, можно выбрать второй | `line_code` — один код; особый расчёт правил 1 и 2 (DOC-274230) — на сервере | при ответе «нужно распределение по обеим» — аналитик добавляет вторую статью руками | | 28.10.2026 | «По пресету» показывает и подходящие нераспределённые строки; «Строка баланса» и «Отклонение» скрывают нераспределённые | пресет — рабочее место по правилу, строки для отнесения нужны рядом | поменять функцию фильтров | +| 28.10.2026 | R-1: список правил в плане — ориентир; при расхождении с api/seed/core/elimination_rules_v1.yaml (в плане статья 1230/1510 встречается дважды) номера и статьи берутся из yaml, тесты плана про правила 1, 3, 6, 7 остаются верными | seed — источник истины | поправка mockData и тестов | diff --git a/web/src/conf/config.ts b/web/src/conf/config.ts index d148de8..e3c303d 100644 --- a/web/src/conf/config.ts +++ b/web/src/conf/config.ts @@ -9,4 +9,7 @@ export const Config = { CLIENT_ID: environment.REACT_APP_CLIENT_ID ?? '', NAMESPACE: environment.REACT_APP_NAMESPACE ?? '', APP_NAME: environment.REACT_APP_APP_NAME ?? '', + + /** Таблица корректировок работает на фейке контракта вместо API (до готовности бэкенда AURORA-1540). */ + CORRECTIONS_MOCK: environment.REACT_APP_CORRECTIONS_MOCK === 'true', }; diff --git a/web/src/services/api/corrections.test.ts b/web/src/services/api/corrections.test.ts new file mode 100644 index 0000000..a834b81 --- /dev/null +++ b/web/src/services/api/corrections.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { correctionsPath } from './corrections'; + +describe('correctionsPath', () => { + it('таблица версии', () => { + expect(correctionsPath('v1')).toBe('/consolidations/versions/v1/corrections'); + }); + it('агрегат по ключу (rule_id, company_1_id, company_2_id)', () => { + expect(correctionsPath('v1', { rule_id: 'r', company_1_id: 'a', company_2_id: 'b' })).toBe( + '/consolidations/versions/v1/corrections/aggregates/r/a/b', + ); + }); +}); diff --git a/web/src/services/api/corrections.ts b/web/src/services/api/corrections.ts new file mode 100644 index 0000000..2f3ff37 --- /dev/null +++ b/web/src/services/api/corrections.ts @@ -0,0 +1,49 @@ +import { api, apiFetchBlob } from '../../api/client'; +import { Config } from '../../conf/config'; +import type { + AggregateKey, + AllocationIn, + CorrectionAggregateOut, + CorrectionIn, + CorrectionRevokeIn, + CorrectionTableOut, + StatementType, +} from '../../types/corrections'; + +/** Пока фейк не подключён (Task 2), флаг `CORRECTIONS_MOCK` не должен давать молчаливый пустой ответ. */ +const fakeNotReady = (): never => { + throw new Error('фейк в Task 2'); +}; + +const STATEMENT_TYPE: StatementType = 'balance'; + +export function correctionsPath(versionId: string, key?: AggregateKey): string { + const base = `/consolidations/versions/${versionId}/corrections`; + if (!key) return base; + return `${base}/aggregates/${key.rule_id}/${key.company_1_id}/${key.company_2_id}`; +} + +export function getCorrectionTable(versionId: string): Promise { + if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + return api.get(correctionsPath(versionId), { statement_type: STATEMENT_TYPE }); +} + +export function saveCorrection(versionId: string, key: AggregateKey, body: CorrectionIn): Promise { + if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + return api.put(correctionsPath(versionId, key), body); +} + +export function revokeCorrection(versionId: string, key: AggregateKey, body: CorrectionRevokeIn): Promise { + if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + return api.post(`${correctionsPath(versionId, key)}/revoke`, body); +} + +export function allocate(versionId: string, body: AllocationIn): Promise { + if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + return api.post(`${correctionsPath(versionId)}/allocations`, body); +} + +export function exportCorrectionTable(versionId: string): Promise<{ blob: Blob; filename: string | null }> { + if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + return apiFetchBlob(`${correctionsPath(versionId)}/export?statement_type=${STATEMENT_TYPE}`); +} diff --git a/web/src/types/corrections.ts b/web/src/types/corrections.ts new file mode 100644 index 0000000..5b1365f --- /dev/null +++ b/web/src/types/corrections.ts @@ -0,0 +1,176 @@ +// Типы таблицы корректировок 1:1 с components.schemas контракта (docs/dev/api-contract-corrections.openapi.yaml). +// Деньги и проценты — строки с двумя знаками, в рублях; перевод в тыс. руб. и округление — на фронте. + +export type StatementType = 'balance' | 'income'; + +export type Side = 'D' | 'K'; + +/** Сумма в рублях, два знака (`"1000000.00"`). */ +export type Money = string; + +/** Неотрицательная сумма для ввода, не более двух знаков. */ +export type MoneyIn = string; + +/** Процент с двумя знаками; `null` — не определён. */ +export type Percent = string | null; + +export interface Ref { + id: string; + name: string; +} + +export type ReadOnlyReason = 'VERSION_NOT_EDITABLE' | 'LOCK_REQUIRED' | 'LOCKED_BY_OTHER'; + +export interface CorrectionRuleOut { + id: string; + rule_no: number; + /** Код статьи; составная — коды через «/» (`"1450/1550"`). */ + article_1_line: string; + article_2_line: string; + side_1: Side; + measure: 'closing' | 'turnover'; + company_1_accounts: string[]; + company_2_accounts: string[]; + company_1_excluded: string[]; + company_2_excluded: string[]; +} + +export interface CorrectionEntryOut { + entry_ids: string[]; + legal_entity_id: string; + counterparty_id: string; + account_code: string; + side: Side; + /** Сальдо (оборот) строки по этой стороне. */ + amount: Money; + decision: 'AUTO' | 'ASSIGN'; + /** «Не включать в КФО»; в сумму стороны не входит. */ + excluded: boolean; + candidate_rule_ids: string[]; +} + +export interface AggregateSideOut { + article_line: string; + accounts: string[]; + amount: Money; + entries: CorrectionEntryOut[]; +} + +export interface CorrectionLineOut { + line_code: string; + amount: Money; +} + +export interface CorrectionBalanceOut { + debit_total: Money; + credit_total: Money; + difference: Money; + difference_percent: Percent; + /** Сумма стороны 1 минус debit_total. */ + remainder_1: Money; + /** Сумма стороны 2 минус credit_total. */ + remainder_2: Money; +} + +export interface CorrectionOut { + status: 'DRAFT' | 'APPLIED'; + version_no: number; + /** Статьи стороны 1, первая — основная. */ + debit: CorrectionLineOut[]; + /** Статьи стороны 2, первая — основная. */ + credit: CorrectionLineOut[]; + comment: string; + reason_code: string | null; + balance: CorrectionBalanceOut; + updated_by: Ref | null; + updated_at: string; +} + +export type AggregateStatus = 'AUTO' | 'DISCREPANCY' | 'ONE_SIDED' | 'MANUAL'; + +export interface CorrectionAggregateOut { + rule_id: string; + rule_no: number; + company_1: Ref; + company_2: Ref; + side_1: AggregateSideOut; + side_2: AggregateSideOut; + deviation_amount: Money; + deviation_percent: Percent; + /** deviation_percent ≥ 5, или одна сторона пуста при ненулевой другой. */ + exceeds_threshold: boolean; + status: AggregateStatus; + eliminated_amount: Money; + /** Состав или суммы изменились после применённой корректировки. */ + composition_changed: boolean; + correction: CorrectionOut | null; +} + +export interface UnallocatedEntryOut { + entry_ids: string[]; + owner: Ref; + counterparty: Ref; + account_code: string; + side: Side; + amount: Money; + candidate_rule_ids: string[]; + reason: 'MULTIPLE_RULES' | 'UNASSIGNED'; + excluded: boolean; +} + +export interface CorrectionTotalsOut { + amount_1: Money; + amount_2: Money; + deviation_amount: Money; + correction_debit: Money; + correction_credit: Money; +} + +export interface CorrectionTableOut { + version_id: string; + statement_type: StatementType; + rules_version_no: number; + /** Может ли текущий пользователь править таблицу. */ + editable: boolean; + read_only_reason: ReadOnlyReason | null; + rules: CorrectionRuleOut[]; + aggregates: CorrectionAggregateOut[]; + unallocated: UnallocatedEntryOut[]; + totals: CorrectionTotalsOut; +} + +export interface CorrectionLineIn { + line_code: string; + amount: MoneyIn; +} + +export interface CorrectionIn { + debit: CorrectionLineIn[]; + credit: CorrectionLineIn[]; + comment: string; + reason_code?: string | null; + status?: 'APPLIED' | 'DRAFT'; + /** Редакция действующей корректировки, которую видел аналитик; `null` — действующей нет. */ + expected_version_no: number | null; +} + +export interface CorrectionRevokeIn { + comment: string; +} + +export type AllocationAction = 'ASSIGN' | 'UNASSIGN' | 'EXCLUDE' | 'INCLUDE' | 'RESET'; + +export interface AllocationIn { + statement_type: StatementType; + entry_ids: string[]; + action: AllocationAction; + /** Обязателен для ASSIGN. */ + rule_id?: string | null; +} + +/** Ключ агрегата в пути запроса: `(rule_id, company_1_id, company_2_id)`. */ +export interface AggregateKey { + rule_id: string; + company_1_id: string; + company_2_id: string; +} -- 2.47.2 From c05830face1b82b2f706b6cb40647090ed2b3a91 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:02:13 +0400 Subject: [PATCH 04/28] =?UTF-8?q?feat(web):=20=D1=84=D0=B5=D0=B9=D0=BA=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=B8=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/corrections-table-ui/design.md | 2 + web/src/services/api/corrections.ts | 16 +- web/src/services/api/correctionsFake.test.ts | 114 +++++++++ web/src/services/api/correctionsFake.ts | 240 ++++++++++++++++++ web/src/widgets/CorrectionTable/mockCalc.ts | 81 ++++++ web/src/widgets/CorrectionTable/mockData.ts | 180 +++++++++++++ .../CorrectionTable/tableModel.test.ts | 135 ++++++++++ web/src/widgets/CorrectionTable/tableModel.ts | 148 +++++++++++ 8 files changed, 906 insertions(+), 10 deletions(-) create mode 100644 web/src/services/api/correctionsFake.test.ts create mode 100644 web/src/services/api/correctionsFake.ts create mode 100644 web/src/widgets/CorrectionTable/mockCalc.ts create mode 100644 web/src/widgets/CorrectionTable/mockData.ts create mode 100644 web/src/widgets/CorrectionTable/tableModel.test.ts create mode 100644 web/src/widgets/CorrectionTable/tableModel.ts diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index f225f3d..cb22064 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -180,3 +180,5 @@ | 28.10.2026 | Составная статья правил 1 и 2 — в окне первый код, можно выбрать второй | `line_code` — один код; особый расчёт правил 1 и 2 (DOC-274230) — на сервере | при ответе «нужно распределение по обеим» — аналитик добавляет вторую статью руками | | 28.10.2026 | «По пресету» показывает и подходящие нераспределённые строки; «Строка баланса» и «Отклонение» скрывают нераспределённые | пресет — рабочее место по правилу, строки для отнесения нужны рядом | поменять функцию фильтров | | 28.10.2026 | R-1: список правил в плане — ориентир; при расхождении с api/seed/core/elimination_rules_v1.yaml (в плане статья 1230/1510 встречается дважды) номера и статьи берутся из yaml, тесты плана про правила 1, 3, 6, 7 остаются верными | seed — источник истины | поправка mockData и тестов | +| 28.10.2026 | R-2: в фейк добавлен `widgets/CorrectionTable/mockCalc.ts` (пересчёт агрегата и итогов через BigInt-копейки) — общий для `mockData` и `correctionsFake`, иначе циклический импорт | план не называл файл; без него циклический импорт | перенос функций, без поведения | +| 28.10.2026 | R-3: в `mockData` сверх дизайна добавлены агрегат правила 6 «ООО "БСК"» — «ПМП ООО» (310000.00 / 300000.00) и агрегат правила 4 «Агро-Холдинг» без второй стороны (ONE_SIDED); `status` в фейке — ONE_SIDED / DISCREPANCY / AUTO, MANUAL не выдаётся | тест фильтра `ооо "бск"` из плана требует агрегат с таким ЮЛ; ONE_SIDED нужен для проверки отображения | поправка данных фейка, на прод не влияет | diff --git a/web/src/services/api/corrections.ts b/web/src/services/api/corrections.ts index 2f3ff37..3094227 100644 --- a/web/src/services/api/corrections.ts +++ b/web/src/services/api/corrections.ts @@ -9,11 +9,7 @@ import type { CorrectionTableOut, StatementType, } from '../../types/corrections'; - -/** Пока фейк не подключён (Task 2), флаг `CORRECTIONS_MOCK` не должен давать молчаливый пустой ответ. */ -const fakeNotReady = (): never => { - throw new Error('фейк в Task 2'); -}; +import { correctionsFake } from './correctionsFake'; const STATEMENT_TYPE: StatementType = 'balance'; @@ -24,26 +20,26 @@ export function correctionsPath(versionId: string, key?: AggregateKey): string { } export function getCorrectionTable(versionId: string): Promise { - if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + if (Config.CORRECTIONS_MOCK) return correctionsFake.getCorrectionTable(versionId); return api.get(correctionsPath(versionId), { statement_type: STATEMENT_TYPE }); } export function saveCorrection(versionId: string, key: AggregateKey, body: CorrectionIn): Promise { - if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + if (Config.CORRECTIONS_MOCK) return correctionsFake.saveCorrection(versionId, key, body); return api.put(correctionsPath(versionId, key), body); } export function revokeCorrection(versionId: string, key: AggregateKey, body: CorrectionRevokeIn): Promise { - if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + if (Config.CORRECTIONS_MOCK) return correctionsFake.revokeCorrection(versionId, key, body); return api.post(`${correctionsPath(versionId, key)}/revoke`, body); } export function allocate(versionId: string, body: AllocationIn): Promise { - if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + if (Config.CORRECTIONS_MOCK) return correctionsFake.allocate(versionId, body); return api.post(`${correctionsPath(versionId)}/allocations`, body); } export function exportCorrectionTable(versionId: string): Promise<{ blob: Blob; filename: string | null }> { - if (Config.CORRECTIONS_MOCK) return fakeNotReady(); + if (Config.CORRECTIONS_MOCK) return correctionsFake.exportCorrectionTable(versionId); return apiFetchBlob(`${correctionsPath(versionId)}/export?statement_type=${STATEMENT_TYPE}`); } diff --git a/web/src/services/api/correctionsFake.test.ts b/web/src/services/api/correctionsFake.test.ts new file mode 100644 index 0000000..e61d7bd --- /dev/null +++ b/web/src/services/api/correctionsFake.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { ApiError } from '../../api/client'; +import type { CorrectionAggregateOut, CorrectionIn } from '../../types/corrections'; +import { MOCK_VERSION_ID, makeMockTable } from '../../widgets/CorrectionTable/mockData'; +import { aggregateKey, toAggregateKey } from '../../widgets/CorrectionTable/tableModel'; +import { correctionsFake as fake, resetCorrectionsFake } from './correctionsFake'; + +const V = MOCK_VERSION_ID; +const find = (t: { aggregates: CorrectionAggregateOut[] }, c1: string) => { + const a = t.aggregates.find((x) => x.company_1.name === c1); + if (!a) throw new Error('нет агрегата'); + return a; +}; +const body = (over: Partial = {}): CorrectionIn => ({ + debit: [{ line_code: '1230', amount: '10.00' }], + credit: [{ line_code: '1520', amount: '10.00' }], + comment: 'ок', + expected_version_no: null, + ...over, +}); +const expectCode = async (p: Promise, status: number, code: string) => { + const err = await p.then( + () => null, + (e) => e, + ); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(status); + expect(err.code).toBe(code); +}; + +beforeEach(() => resetCorrectionsFake()); + +describe('correctionsFake', () => { + it('таблица из дизайна: ЭПТ — ИЛС', async () => { + const t = await fake.getCorrectionTable(V); + const a = find(t, 'ЭПТ'); + expect(a.side_1.amount).toBe('1000000.00'); + expect(a.side_2.amount).toBe('202320.00'); + expect(a.deviation_percent).toBe('394.27'); + expect(a.exceeds_threshold).toBe(true); + expect(a.correction?.comment).toBe('Требуется сверка с контрагентом'); + expect(t.editable).toBe(true); + }); + + it('EXCLUDE строки 62.03 уменьшает сумму стороны', async () => { + const table0 = await fake.getCorrectionTable(V); + const before = find(table0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"'); + const row = before.side_1.entries.find((e) => e.account_code === '62.03'); + if (!row) throw new Error('нет строки'); + const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'EXCLUDE' }); + const after = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"'); + expect(after.side_1.amount).toBe('1325000.00'); + expect(after.side_1.entries.find((e) => e.account_code === '62.03')?.excluded).toBe(true); + expect(table0.totals.amount_1).toBe('37727550.00'); + expect(t.totals.amount_1).toBe('37377550.00'); + }); + + it('EXCLUDE в агрегате с корректировкой отмечает смену состава', async () => { + const t0 = await fake.getCorrectionTable(V); + const ept = find(t0, 'ЭПТ'); + const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: ept.side_1.entries[0].entry_ids, action: 'EXCLUDE' }); + expect(find(t, 'ЭПТ').composition_changed).toBe(true); + }); + + it('ASSIGN: только в правило из candidate_rule_ids', async () => { + const t = await fake.getCorrectionTable(V); + const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries.find((e) => e.account_code === '62.03'); + if (!row) throw new Error('нет строки'); + await expectCode(fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'ASSIGN', rule_id: 'r1' }), 422, 'RULE_NOT_APPLICABLE'); + }); + + it('ASSIGN нераспределённой строки в правило создаёт агрегат, UNASSIGN возвращает строку', async () => { + const t0 = await fake.getCorrectionTable(V); + const u = t0.unallocated[0]; + const t1 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, action: 'ASSIGN', rule_id: 'r7' }); + expect(t1.unallocated).toHaveLength(t0.unallocated.length - 1); + const created = t1.aggregates.find((a) => a.rule_id === 'r7'); + expect(created?.side_1.amount).toBe(u.amount); + const t2 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, action: 'UNASSIGN' }); + expect(t2.aggregates.find((a) => a.rule_id === 'r7')).toBeUndefined(); + expect(t2.unallocated.find((x) => x.entry_ids[0] === u.entry_ids[0])?.reason).toBe('UNASSIGNED'); + }); + + it('saveCorrection: комментарий обязателен, версия проверяется', async () => { + const t = await fake.getCorrectionTable(V); + const key = toAggregateKey(find(t, 'ООО "БСК"')); + await expectCode(fake.saveCorrection(V, key, body({ comment: ' ' })), 422, 'COMMENT_REQUIRED'); + const saved = await fake.saveCorrection(V, key, body()); + expect(saved.correction?.version_no).toBe(1); + expect(saved.correction?.balance.debit_total).toBe('10.00'); + await expectCode(fake.saveCorrection(V, key, body()), 409, 'CORRECTION_VERSION_CONFLICT'); + const again = await fake.saveCorrection(V, key, body({ expected_version_no: 1 })); + expect(again.correction?.version_no).toBe(2); + }); + + it('revokeCorrection и повторное сохранение', async () => { + const t = await fake.getCorrectionTable(V); + const ept = find(t, 'ЭПТ'); + const key = toAggregateKey(ept); + await expectCode(fake.revokeCorrection(V, key, { comment: ' ' }), 422, 'COMMENT_REQUIRED'); + const revoked = await fake.revokeCorrection(V, key, { comment: 'сверили' }); + expect(revoked.correction).toBeNull(); + await expectCode(fake.revokeCorrection(V, key, { comment: 'ещё раз' }), 409, 'CORRECTION_NOT_APPLIED'); + const saved = await fake.saveCorrection(V, key, body()); + expect(saved.correction).not.toBeNull(); + expect(aggregateKey(saved)).toBe(aggregateKey(ept)); + }); + + it('ответы — копии: правка снаружи не портит состояние', async () => { + const t = await fake.getCorrectionTable(V); + t.aggregates.length = 0; + expect((await fake.getCorrectionTable(V)).aggregates.length).toBe(makeMockTable().aggregates.length); + }); +}); diff --git a/web/src/services/api/correctionsFake.ts b/web/src/services/api/correctionsFake.ts new file mode 100644 index 0000000..2c3a8f0 --- /dev/null +++ b/web/src/services/api/correctionsFake.ts @@ -0,0 +1,240 @@ +// Фейк контракта корректировок (docs/dev/api-contract-corrections.md) для REACT_APP_CORRECTIONS_MOCK: состояние в памяти модуля. +// Повторяет проверки сервера (комментарий, редакция, применимость правила), чтобы экран отрабатывал те же ошибки. +import { ApiError } from '../../api/client'; +import type { + AggregateKey, + AllocationIn, + CorrectionAggregateOut, + CorrectionEntryOut, + CorrectionIn, + CorrectionRevokeIn, + CorrectionTableOut, + Ref, + UnallocatedEntryOut, +} from '../../types/corrections'; +import { computeTotals, recomputeAggregate } from '../../widgets/CorrectionTable/mockCalc'; +import { makeMockTable } from '../../widgets/CorrectionTable/mockData'; +import { aggregateKey } from '../../widgets/CorrectionTable/tableModel'; + +let state: CorrectionTableOut = makeMockTable(); + +export function resetCorrectionsFake(): void { + state = makeMockTable(); +} + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +const fail = (status: number, code: string, message: string): never => { + throw new ApiError(status, code, message, { detail: { code, message } }); +}; + +const keyOf = (k: AggregateKey): string => `${k.rule_id}|${k.company_1_id}|${k.company_2_id}`; + +function snapshot(): CorrectionTableOut { + state.totals = computeTotals(state.aggregates); + return clone(state); +} + +function aggregateByKey(k: AggregateKey): CorrectionAggregateOut { + return state.aggregates.find((a) => aggregateKey(a) === keyOf(k)) ?? fail(404, 'NOT_FOUND', 'Агрегат не найден'); +} + +/** Состав агрегата с корректировкой изменился — сервер помечает `composition_changed`. */ +function touch(a: CorrectionAggregateOut): void { + recomputeAggregate(a); + if (a.correction) a.composition_changed = true; +} + +type Located = + | { where: 'aggregate'; aggregate: CorrectionAggregateOut; sideNo: 1 | 2; entry: CorrectionEntryOut } + | { where: 'unallocated'; item: UnallocatedEntryOut }; + +function locate(id: string): Located { + for (const aggregate of state.aggregates) { + for (const sideNo of [1, 2] as const) { + const entry = (sideNo === 1 ? aggregate.side_1 : aggregate.side_2).entries.find((e) => e.entry_ids.includes(id)); + if (entry) return { where: 'aggregate', aggregate, sideNo, entry }; + } + } + const item = state.unallocated.find((u) => u.entry_ids.includes(id)); + if (item) return { where: 'unallocated', item }; + return fail(404, 'NOT_FOUND', 'Строка не найдена'); +} + +function refOf(a: CorrectionAggregateOut, id: string): Ref { + return a.company_1.id === id ? a.company_1 : a.company_2; +} + +function detach(found: Located): { owner: Ref; counterparty: Ref; account_code: string; side: 'D' | 'K'; amount: string; entry_ids: string[]; candidates: string[]; excluded: boolean } { + if (found.where === 'unallocated') { + const u = found.item; + state.unallocated = state.unallocated.filter((x) => x !== u); + return { owner: u.owner, counterparty: u.counterparty, account_code: u.account_code, side: u.side, amount: u.amount, entry_ids: u.entry_ids, candidates: u.candidate_rule_ids, excluded: u.excluded }; + } + const { aggregate: a, sideNo, entry: e } = found; + const side = sideNo === 1 ? a.side_1 : a.side_2; + side.entries = side.entries.filter((x) => x !== e); + const out = { + owner: refOf(a, e.legal_entity_id), + counterparty: refOf(a, e.counterparty_id), + account_code: e.account_code, + side: e.side, + amount: e.amount, + entry_ids: e.entry_ids, + candidates: e.candidate_rule_ids, + excluded: e.excluded, + }; + touch(a); + if (!a.side_1.entries.length && !a.side_2.entries.length) state.aggregates = state.aggregates.filter((x) => x !== a); + return out; +} + +function assign(found: Located, ruleId: string): void { + const rule = state.rules.find((r) => r.id === ruleId); + const candidates = found.where === 'unallocated' ? found.item.candidate_rule_ids : found.entry.candidate_rule_ids; + if (!rule || !candidates.includes(ruleId)) fail(422, 'RULE_NOT_APPLICABLE', 'Строка не подходит под это правило'); + const moved = detach(found); + let target = state.aggregates.find( + (a) => a.rule_id === ruleId && [a.company_1.id, a.company_2.id].includes(moved.owner.id) && [a.company_1.id, a.company_2.id].includes(moved.counterparty.id), + ); + if (!target) { + target = { + rule_id: ruleId, + rule_no: rule?.rule_no ?? 0, + company_1: moved.owner, + company_2: moved.counterparty, + side_1: { article_line: rule?.article_1_line ?? '', accounts: [], amount: '0.00', entries: [] }, + side_2: { article_line: rule?.article_2_line ?? '', accounts: [], amount: '0.00', entries: [] }, + deviation_amount: '0.00', + deviation_percent: null, + exceeds_threshold: false, + status: 'AUTO', + eliminated_amount: '0.00', + composition_changed: false, + correction: null, + }; + state.aggregates.push(target); + } + const entry: CorrectionEntryOut = { + entry_ids: moved.entry_ids, + legal_entity_id: moved.owner.id, + counterparty_id: moved.counterparty.id, + account_code: moved.account_code, + side: moved.side, + amount: moved.amount, + decision: 'ASSIGN', + excluded: moved.excluded, + candidate_rule_ids: moved.candidates.filter((id) => id !== ruleId), + }; + (target.company_1.id === moved.owner.id ? target.side_1 : target.side_2).entries.push(entry); + touch(target); +} + +function applyAction(id: string, body: AllocationIn): void { + const found = locate(id); + const flag = (excluded: boolean) => { + if (found.where === 'unallocated') found.item.excluded = excluded; + else { + found.entry.excluded = excluded; + touch(found.aggregate); + } + }; + switch (body.action) { + case 'EXCLUDE': + flag(true); + break; + case 'INCLUDE': + flag(false); + break; + case 'ASSIGN': + if (!body.rule_id) fail(422, 'RULE_NOT_APPLICABLE', 'Не указано правило'); + assign(found, body.rule_id as string); + break; + case 'UNASSIGN': { + if (found.where === 'unallocated') break; + const moved = detach(found); + state.unallocated.push({ + entry_ids: moved.entry_ids, + owner: moved.owner, + counterparty: moved.counterparty, + account_code: moved.account_code, + side: moved.side, + amount: moved.amount, + candidate_rule_ids: [...new Set([...moved.candidates, found.aggregate.rule_id])], + reason: 'UNASSIGNED', + excluded: moved.excluded, + }); + break; + } + default: // RESET + if (found.where === 'unallocated') found.item.excluded = false; + else { + found.entry.excluded = false; + found.entry.decision = 'AUTO'; + touch(found.aggregate); + } + break; + } +} + +export const correctionsFake = { + getCorrectionTable(_versionId: string): Promise { + return Promise.resolve(snapshot()); + }, + + saveCorrection(_versionId: string, key: AggregateKey, body: CorrectionIn): Promise { + return run(() => { + const a = aggregateByKey(key); + if (body.comment.trim() === '') fail(422, 'COMMENT_REQUIRED', 'Комментарий обязателен'); + const current = a.correction?.version_no ?? null; + if (body.expected_version_no !== current) fail(409, 'CORRECTION_VERSION_CONFLICT', 'Корректировку уже изменили, обновите таблицу'); + a.correction = { + status: body.status ?? 'APPLIED', + version_no: (current ?? 0) + 1, + debit: clone(body.debit), + credit: clone(body.credit), + comment: body.comment.trim(), + reason_code: body.reason_code ?? null, + balance: { debit_total: '0.00', credit_total: '0.00', difference: '0.00', difference_percent: null, remainder_1: '0.00', remainder_2: '0.00' }, + updated_by: { id: 'u-mock', name: 'Иванов И. И.' }, + updated_at: new Date().toISOString(), + }; + a.composition_changed = false; + recomputeAggregate(a); + return clone(a); + }); + }, + + revokeCorrection(_versionId: string, key: AggregateKey, body: CorrectionRevokeIn): Promise { + return run(() => { + const a = aggregateByKey(key); + if (body.comment.trim() === '') fail(422, 'COMMENT_REQUIRED', 'Комментарий обязателен'); + if (!a.correction) fail(409, 'CORRECTION_NOT_APPLIED', 'Корректировка не применена'); + a.correction = null; + a.composition_changed = false; + return clone(a); + }); + }, + + allocate(_versionId: string, body: AllocationIn): Promise { + return run(() => { + if (!body.entry_ids.length) fail(422, 'VALIDATION_ERROR', 'Не указаны строки'); + for (const id of body.entry_ids) applyAction(id, body); + return snapshot(); + }); + }, + + exportCorrectionTable(_versionId: string): Promise<{ blob: Blob; filename: string | null }> { + const rows = state.aggregates.map((a) => [a.company_1.name, a.company_2.name, a.side_1.amount, a.side_2.amount, a.deviation_amount].join(';')); + return Promise.resolve({ blob: new Blob([`${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' }), filename: 'corrections-mock.csv' }); + }, +}; + +/** Синхронные проверки превращаем в отклонённый Promise, как у настоящего запроса. */ +function run(fn: () => T): Promise { + try { + return Promise.resolve(fn()); + } catch (e) { + return Promise.reject(e); + } +} diff --git a/web/src/widgets/CorrectionTable/mockCalc.ts b/web/src/widgets/CorrectionTable/mockCalc.ts new file mode 100644 index 0000000..2541f14 --- /dev/null +++ b/web/src/widgets/CorrectionTable/mockCalc.ts @@ -0,0 +1,81 @@ +import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money, Percent } from '../../types/corrections'; +// Пересчёт агрегата таблицы корректировок — общий для данных фейка и самого фейка. Деньги — строки, арифметика — BigInt-копейки. +// Это же делает сервер (контракт, раздел 4); на экране эти функции не используются: фронт только показывает ответ. +import { sumAmounts } from '../../utils/money'; + +export const THRESHOLD_PERCENT_HUNDREDTHS = 500n; + +export function toCents(value: Money): bigint { + const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(value); + if (!match) throw new Error(`Не сумма: ${value}`); + const cents = BigInt(match[2]) * 100n + BigInt((match[3] ?? '').padEnd(2, '0')); + return match[1] ? -cents : cents; +} + +export function fromCents(cents: bigint): Money { + const abs = cents < 0n ? -cents : cents; + return `${cents < 0n ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`; +} + +const sumOrZero = (values: readonly Money[]): Money => sumAmounts(values) ?? '0.00'; + +/** Процент отклонения к меньшей сумме, два знака, половина вверх; `null`, если меньшая сумма не положительна. */ +export function percentOfSmaller(a: Money, b: Money): Percent { + const x = toCents(a); + const y = toCents(b); + const min = x < y ? x : y; + if (min <= 0n) return null; + const diff = x < y ? y - x : x - y; + return fromCents((diff * 20000n + min) / (2n * min)); +} + +function sideAmount(side: AggregateSideOut): void { + side.amount = sumOrZero(side.entries.filter((e) => !e.excluded).map((e) => e.amount)); + side.accounts = [...new Set(side.entries.map((e) => e.account_code))].sort(); +} + +export function balanceOf(a: CorrectionAggregateOut): CorrectionBalanceOut | null { + if (!a.correction) return null; + const debit = sumOrZero(a.correction.debit.map((l) => l.amount)); + const credit = sumOrZero(a.correction.credit.map((l) => l.amount)); + const d = toCents(debit); + const c = toCents(credit); + return { + debit_total: debit, + credit_total: credit, + difference: fromCents(d > c ? d - c : c - d), + difference_percent: percentOfSmaller(debit, credit), + remainder_1: fromCents(toCents(a.side_1.amount) - d), + remainder_2: fromCents(toCents(a.side_2.amount) - c), + }; +} + +/** Суммы сторон, отклонение, порог 5 %, статус и контроль корректировки — из строк агрегата. */ +export function recomputeAggregate(a: CorrectionAggregateOut): void { + sideAmount(a.side_1); + sideAmount(a.side_2); + const x = toCents(a.side_1.amount); + const y = toCents(a.side_2.amount); + a.deviation_amount = fromCents(x > y ? x - y : y - x); + a.deviation_percent = percentOfSmaller(a.side_1.amount, a.side_2.amount); + const oneSided = (x === 0n) !== (y === 0n); + a.exceeds_threshold = oneSided || (a.deviation_percent !== null && toCents(a.deviation_percent) >= THRESHOLD_PERCENT_HUNDREDTHS); + if (oneSided) a.status = 'ONE_SIDED'; + else a.status = a.exceeds_threshold ? 'DISCREPANCY' : 'AUTO'; + // Правила 1 и 2 считаются отдельно (DOC-274230) и автоматически не элиминируются. + a.eliminated_amount = a.status === 'AUTO' && a.rule_no > 2 ? fromCents(x < y ? x : y) : '0.00'; + if (a.correction) { + const balance = balanceOf(a); + if (balance) a.correction.balance = balance; + } +} + +export function computeTotals(aggregates: readonly CorrectionAggregateOut[]): CorrectionTotalsOut { + return { + amount_1: sumOrZero(aggregates.map((a) => a.side_1.amount)), + amount_2: sumOrZero(aggregates.map((a) => a.side_2.amount)), + deviation_amount: sumOrZero(aggregates.map((a) => a.deviation_amount)), + correction_debit: sumOrZero(aggregates.flatMap((a) => a.correction?.debit.map((l) => l.amount) ?? [])), + correction_credit: sumOrZero(aggregates.flatMap((a) => a.correction?.credit.map((l) => l.amount) ?? [])), + }; +} diff --git a/web/src/widgets/CorrectionTable/mockData.ts b/web/src/widgets/CorrectionTable/mockData.ts new file mode 100644 index 0000000..03c4fe5 --- /dev/null +++ b/web/src/widgets/CorrectionTable/mockData.ts @@ -0,0 +1,180 @@ +// Данные фейка контракта корректировок — из дизайна 23.10.2026 (суммы в рублях). Правила — как api/seed/core/elimination_rules_v1.yaml. +import type { + AggregateSideOut, + CorrectionAggregateOut, + CorrectionEntryOut, + CorrectionRuleOut, + CorrectionTableOut, + Ref, + Side, + UnallocatedEntryOut, +} from '../../types/corrections'; +import { computeTotals, recomputeAggregate } from './mockCalc'; + +export const MOCK_VERSION_ID = 'mock-version'; + +const rule = ( + no: number, + a1: string, + a2: string, + c1: string[], + c2: string[], + c1x: string[] = [], + c2x: string[] = [], +): CorrectionRuleOut => ({ + id: `r${no}`, + rule_no: no, + article_1_line: a1, + article_2_line: a2, + side_1: 'D', + measure: 'closing', + company_1_accounts: c1, + company_2_accounts: c2, + company_1_excluded: c1x, + company_2_excluded: c2x, +}); + +const A_SIDE_6 = ['60.02', '60.22', '60.32', '62.01', '62.03', '62.21', '62.31', '76']; +const A_EXCL_6 = ['76.07.5', '76.07.9']; + +export const MOCK_RULES: CorrectionRuleOut[] = [ + rule(1, '1150', '1450/1550', ['03', '76.07.9', '01'], ['76.07.1'], [], ['76.07.5', '76.07.9']), + rule(2, '1170', '1310/1350', ['58.01'], ['80', '83']), + rule(3, '1170', '1410', ['58.03'], ['67.03']), + rule(4, '1240', '1510', ['58.03'], ['66.03']), + rule(5, '1230', '1510', ['76'], ['66.04', '67.04'], A_EXCL_6), + rule(6, '1230', '1520', A_SIDE_6, ['60.01', '60.03', '60.21', '60.31', '62.02', '62.22', '62.32', '76'], A_EXCL_6, ['76.07.1']), + rule(7, '1230', '1410', A_SIDE_6, ['67'], A_EXCL_6), + rule(8, '1230', '1510', A_SIDE_6, ['66'], A_EXCL_6), +]; + +const ept: Ref = { id: 'c-ept', name: 'ЭПТ' }; +const ils: Ref = { id: 'c-ils', name: 'ИЛС' }; +const bsz: Ref = { id: 'c-bsz', name: 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"' }; +const bsk: Ref = { id: 'c-bsk', name: 'БЕКОВСКИЙ САХАРНЫЙ КОМБИНАТ ООО' }; +const pmp: Ref = { id: 'c-pmp', name: 'Пензенский Мир Продуктов' }; +const rsg: Ref = { id: 'c-rsg', name: 'Регион-Сахар' }; +const bscOoo: Ref = { id: 'c-bsc', name: 'ООО "БСК"' }; +const pmpOoo: Ref = { id: 'c-pmpooo', name: 'ПМП ООО' }; +const agroH: Ref = { id: 'c-agrh', name: 'Агро-Холдинг' }; +const agroF: Ref = { id: 'c-agrf', name: 'Агро-Финанс' }; + +let entryNo = 0; +const entry = (owner: Ref, cp: Ref, account: string, side: Side, amount: string, candidates: string[] = []): CorrectionEntryOut => { + entryNo += 1; + return { + entry_ids: [`e${entryNo}`], + legal_entity_id: owner.id, + counterparty_id: cp.id, + account_code: account, + side, + amount, + decision: 'AUTO', + excluded: false, + candidate_rule_ids: candidates, + }; +}; + +const side = (articleLine: string, entries: CorrectionEntryOut[]): AggregateSideOut => ({ article_line: articleLine, accounts: [], amount: '0.00', entries }); + +function aggregate(ruleNo: number, c1: Ref, c2: Ref, s1: CorrectionEntryOut[], s2: CorrectionEntryOut[]): CorrectionAggregateOut { + const r = MOCK_RULES[ruleNo - 1]; + const a: CorrectionAggregateOut = { + rule_id: r.id, + rule_no: ruleNo, + company_1: c1, + company_2: c2, + side_1: side(r.article_1_line, s1), + side_2: side(r.article_2_line, s2), + deviation_amount: '0.00', + deviation_percent: null, + exceeds_threshold: false, + status: 'AUTO', + eliminated_amount: '0.00', + composition_changed: false, + correction: null, + }; + recomputeAggregate(a); + return a; +} + +const unallocated = ( + owner: Ref, + cp: Ref, + account: string, + s: Side, + amount: string, + candidates: string[], + reason: UnallocatedEntryOut['reason'] = 'MULTIPLE_RULES', +): UnallocatedEntryOut => { + entryNo += 1; + return { entry_ids: [`e${entryNo}`], owner, counterparty: cp, account_code: account, side: s, amount, candidate_rule_ids: candidates, reason, excluded: false }; +}; + +/** Свежая копия таблицы из дизайна 23.10: у каждого вызова свои объекты, фейк и тесты друг друга не портят. */ +export function makeMockTable(): CorrectionTableOut { + entryNo = 0; + const eptIls = aggregate( + 6, + ept, + ils, + [entry(ept, ils, '62.01', 'D', '1000000.00')], + [entry(ils, ept, '60.01', 'K', '202320.00')], + ); + eptIls.correction = { + status: 'APPLIED', + version_no: 1, + debit: [{ line_code: '1230', amount: '1000000.00' }], + credit: [{ line_code: '1520', amount: '202320.00' }], + comment: 'Требуется сверка с контрагентом', + reason_code: null, + balance: { debit_total: '0.00', credit_total: '0.00', difference: '0.00', difference_percent: null, remainder_1: '0.00', remainder_2: '0.00' }, + updated_by: { id: 'u-mock', name: 'Иванов И. И.' }, + updated_at: '2026-10-28T12:00:00+03:00', + }; + recomputeAggregate(eptIls); + + const bekovsky = aggregate( + 6, + bsz, + bsk, + [ + entry(bsz, bsk, '62.01', 'D', '1200000.00', ['r7', 'r8']), + entry(bsz, bsk, '62.03', 'D', '350000.00', ['r7', 'r8']), + entry(bsz, bsk, '62.21', 'D', '80000.00', ['r7', 'r8']), + entry(bsz, bsk, '76.05', 'D', '45000.00', ['r5', 'r7', 'r8']), + ], + [ + entry(bsk, bsz, '60.01', 'K', '1100000.00'), + entry(bsk, bsz, '60.21', 'K', '400000.00'), + entry(bsk, bsz, '62.02', 'K', '120000.00'), + entry(bsk, bsz, '76.05', 'K', '30000.00'), + ], + ); + + const pmpRs = aggregate(3, pmp, rsg, [entry(pmp, rsg, '58.03', 'D', '29742550.00', ['r4'])], [entry(rsg, pmp, '67.03', 'K', '29242550.00')]); + + const bscPmp = aggregate(6, bscOoo, pmpOoo, [entry(bscOoo, pmpOoo, '62.01', 'D', '310000.00', ['r7', 'r8'])], [entry(pmpOoo, bscOoo, '60.01', 'K', '300000.00')]); + + const agro = aggregate(4, agroH, agroF, [entry(agroH, agroF, '58.03', 'D', '5000000.00', ['r3'])], []); + + const aggregates = [eptIls, bekovsky, pmpRs, bscPmp, agro]; + return { + version_id: MOCK_VERSION_ID, + statement_type: 'balance', + rules_version_no: 1, + editable: true, + read_only_reason: null, + rules: MOCK_RULES.map((r) => ({ ...r })), + aggregates, + unallocated: [ + unallocated(bscOoo, pmpOoo, '76.05', 'K', '142878.00', ['r6', 'r7']), + unallocated(ept, ils, '76.09', 'D', '55000.00', ['r5', 'r6', 'r8']), + unallocated(bsz, bsk, '62.31', 'D', '21000.00', ['r6', 'r7', 'r8']), + unallocated(pmp, rsg, '58.03', 'D', '800000.00', ['r3', 'r4']), + unallocated(rsg, pmpOoo, '67.04', 'K', '15000.00', ['r5'], 'UNASSIGNED'), + unallocated(ils, ept, '60.01', 'K', '70000.00', ['r6']), + ], + totals: computeTotals(aggregates), + }; +} diff --git a/web/src/widgets/CorrectionTable/tableModel.test.ts b/web/src/widgets/CorrectionTable/tableModel.test.ts new file mode 100644 index 0000000..bf76e37 --- /dev/null +++ b/web/src/widgets/CorrectionTable/tableModel.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import type { CorrectionAggregateOut } from '../../types/corrections'; +import { makeMockTable } from './mockData'; +import { + EMPTY_FILTERS, + aggregateKey, + articleCodes, + buildRows, + filterTable, + findAggregate, + includeState, + isFiltered, + lineOptions, + ruleLabel, + toAggregateKey, + visibleTotals, +} from './tableModel'; + +const table = () => makeMockTable(); +const bekovsky = (t = table()): CorrectionAggregateOut => { + const a = t.aggregates.find((x) => x.company_1.name.includes('БЕКОВСКИЙ САХАРНЫЙ ЗАВОД')); + if (!a) throw new Error('нет агрегата'); + return a; +}; + +describe('подписи и коды статей', () => { + it('ruleLabel', () => { + const t = table(); + expect(ruleLabel(t.rules[0])).toBe('Правило 1: 1150 — 1450 / 1550'); + expect(ruleLabel(t.rules[5])).toBe('Правило 6: 1230 — 1520'); + }); + it('articleCodes', () => { + expect(articleCodes('1310/1350')).toEqual(['1310', '1350']); + expect(articleCodes('1230')).toEqual(['1230']); + }); + it('lineOptions — отдельные коды по возрастанию', () => { + const opts = lineOptions(table()); + expect(opts).toEqual([...opts].sort()); + expect(opts).toContain('1230'); + expect(opts).toContain('1520'); + }); + it('ключ агрегата', () => { + const a = bekovsky(); + expect(aggregateKey(a)).toBe('r6|c-bsz|c-bsk'); + expect(toAggregateKey(a)).toEqual({ rule_id: 'r6', company_1_id: 'c-bsz', company_2_id: 'c-bsk' }); + }); +}); + +describe('includeState', () => { + it('Да / Частично / Нет', () => { + const a = bekovsky(); + const entries = [...a.side_1.entries, ...a.side_2.entries]; + expect(entries).toHaveLength(8); + expect(includeState(a)).toBe('Да'); + entries[0].excluded = true; + expect(includeState(a)).toBe('Частично'); + for (const e of entries) e.excluded = true; + expect(includeState(a)).toBe('Нет'); + }); +}); + +describe('filterTable', () => { + it('пресет правила 3', () => { + const view = filterTable(table(), { ...EMPTY_FILTERS, display: { mode: 'preset', ruleId: 'r3' } }); + expect(view.aggregates.length).toBeGreaterThan(0); + expect(view.aggregates.every((a) => a.rule_id === 'r3')).toBe(true); + expect(view.unallocated.length).toBeGreaterThan(0); + expect(view.unallocated.every((u) => u.candidate_rule_ids.includes('r3'))).toBe(true); + }); + it('тип записи', () => { + expect(filterTable(table(), { ...EMPTY_FILTERS, recordType: 'unallocated' }).aggregates).toEqual([]); + expect(filterTable(table(), { ...EMPTY_FILTERS, recordType: 'posting' }).unallocated).toEqual([]); + }); + it('юридическое лицо — подстрока без регистра в обеих сторонах', () => { + const view = filterTable(table(), { ...EMPTY_FILTERS, entity: 'ооо "бск"' }); + expect(view.aggregates.map((a) => a.company_1.name)).toEqual(['ООО "БСК"']); + expect(view.unallocated.length).toBeGreaterThan(0); + expect(view.unallocated.every((u) => u.owner.name === 'ООО "БСК"' || u.counterparty.name === 'ООО "БСК"')).toBe(true); + expect(filterTable(table(), { ...EMPTY_FILTERS, entity: 'ПМП' }).aggregates.length).toBeGreaterThan(0); + }); + it('отклонение: только агрегаты', () => { + const over = filterTable(table(), { ...EMPTY_FILTERS, deviation: 'over' }); + expect(over.aggregates.length).toBeGreaterThan(0); + expect(over.aggregates.every((a) => a.exceeds_threshold)).toBe(true); + expect(over.unallocated).toEqual([]); + const under = filterTable(table(), { ...EMPTY_FILTERS, deviation: 'under' }); + expect(under.aggregates.every((a) => !a.exceeds_threshold)).toBe(true); + }); + it('строка баланса: по кодам составной статьи, нераспределённые скрыты', () => { + const view = filterTable(table(), { ...EMPTY_FILTERS, line: '1520' }); + expect(view.aggregates.length).toBeGreaterThan(0); + expect(view.aggregates.every((a) => a.rule_id === 'r6')).toBe(true); + expect(view.unallocated).toEqual([]); + }); + it('счёт: начало кода', () => { + const view = filterTable(table(), { ...EMPTY_FILTERS, account: '58.0' }); + expect(view.aggregates.map((a) => a.rule_id).sort()).toEqual(['r3', 'r4']); + expect(view.unallocated.every((u) => u.account_code.startsWith('58.0'))).toBe(true); + }); +}); + +describe('buildRows и итоги', () => { + it('раскрытый агрегат: 4 строки стороны 1, затем 4 стороны 2', () => { + const t = table(); + const a = bekovsky(t); + const key = aggregateKey(a); + const rows = buildRows(filterTable(t, EMPTY_FILTERS), new Set([key])); + const at = rows.findIndex((r) => r.kind === 'aggregate' && r.key === key); + const next = rows.slice(at + 1, at + 9); + expect(next.map((r) => (r.kind === 'entry' ? r.side : 0))).toEqual([1, 1, 1, 1, 2, 2, 2, 2]); + expect(next.every((r) => r.kind === 'entry' && r.parentKey === key)).toBe(true); + expect(rows[at + 9].kind).not.toBe('entry'); + expect(rows.filter((r) => r.kind === 'entry')).toHaveLength(8); + expect(rows.filter((r) => r.kind === 'unallocated')).toHaveLength(t.unallocated.length); + }); + it('visibleTotals по ЭПТ — ИЛС и правилу 3', () => { + const t = table(); + const picked = t.aggregates.filter((a) => a.company_1.name === 'ЭПТ' || a.rule_id === 'r3'); + const totals = visibleTotals(picked); + expect(totals.amount_1).toBe('30742550.00'); + expect(totals.correction_debit).toBe('1000000.00'); + expect(visibleTotals([]).amount_1).toBeNull(); + }); + it('isFiltered', () => { + expect(isFiltered(EMPTY_FILTERS)).toBe(false); + expect(isFiltered({ ...EMPTY_FILTERS, entity: ' ' })).toBe(false); + expect(isFiltered({ ...EMPTY_FILTERS, line: '1230' })).toBe(true); + expect(isFiltered({ ...EMPTY_FILTERS, display: { mode: 'preset', ruleId: 'r1' } })).toBe(true); + }); + it('findAggregate — несуществующий ключ даёт null', () => { + const t = table(); + expect(findAggregate(t, 'нет')).toBeNull(); + expect(findAggregate(t, aggregateKey(t.aggregates[0]))).toBe(t.aggregates[0]); + }); +}); diff --git a/web/src/widgets/CorrectionTable/tableModel.ts b/web/src/widgets/CorrectionTable/tableModel.ts new file mode 100644 index 0000000..4386514 --- /dev/null +++ b/web/src/widgets/CorrectionTable/tableModel.ts @@ -0,0 +1,148 @@ +// Модель таблицы корректировок: фильтры, плоский список строк, итоги видимого. Только чистые функции; суммы — строки. +import type { + AggregateKey, + CorrectionAggregateOut, + CorrectionEntryOut, + CorrectionRuleOut, + CorrectionTableOut, + UnallocatedEntryOut, +} from '../../types/corrections'; +import { sumAmounts } from '../../utils/money'; + +export type IncludeState = 'Да' | 'Частично' | 'Нет'; + +export type TableRow = + | { kind: 'aggregate'; key: string; aggregate: CorrectionAggregateOut; include: IncludeState } + | { kind: 'entry'; key: string; parentKey: string; side: 1 | 2; entry: CorrectionEntryOut } + | { kind: 'unallocated'; key: string; entry: UnallocatedEntryOut }; + +export type DisplayMode = { mode: 'all' } | { mode: 'preset'; ruleId: string }; +export type RecordType = 'all' | 'posting' | 'unallocated'; +export type DeviationFilter = 'all' | 'over' | 'under'; + +export type TableFilters = { + display: DisplayMode; + recordType: RecordType; + entity: string; + line: string | null; + account: string; + deviation: DeviationFilter; +}; + +export const EMPTY_FILTERS: TableFilters = { + display: { mode: 'all' }, + recordType: 'all', + entity: '', + line: null, + account: '', + deviation: 'all', +}; + +export function aggregateKey(a: CorrectionAggregateOut): string { + return `${a.rule_id}|${a.company_1.id}|${a.company_2.id}`; +} + +export function toAggregateKey(a: CorrectionAggregateOut): AggregateKey { + return { rule_id: a.rule_id, company_1_id: a.company_1.id, company_2_id: a.company_2.id }; +} + +/** Производное «Учитывать в КФО» агрегата: по флагам `excluded` его строк. */ +export function includeState(a: CorrectionAggregateOut): IncludeState { + const entries = [...a.side_1.entries, ...a.side_2.entries]; + const excluded = entries.filter((e) => e.excluded).length; + if (excluded === 0) return 'Да'; + return excluded === entries.length ? 'Нет' : 'Частично'; +} + +/** Составная статья показывается через « / »: «Правило 1: 1150 — 1450 / 1550». */ +export function ruleLabel(r: CorrectionRuleOut): string { + const show = (line: string) => articleCodes(line).join(' / '); + return `Правило ${r.rule_no}: ${show(r.article_1_line)} — ${show(r.article_2_line)}`; +} + +export function articleCodes(line: string): string[] { + return line + .split('/') + .map((c) => c.trim()) + .filter((c) => c !== ''); +} + +export function lineOptions(table: CorrectionTableOut): string[] { + const codes = new Set(); + for (const a of table.aggregates) { + for (const c of [...articleCodes(a.side_1.article_line), ...articleCodes(a.side_2.article_line)]) codes.add(c); + } + return [...codes].sort(); +} + +export function isFiltered(f: TableFilters): boolean { + return f.display.mode !== 'all' || f.recordType !== 'all' || f.entity.trim() !== '' || f.line !== null || f.account.trim() !== '' || f.deviation !== 'all'; +} + +export function filterTable( + table: CorrectionTableOut, + f: TableFilters, +): { aggregates: CorrectionAggregateOut[]; unallocated: UnallocatedEntryOut[] } { + const entity = f.entity.trim().toLowerCase(); + const account = f.account.trim(); + const aggregateOnly = f.line !== null || f.deviation !== 'all'; + + const aggregates = table.aggregates.filter((a) => { + if (f.recordType === 'unallocated') return false; + if (f.display.mode === 'preset' && a.rule_id !== f.display.ruleId) return false; + if (entity && !`${a.company_1.name}\n${a.company_2.name}`.toLowerCase().includes(entity)) return false; + if (account && ![...a.side_1.accounts, ...a.side_2.accounts].some((c) => c.startsWith(account))) return false; + if (f.line !== null && ![...articleCodes(a.side_1.article_line), ...articleCodes(a.side_2.article_line)].includes(f.line)) return false; + if (f.deviation === 'over' && !a.exceeds_threshold) return false; + if (f.deviation === 'under' && a.exceeds_threshold) return false; + return true; + }); + + const unallocated = table.unallocated.filter((u) => { + if (f.recordType === 'posting' || aggregateOnly) return false; + if (f.display.mode === 'preset' && !u.candidate_rule_ids.includes(f.display.ruleId)) return false; + if (entity && !`${u.owner.name}\n${u.counterparty.name}`.toLowerCase().includes(entity)) return false; + if (account && !u.account_code.startsWith(account)) return false; + return true; + }); + + return { aggregates, unallocated }; +} + +export function buildRows(view: ReturnType, expanded: ReadonlySet): TableRow[] { + const rows: TableRow[] = []; + for (const aggregate of view.aggregates) { + const key = aggregateKey(aggregate); + rows.push({ kind: 'aggregate', key, aggregate, include: includeState(aggregate) }); + if (!expanded.has(key)) continue; + for (const [side, entries] of [ + [1, aggregate.side_1.entries], + [2, aggregate.side_2.entries], + ] as const) { + for (const entry of entries) rows.push({ kind: 'entry', key: `${key}|${side}|${entry.entry_ids.join(',')}`, parentKey: key, side, entry }); + } + } + for (const entry of view.unallocated) rows.push({ kind: 'unallocated', key: `u|${entry.entry_ids.join(',')}`, entry }); + return rows; +} + +/** «Итого» по видимым агрегатам; нет агрегатов — прочерки (`null`). */ +export function visibleTotals(aggregates: CorrectionAggregateOut[]): { + amount_1: string | null; + amount_2: string | null; + deviation_amount: string | null; + correction_debit: string | null; + correction_credit: string | null; +} { + return { + amount_1: sumAmounts(aggregates.map((a) => a.side_1.amount)), + amount_2: sumAmounts(aggregates.map((a) => a.side_2.amount)), + deviation_amount: sumAmounts(aggregates.map((a) => a.deviation_amount)), + correction_debit: aggregates.length ? (sumAmounts(aggregates.flatMap((a) => a.correction?.debit.map((l) => l.amount) ?? [])) ?? '0.00') : null, + correction_credit: aggregates.length ? (sumAmounts(aggregates.flatMap((a) => a.correction?.credit.map((l) => l.amount) ?? [])) ?? '0.00') : null, + }; +} + +export function findAggregate(table: CorrectionTableOut, key: string): CorrectionAggregateOut | null { + return table.aggregates.find((a) => aggregateKey(a) === key) ?? null; +} -- 2.47.2 From f4edb66f82f9ade44d889c257cd48167ad165a0b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:05:13 +0400 Subject: [PATCH 05/28] =?UTF-8?q?fix(web):=20=D1=84=D0=B5=D0=B9=D0=BA=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=BA=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E=20Task=202=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Стороны нового агрегата по знаку строки, RULE_REQUIRED, ENTRY_NOT_IN_VERSION, атомарность allocate, возврат прежнего правила в кандидаты, тесты. Co-Authored-By: Claude Opus 5.5 --- web/src/services/api/correctionsFake.test.ts | 50 ++++++++++++++++++- web/src/services/api/correctionsFake.ts | 43 ++++++++++------ .../widgets/CorrectionTable/mockCalc.test.ts | 12 +++++ web/src/widgets/CorrectionTable/mockCalc.ts | 3 +- 4 files changed, 90 insertions(+), 18 deletions(-) create mode 100644 web/src/widgets/CorrectionTable/mockCalc.test.ts diff --git a/web/src/services/api/correctionsFake.test.ts b/web/src/services/api/correctionsFake.test.ts index e61d7bd..f22d7cf 100644 --- a/web/src/services/api/correctionsFake.test.ts +++ b/web/src/services/api/correctionsFake.test.ts @@ -74,13 +74,61 @@ describe('correctionsFake', () => { const u = t0.unallocated[0]; const t1 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, action: 'ASSIGN', rule_id: 'r7' }); expect(t1.unallocated).toHaveLength(t0.unallocated.length - 1); + // строка кредитовая (K): владелец — компания 2, контрагент — компания 1, строка в side_2 + expect(u.side).toBe('K'); const created = t1.aggregates.find((a) => a.rule_id === 'r7'); - expect(created?.side_1.amount).toBe(u.amount); + expect(created?.company_2.name).toBe('ООО "БСК"'); + expect(created?.company_1.name).toBe('ПМП ООО'); + expect(created?.side_2.amount).toBe(u.amount); + expect(created?.side_1.entries).toEqual([]); const t2 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, action: 'UNASSIGN' }); expect(t2.aggregates.find((a) => a.rule_id === 'r7')).toBeUndefined(); expect(t2.unallocated.find((x) => x.entry_ids[0] === u.entry_ids[0])?.reason).toBe('UNASSIGNED'); }); + it('ASSIGN без rule_id и неизвестная строка', async () => { + const u = (await fake.getCorrectionTable(V)).unallocated[0]; + await expectCode(fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, action: 'ASSIGN' }), 422, 'RULE_REQUIRED'); + await expectCode(fake.allocate(V, { statement_type: 'balance', entry_ids: ['нет'], action: 'EXCLUDE' }), 422, 'ENTRY_NOT_IN_VERSION'); + }); + + it('allocate атомарен: ошибка на второй строке откатывает первую', async () => { + const t = await fake.getCorrectionTable(V); + const first = t.unallocated[0].entry_ids[0]; + await expectCode(fake.allocate(V, { statement_type: 'balance', entry_ids: [first, 'нет'], action: 'EXCLUDE' }), 422, 'ENTRY_NOT_IN_VERSION'); + expect((await fake.getCorrectionTable(V)).unallocated[0].excluded).toBe(false); + }); + + it('перенос между правилами возвращает прежнее правило в кандидаты', async () => { + const t = await fake.getCorrectionTable(V); + const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries.find((e) => e.account_code === '62.03'); + if (!row) throw new Error('нет строки'); + const moved = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'ASSIGN', rule_id: 'r7' }); + const target = moved.aggregates.find((a) => a.rule_id === 'r7'); + const e = target?.side_1.entries[0]; + expect(e?.decision).toBe('ASSIGN'); + expect(e?.candidate_rule_ids).toContain('r6'); + expect(e?.candidate_rule_ids).not.toContain('r7'); + }); + + it('INCLUDE возвращает строку, RESET сбрасывает решение', async () => { + const t = await fake.getCorrectionTable(V); + const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries[0]; + await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'EXCLUDE' }); + const back = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'INCLUDE' }); + expect(find(back, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.amount).toBe('1675000.00'); + await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'EXCLUDE' }); + const reset = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'RESET' }); + expect(find(reset, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries[0].excluded).toBe(false); + }); + + it('агрегат с одной стороной: ONE_SIDED, порог превышен', async () => { + const a = find(await fake.getCorrectionTable(V), 'Агро-Холдинг'); + expect(a.status).toBe('ONE_SIDED'); + expect(a.exceeds_threshold).toBe(true); + expect(a.deviation_percent).toBeNull(); + }); + it('saveCorrection: комментарий обязателен, версия проверяется', async () => { const t = await fake.getCorrectionTable(V); const key = toAggregateKey(find(t, 'ООО "БСК"')); diff --git a/web/src/services/api/correctionsFake.ts b/web/src/services/api/correctionsFake.ts index 2c3a8f0..76faf92 100644 --- a/web/src/services/api/correctionsFake.ts +++ b/web/src/services/api/correctionsFake.ts @@ -58,7 +58,7 @@ function locate(id: string): Located { } const item = state.unallocated.find((u) => u.entry_ids.includes(id)); if (item) return { where: 'unallocated', item }; - return fail(404, 'NOT_FOUND', 'Строка не найдена'); + return fail(422, 'ENTRY_NOT_IN_VERSION', 'Строки нет среди строк версии'); } function refOf(a: CorrectionAggregateOut, id: string): Ref { @@ -81,7 +81,8 @@ function detach(found: Located): { owner: Ref; counterparty: Ref; account_code: side: e.side, amount: e.amount, entry_ids: e.entry_ids, - candidates: e.candidate_rule_ids, + // прежнее правило становится одним из кандидатов, как при UNASSIGN + candidates: [...new Set([...e.candidate_rule_ids, a.rule_id])], excluded: e.excluded, }; touch(a); @@ -90,21 +91,23 @@ function detach(found: Located): { owner: Ref; counterparty: Ref; account_code: } function assign(found: Located, ruleId: string): void { - const rule = state.rules.find((r) => r.id === ruleId); + const rule = state.rules.find((r) => r.id === ruleId) ?? fail(404, 'NOT_FOUND', 'Правило не найдено'); const candidates = found.where === 'unallocated' ? found.item.candidate_rule_ids : found.entry.candidate_rule_ids; - if (!rule || !candidates.includes(ruleId)) fail(422, 'RULE_NOT_APPLICABLE', 'Строка не подходит под это правило'); + if (!candidates.includes(ruleId)) fail(422, 'RULE_NOT_APPLICABLE', 'Строка не подходит под это правило'); const moved = detach(found); - let target = state.aggregates.find( - (a) => a.rule_id === ruleId && [a.company_1.id, a.company_2.id].includes(moved.owner.id) && [a.company_1.id, a.company_2.id].includes(moved.counterparty.id), - ); + // Сторона 1 правила — дебетовая: строка с этой стороной принадлежит компании 1, кредитовая — компании 2. + const isSide1 = moved.side === rule.side_1; + const c1 = isSide1 ? moved.owner : moved.counterparty; + const c2 = isSide1 ? moved.counterparty : moved.owner; + let target = state.aggregates.find((a) => a.rule_id === ruleId && a.company_1.id === c1.id && a.company_2.id === c2.id); if (!target) { target = { rule_id: ruleId, - rule_no: rule?.rule_no ?? 0, - company_1: moved.owner, - company_2: moved.counterparty, - side_1: { article_line: rule?.article_1_line ?? '', accounts: [], amount: '0.00', entries: [] }, - side_2: { article_line: rule?.article_2_line ?? '', accounts: [], amount: '0.00', entries: [] }, + rule_no: rule.rule_no, + company_1: c1, + company_2: c2, + side_1: { article_line: rule.article_1_line, accounts: [], amount: '0.00', entries: [] }, + side_2: { article_line: rule.article_2_line, accounts: [], amount: '0.00', entries: [] }, deviation_amount: '0.00', deviation_percent: null, exceeds_threshold: false, @@ -126,7 +129,7 @@ function assign(found: Located, ruleId: string): void { excluded: moved.excluded, candidate_rule_ids: moved.candidates.filter((id) => id !== ruleId), }; - (target.company_1.id === moved.owner.id ? target.side_1 : target.side_2).entries.push(entry); + (isSide1 ? target.side_1 : target.side_2).entries.push(entry); touch(target); } @@ -147,7 +150,7 @@ function applyAction(id: string, body: AllocationIn): void { flag(false); break; case 'ASSIGN': - if (!body.rule_id) fail(422, 'RULE_NOT_APPLICABLE', 'Не указано правило'); + if (!body.rule_id) fail(422, 'RULE_REQUIRED', 'Для ASSIGN нужно правило'); assign(found, body.rule_id as string); break; case 'UNASSIGN': { @@ -160,7 +163,7 @@ function applyAction(id: string, body: AllocationIn): void { account_code: moved.account_code, side: moved.side, amount: moved.amount, - candidate_rule_ids: [...new Set([...moved.candidates, found.aggregate.rule_id])], + candidate_rule_ids: moved.candidates, reason: 'UNASSIGNED', excluded: moved.excluded, }); @@ -219,11 +222,19 @@ export const correctionsFake = { allocate(_versionId: string, body: AllocationIn): Promise { return run(() => { if (!body.entry_ids.length) fail(422, 'VALIDATION_ERROR', 'Не указаны строки'); - for (const id of body.entry_ids) applyAction(id, body); + // всё или ничего: при ошибке на любой строке возвращаем состояние, каким оно было + const backup = clone(state); + try { + for (const id of body.entry_ids) applyAction(id, body); + } catch (e) { + state = backup; + throw e; + } return snapshot(); }); }, + // CSV-заглушка вместо xlsx сервера: проверяет только скачивание. exportCorrectionTable(_versionId: string): Promise<{ blob: Blob; filename: string | null }> { const rows = state.aggregates.map((a) => [a.company_1.name, a.company_2.name, a.side_1.amount, a.side_2.amount, a.deviation_amount].join(';')); return Promise.resolve({ blob: new Blob([`${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' }), filename: 'corrections-mock.csv' }); diff --git a/web/src/widgets/CorrectionTable/mockCalc.test.ts b/web/src/widgets/CorrectionTable/mockCalc.test.ts new file mode 100644 index 0000000..96cb78d --- /dev/null +++ b/web/src/widgets/CorrectionTable/mockCalc.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { percentOfSmaller } from './mockCalc'; + +describe('percentOfSmaller', () => { + it('округляет половину вверх', () => { + expect(percentOfSmaller('9.00', '8.00')).toBe('12.50'); + expect(percentOfSmaller('1.00', '3.00')).toBe('200.00'); + // отклонение 0.01 к 8.00 = 0.125 % -> 0.13 (половина вверх) + expect(percentOfSmaller('8.01', '8.00')).toBe('0.13'); + expect(percentOfSmaller('0.00', '5.00')).toBeNull(); + }); +}); diff --git a/web/src/widgets/CorrectionTable/mockCalc.ts b/web/src/widgets/CorrectionTable/mockCalc.ts index 2541f14..5de9b80 100644 --- a/web/src/widgets/CorrectionTable/mockCalc.ts +++ b/web/src/widgets/CorrectionTable/mockCalc.ts @@ -1,10 +1,11 @@ import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money, Percent } from '../../types/corrections'; +import { sumAmounts } from '../../utils/money'; // Пересчёт агрегата таблицы корректировок — общий для данных фейка и самого фейка. Деньги — строки, арифметика — BigInt-копейки. // Это же делает сервер (контракт, раздел 4); на экране эти функции не используются: фронт только показывает ответ. -import { sumAmounts } from '../../utils/money'; export const THRESHOLD_PERCENT_HUNDREDTHS = 500n; +// toCents/fromCents дублируют BigInt-разбор из utils/money.ts: там он не экспортируется, а правка утилиты вне объёма задачи. export function toCents(value: Money): bigint { const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(value); if (!match) throw new Error(`Не сумма: ${value}`); -- 2.47.2 From b8a7782eaeab56725fd51a076d3dc7729061b564 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:08:35 +0400 Subject: [PATCH 06/28] =?UTF-8?q?feat(web):=20=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=20=D0=BE=D0=BA=D0=BD=D0=B0=20=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=20(#2?= =?UTF-8?q?06)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Состояние окна «Корректировка операции», проверки ввода, живой контроль (исходные суммы и распределение), кандидаты «Добавить строку ОСВ» и черновик в localStorage. Деньги — строки, сложение через sumAmounts, ввод через parseAmountInput; процент — percentOfSmaller из mockCalc. Co-Authored-By: Claude Opus 5.5 --- .../changes/corrections-table-ui/design.md | 3 + .../CorrectionTable/draftStorage.test.ts | 96 ++++++++++ .../widgets/CorrectionTable/draftStorage.ts | 79 ++++++++ .../CorrectionTable/editorModel.test.ts | 174 ++++++++++++++++++ .../widgets/CorrectionTable/editorModel.ts | 159 ++++++++++++++++ 5 files changed, 511 insertions(+) create mode 100644 web/src/widgets/CorrectionTable/draftStorage.test.ts create mode 100644 web/src/widgets/CorrectionTable/draftStorage.ts create mode 100644 web/src/widgets/CorrectionTable/editorModel.test.ts create mode 100644 web/src/widgets/CorrectionTable/editorModel.ts diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index cb22064..1a2afcf 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -182,3 +182,6 @@ | 28.10.2026 | R-1: список правил в плане — ориентир; при расхождении с api/seed/core/elimination_rules_v1.yaml (в плане статья 1230/1510 встречается дважды) номера и статьи берутся из yaml, тесты плана про правила 1, 3, 6, 7 остаются верными | seed — источник истины | поправка mockData и тестов | | 28.10.2026 | R-2: в фейк добавлен `widgets/CorrectionTable/mockCalc.ts` (пересчёт агрегата и итогов через BigInt-копейки) — общий для `mockData` и `correctionsFake`, иначе циклический импорт | план не называл файл; без него циклический импорт | перенос функций, без поведения | | 28.10.2026 | R-3: в `mockData` сверх дизайна добавлены агрегат правила 6 «ООО "БСК"» — «ПМП ООО» (310000.00 / 300000.00) и агрегат правила 4 «Агро-Холдинг» без второй стороны (ONE_SIDED); `status` в фейке — ONE_SIDED / DISCREPANCY / AUTO, MANUAL не выдаётся | тест фильтра `ооо "бск"` из плана требует агрегат с таким ЮЛ; ONE_SIDED нужен для проверки отображения | поправка данных фейка, на прод не влияет | +| 28.10.2026 | R-4: в окне корректировки сумма с пробелами («1 000 000,5») отклоняется с ошибкой «Сумма без пробелов», хотя `parseAmountInput` пробелы принимает | Review Focus плана прямо требует ошибку для этого ввода | если заказчик хочет пробелы — снять одну проверку в `validateLines` | +| 28.10.2026 | R-5: `editorModel` переиспользует `percentOfSmaller`/`toCents`/`fromCents` из `mockCalc.ts`; вынос в `utils/money.ts` отложен | не раздувать задачу; поведение одинаково | при подключении API `mockCalc` может уйти — функции придётся переехать | +| 28.10.2026 | R-6: тексты ошибок валидации окна и ключ ошибки `lines` для «Добавьте статью» выбраны исполнителем | бриф их не задаёт; тексты в spec не указаны | правка строк | diff --git a/web/src/widgets/CorrectionTable/draftStorage.test.ts b/web/src/widgets/CorrectionTable/draftStorage.test.ts new file mode 100644 index 0000000..5f87b0f --- /dev/null +++ b/web/src/widgets/CorrectionTable/draftStorage.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import type { CorrectionAggregateOut } from '../../types/corrections'; +import { type Draft, clearDraft, draftKey, isStale, loadDraft, saveDraft } from './draftStorage'; +import type { EditorState } from './editorModel'; +import { makeMockTable } from './mockData'; + +const mapStorage = () => { + const m = new Map(); + return { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => void m.set(k, v), + removeItem: (k: string) => void m.delete(k), + raw: m, + }; +}; + +const state: EditorState = { + debit: [{ line_code: '1170', amount: '29742550.00' }], + credit: [{ line_code: '1410', amount: '29242550.00' }], + comment: 'сверка', +}; +const draft = (baseVersionNo: number | null): Draft => ({ state, savedAt: '2026-10-28T10:00:00.000Z', baseVersionNo }); + +describe('draftKey', () => { + it('формат ключа', () => { + expect(draftKey('u1', 'v1', 'r3|c-pmp|c-rsg')).toBe('armka:correction-draft:u1:v1:r3|c-pmp|c-rsg'); + }); +}); + +describe('saveDraft / loadDraft / clearDraft', () => { + it('сохранённый черновик читается обратно', () => { + const s = mapStorage(); + saveDraft('k', draft(2), s); + expect(loadDraft('k', s)).toEqual(draft(2)); + }); + + it('очистка удаляет черновик', () => { + const s = mapStorage(); + saveDraft('k', draft(null), s); + clearDraft('k', s); + expect(loadDraft('k', s)).toBeNull(); + }); + + it('старый или чужой формат — null', () => { + const s = mapStorage(); + s.setItem('k', '{"x":1}'); + expect(loadDraft('k', s)).toBeNull(); + }); + + it('испорченный JSON — null', () => { + const s = mapStorage(); + s.setItem('k', '{не json'); + expect(loadDraft('k', s)).toBeNull(); + }); + + it('хранилище бросает исключение — null без ошибки', () => { + const broken = { + getItem: () => { + throw new Error('denied'); + }, + setItem: () => { + throw new Error('denied'); + }, + removeItem: () => { + throw new Error('denied'); + }, + }; + expect(() => saveDraft('k', draft(null), broken)).not.toThrow(); + expect(loadDraft('k', broken)).toBeNull(); + expect(() => clearDraft('k', broken)).not.toThrow(); + }); + + it('хранилища нет (null) — запись молча пропускается, чтение — null', () => { + expect(() => saveDraft('k', draft(null), null)).not.toThrow(); + expect(loadDraft('k', null)).toBeNull(); + }); +}); + +describe('isStale', () => { + const aggregate = (versionNo: number | null): CorrectionAggregateOut => { + const a = makeMockTable().aggregates[0]; + if (!a.correction) throw new Error('у фейка ЭПТ — ИЛС должна быть корректировка'); + a.correction = versionNo === null ? null : { ...a.correction, version_no: versionNo }; + return a; + }; + + it('версия в черновике 2, в таблице 3 — устарел', () => { + expect(isStale(draft(2), aggregate(3))).toBe(true); + }); + + it('совпадают — не устарел; нет корректировки и в черновике null — не устарел', () => { + expect(isStale(draft(3), aggregate(3))).toBe(false); + expect(isStale(draft(null), aggregate(null))).toBe(false); + expect(isStale(draft(null), aggregate(1))).toBe(true); + }); +}); diff --git a/web/src/widgets/CorrectionTable/draftStorage.ts b/web/src/widgets/CorrectionTable/draftStorage.ts new file mode 100644 index 0000000..e290bf2 --- /dev/null +++ b/web/src/widgets/CorrectionTable/draftStorage.ts @@ -0,0 +1,79 @@ +// Черновик окна корректировки в localStorage: per-viewer удобство. Любой сбой хранилища не ломает окно — работаем без черновика. +import type { CorrectionAggregateOut } from '../../types/corrections'; +import type { EditorState } from './editorModel'; + +export type Draft = { state: EditorState; savedAt: string; baseVersionNo: number | null }; + +export type DraftStorage = Pick; + +/** Ключ: пользователь + версия + агрегат. */ +export function draftKey(userId: string, versionId: string, aggregateKey: string): string { + return `armka:correction-draft:${userId}:${versionId}:${aggregateKey}`; +} + +/** localStorage, если он доступен; в приватном режиме и без хранилища — null. */ +function defaultStorage(): DraftStorage | null { + try { + return window.localStorage; + } catch { + return null; + } +} + +function isLine(value: unknown): value is { line_code: string; amount: string } { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + return typeof v.line_code === 'string' && typeof v.amount === 'string'; +} + +function isDraft(value: unknown): value is Draft { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + if (typeof v.savedAt !== 'string') return false; + if (v.baseVersionNo !== null && typeof v.baseVersionNo !== 'number') return false; + const s = v.state as Record | null; + if (typeof s !== 'object' || s === null) return false; + return ( + Array.isArray(s.debit) && + s.debit.every(isLine) && + Array.isArray(s.credit) && + s.credit.every(isLine) && + typeof s.comment === 'string' + ); +} + +export function saveDraft(key: string, draft: Draft, storage: DraftStorage | null = defaultStorage()): void { + if (!storage) return; + try { + storage.setItem(key, JSON.stringify(draft)); + } catch { + // Хранилище недоступно или переполнено: окно работает без черновика. + } +} + +/** Старый или чужой формат, испорченный JSON и сбой хранилища — `null`. */ +export function loadDraft(key: string, storage: DraftStorage | null = defaultStorage()): Draft | null { + if (!storage) return null; + try { + const raw = storage.getItem(key); + if (raw === null) return null; + const parsed: unknown = JSON.parse(raw); + return isDraft(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function clearDraft(key: string, storage: DraftStorage | null = defaultStorage()): void { + if (!storage) return; + try { + storage.removeItem(key); + } catch { + // Нечего удалять, если хранилище недоступно. + } +} + +/** Черновик начат от другой редакции корректировки (или от её отсутствия). */ +export function isStale(draft: Draft, a: CorrectionAggregateOut): boolean { + return draft.baseVersionNo !== (a.correction?.version_no ?? null); +} diff --git a/web/src/widgets/CorrectionTable/editorModel.test.ts b/web/src/widgets/CorrectionTable/editorModel.test.ts new file mode 100644 index 0000000..7931833 --- /dev/null +++ b/web/src/widgets/CorrectionTable/editorModel.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; +import type { CorrectionAggregateOut, CorrectionTableOut, UnallocatedEntryOut } from '../../types/corrections'; +import type { EditorState } from './editorModel'; +import { + initialEditorState, + isDirty, + liveControl, + pickerCandidates, + toCorrectionIn, + validateLines, +} from './editorModel'; +import { makeMockTable } from './mockData'; +import { aggregateKey } from './tableModel'; + +const table = () => makeMockTable(); +const findBy = (t: CorrectionTableOut, name: string): CorrectionAggregateOut => { + const a = t.aggregates.find((x) => x.company_1.name.includes(name)); + if (!a) throw new Error(`нет агрегата ${name}`); + return a; +}; + +const single = (amount: string): EditorState => ({ debit: [{ line_code: '1170', amount }], credit: [], comment: '' }); + +describe('initialEditorState', () => { + it('агрегат правила 3 без корректировки — по одной статье на сторону с суммами сторон', () => { + const a = findBy(table(), 'Пензенский Мир Продуктов'); + const s = initialEditorState(a); + expect(s.debit).toEqual([{ line_code: '1170', amount: '29742550.00' }]); + expect(s.credit).toEqual([{ line_code: '1410', amount: '29242550.00' }]); + expect(s.comment).toBe(''); + }); + + it('агрегат правила 1 без корректировки — первый код составной статьи', () => { + const base = findBy(table(), 'Пензенский Мир Продуктов'); + const a: CorrectionAggregateOut = { + ...base, + rule_id: 'r1', + rule_no: 1, + side_2: { ...base.side_2, article_line: '1450/1550' }, + }; + const s = initialEditorState(a); + expect(s.credit[0].line_code).toBe('1450'); + }); + + it('есть корректировка — её статьи и суммы как в API', () => { + const a = findBy(table(), 'ЭПТ'); + const s = initialEditorState(a); + expect(s.debit).toEqual([{ line_code: '1230', amount: '1000000.00' }]); + expect(s.credit).toEqual([{ line_code: '1520', amount: '202320.00' }]); + expect(s.comment).toBe('Требуется сверка с контрагентом'); + }); +}); + +describe('validateLines', () => { + it.each(['1 000 000,5', '1000000,567', '-5', ''])('сумма %j — ошибка у amount', (amount) => { + const r = validateLines(single(amount)); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.errors['debit.0.amount']).toBeTruthy(); + }); + + it('сумма 500000,5 → строка 500000.50 в body', () => { + const r = validateLines(single('500000,5')); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.body.debit).toEqual([{ line_code: '1170', amount: '500000.50' }]); + expect(r.body.credit).toEqual([]); + }); + + it('обе стороны пустые — ошибка «Добавьте статью»', () => { + const r = validateLines({ debit: [], credit: [], comment: '' }); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(Object.values(r.errors)).toContain('Добавьте статью'); + }); + + it('одна пустая сторона — ок; код — ровно 4 цифры', () => { + expect(validateLines({ debit: [], credit: [{ line_code: '1410', amount: '1' }], comment: '' }).ok).toBe(true); + const r = validateLines({ debit: [{ line_code: '117', amount: '1' }], credit: [], comment: '' }); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.errors['debit.0.line_code']).toBeTruthy(); + }); +}); + +describe('liveControl', () => { + it('исходные суммы агрегата ЭПТ — ИЛС: процент 394.27, красный', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, initialEditorState(a)); + expect(c.sourceDiff.percent).toBe('394.27'); + expect(c.sourceDiff.red).toBe(true); + }); + + it('распределение Дт 1000000.00 и Кт 1000000.00 — разница 0.00, не красный', () => { + const a = findBy(table(), 'ЭПТ'); + const s: EditorState = { + debit: [{ line_code: '1230', amount: '1000000.00' }], + credit: [{ line_code: '1520', amount: '1000000.00' }], + comment: '', + }; + const c = liveControl(a, s); + expect(c.allocationDiff.difference).toBe('0.00'); + expect(c.allocationDiff.red).toBe(false); + }); + + it('одна сторона распределения пуста при ненулевой другой — красный', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, { debit: [{ line_code: '1230', amount: '5.00' }], credit: [], comment: '' }); + expect(c.allocationDiff.red).toBe(true); + }); +}); + +describe('isDirty', () => { + it('одинаковые состояния — не грязно; правка комментария — грязно', () => { + const a = findBy(table(), 'Пензенский Мир Продуктов'); + const init = initialEditorState(a); + expect(isDirty(init, initialEditorState(a))).toBe(false); + expect(isDirty(init, { ...init, comment: 'x' })).toBe(true); + }); +}); + +describe('toCorrectionIn', () => { + it('статус APPLIED и expected_version_no из аргумента', () => { + const r = validateLines(single('1.5')); + if (!r.ok) throw new Error('ожидалось ok'); + expect(toCorrectionIn(r.body, 3)).toEqual({ ...r.body, status: 'APPLIED', expected_version_no: 3 }); + expect(toCorrectionIn(r.body, null).expected_version_no).toBeNull(); + }); +}); + +describe('pickerCandidates', () => { + const entry = (id: string, owner: { id: string; name: string }, cp: { id: string; name: string }, side: 'D' | 'K', extra: Partial = {}): UnallocatedEntryOut => ({ + entry_ids: [id], + owner, + counterparty: cp, + account_code: '60.01', + side, + amount: '100.00', + candidate_rule_ids: ['r6'], + reason: 'MULTIPLE_RULES', + excluded: false, + ...extra, + }); + + it('пара ПМП — Бековский комбинат, правило 6, сторона Кт — только кредитовые строки комбината на ПМП', () => { + const t = table(); + const pmp = { id: 'c-pmp', name: 'Пензенский Мир Продуктов' }; + const bsk = { id: 'c-bsk', name: 'БЕКОВСКИЙ САХАРНЫЙ КОМБИНАТ ООО' }; + const a: CorrectionAggregateOut = { ...findBy(t, 'БЕКОВСКИЙ САХАРНЫЙ ЗАВОД'), company_1: pmp, company_2: bsk }; + // Фикстуры строк под эту пару: в mockData такой нераспределённой строки нет. + t.unallocated.push( + entry('ok', bsk, pmp, 'K'), + entry('wrong-side', bsk, pmp, 'D'), + entry('wrong-owner', pmp, bsk, 'K'), + entry('wrong-rule', bsk, pmp, 'K', { candidate_rule_ids: ['r7'] }), + entry('excluded', bsk, pmp, 'K', { excluded: true }), + ); + expect(pickerCandidates(t, a, 2).map((u) => u.entry_ids[0])).toEqual(['ok']); + }); + + it('сторона Дт — владелец company_1, противоположная сторона правила не подходит', () => { + const t = table(); + const pmp = { id: 'c-pmp', name: 'Пензенский Мир Продуктов' }; + const bsk = { id: 'c-bsk', name: 'БЕКОВСКИЙ САХАРНЫЙ КОМБИНАТ ООО' }; + const a: CorrectionAggregateOut = { ...findBy(t, 'БЕКОВСКИЙ САХАРНЫЙ ЗАВОД'), company_1: pmp, company_2: bsk }; + t.unallocated.push(entry('d-ok', pmp, bsk, 'D'), entry('d-bad', pmp, bsk, 'K')); + expect(pickerCandidates(t, a, 1).map((u) => u.entry_ids[0])).toEqual(['d-ok']); + }); + + it('ключ агрегата не меняется от состояния редактора', () => { + const a = findBy(table(), 'ЭПТ'); + expect(aggregateKey(a)).toBe('r6|c-ept|c-ils'); + }); +}); diff --git a/web/src/widgets/CorrectionTable/editorModel.ts b/web/src/widgets/CorrectionTable/editorModel.ts new file mode 100644 index 0000000..84e4676 --- /dev/null +++ b/web/src/widgets/CorrectionTable/editorModel.ts @@ -0,0 +1,159 @@ +// Модель окна «Корректировка операции»: состояние редактора, проверки ввода, живой контроль, кандидаты для «Добавить строку ОСВ». +// Деньги — строки; сложение — sumAmounts, разбор ввода — parseAmountInput, процент и BigInt-копейки — из mockCalc. +import type { + CorrectionAggregateOut, + CorrectionIn, + CorrectionLineIn, + CorrectionTableOut, + Percent, + Side, + UnallocatedEntryOut, +} from '../../types/corrections'; +import { parseAmountInput, sumAmounts } from '../../utils/money'; +import { fromCents, percentOfSmaller, toCents } from './mockCalc'; +import { articleCodes } from './tableModel'; + +export type EditLine = { line_code: string; amount: string }; +export type EditorState = { debit: EditLine[]; credit: EditLine[]; comment: string }; + +export type Diff = { left: string | null; right: string | null; difference: string | null; percent: string | null; red: boolean }; + +export type LinesValidation = + | { ok: true; body: Pick } + | { ok: false; errors: Record }; + +/** Порог отклонения, с которого процент и сумма краснеют (в сотых процента). */ +const RED_PERCENT_HUNDREDTHS = 500n; + +/** Без корректировки — по одной статье на сторону (первый код составной статьи) с суммой стороны; есть корректировка — её статьи. */ +export function initialEditorState(a: CorrectionAggregateOut): EditorState { + const c = a.correction; + if (c) { + return { + debit: c.debit.map((l) => ({ line_code: l.line_code, amount: l.amount })), + credit: c.credit.map((l) => ({ line_code: l.line_code, amount: l.amount })), + comment: c.comment, + }; + } + return { + debit: [{ line_code: articleCodes(a.side_1.article_line)[0] ?? '', amount: a.side_1.amount }], + credit: [{ line_code: articleCodes(a.side_2.article_line)[0] ?? '', amount: a.side_2.amount }], + comment: '', + }; +} + +/** Сумма строки с двумя знаками: «500000.5» → «500000.50». */ +function withCents(value: string): string { + const [int, frac = ''] = value.split('.'); + return `${int}.${frac.padEnd(2, '0')}`; +} + +function lineError(line: EditLine, prefix: string, errors: Record): CorrectionLineIn | null { + const code = line.line_code.trim(); + const raw = line.amount.trim(); + const parsed = raw === '' || /\s/.test(raw) ? null : parseAmountInput(raw); + let ok = true; + if (!/^\d{4}$/.test(code)) { + errors[`${prefix}.line_code`] = 'Код статьи — 4 цифры'; + ok = false; + } + if (raw === '') { + errors[`${prefix}.amount`] = 'Введите сумму'; + ok = false; + } else if (/\s/.test(raw)) { + errors[`${prefix}.amount`] = 'Сумма без пробелов'; + ok = false; + } else if (!parsed?.ok || parsed.value === null) { + errors[`${prefix}.amount`] = 'Сумма: не более двух знаков после запятой'; + ok = false; + } else if (parsed.value.startsWith('-')) { + errors[`${prefix}.amount`] = 'Сумма не может быть отрицательной'; + ok = false; + } + if (!ok || !parsed?.ok || parsed.value === null) return null; + return { line_code: code, amount: withCents(parsed.value) }; +} + +/** Проверка статей перед сохранением: код — 4 цифры, сумма — неотрицательная, до двух знаков; пустая сторона допустима. */ +export function validateLines(s: EditorState): LinesValidation { + const errors: Record = {}; + if (s.debit.length === 0 && s.credit.length === 0) { + errors.lines = 'Добавьте статью'; + } + const debit: CorrectionLineIn[] = []; + const credit: CorrectionLineIn[] = []; + s.debit.forEach((line, i) => { + const ok = lineError(line, `debit.${i}`, errors); + if (ok) debit.push(ok); + }); + s.credit.forEach((line, i) => { + const ok = lineError(line, `credit.${i}`, errors); + if (ok) credit.push(ok); + }); + if (Object.keys(errors).length > 0) return { ok: false, errors }; + return { ok: true, body: { debit, credit, comment: s.comment.trim() } }; +} + +/** Сумма статей стороны; некорректные строки в живом контроле пропускаются (их покажет validateLines). */ +function sideSum(lines: EditLine[]): string | null { + return sumAmounts( + lines.map((l) => { + const parsed = parseAmountInput(l.amount); + return parsed.ok && parsed.value !== null ? withCents(parsed.value) : null; + }), + ); +} + +/** Разница двух сумм и процент к меньшей; красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */ +function diffOf(left: string | null, right: string | null): Diff { + const l = left ?? '0.00'; + const r = right ?? '0.00'; + const lc = toCents(l); + const rc = toCents(r); + const difference = fromCents(lc > rc ? lc - rc : rc - lc); + const percent: Percent = percentOfSmaller(l, r); + const oneZero = (lc === 0n) !== (rc === 0n); + const red = (percent !== null && toCents(percent) >= RED_PERCENT_HUNDREDTHS) || oneZero; + return { left, right, difference, percent, red }; +} + +/** Живой контроль окна: исходные суммы сторон агрегата и распределение статей из текущего состояния редактора. */ +export function liveControl(a: CorrectionAggregateOut, s: EditorState): { sourceDiff: Diff; allocationDiff: Diff } { + return { + sourceDiff: diffOf(a.side_1.amount, a.side_2.amount), + allocationDiff: diffOf(sideSum(s.debit), sideSum(s.credit)), + }; +} + +function sameLines(x: EditLine[], y: EditLine[]): boolean { + return x.length === y.length && x.every((l, i) => l.line_code === y[i].line_code && l.amount === y[i].amount); +} + +/** Есть ли несохранённые правки относительно исходного состояния окна. */ +export function isDirty(initial: EditorState, current: EditorState): boolean { + return !sameLines(initial.debit, current.debit) || !sameLines(initial.credit, current.credit) || initial.comment !== current.comment; +} + +const opposite = (s: Side): Side => (s === 'D' ? 'K' : 'D'); + +/** Нераспределённые строки для «Добавить строку ОСВ»: владелец и контрагент — из пары, сторона — по правилу, правило — в кандидатах. */ +export function pickerCandidates(table: CorrectionTableOut, a: CorrectionAggregateOut, side: 1 | 2): UnallocatedEntryOut[] { + const rule = table.rules.find((r) => r.id === a.rule_id); + if (!rule) return []; + const owner = side === 1 ? a.company_1.id : a.company_2.id; + const counterparty = side === 1 ? a.company_2.id : a.company_1.id; + const entrySide: Side = side === 1 ? rule.side_1 : opposite(rule.side_1); + return table.unallocated.filter( + (u) => + !u.excluded && + u.owner.id === owner && + u.counterparty.id === counterparty && + u.side === entrySide && + u.candidate_rule_ids.includes(a.rule_id), + ); +} + +/** Тело PUT: статус APPLIED и expected_version_no из ответа таблицы (null — действующей корректировки нет). */ +export function toCorrectionIn(body: Pick, expectedVersionNo: number | null): CorrectionIn { + return { ...body, status: 'APPLIED', expected_version_no: expectedVersionNo }; +} -- 2.47.2 From ec6ece127f5b173234ac603ad2c258c30084f76a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:12:41 +0400 Subject: [PATCH 07/28] =?UTF-8?q?fix(web):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20?= =?UTF-8?q?Task=203=20=E2=80=94=20=D1=83=D1=82=D0=B8=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=B5=D0=BD=D0=B5=D0=B3,=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=20=D1=81=D1=83=D0=BC=D0=BC,=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Копейки, процент и порог 5 % вынесены из mockCalc в utils/money; editorModel больше не зависит от фейка, тесты перенесены. - Один разбор суммы строки ввода для проверки, живого контроля и сравнения; некорректные строки в живой контроль не входят. - isDirty сравнивает суммы копейками; комментарий — точно. - Черновик: savedAt проверяется через Date.parse. - Тесты границ порога, пустых сторон и неизвестного rule_id. Co-Authored-By: Claude Opus 5.5 --- .../changes/corrections-table-ui/design.md | 2 +- web/src/utils/money.test.ts | 27 +++++- web/src/utils/money.ts | 27 ++++++ .../CorrectionTable/draftStorage.test.ts | 8 ++ .../widgets/CorrectionTable/draftStorage.ts | 2 +- .../CorrectionTable/editorModel.test.ts | 58 +++++++++++++ .../widgets/CorrectionTable/editorModel.ts | 82 ++++++++++--------- .../widgets/CorrectionTable/mockCalc.test.ts | 12 --- web/src/widgets/CorrectionTable/mockCalc.ts | 31 +------ 9 files changed, 167 insertions(+), 82 deletions(-) delete mode 100644 web/src/widgets/CorrectionTable/mockCalc.test.ts diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index 1a2afcf..36eb34a 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -183,5 +183,5 @@ | 28.10.2026 | R-2: в фейк добавлен `widgets/CorrectionTable/mockCalc.ts` (пересчёт агрегата и итогов через BigInt-копейки) — общий для `mockData` и `correctionsFake`, иначе циклический импорт | план не называл файл; без него циклический импорт | перенос функций, без поведения | | 28.10.2026 | R-3: в `mockData` сверх дизайна добавлены агрегат правила 6 «ООО "БСК"» — «ПМП ООО» (310000.00 / 300000.00) и агрегат правила 4 «Агро-Холдинг» без второй стороны (ONE_SIDED); `status` в фейке — ONE_SIDED / DISCREPANCY / AUTO, MANUAL не выдаётся | тест фильтра `ооо "бск"` из плана требует агрегат с таким ЮЛ; ONE_SIDED нужен для проверки отображения | поправка данных фейка, на прод не влияет | | 28.10.2026 | R-4: в окне корректировки сумма с пробелами («1 000 000,5») отклоняется с ошибкой «Сумма без пробелов», хотя `parseAmountInput` пробелы принимает | Review Focus плана прямо требует ошибку для этого ввода | если заказчик хочет пробелы — снять одну проверку в `validateLines` | -| 28.10.2026 | R-5: `editorModel` переиспользует `percentOfSmaller`/`toCents`/`fromCents` из `mockCalc.ts`; вынос в `utils/money.ts` отложен | не раздувать задачу; поведение одинаково | при подключении API `mockCalc` может уйти — функции придётся переехать | +| 28.10.2026 | R-5: `percentOfSmaller`, `toCents`, `fromCents` и порог 5 % (`THRESHOLD_PERCENT_HUNDREDTHS`) вынесены в `utils/money.ts`; `editorModel` и `mockCalc` импортируют оттуда, зависимости `editorModel` → `mockCalc` нет | фейк `mockCalc` вырезается при подключении API, экран не должен от него зависеть (ревью Task 3, T3.1) | поведение то же; тесты перенесены в `utils/money.test.ts` | | 28.10.2026 | R-6: тексты ошибок валидации окна и ключ ошибки `lines` для «Добавьте статью» выбраны исполнителем | бриф их не задаёт; тексты в spec не указаны | правка строк | diff --git a/web/src/utils/money.test.ts b/web/src/utils/money.test.ts index 88a6ff0..561c875 100644 --- a/web/src/utils/money.test.ts +++ b/web/src/utils/money.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatAmount, formatScaledAmount, parseAmountInput, sumAmounts } from './money'; +import { THRESHOLD_PERCENT_HUNDREDTHS, formatAmount, formatScaledAmount, fromCents, parseAmountInput, percentOfSmaller, sumAmounts, toCents } from './money'; const NBSP = ' '; @@ -115,3 +115,28 @@ describe('formatScaledAmount', () => { expect(formatScaledAmount('1e5', rub(2))).toBe('1e5'); }); }); + +describe('percentOfSmaller', () => { + it('округляет половину вверх', () => { + expect(percentOfSmaller('9.00', '8.00')).toBe('12.50'); + expect(percentOfSmaller('1.00', '3.00')).toBe('200.00'); + // отклонение 0.01 к 8.00 = 0.125 % -> 0.13 (половина вверх) + expect(percentOfSmaller('8.01', '8.00')).toBe('0.13'); + expect(percentOfSmaller('0.00', '5.00')).toBeNull(); + }); +}); + +describe('toCents / fromCents', () => { + it('копейки и обратно, без float', () => { + expect(toCents('-1.5')).toBe(-150n); + expect(toCents('9007199254740993.01')).toBe(900719925474099301n); + expect(fromCents(-5n)).toBe('-0.05'); + expect(fromCents(0n)).toBe('0.00'); + }); + it('не сумма — ошибка', () => { + expect(() => toCents('1e5')).toThrow(); + }); + it('порог — 5 % в сотых', () => { + expect(THRESHOLD_PERCENT_HUNDREDTHS).toBe(500n); + }); +}); diff --git a/web/src/utils/money.ts b/web/src/utils/money.ts index f1e6082..f9c3ae7 100644 --- a/web/src/utils/money.ts +++ b/web/src/utils/money.ts @@ -114,3 +114,30 @@ export function sumAmounts(values: readonly (string | null | undefined)[]): stri const abs = negative ? -cents : cents; return `${negative ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`; } + +/** Порог отклонения в сотых процента: 5 % = 500. Выше или равно — «красный» (см. контракт, `exceeds_threshold`). */ +export const THRESHOLD_PERCENT_HUNDREDTHS = 500n; + +/** Строка суммы (`"-12.5"`, до двух знаков) → копейки BigInt. Не сумма — ошибка. */ +export function toCents(value: string): bigint { + const match = MONEY.exec(value); + if (!match) throw new Error(`Не сумма: ${value}`); + const cents = BigInt(match[2]) * 100n + BigInt((match[3] ?? '').padEnd(2, '0')); + return match[1] ? -cents : cents; +} + +/** Копейки BigInt → строка суммы с двумя знаками (`"-0.05"`). */ +export function fromCents(cents: bigint): string { + const abs = cents < 0n ? -cents : cents; + return `${cents < 0n ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`; +} + +/** Процент отклонения к меньшей сумме, два знака, половина вверх; `null`, если меньшая сумма не положительна. */ +export function percentOfSmaller(a: string, b: string): string | null { + const x = toCents(a); + const y = toCents(b); + const min = x < y ? x : y; + if (min <= 0n) return null; + const diff = x < y ? y - x : x - y; + return fromCents((diff * 20000n + min) / (2n * min)); +} diff --git a/web/src/widgets/CorrectionTable/draftStorage.test.ts b/web/src/widgets/CorrectionTable/draftStorage.test.ts index 5f87b0f..e17edf1 100644 --- a/web/src/widgets/CorrectionTable/draftStorage.test.ts +++ b/web/src/widgets/CorrectionTable/draftStorage.test.ts @@ -47,6 +47,14 @@ describe('saveDraft / loadDraft / clearDraft', () => { expect(loadDraft('k', s)).toBeNull(); }); + it('мусорный savedAt — null', () => { + const s = mapStorage(); + s.setItem('k', JSON.stringify({ ...draft(null), savedAt: 'не дата' })); + expect(loadDraft('k', s)).toBeNull(); + s.setItem('k', JSON.stringify({ ...draft(null), savedAt: '' })); + expect(loadDraft('k', s)).toBeNull(); + }); + it('испорченный JSON — null', () => { const s = mapStorage(); s.setItem('k', '{не json'); diff --git a/web/src/widgets/CorrectionTable/draftStorage.ts b/web/src/widgets/CorrectionTable/draftStorage.ts index e290bf2..3591d26 100644 --- a/web/src/widgets/CorrectionTable/draftStorage.ts +++ b/web/src/widgets/CorrectionTable/draftStorage.ts @@ -29,7 +29,7 @@ function isLine(value: unknown): value is { line_code: string; amount: string } function isDraft(value: unknown): value is Draft { if (typeof value !== 'object' || value === null) return false; const v = value as Record; - if (typeof v.savedAt !== 'string') return false; + if (typeof v.savedAt !== 'string' || !Number.isFinite(Date.parse(v.savedAt))) return false; if (v.baseVersionNo !== null && typeof v.baseVersionNo !== 'number') return false; const s = v.state as Record | null; if (typeof s !== 'object' || s === null) return false; diff --git a/web/src/widgets/CorrectionTable/editorModel.test.ts b/web/src/widgets/CorrectionTable/editorModel.test.ts index 7931833..6bae288 100644 --- a/web/src/widgets/CorrectionTable/editorModel.test.ts +++ b/web/src/widgets/CorrectionTable/editorModel.test.ts @@ -110,6 +110,49 @@ describe('liveControl', () => { }); }); +describe('граница порога и пустые стороны', () => { + const base = { debit: [], credit: [], comment: '' }; + it('отклонение ровно 5.00 % — красный', () => { + const a = findBy(table(), 'ЭПТ'); + // 100.00 к 105.00 — 5.00 % к меньшей + const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '100.00' }], credit: [{ line_code: '1520', amount: '105.00' }] }); + expect(c.allocationDiff.percent).toBe('5.00'); + expect(c.allocationDiff.red).toBe(true); + }); + it('отклонение 4.99 % — не красный', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '100.00' }], credit: [{ line_code: '1520', amount: '104.99' }] }); + expect(c.allocationDiff.percent).toBe('4.99'); + expect(c.allocationDiff.red).toBe(false); + }); + it('обе стороны распределения пусты — не красный, процент null', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, base); + expect(c.allocationDiff).toEqual({ left: null, right: null, difference: '0.00', percent: null, red: false }); + }); + it('обе стороны с нулевой суммой — не красный', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '0' }], credit: [{ line_code: '1520', amount: '0.00' }] }); + expect(c.allocationDiff.red).toBe(false); + }); +}); + +describe('живой контроль не считает некорректные строки', () => { + it('строки с пробелами и отрицательные не входят в сумму стороны', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, { + debit: [ + { line_code: '1230', amount: '1 000' }, + { line_code: '1230', amount: '-5' }, + { line_code: '1230', amount: '5,5' }, + ], + credit: [], + comment: '', + }); + expect(c.allocationDiff.left).toBe('5.50'); + }); +}); + describe('isDirty', () => { it('одинаковые состояния — не грязно; правка комментария — грязно', () => { const a = findBy(table(), 'Пензенский Мир Продуктов'); @@ -117,6 +160,15 @@ describe('isDirty', () => { expect(isDirty(init, initialEditorState(a))).toBe(false); expect(isDirty(init, { ...init, comment: 'x' })).toBe(true); }); + it('сумма сравнивается копейками: 1000000.00 и 1000000,00 равны; другая сумма — нет', () => { + const base: EditorState = { debit: [{ line_code: '1230', amount: '1000000.00' }], credit: [], comment: '' }; + expect(isDirty(base, { ...base, debit: [{ line_code: '1230', amount: '1000000,00' }] })).toBe(false); + expect(isDirty(base, { ...base, debit: [{ line_code: '1230', amount: '1000000,01' }] })).toBe(true); + }); + it('комментарий сравнивается точно: пробел в конце — правка', () => { + const base: EditorState = { debit: [], credit: [], comment: 'сверка' }; + expect(isDirty(base, { ...base, comment: 'сверка ' })).toBe(true); + }); }); describe('toCorrectionIn', () => { @@ -167,6 +219,12 @@ describe('pickerCandidates', () => { expect(pickerCandidates(t, a, 1).map((u) => u.entry_ids[0])).toEqual(['d-ok']); }); + it('неизвестный rule_id — пустой список', () => { + const t = table(); + const a: CorrectionAggregateOut = { ...findBy(t, 'БЕКОВСКИЙ САХАРНЫЙ ЗАВОД'), rule_id: 'no-such-rule' }; + expect(pickerCandidates(t, a, 2)).toEqual([]); + }); + it('ключ агрегата не меняется от состояния редактора', () => { const a = findBy(table(), 'ЭПТ'); expect(aggregateKey(a)).toBe('r6|c-ept|c-ils'); diff --git a/web/src/widgets/CorrectionTable/editorModel.ts b/web/src/widgets/CorrectionTable/editorModel.ts index 84e4676..43540e5 100644 --- a/web/src/widgets/CorrectionTable/editorModel.ts +++ b/web/src/widgets/CorrectionTable/editorModel.ts @@ -1,5 +1,5 @@ // Модель окна «Корректировка операции»: состояние редактора, проверки ввода, живой контроль, кандидаты для «Добавить строку ОСВ». -// Деньги — строки; сложение — sumAmounts, разбор ввода — parseAmountInput, процент и BigInt-копейки — из mockCalc. +// Деньги — строки; сложение — sumAmounts, разбор ввода — parseAmountInput, копейки и процент — из utils/money. import type { CorrectionAggregateOut, CorrectionIn, @@ -9,8 +9,7 @@ import type { Side, UnallocatedEntryOut, } from '../../types/corrections'; -import { parseAmountInput, sumAmounts } from '../../utils/money'; -import { fromCents, percentOfSmaller, toCents } from './mockCalc'; +import { THRESHOLD_PERCENT_HUNDREDTHS, fromCents, parseAmountInput, percentOfSmaller, sumAmounts, toCents } from '../../utils/money'; import { articleCodes } from './tableModel'; export type EditLine = { line_code: string; amount: string }; @@ -22,9 +21,6 @@ export type LinesValidation = | { ok: true; body: Pick } | { ok: false; errors: Record }; -/** Порог отклонения, с которого процент и сумма краснеют (в сотых процента). */ -const RED_PERCENT_HUNDREDTHS = 500n; - /** Без корректировки — по одной статье на сторону (первый код составной статьи) с суммой стороны; есть корректировка — её статьи. */ export function initialEditorState(a: CorrectionAggregateOut): EditorState { const c = a.correction; @@ -48,30 +44,34 @@ function withCents(value: string): string { return `${int}.${frac.padEnd(2, '0')}`; } +const AMOUNT_ERROR = { + empty: 'Введите сумму', + spaces: 'Сумма без пробелов', + format: 'Сумма: не более двух знаков после запятой', + negative: 'Сумма не может быть отрицательной', +} as const; + +type AmountResult = { ok: true; value: string } | { ok: false; error: string }; + +/** Разбор суммы строки ввода — общий для проверки, живого контроля и сравнения. Строка с двумя знаками или причина отказа. */ +function parseLineAmount(raw: string): AmountResult { + const text = raw.trim(); + if (text === '') return { ok: false, error: AMOUNT_ERROR.empty }; + if (/\s/.test(text)) return { ok: false, error: AMOUNT_ERROR.spaces }; + const parsed = parseAmountInput(text); + if (!parsed.ok || parsed.value === null) return { ok: false, error: AMOUNT_ERROR.format }; + if (parsed.value.startsWith('-')) return { ok: false, error: AMOUNT_ERROR.negative }; + return { ok: true, value: withCents(parsed.value) }; +} + function lineError(line: EditLine, prefix: string, errors: Record): CorrectionLineIn | null { const code = line.line_code.trim(); - const raw = line.amount.trim(); - const parsed = raw === '' || /\s/.test(raw) ? null : parseAmountInput(raw); - let ok = true; - if (!/^\d{4}$/.test(code)) { - errors[`${prefix}.line_code`] = 'Код статьи — 4 цифры'; - ok = false; - } - if (raw === '') { - errors[`${prefix}.amount`] = 'Введите сумму'; - ok = false; - } else if (/\s/.test(raw)) { - errors[`${prefix}.amount`] = 'Сумма без пробелов'; - ok = false; - } else if (!parsed?.ok || parsed.value === null) { - errors[`${prefix}.amount`] = 'Сумма: не более двух знаков после запятой'; - ok = false; - } else if (parsed.value.startsWith('-')) { - errors[`${prefix}.amount`] = 'Сумма не может быть отрицательной'; - ok = false; - } - if (!ok || !parsed?.ok || parsed.value === null) return null; - return { line_code: code, amount: withCents(parsed.value) }; + const codeOk = /^\d{4}$/.test(code); + const amount = parseLineAmount(line.amount); + if (!codeOk) errors[`${prefix}.line_code`] = 'Код статьи — 4 цифры'; + if (!amount.ok) errors[`${prefix}.amount`] = amount.error; + if (!codeOk || !amount.ok) return null; + return { line_code: code, amount: amount.value }; } /** Проверка статей перед сохранением: код — 4 цифры, сумма — неотрицательная, до двух знаков; пустая сторона допустима. */ @@ -94,14 +94,12 @@ export function validateLines(s: EditorState): LinesValidation { return { ok: true, body: { debit, credit, comment: s.comment.trim() } }; } -/** Сумма статей стороны; некорректные строки в живом контроле пропускаются (их покажет validateLines). */ +/** Сумма статей стороны для живого контроля: строки, не прошедшие parseLineAmount, в сумму не входят (их покажет validateLines). */ function sideSum(lines: EditLine[]): string | null { - return sumAmounts( - lines.map((l) => { - const parsed = parseAmountInput(l.amount); - return parsed.ok && parsed.value !== null ? withCents(parsed.value) : null; - }), - ); + return sumAmounts(lines.map((l) => { + const r = parseLineAmount(l.amount); + return r.ok ? r.value : null; + })); } /** Разница двух сумм и процент к меньшей; красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */ @@ -113,7 +111,7 @@ function diffOf(left: string | null, right: string | null): Diff { const difference = fromCents(lc > rc ? lc - rc : rc - lc); const percent: Percent = percentOfSmaller(l, r); const oneZero = (lc === 0n) !== (rc === 0n); - const red = (percent !== null && toCents(percent) >= RED_PERCENT_HUNDREDTHS) || oneZero; + const red = (percent !== null && toCents(percent) >= THRESHOLD_PERCENT_HUNDREDTHS) || oneZero; return { left, right, difference, percent, red }; } @@ -125,11 +123,17 @@ export function liveControl(a: CorrectionAggregateOut, s: EditorState): { source }; } -function sameLines(x: EditLine[], y: EditLine[]): boolean { - return x.length === y.length && x.every((l, i) => l.line_code === y[i].line_code && l.amount === y[i].amount); +/** Сумма для сравнения черновика с исходным состоянием: копейки, если разбирается; иначе сама строка. */ +function amountKey(raw: string): string { + const r = parseLineAmount(raw); + return r.ok ? toCents(r.value).toString() : `raw:${raw}`; } -/** Есть ли несохранённые правки относительно исходного состояния окна. */ +function sameLines(x: EditLine[], y: EditLine[]): boolean { + return x.length === y.length && x.every((l, i) => l.line_code === y[i].line_code && amountKey(l.amount) === amountKey(y[i].amount)); +} + +/** Есть ли несохранённые правки. Суммы сравниваются копейками (`1000000.00` = `1000000,00`); комментарий — точно, как введён. */ export function isDirty(initial: EditorState, current: EditorState): boolean { return !sameLines(initial.debit, current.debit) || !sameLines(initial.credit, current.credit) || initial.comment !== current.comment; } diff --git a/web/src/widgets/CorrectionTable/mockCalc.test.ts b/web/src/widgets/CorrectionTable/mockCalc.test.ts deleted file mode 100644 index 96cb78d..0000000 --- a/web/src/widgets/CorrectionTable/mockCalc.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { percentOfSmaller } from './mockCalc'; - -describe('percentOfSmaller', () => { - it('округляет половину вверх', () => { - expect(percentOfSmaller('9.00', '8.00')).toBe('12.50'); - expect(percentOfSmaller('1.00', '3.00')).toBe('200.00'); - // отклонение 0.01 к 8.00 = 0.125 % -> 0.13 (половина вверх) - expect(percentOfSmaller('8.01', '8.00')).toBe('0.13'); - expect(percentOfSmaller('0.00', '5.00')).toBeNull(); - }); -}); diff --git a/web/src/widgets/CorrectionTable/mockCalc.ts b/web/src/widgets/CorrectionTable/mockCalc.ts index 5de9b80..7221f3d 100644 --- a/web/src/widgets/CorrectionTable/mockCalc.ts +++ b/web/src/widgets/CorrectionTable/mockCalc.ts @@ -1,35 +1,10 @@ -import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money, Percent } from '../../types/corrections'; -import { sumAmounts } from '../../utils/money'; +import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money } from '../../types/corrections'; +import { THRESHOLD_PERCENT_HUNDREDTHS, fromCents, percentOfSmaller, sumAmounts, toCents } from '../../utils/money'; // Пересчёт агрегата таблицы корректировок — общий для данных фейка и самого фейка. Деньги — строки, арифметика — BigInt-копейки. -// Это же делает сервер (контракт, раздел 4); на экране эти функции не используются: фронт только показывает ответ. - -export const THRESHOLD_PERCENT_HUNDREDTHS = 500n; - -// toCents/fromCents дублируют BigInt-разбор из utils/money.ts: там он не экспортируется, а правка утилиты вне объёма задачи. -export function toCents(value: Money): bigint { - const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(value); - if (!match) throw new Error(`Не сумма: ${value}`); - const cents = BigInt(match[2]) * 100n + BigInt((match[3] ?? '').padEnd(2, '0')); - return match[1] ? -cents : cents; -} - -export function fromCents(cents: bigint): Money { - const abs = cents < 0n ? -cents : cents; - return `${cents < 0n ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`; -} +// Это фейк контракта (раздел 4): сервер считает то же сам. Экран эти функции не вызывает, только утилиты из utils/money. const sumOrZero = (values: readonly Money[]): Money => sumAmounts(values) ?? '0.00'; -/** Процент отклонения к меньшей сумме, два знака, половина вверх; `null`, если меньшая сумма не положительна. */ -export function percentOfSmaller(a: Money, b: Money): Percent { - const x = toCents(a); - const y = toCents(b); - const min = x < y ? x : y; - if (min <= 0n) return null; - const diff = x < y ? y - x : x - y; - return fromCents((diff * 20000n + min) / (2n * min)); -} - function sideAmount(side: AggregateSideOut): void { side.amount = sumOrZero(side.entries.filter((e) => !e.excluded).map((e) => e.amount)); side.accounts = [...new Set(side.entries.map((e) => e.account_code))].sort(); -- 2.47.2 From d98f264f90ec760b625139964464b8d171d2dbf9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:22:24 +0400 Subject: [PATCH 08/28] =?UTF-8?q?feat(web):=20=D0=B2=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B4=D0=BA=D0=B0=20=C2=AB=D0=A2=D0=B0=D0=B1=D0=BB=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B8=C2=BB=20=E2=80=94=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D0=B0=20=D0=B8=20=D0=BF=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/corrections-table-ui/design.md | 3 + web/src/layouts/ConsolidationReportPage.tsx | 5 +- web/src/widgets/ConsolidatedOsv/OsvTabs.tsx | 7 +- .../CorrectionTable/CorrectionContent.tsx | 199 +++++++++ .../CorrectionTable/CorrectionTable.tsx | 398 ++++++++++++++++++ .../CorrectionTable/CorrectionToolbar.tsx | 115 +++++ .../widgets/CorrectionTable/HeaderFilter.tsx | 57 +++ web/src/widgets/CorrectionTable/columns.ts | 68 +++ .../useCorrectionTable.test.ts | 20 + .../CorrectionTable/useCorrectionTable.ts | 81 ++++ 10 files changed, 946 insertions(+), 7 deletions(-) create mode 100644 web/src/widgets/CorrectionTable/CorrectionContent.tsx create mode 100644 web/src/widgets/CorrectionTable/CorrectionTable.tsx create mode 100644 web/src/widgets/CorrectionTable/CorrectionToolbar.tsx create mode 100644 web/src/widgets/CorrectionTable/HeaderFilter.tsx create mode 100644 web/src/widgets/CorrectionTable/columns.ts create mode 100644 web/src/widgets/CorrectionTable/useCorrectionTable.test.ts create mode 100644 web/src/widgets/CorrectionTable/useCorrectionTable.ts diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index 36eb34a..59f3fbf 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -185,3 +185,6 @@ | 28.10.2026 | R-4: в окне корректировки сумма с пробелами («1 000 000,5») отклоняется с ошибкой «Сумма без пробелов», хотя `parseAmountInput` пробелы принимает | Review Focus плана прямо требует ошибку для этого ввода | если заказчик хочет пробелы — снять одну проверку в `validateLines` | | 28.10.2026 | R-5: `percentOfSmaller`, `toCents`, `fromCents` и порог 5 % (`THRESHOLD_PERCENT_HUNDREDTHS`) вынесены в `utils/money.ts`; `editorModel` и `mockCalc` импортируют оттуда, зависимости `editorModel` → `mockCalc` нет | фейк `mockCalc` вырезается при подключении API, экран не должен от него зависеть (ревью Task 3, T3.1) | поведение то же; тесты перенесены в `utils/money.test.ts` | | 28.10.2026 | R-6: тексты ошибок валидации окна и ключ ошибки `lines` для «Добавьте статью» выбраны исполнителем | бриф их не задаёт; тексты в spec не указаны | правка строк | +| 28.10.2026 | R-7: флажки «Учитывать в КФО» и меню строк неактивны всегда (`actionsEnabled=false`) до Task 5–6, подсказка «только чтение» подключена | бриф Task 4: решения по строкам — в Task 5 | забыть включить флаг в Task 5 | +| 28.10.2026 | R-8: ошибка загрузки показывается уведомлением с «Повторить» (по спеке) и кнопкой на месте таблицы | после закрытия уведомления аналитик не остаётся без выхода | две кнопки повтора | +| 28.10.2026 | R-9: «Итого» показывается только при наличии видимых агрегатов (при «Нераспределенные записи» его нет) | сценарий спеки | показать «Итого» с пустыми значениями | diff --git a/web/src/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx index ea92e89..214bf63 100644 --- a/web/src/layouts/ConsolidationReportPage.tsx +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -16,6 +16,7 @@ import { statusColorMapping } from '../widgets/ConsoleTable/consts'; import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent'; import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent'; +import { CorrectionContent } from '../widgets/CorrectionTable/CorrectionContent'; import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; function ReportContent() { @@ -31,6 +32,8 @@ function ReportContent() { osv: , chess: , + + correction: , }; const ActiveTab = TAB_COMPONENTS[tab as string] ?? null; @@ -61,7 +64,7 @@ function ReportContent() { }, [version_id]); useEffect(() => { - const isValidTab = !tab || tab === 'osv' || tab === 'chess'; + const isValidTab = !tab || tab === 'osv' || tab === 'chess' || tab === 'correction'; if (!isValidTab) { const pathParts = pathname.split('/'); diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index 5387a01..741dcfe 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -17,12 +17,7 @@ import { pairsViewSearch } from './counterPairs'; const TAB_CONFIG = [ { key: 'osv', label: 'ОСВ' }, { key: 'chess', label: 'Шахматка' }, - { - key: 'correction', - label: 'Таблица корректировки', - disabled: true, - tooltip: 'Будет в следующих срезах', - }, + { key: 'correction', label: 'Таблица корректировки' }, { key: 'goodwill', label: 'Гудвилл', diff --git a/web/src/widgets/CorrectionTable/CorrectionContent.tsx b/web/src/widgets/CorrectionTable/CorrectionContent.tsx new file mode 100644 index 0000000..e19c371 --- /dev/null +++ b/web/src/widgets/CorrectionTable/CorrectionContent.tsx @@ -0,0 +1,199 @@ +// Вкладка «Таблица корректировки»: загрузка, состояния, панель и таблица. Правки (окно, решения по строкам) — в следующих задачах. +import { App, Button, ConfigProvider, Spin } from 'antd'; +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { ApiError } from '../../api/client'; +import { useVisibleColumns } from '../../components/GenericTable/useVisibleColumns'; +import { exportCorrectionTable } from '../../services/api/corrections'; +import type { VersionCard } from '../../types/consolidations/version_members'; +import type { CorrectionTableOut, ReadOnlyReason } from '../../types/corrections'; +import { saveBlob, xlsxFilename } from '../../utils/download'; +import { useNotificationApi } from '../../utils/useNotification'; +import { useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay'; +import { CorrectionTable, type RowActions } from './CorrectionTable'; +import { CorrectionToolbar } from './CorrectionToolbar'; +import { COLUMN_DEFS, type ColKey, type ColShape } from './columns'; +import { EMPTY_FILTERS, type TableFilters, buildRows, filterTable, lineOptions, visibleTotals } from './tableModel'; +import { useCorrectionTable } from './useCorrectionTable'; + +/** Подсказки для режима «только чтение» (дословно из спеки). */ +const READ_ONLY_HINTS: Record = { + VERSION_NOT_EDITABLE: 'Версия закрыта для изменений', + LOCK_REQUIRED: 'Возьмите консолидацию в работу', + LOCKED_BY_OTHER: 'Консолидацию редактирует другой пользователь', +}; + +/** Подсказка «почему только чтение»; правки открыты или причина не названа — `null`. */ +function readOnlyHint(t: CorrectionTableOut): string | null { + if (t.editable || !t.read_only_reason) return null; + return READ_ONLY_HINTS[t.read_only_reason]; +} + +const LOAD_ERROR_KEY = 'corrections-load-error'; + +type Props = { versionId: string; card?: VersionCard | null }; + +export function CorrectionContent({ versionId, card = null }: Props) { + const { table, loading, error, reload } = useCorrectionTable(versionId); + const { notification } = App.useApp(); + const notify = useNotificationApi(); + // по умолчанию — «тыс. руб.», 2 знака, как в дизайне + const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay({ unit: 'тыс. руб.', precision: 2 }); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [expanded, setExpanded] = useState>(new Set()); + const { visibleKeys, setVisibleKeys } = useVisibleColumns({ rowKey: 'include', columns: COLUMN_DEFS }); + const [exporting, setExporting] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const boxRef = useRef(null); + + // ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить» + useEffect(() => { + if (!error) { + notification.destroy(LOAD_ERROR_KEY); + return; + } + notification.error({ + key: LOAD_ERROR_KEY, + title: 'Ошибка', + description: error, + placement: 'bottomRight', + duration: 0, + btn: , + }); + }, [error, notification, reload]); + + useEffect(() => { + const onChange = () => { + if (!document.fullscreenElement) setFullscreen(false); + }; + document.addEventListener('fullscreenchange', onChange); + return () => document.removeEventListener('fullscreenchange', onChange); + }, []); + + const toggleFullscreen = async () => { + if (!boxRef.current) return; + try { + if (!document.fullscreenElement) { + await boxRef.current.requestFullscreen(); + setFullscreen(true); + } else { + await document.exitFullscreen(); + setFullscreen(false); + } + } catch (e) { + notify.error({ description: e instanceof Error ? e.message : 'Не удалось переключить полноэкранный режим' }); + } + }; + + const download = async () => { + setExporting(true); + try { + const { blob, filename } = await exportCorrectionTable(versionId); + const info = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null; + saveBlob(blob, filename ?? xlsxFilename('Таблица_корректировки', info)); + } catch (e) { + notify.error({ description: e instanceof ApiError ? e.message : 'Не удалось выгрузить таблицу' }); + } finally { + setExporting(false); + } + }; + + // фильтры, строки и «Итого» пересчитываются только при смене таблицы, фильтров или раскрытия + const view = useMemo(() => (table ? filterTable(table, filters) : null), [table, filters]); + const rows = useMemo(() => (view ? buildRows(view, expanded) : []), [view, expanded]); + const totals = useMemo(() => (view && view.aggregates.length > 0 ? visibleTotals(view.aggregates) : null), [view]); + const lines = useMemo(() => (table ? lineOptions(table) : []), [table]); + + const toggle = (key: string) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + // заглушки: окно корректировки и решения по строкам подключают следующие задачи плана + const actions: RowActions = { + onCorrect: () => notify.info({ description: 'Окно корректировки будет в следующей версии экрана' }), + onRevoke: () => notify.info({ description: 'Отмена корректировки будет в следующей версии экрана' }), + onAllocate: () => notify.info({ description: 'Решения по строкам будут в следующей версии экрана' }), + }; + + const noData = table !== null && table.aggregates.length === 0 && table.unallocated.length === 0; + const emptyText = noData ? 'Внутригрупповых остатков между компаниями периметра нет' : 'Нет записей для выбранных фильтров'; + + let body: ReactNode; + if (table) { + body = ( + + + + ); + } else if (loading) { + body = ( +
+ +
+ ); + } else { + body = ( +
+ {error ?? 'Таблица не загружена'} + +
+ ); + } + + return ( + (document.fullscreenElement as HTMLElement) || document.body}> +
+ setFilters(EMPTY_FILTERS)} + unit={unit} + onUnitChange={setUnit} + precision={precision} + onPrecisionChange={setPrecision} + visibleKeys={visibleKeys as ColKey[]} + onVisibleKeysChange={setVisibleKeys} + onExport={download} + exporting={exporting} + exportDisabled={!table} + fullscreen={fullscreen} + onToggleFullscreen={toggleFullscreen} + /> + {body} +
+
+ ); +} diff --git a/web/src/widgets/CorrectionTable/CorrectionTable.tsx b/web/src/widgets/CorrectionTable/CorrectionTable.tsx new file mode 100644 index 0000000..ea09a8b --- /dev/null +++ b/web/src/widgets/CorrectionTable/CorrectionTable.tsx @@ -0,0 +1,398 @@ +// Таблица корректировок (только отображение): строки агрегатов, их строки ОСВ при раскрытии, нераспределённые, «Итого». +// Фильтры живут в родителе (CorrectionContent); здесь только рисуем и сообщаем о действиях через `actions`. +import { DownOutlined, EditOutlined, MoreOutlined, RightOutlined, WarningOutlined } from '@ant-design/icons'; +import { Button, Checkbox, Dropdown, Table, Tag, Tooltip } from 'antd'; +import type { MenuProps } from 'antd'; +import type { ColumnGroupType, ColumnType } from 'antd/es/table'; +import { type ReactNode, useMemo } from 'react'; +import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections'; +import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter'; +import { COLUMN_DEFS, type ColKey } from './columns'; +import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel'; +import type { visibleTotals } from './tableModel'; + +/** Действия над строками; их подключают задачи окна корректировки и решений по строкам. */ +export type RowActions = { + onCorrect(a: CorrectionAggregateOut): void; + onRevoke(a: CorrectionAggregateOut): void; + onAllocate(body: AllocationIn): void; +}; + +type Props = { + rows: TableRow[]; + totals: ReturnType | null; + /** Форматтер сумм (единицы и знаки из панели). */ + format: (raw: string | null) => string; + expanded: ReadonlySet; + onToggle: (key: string) => void; + rules: CorrectionRuleOut[]; + /** Сервер разрешил правки. */ + editable: boolean; + /** Подсказка, почему правки закрыты; `null` — открыты. */ + readOnlyHint: string | null; + /** Выключатель флажков и меню строк: пока не подключены решения по строкам — false. */ + actionsEnabled: boolean; + actions: RowActions; + filters: TableFilters; + onFiltersChange: (next: TableFilters) => void; + lineOptions: string[]; + visibleKeys: ColKey[]; + /** Высота области прокрутки таблицы. */ + scrollY: string; + emptyText: string; +}; + +const DEVIATION_OPTIONS: { value: DeviationFilter; label: string }[] = [ + { value: 'all', label: 'Все' }, + { value: 'over', label: '≥ 5 %' }, + { value: 'under', label: '< 5 %' }, +]; + +const COLOR_OVER = '#d92d20'; +const BG_DEBIT = '#f4faf5'; +const BG_CREDIT = '#f6f8fc'; +const BG_UNALLOCATED = '#f2f4f7'; +const BG_CHANGED = '#fff6e5'; + +/** «394.27» → «394,27%»; строка не переводится в число. */ +const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`); + +const showLine = (line: string): string => articleCodes(line).join(' / '); + +const sideSuffix = (row: TableRow) => { + // одна и та же функция отдаёт, какая сторона строки ОСВ/нераспределённой заполнена: 'D' — Дебет, 'K' — Кредит + if (row.kind === 'entry') return row.side === 1 ? 'D' : 'K'; + if (row.kind === 'unallocated') return row.entry.side; + return null; +}; + +export function CorrectionTable(props: Props) { + const { rows, totals, format, expanded, onToggle, rules, editable, readOnlyHint, actionsEnabled, actions, filters, onFiltersChange } = props; + const canAct = editable && actionsEnabled; + const ruleById = useMemo(() => new Map(rules.map((r) => [r.id, r])), [rules]); + + const rowBg = (row: TableRow): string | undefined => { + if (row.kind === 'unallocated') return BG_UNALLOCATED; + if (row.kind === 'aggregate' && row.aggregate.composition_changed) return BG_CHANGED; + return undefined; + }; + + // меню решений по строке ОСВ / нераспределённой строке + const entryMenu = (row: Extract): MenuProps['items'] => { + const e = row.entry; + const ruleItems = e.candidate_rule_ids.flatMap((id) => { + const r = ruleById.get(id); + return r ? [{ key: `assign:${id}`, label: ruleLabel(r) }] : []; + }); + const items: NonNullable = [ + { + key: 'assign', + label: row.kind === 'entry' ? 'Перенести в правило…' : 'Отнести к правилу…', + disabled: ruleItems.length === 0, + children: ruleItems, + }, + ]; + if (row.kind === 'entry') items.push({ key: 'unassign', label: 'Убрать из правила' }); + if ((row.kind === 'entry' && row.entry.decision === 'ASSIGN') || (row.kind === 'unallocated' && row.entry.reason === 'UNASSIGNED')) { + items.push({ key: 'reset', label: 'Вернуть к автоматике' }); + } + return items; + }; + + const onEntryMenu = (row: Extract, key: string) => { + const base = { statement_type: 'balance' as const, entry_ids: row.entry.entry_ids }; + if (key.startsWith('assign:')) actions.onAllocate({ ...base, action: 'ASSIGN', rule_id: key.slice('assign:'.length) }); + else if (key === 'unassign') actions.onAllocate({ ...base, action: 'UNASSIGN' }); + else if (key === 'reset') actions.onAllocate({ ...base, action: 'RESET' }); + }; + + const withHint = (node: ReactNode) => (readOnlyHint ? {node} : node); + + const lines = (list: CorrectionLineOut[] | undefined, pick: (l: CorrectionLineOut) => string): ReactNode => + list && list.length > 0 ? list.map((l) =>
{pick(l)}
) : '—'; + + const cell = (key: ColKey, row: TableRow): ReactNode => { + if (row.kind === 'aggregate') { + const a = row.aggregate; + const red = a.exceeds_threshold ? { color: COLOR_OVER } : undefined; + switch (key) { + case 'include': + return withHint( + + + {row.include} + {a.composition_changed && ( + + + + )} + , + ); + case 'd_entity': + return a.company_1.name; + case 'k_entity': + return a.company_2.name; + case 'd_line': + return showLine(a.side_1.article_line); + case 'k_line': + return showLine(a.side_2.article_line); + case 'd_account': + return a.side_1.accounts.join(', '); + case 'k_account': + return a.side_2.accounts.join(', '); + case 'd_amount': + return format(a.side_1.amount); + case 'k_amount': + return format(a.side_2.amount); + case 'dev_percent': + return {percentText(a.deviation_percent)}; + case 'dev_amount': + return {format(a.deviation_amount)}; + case 'c_d_line': + return lines(a.correction?.debit, (l) => l.line_code); + case 'c_d_amount': + return lines(a.correction?.debit, (l) => format(l.amount)); + case 'c_k_line': + return lines(a.correction?.credit, (l) => l.line_code); + case 'c_k_amount': + return lines(a.correction?.credit, (l) => format(l.amount)); + case 'comment': + return a.correction ? ( + + {a.correction.status === 'DRAFT' && ( + + черновик + + )} + {a.correction.comment || '—'} + + ) : ( + '—' + ); + case 'actions': + return ( + + + {withHint( + actions.onRevoke(a), + }}> + + ), + }, ]} /> diff --git a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx index 86b20a2..9a7a43d 100644 --- a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx @@ -1,14 +1,14 @@ // Окно «Корректировка операции»: исходные строки ОСВ и распределение корректировки по двум сторонам агрегата, живой контроль, // комментарий. Правки статей и комментария пишутся в черновик (localStorage); действия со строками ОСВ уходят на сервер сразу. import { Alert, App, Button, Input, Modal } from 'antd'; -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useCurrentUser } from '../../auth/MeContext'; import { allocate, saveCorrection } from '../../services/api/corrections'; import type { CorrectionAggregateOut, CorrectionEntryOut, CorrectionTableOut, Side, UnallocatedEntryOut } from '../../types/corrections'; import { AllocationPicker } from './AllocationPicker'; import { ControlRow, DistributionBlock, type KeyedLine, OsvBlock, SideHeader } from './EditorSide'; import { type Draft, clearDraft, draftKey, loadDraft, saveDraft } from './draftStorage'; -import { type EditorState, initialEditorState, isDirty, liveControl, pickerCandidates, toCorrectionIn, validateLines } from './editorModel'; +import { type EditorState, initialEditorState, isDirty, liveControl, pickerCandidates, rebaseState, toCorrectionIn, validateLines } from './editorModel'; import { DISCARD_QUESTION, controlText, draftNotice, saveErrorOutcome } from './editorView'; import { COMMENT_MAX_LENGTH, errorOutcome, readOnlyHint } from './readOnly'; import { articleCodes, findAggregate, toAggregateKey } from './tableModel'; @@ -68,7 +68,13 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable base: useDraft && draft ? draft.baseVersionNo : (aggregate?.correction?.version_no ?? null), }; }); - const [state, setState] = useState(start.state); + const [state, setStateRaw] = useState(start.state); + // ref обновляется там же, где состояние (а не при рендере): после await обработчик читает свежие правки, а не замыкание + const stateRef = useRef(state); + const setState = (next: KeyedState) => { + stateRef.current = next; + setStateRaw(next); + }; // savedAt — время черновика, пока плашка видна; base — редакция корректировки, от которой начаты правки const [savedAt, setSavedAt] = useState(start.savedAt); const [base, setBase] = useState(start.base); @@ -81,16 +87,26 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable const [formError, setFormError] = useState(null); const [pickerSide, setPickerSide] = useState(null); - const stateRef = useRef(state); - stateRef.current = state; const busy = saving || allocating; - const dirty = editable && isDirty(initial, plain(state)); + // Агрегат пересобрался (перечитали таблицу, перенос строки ОСВ): без своих правок окно берёт новые суммы, с правками — ввод остаётся. + const prevInitial = useRef(initial); + // biome-ignore lint/correctness/useExhaustiveDependencies: реагируем только на смену исходного состояния агрегата + useEffect(() => { + if (prevInitial.current === initial) return; + const next = rebaseState(prevInitial.current, initial, plain(stateRef.current)); + prevInitial.current = initial; + if (next) setState(withIds(next)); + }, [initial]); + + // без агрегата окно только на просмотр: правок и «несохранённого» нет + const edit = editable && aggregate !== null; + const dirty = edit && isDirty(initial, plain(state)); /** Любая правка статей/комментария: в состояние и в черновик (совпало с исходным — черновик удаляем). */ const commit = (next: KeyedState) => { setState(next); - if (!editable || !aggregate) return; + if (!edit || !aggregate) return; const p = plain(next); if (isDirty(initial, p)) saveDraft(key, { state: p, savedAt: new Date().toISOString(), baseVersionNo: base }); else { @@ -146,9 +162,6 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable try { const t = await allocate(versionId, body); onTable(t); - // без своих правок суммы по умолчанию следуют за новыми суммами сторон - const fresh = findAggregate(t, aggregateKey); - if (fresh && !isDirty(initial, plain(stateRef.current))) setState(withIds(initialEditorState(fresh))); return true; } catch (e) { const out = errorOutcome(e, 'Не удалось выполнить действие со строкой'); @@ -171,7 +184,7 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable const validation = validateLines(plain(state)); const save = async () => { - if (!aggregate || inflight.current || !validation.ok) return; + if (!edit || !aggregate || inflight.current || !validation.ok) return; inflight.current = true; setSaving(true); setFormError(null); @@ -246,9 +259,9 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable footer={
- {editable && ( + {edit && ( @@ -256,15 +269,16 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
}>
- {hint && } - {!aggregate && } + {hint && } + {!aggregate && } {notice && ( Сбросить @@ -272,14 +286,14 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable } /> )} - {formError && } + {formError && }
- setPickerSide(1)} onUnassign={unassign} /> - setPickerSide(2)} onUnassign={unassign} /> + setPickerSide(1)} onUnassign={unassign} /> + setPickerSide(2)} onUnassign={unassign} />
@@ -289,7 +303,8 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable articleCodes={n === 1 ? s1.codes : s2.codes} lines={n === 1 ? state.debit : state.credit} lineErrors={lineErrors(n)} - editable={editable} + editable={edit} + sideName={n === 1 ? 'дебет' : 'кредит'} onAddLine={() => editLines(n, (ls) => [...ls, { id: nextId.current++, line_code: '', amount: '' }])} onRemoveLine={(id) => editLines(n, (ls) => ls.filter((l) => l.id !== id))} onChangeLine={(id, patch) => editLines(n, (ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l)))} @@ -297,7 +312,11 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable ))}
- {!validation.ok && validation.errors.lines &&
{validation.errors.lines}
} + {!validation.ok && validation.errors.lines && ( +
+ {validation.errors.lines} +
+ )}
@@ -305,17 +324,22 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable { setCommentError(null); commit({ ...state, comment: e.target.value }); }} /> - {commentError &&
{commentError}
} + {commentError && ( + + )}
diff --git a/web/src/widgets/CorrectionTable/EditorSide.tsx b/web/src/widgets/CorrectionTable/EditorSide.tsx index ecee983..021adf1 100644 --- a/web/src/widgets/CorrectionTable/EditorSide.tsx +++ b/web/src/widgets/CorrectionTable/EditorSide.tsx @@ -97,6 +97,8 @@ type DistProps = { lines: KeyedLine[]; lineErrors: LineErrors; editable: boolean; + /** Сторона в подписях для скринридера: «дебет» / «кредит». */ + sideName: string; onAddLine(): void; onRemoveLine(id: number): void; onChangeLine(id: number, patch: Partial>): void; @@ -120,7 +122,7 @@ export function DistributionBlock(p: DistProps) { {p.lines.map((line, i) => { const err = p.lineErrors(i); - return ; + return ; })} @@ -129,10 +131,16 @@ export function DistributionBlock(p: DistProps) { type RowProps = Pick & { line: KeyedLine; + /** Номер строки в стороне с нуля. */ + index: number; + sideName: string; err: { line_code?: string; amount?: string }; }; -function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine }: RowProps) { +function LineRow({ line, index, sideName, err, articleCodes, editable, onRemoveLine, onChangeLine }: RowProps) { + const where = `${sideName}, ${index + 1}`; + const codeErrId = `line-${line.id}-code-error`; + const amountErrId = `line-${line.id}-amount-error`; return ( <>
@@ -142,9 +150,13 @@ function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine disabled={!editable} style={{ width: '100%' }} onChange={(v) => onChangeLine(line.id, { line_code: v })}> - + - {err.line_code &&
{err.line_code}
} + {err.line_code && ( +
+ {err.line_code} +
+ )}
onChangeLine(line.id, { amount: e.target.value })} /> - {err.amount &&
{err.amount}
} + {err.amount && ( +
+ {err.amount} +
+ )}
- {editable &&
); @@ -168,7 +185,7 @@ function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine /** Строка контроля: сумма слева, «≠ разница (процент)» посередине (красная при превышении порога), сумма справа. */ export function ControlRow({ left, middle, right, red }: { left: string; middle: string; right: string; red: boolean }) { return ( -
+
{left} {middle} {right} diff --git a/web/src/widgets/CorrectionTable/editorModel.test.ts b/web/src/widgets/CorrectionTable/editorModel.test.ts index 6bae288..0cbf776 100644 --- a/web/src/widgets/CorrectionTable/editorModel.test.ts +++ b/web/src/widgets/CorrectionTable/editorModel.test.ts @@ -6,6 +6,7 @@ import { isDirty, liveControl, pickerCandidates, + rebaseState, toCorrectionIn, validateLines, } from './editorModel'; @@ -230,3 +231,18 @@ describe('pickerCandidates', () => { expect(aggregateKey(a)).toBe('r6|c-ept|c-ils'); }); }); + +describe('rebaseState', () => { + const st = (amount: string, comment = ''): EditorState => ({ debit: [{ line_code: '1230', amount }], credit: [], comment }); + it('аналитик не правил — окно берёт новое исходное состояние', () => { + expect(rebaseState(st('100.00'), st('250.00'), st('100.00'))).toEqual(st('250.00')); + }); + it('аналитик правил — ввод не затирается', () => { + expect(rebaseState(st('100.00'), st('250.00'), st('999.00'))).toBeNull(); + expect(rebaseState(st('100.00'), st('250.00'), st('100.00', 'комментарий'))).toBeNull(); + }); + it('исходное не изменилось или совпало с текущим — пересобирать нечего', () => { + expect(rebaseState(st('100.00'), st('100.00'), st('100.00'))).toBeNull(); + expect(rebaseState(st('100.00'), st('250.00'), st('250,00'))).toBeNull(); + }); +}); diff --git a/web/src/widgets/CorrectionTable/editorModel.ts b/web/src/widgets/CorrectionTable/editorModel.ts index 43540e5..6152200 100644 --- a/web/src/widgets/CorrectionTable/editorModel.ts +++ b/web/src/widgets/CorrectionTable/editorModel.ts @@ -138,6 +138,16 @@ export function isDirty(initial: EditorState, current: EditorState): boolean { return !sameLines(initial.debit, current.debit) || !sameLines(initial.credit, current.credit) || initial.comment !== current.comment; } +/** + * Состояние окна после смены исходного состояния агрегата (перечитали таблицу, строка ОСВ перенесена): если аналитик + * ничего не правил относительно прежнего `prev`, окно показывает новое `next` (вернём его); правил — ввод не трогаем (`null`). + * `null` и когда пересобирать нечего (новое состояние совпало с текущим). + */ +export function rebaseState(prev: EditorState, next: EditorState, current: EditorState): EditorState | null { + if (isDirty(prev, current)) return null; + return isDirty(next, current) ? next : null; +} + const opposite = (s: Side): Side => (s === 'D' ? 'K' : 'D'); /** Нераспределённые строки для «Добавить строку ОСВ»: владелец и контрагент — из пары, сторона — по правилу, правило — в кандидатах. */ diff --git a/web/src/widgets/CorrectionTable/editorView.ts b/web/src/widgets/CorrectionTable/editorView.ts index 65f478a..f0dff50 100644 --- a/web/src/widgets/CorrectionTable/editorView.ts +++ b/web/src/widgets/CorrectionTable/editorView.ts @@ -2,7 +2,7 @@ import { ApiError } from '../../api/client'; import type { CorrectionAggregateOut } from '../../types/corrections'; import { formatScaledAmount } from '../../utils/money'; -import type { Draft } from './draftStorage'; +import { type Draft, isStale } from './draftStorage'; import type { Diff } from './editorModel'; import { errorOutcome } from './readOnly'; @@ -71,7 +71,7 @@ export type DraftNotice = { kind: 'restored' | 'stale'; text: string }; */ export function draftNotice(savedAt: string | null, baseVersionNo: number | null, a: CorrectionAggregateOut | null): DraftNotice | null { if (savedAt === null) return null; - if (a && baseVersionNo !== (a.correction?.version_no ?? null)) return { kind: 'stale', text: 'Корректировку изменили после ваших правок' }; + if (a && isStale({ state: { debit: [], credit: [], comment: '' }, savedAt, baseVersionNo }, a)) return { kind: 'stale', text: 'Корректировку изменили после ваших правок' }; return { kind: 'restored', text: `Восстановлены несохранённые правки от ${timeHHMM(savedAt)}` }; } -- 2.47.2 From 84c4f25a9bd0ab4ae4e8f1bd361264a73cffdf77 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:55:35 +0400 Subject: [PATCH 14/28] =?UTF-8?q?fix(web):=20=D0=BE=D0=BA=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=E2=80=94=20=D0=B1=D0=B0=D0=B7=D0=B0=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=B0,=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=8C=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=B0=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T6.11: при пересборке окна из нового агрегата (правок не было) база черновика обновляется до текущей редакции корректировки, иначе при следующем открытии показывалась ложная плашка об изменении. T6.12: подпись кнопки «Выбрать» называет контрагента; ошибки полей статьи объявляются role=alert. Co-Authored-By: Claude Opus 5.5 --- web/src/widgets/CorrectionTable/AllocationPicker.tsx | 4 ++-- web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx | 6 +++++- web/src/widgets/CorrectionTable/EditorSide.tsx | 4 ++-- web/src/widgets/CorrectionTable/editorView.test.ts | 8 +++++++- web/src/widgets/CorrectionTable/editorView.ts | 3 +++ 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/web/src/widgets/CorrectionTable/AllocationPicker.tsx b/web/src/widgets/CorrectionTable/AllocationPicker.tsx index c3a093f..4d04c6f 100644 --- a/web/src/widgets/CorrectionTable/AllocationPicker.tsx +++ b/web/src/widgets/CorrectionTable/AllocationPicker.tsx @@ -1,7 +1,7 @@ // Выбор нераспределённой строки для «Добавить строку ОСВ»: клик по строке отправляет ASSIGN (в окне, через onPick). import { Button, Modal, Table } from 'antd'; import type { UnallocatedEntryOut } from '../../types/corrections'; -import { formatRub } from './editorView'; +import { formatRub, pickLabel } from './editorView'; type Props = { open: boolean; @@ -48,7 +48,7 @@ export function AllocationPicker({ open, sideLabel, article, candidates, busy, o , + btn: ( + + ), }); }, [error, notification, reload]); diff --git a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx index 705fba1..8573f23 100644 --- a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx @@ -8,7 +8,16 @@ import type { CorrectionAggregateOut, CorrectionEntryOut, CorrectionTableOut, Si import { AllocationPicker } from './AllocationPicker'; import { ControlRow, DistributionBlock, type KeyedLine, OsvBlock, SideHeader } from './EditorSide'; import { type Draft, clearDraft, draftKey, loadDraft, saveDraft } from './draftStorage'; -import { type EditorState, initialEditorState, isDirty, liveControl, pickerCandidates, rebaseState, toCorrectionIn, validateLines } from './editorModel'; +import { + type EditorState, + initialEditorState, + isDirty, + liveControl, + pickerCandidates, + rebaseState, + toCorrectionIn, + validateLines, +} from './editorModel'; import { DISCARD_QUESTION, controlText, draftNotice, saveErrorOutcome } from './editorView'; import { COMMENT_MAX_LENGTH, errorOutcome, readOnlyHint } from './readOnly'; import { articleCodes, findAggregate, toAggregateKey } from './tableModel'; @@ -195,7 +204,11 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable setCommentError(null); setLineError(null); try { - const out = await saveCorrection(versionId, toAggregateKey(aggregate), toCorrectionIn(validation.body, aggregate.correction?.version_no ?? null)); + const out = await saveCorrection( + versionId, + toAggregateKey(aggregate), + toCorrectionIn(validation.body, aggregate.correction?.version_no ?? null), + ); clearDraft(key); onAggregate(out); onClose(); @@ -223,7 +236,9 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable const errs = validation.ok ? {} : validation.errors; const line = state[name][i]; return { - line_code: errs[`${name}.${i}.line_code`] ?? (lineError && line && line.line_code.trim() === lineError.lineCode ? lineError.message : undefined), + line_code: + errs[`${name}.${i}.line_code`] ?? + (lineError && line && line.line_code.trim() === lineError.lineCode ? lineError.message : undefined), amount: errs[`${name}.${i}.amount`], }; }; @@ -233,8 +248,10 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable ? { 1: sideText(rule.side_1), 2: sideText(rule.side_1 === 'D' ? 'K' : 'D') } : { 1: 'Дт', 2: 'Кт' }; const sideData = (n: SideNo) => { - const side = aggregate ? (n === 1 ? aggregate.side_1 : aggregate.side_2) : null; - const company = aggregate ? (n === 1 ? aggregate.company_1.name : aggregate.company_2.name) : null; + const first = n === 1; + const pick = (a: T, b: T): T => (first ? a : b); + const side = aggregate ? pick(aggregate.side_1, aggregate.side_2) : null; + const company = aggregate ? pick(aggregate.company_1, aggregate.company_2).name : null; return { company, article: side ? articleCodes(side.article_line).join(' / ') : '', @@ -274,7 +291,9 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable }>
{hint && } - {!aggregate && } + {!aggregate && ( + + )} {notice && ( - setPickerSide(1)} onUnassign={unassign} /> - setPickerSide(2)} onUnassign={unassign} /> + setPickerSide(1)} + onUnassign={unassign} + /> + setPickerSide(2)} + onUnassign={unassign} + />
diff --git a/web/src/widgets/CorrectionTable/CorrectionTable.tsx b/web/src/widgets/CorrectionTable/CorrectionTable.tsx index f6d787f..12f058d 100644 --- a/web/src/widgets/CorrectionTable/CorrectionTable.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionTable.tsx @@ -117,7 +117,12 @@ export function CorrectionTable(props: Props) { case 'include': return withHint( - + {row.include} {a.composition_changed && ( @@ -182,7 +187,13 @@ export function CorrectionTable(props: Props) { items: [{ key: 'revoke', label: 'Отменить корректировку', disabled: !a.correction }], onClick: () => actions.onRevoke(a), }}> -
); @@ -185,7 +192,16 @@ function LineRow({ line, index, sideName, err, articleCodes, editable, onRemoveL /** Строка контроля: сумма слева, «≠ разница (процент)» посередине (красная при превышении порога), сумма справа. */ export function ControlRow({ left, middle, right, red }: { left: string; middle: string; right: string; red: boolean }) { return ( -
+
{left} {middle} {right} diff --git a/web/src/widgets/CorrectionTable/HeaderFilter.tsx b/web/src/widgets/CorrectionTable/HeaderFilter.tsx index 1631b07..453aba9 100644 --- a/web/src/widgets/CorrectionTable/HeaderFilter.tsx +++ b/web/src/widgets/CorrectionTable/HeaderFilter.tsx @@ -23,7 +23,7 @@ export function HeaderFilter({ label, active, children }: Props) { style={{ cursor: 'pointer', border: 0, background: 'none', padding: 0 }}> {active ? : } - + ); } diff --git a/web/src/widgets/CorrectionTable/draftStorage.ts b/web/src/widgets/CorrectionTable/draftStorage.ts index 3591d26..f738a2b 100644 --- a/web/src/widgets/CorrectionTable/draftStorage.ts +++ b/web/src/widgets/CorrectionTable/draftStorage.ts @@ -34,11 +34,7 @@ function isDraft(value: unknown): value is Draft { const s = v.state as Record | null; if (typeof s !== 'object' || s === null) return false; return ( - Array.isArray(s.debit) && - s.debit.every(isLine) && - Array.isArray(s.credit) && - s.credit.every(isLine) && - typeof s.comment === 'string' + Array.isArray(s.debit) && s.debit.every(isLine) && Array.isArray(s.credit) && s.credit.every(isLine) && typeof s.comment === 'string' ); } diff --git a/web/src/widgets/CorrectionTable/editorModel.test.ts b/web/src/widgets/CorrectionTable/editorModel.test.ts index 0cbf776..442000b 100644 --- a/web/src/widgets/CorrectionTable/editorModel.test.ts +++ b/web/src/widgets/CorrectionTable/editorModel.test.ts @@ -1,15 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { CorrectionAggregateOut, CorrectionTableOut, UnallocatedEntryOut } from '../../types/corrections'; import type { EditorState } from './editorModel'; -import { - initialEditorState, - isDirty, - liveControl, - pickerCandidates, - rebaseState, - toCorrectionIn, - validateLines, -} from './editorModel'; +import { initialEditorState, isDirty, liveControl, pickerCandidates, rebaseState, toCorrectionIn, validateLines } from './editorModel'; import { makeMockTable } from './mockData'; import { aggregateKey } from './tableModel'; @@ -116,13 +108,21 @@ describe('граница порога и пустые стороны', () => { it('отклонение ровно 5.00 % — красный', () => { const a = findBy(table(), 'ЭПТ'); // 100.00 к 105.00 — 5.00 % к меньшей - const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '100.00' }], credit: [{ line_code: '1520', amount: '105.00' }] }); + const c = liveControl(a, { + ...base, + debit: [{ line_code: '1230', amount: '100.00' }], + credit: [{ line_code: '1520', amount: '105.00' }], + }); expect(c.allocationDiff.percent).toBe('5.00'); expect(c.allocationDiff.red).toBe(true); }); it('отклонение 4.99 % — не красный', () => { const a = findBy(table(), 'ЭПТ'); - const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '100.00' }], credit: [{ line_code: '1520', amount: '104.99' }] }); + const c = liveControl(a, { + ...base, + debit: [{ line_code: '1230', amount: '100.00' }], + credit: [{ line_code: '1520', amount: '104.99' }], + }); expect(c.allocationDiff.percent).toBe('4.99'); expect(c.allocationDiff.red).toBe(false); }); @@ -182,7 +182,13 @@ describe('toCorrectionIn', () => { }); describe('pickerCandidates', () => { - const entry = (id: string, owner: { id: string; name: string }, cp: { id: string; name: string }, side: 'D' | 'K', extra: Partial = {}): UnallocatedEntryOut => ({ + const entry = ( + id: string, + owner: { id: string; name: string }, + cp: { id: string; name: string }, + side: 'D' | 'K', + extra: Partial = {}, + ): UnallocatedEntryOut => ({ entry_ids: [id], owner, counterparty: cp, diff --git a/web/src/widgets/CorrectionTable/editorModel.ts b/web/src/widgets/CorrectionTable/editorModel.ts index 6152200..b84e175 100644 --- a/web/src/widgets/CorrectionTable/editorModel.ts +++ b/web/src/widgets/CorrectionTable/editorModel.ts @@ -96,10 +96,12 @@ export function validateLines(s: EditorState): LinesValidation { /** Сумма статей стороны для живого контроля: строки, не прошедшие parseLineAmount, в сумму не входят (их покажет validateLines). */ function sideSum(lines: EditLine[]): string | null { - return sumAmounts(lines.map((l) => { - const r = parseLineAmount(l.amount); - return r.ok ? r.value : null; - })); + return sumAmounts( + lines.map((l) => { + const r = parseLineAmount(l.amount); + return r.ok ? r.value : null; + }), + ); } /** Разница двух сумм и процент к меньшей; красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */ diff --git a/web/src/widgets/CorrectionTable/editorView.test.ts b/web/src/widgets/CorrectionTable/editorView.test.ts index c4bb8c2..073dd9e 100644 --- a/web/src/widgets/CorrectionTable/editorView.test.ts +++ b/web/src/widgets/CorrectionTable/editorView.test.ts @@ -6,20 +6,30 @@ import { CONFLICT_MESSAGE, controlText, draftNotice, formatRub, pickLabel, saveE const err = (status: number, code: string, detail: Record = {}) => new ApiError(status, code, `msg ${code}`, { detail: { code, message: `msg ${code}`, ...detail } }); -const agg = (versionNo: number | null) => ({ correction: versionNo === null ? null : { version_no: versionNo } }) as unknown as CorrectionAggregateOut; +const agg = (versionNo: number | null) => + ({ correction: versionNo === null ? null : { version_no: versionNo } }) as unknown as CorrectionAggregateOut; describe('saveErrorOutcome', () => { it('COMMENT_REQUIRED — у комментария, без перечитывания', () => { expect(saveErrorOutcome(err(422, 'COMMENT_REQUIRED'))).toMatchObject({ kind: 'comment', reload: false }); }); it('INVALID_LINE_CODE — у статьи detail.line_code', () => { - expect(saveErrorOutcome(err(422, 'INVALID_LINE_CODE', { line_code: '9999' }))).toMatchObject({ kind: 'line', lineCode: '9999', reload: false }); + expect(saveErrorOutcome(err(422, 'INVALID_LINE_CODE', { line_code: '9999' }))).toMatchObject({ + kind: 'line', + lineCode: '9999', + reload: false, + }); }); it('INVALID_LINE_CODE без line_code в теле — статья не определена, окно не падает', () => { expect(saveErrorOutcome(err(422, 'INVALID_LINE_CODE'))).toMatchObject({ kind: 'line', lineCode: null }); }); it('CORRECTION_VERSION_CONFLICT — фиксированный текст и перечитывание', () => { - expect(saveErrorOutcome(err(409, 'CORRECTION_VERSION_CONFLICT'))).toEqual({ kind: 'conflict', message: CONFLICT_MESSAGE, lineCode: null, reload: true }); + expect(saveErrorOutcome(err(409, 'CORRECTION_VERSION_CONFLICT'))).toEqual({ + kind: 'conflict', + message: CONFLICT_MESSAGE, + lineCode: null, + reload: true, + }); }); it('прочий 409 — текст сервера и перечитывание; 500 — без перечитывания', () => { expect(saveErrorOutcome(err(409, 'LOCKED_BY_OTHER'))).toMatchObject({ kind: 'other', message: 'msg LOCKED_BY_OTHER', reload: true }); diff --git a/web/src/widgets/CorrectionTable/editorView.ts b/web/src/widgets/CorrectionTable/editorView.ts index 0ccd696..fa46fb9 100644 --- a/web/src/widgets/CorrectionTable/editorView.ts +++ b/web/src/widgets/CorrectionTable/editorView.ts @@ -14,7 +14,8 @@ export const DISCARD_QUESTION = 'Есть несохранённые измен export const formatRub = (value: string | null): string => formatScaledAmount(value, { scale: 0, precision: 2 }); /** Подпись кнопки «Выбрать» в списке нераспределённых: читалка экрана называет контрагента, счёт и сумму. */ -export const pickLabel = (counterparty: string, account: string, amount: string): string => `Выбрать: ${counterparty}, ${account}, ${formatRub(amount)}`; +export const pickLabel = (counterparty: string, account: string, amount: string): string => + `Выбрать: ${counterparty}, ${account}, ${formatRub(amount)}`; /** «394.27» → «394,27%»; строка в число не переводится. */ export const percentText = (p: string): string => `${p.replace('.', ',')}%`; @@ -74,7 +75,8 @@ export type DraftNotice = { kind: 'restored' | 'stale'; text: string }; */ export function draftNotice(savedAt: string | null, baseVersionNo: number | null, a: CorrectionAggregateOut | null): DraftNotice | null { if (savedAt === null) return null; - if (a && isStale({ state: { debit: [], credit: [], comment: '' }, savedAt, baseVersionNo }, a)) return { kind: 'stale', text: 'Корректировку изменили после ваших правок' }; + if (a && isStale({ state: { debit: [], credit: [], comment: '' }, savedAt, baseVersionNo }, a)) + return { kind: 'stale', text: 'Корректировку изменили после ваших правок' }; return { kind: 'restored', text: `Восстановлены несохранённые правки от ${timeHHMM(savedAt)}` }; } diff --git a/web/src/widgets/CorrectionTable/mockData.ts b/web/src/widgets/CorrectionTable/mockData.ts index 03c4fe5..8e0b863 100644 --- a/web/src/widgets/CorrectionTable/mockData.ts +++ b/web/src/widgets/CorrectionTable/mockData.ts @@ -75,7 +75,12 @@ const entry = (owner: Ref, cp: Ref, account: string, side: Side, amount: string, }; }; -const side = (articleLine: string, entries: CorrectionEntryOut[]): AggregateSideOut => ({ article_line: articleLine, accounts: [], amount: '0.00', entries }); +const side = (articleLine: string, entries: CorrectionEntryOut[]): AggregateSideOut => ({ + article_line: articleLine, + accounts: [], + amount: '0.00', + entries, +}); function aggregate(ruleNo: number, c1: Ref, c2: Ref, s1: CorrectionEntryOut[], s2: CorrectionEntryOut[]): CorrectionAggregateOut { const r = MOCK_RULES[ruleNo - 1]; @@ -108,19 +113,23 @@ const unallocated = ( reason: UnallocatedEntryOut['reason'] = 'MULTIPLE_RULES', ): UnallocatedEntryOut => { entryNo += 1; - return { entry_ids: [`e${entryNo}`], owner, counterparty: cp, account_code: account, side: s, amount, candidate_rule_ids: candidates, reason, excluded: false }; + return { + entry_ids: [`e${entryNo}`], + owner, + counterparty: cp, + account_code: account, + side: s, + amount, + candidate_rule_ids: candidates, + reason, + excluded: false, + }; }; /** Свежая копия таблицы из дизайна 23.10: у каждого вызова свои объекты, фейк и тесты друг друга не портят. */ export function makeMockTable(): CorrectionTableOut { entryNo = 0; - const eptIls = aggregate( - 6, - ept, - ils, - [entry(ept, ils, '62.01', 'D', '1000000.00')], - [entry(ils, ept, '60.01', 'K', '202320.00')], - ); + const eptIls = aggregate(6, ept, ils, [entry(ept, ils, '62.01', 'D', '1000000.00')], [entry(ils, ept, '60.01', 'K', '202320.00')]); eptIls.correction = { status: 'APPLIED', version_no: 1, @@ -128,7 +137,14 @@ export function makeMockTable(): CorrectionTableOut { credit: [{ line_code: '1520', amount: '202320.00' }], comment: 'Требуется сверка с контрагентом', reason_code: null, - balance: { debit_total: '0.00', credit_total: '0.00', difference: '0.00', difference_percent: null, remainder_1: '0.00', remainder_2: '0.00' }, + balance: { + debit_total: '0.00', + credit_total: '0.00', + difference: '0.00', + difference_percent: null, + remainder_1: '0.00', + remainder_2: '0.00', + }, updated_by: { id: 'u-mock', name: 'Иванов И. И.' }, updated_at: '2026-10-28T12:00:00+03:00', }; @@ -152,9 +168,21 @@ export function makeMockTable(): CorrectionTableOut { ], ); - const pmpRs = aggregate(3, pmp, rsg, [entry(pmp, rsg, '58.03', 'D', '29742550.00', ['r4'])], [entry(rsg, pmp, '67.03', 'K', '29242550.00')]); + const pmpRs = aggregate( + 3, + pmp, + rsg, + [entry(pmp, rsg, '58.03', 'D', '29742550.00', ['r4'])], + [entry(rsg, pmp, '67.03', 'K', '29242550.00')], + ); - const bscPmp = aggregate(6, bscOoo, pmpOoo, [entry(bscOoo, pmpOoo, '62.01', 'D', '310000.00', ['r7', 'r8'])], [entry(pmpOoo, bscOoo, '60.01', 'K', '300000.00')]); + const bscPmp = aggregate( + 6, + bscOoo, + pmpOoo, + [entry(bscOoo, pmpOoo, '62.01', 'D', '310000.00', ['r7', 'r8'])], + [entry(pmpOoo, bscOoo, '60.01', 'K', '300000.00')], + ); const agro = aggregate(4, agroH, agroF, [entry(agroH, agroF, '58.03', 'D', '5000000.00', ['r3'])], []); diff --git a/web/src/widgets/CorrectionTable/readOnly.test.ts b/web/src/widgets/CorrectionTable/readOnly.test.ts index 771d2a0..c95f394 100644 --- a/web/src/widgets/CorrectionTable/readOnly.test.ts +++ b/web/src/widgets/CorrectionTable/readOnly.test.ts @@ -116,7 +116,10 @@ describe('includeBody / menuBody', () => { describe('errorOutcome', () => { it('ошибка API: текст сервера; 409 — перечитать', () => { - expect(errorOutcome(new ApiError(409, 'LOCK_REQUIRED', 'Возьмите блокировку'), 'запасной')).toEqual({ message: 'Возьмите блокировку', reload: true }); + expect(errorOutcome(new ApiError(409, 'LOCK_REQUIRED', 'Возьмите блокировку'), 'запасной')).toEqual({ + message: 'Возьмите блокировку', + reload: true, + }); }); it('422 — без перечитывания', () => { expect(errorOutcome(new ApiError(422, 'COMMENT_REQUIRED', 'Комментарий обязателен'), 'з').reload).toBe(false); diff --git a/web/src/widgets/CorrectionTable/tableModel.ts b/web/src/widgets/CorrectionTable/tableModel.ts index 4386514..92b5739 100644 --- a/web/src/widgets/CorrectionTable/tableModel.ts +++ b/web/src/widgets/CorrectionTable/tableModel.ts @@ -76,7 +76,14 @@ export function lineOptions(table: CorrectionTableOut): string[] { } export function isFiltered(f: TableFilters): boolean { - return f.display.mode !== 'all' || f.recordType !== 'all' || f.entity.trim() !== '' || f.line !== null || f.account.trim() !== '' || f.deviation !== 'all'; + return ( + f.display.mode !== 'all' || + f.recordType !== 'all' || + f.entity.trim() !== '' || + f.line !== null || + f.account.trim() !== '' || + f.deviation !== 'all' + ); } export function filterTable( @@ -119,7 +126,8 @@ export function buildRows(view: ReturnType, expanded: Readon [1, aggregate.side_1.entries], [2, aggregate.side_2.entries], ] as const) { - for (const entry of entries) rows.push({ kind: 'entry', key: `${key}|${side}|${entry.entry_ids.join(',')}`, parentKey: key, side, entry }); + for (const entry of entries) + rows.push({ kind: 'entry', key: `${key}|${side}|${entry.entry_ids.join(',')}`, parentKey: key, side, entry }); } } for (const entry of view.unallocated) rows.push({ kind: 'unallocated', key: `u|${entry.entry_ids.join(',')}`, entry }); @@ -138,8 +146,12 @@ export function visibleTotals(aggregates: CorrectionAggregateOut[]): { amount_1: sumAmounts(aggregates.map((a) => a.side_1.amount)), amount_2: sumAmounts(aggregates.map((a) => a.side_2.amount)), deviation_amount: sumAmounts(aggregates.map((a) => a.deviation_amount)), - correction_debit: aggregates.length ? (sumAmounts(aggregates.flatMap((a) => a.correction?.debit.map((l) => l.amount) ?? [])) ?? '0.00') : null, - correction_credit: aggregates.length ? (sumAmounts(aggregates.flatMap((a) => a.correction?.credit.map((l) => l.amount) ?? [])) ?? '0.00') : null, + correction_debit: aggregates.length + ? (sumAmounts(aggregates.flatMap((a) => a.correction?.debit.map((l) => l.amount) ?? [])) ?? '0.00') + : null, + correction_credit: aggregates.length + ? (sumAmounts(aggregates.flatMap((a) => a.correction?.credit.map((l) => l.amount) ?? [])) ?? '0.00') + : null, }; } diff --git a/web/src/widgets/CorrectionTable/useCorrectionTable.ts b/web/src/widgets/CorrectionTable/useCorrectionTable.ts index f9e8395..3fba946 100644 --- a/web/src/widgets/CorrectionTable/useCorrectionTable.ts +++ b/web/src/widgets/CorrectionTable/useCorrectionTable.ts @@ -75,7 +75,9 @@ export function useCorrectionTable(versionId: string): { (a: CorrectionAggregateOut) => { requests.next(); setLoading(false); - setTable((prev) => (prev ? { ...prev, aggregates: prev.aggregates.map((x) => (aggregateKey(x) === aggregateKey(a) ? a : x)) } : prev)); + setTable((prev) => + prev ? { ...prev, aggregates: prev.aggregates.map((x) => (aggregateKey(x) === aggregateKey(a) ? a : x)) } : prev, + ); }, [requests], ); -- 2.47.2 From 0667bca1969a2e6d92bb8c3514b9936bee29e28c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:04:10 +0400 Subject: [PATCH 16/28] =?UTF-8?q?fix(web):=20=D1=83=D0=B2=D0=B5=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B5=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=B8=20=E2=80=94=20actions=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=D1=83=D1=81=D1=82=D0=B0=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=88=D0=B5=D0=B3=D0=BE=20btn=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/widgets/CorrectionTable/CorrectionContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/widgets/CorrectionTable/CorrectionContent.tsx b/web/src/widgets/CorrectionTable/CorrectionContent.tsx index 1ac4c8b..135f298 100644 --- a/web/src/widgets/CorrectionTable/CorrectionContent.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionContent.tsx @@ -63,7 +63,7 @@ export function CorrectionContent({ versionId, card = null }: Props) { description: error, placement: 'bottomRight', duration: 0, - btn: ( + actions: ( -- 2.47.2 From b499787d10e0c55327fb07a4711b46d841310871 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:05:02 +0400 Subject: [PATCH 17/28] =?UTF-8?q?docs(web):=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20=E2=80=94=20README,=20AR?= =?UTF-8?q?CHITECTURE,=20tasks=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/corrections-table-ui/design.md | 4 ++ .../changes/corrections-table-ui/tasks.md | 42 +++++++++++++++++++ web/ARCHITECTURE.md | 29 +++++++++++++ web/README.md | 10 +++++ 4 files changed, 85 insertions(+) create mode 100644 openspec/changes/corrections-table-ui/tasks.md diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index 00b07e5..37942d8 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -200,3 +200,7 @@ | 28.10.2026 | R-18: суммы в окне показываются в рублях (`1 000 000,00`) независимо от единиц панели, а в полях ввода — как есть (`1000000.00`, пробелы запрещены R-4); у строки контроля без процента (`percent = null`, одна сторона 0) процент не пишется, у равенства — «= 0,00» | единицы панели — про таблицу; дизайн показывает «(100%)» там, где процент к меньшей стороне не определён, фронт его не выдумывает | расхождение с дизайном в тексте контроля | | 28.10.2026 | R-19: агрегат исчез из ответа — окно открыто, стороны «—», «Сохранить» неактивна, пояснение в плашке; ключ PUT берётся из ответа, поэтому без агрегата сохранить нельзя | Review Focus плана | — | | 28.10.2026 | R-20: в окне нет названий статей рядом с кодом («дт 1230 Дебиторская задолженность» в дизайне) | в ответе API названий статей нет, а фронт их не выдумывает | добавить поле `article_name` в контракт и показать | +| 28.10.2026 | R-21: код фейка (`correctionsFake`, `mockData`, `mockCalc`, ~20 КБ исходников) остаётся в прод-бандле при сборке без флага; сам не вырезал | `Config.CORRECTIONS_MOCK` — обычное чтение `process.env.REACT_APP_CORRECTIONS_MOCK`, импорты статические: `npm run build` без флага даёт строку «Требуется сверка» в `index_script`; вырезание требует динамического импорта фейка или `define`-константы в rsbuild — не мелкая правка; Task 8 фейк всё равно удаляет | лишние ~8 КБ gzip в бандле до Task 8; вопрос владельцу: делать ли вырезание сейчас | +| 28.10.2026 | R-22: Prettier и ESLint применены к файлам задачи (`npm run lint` падал на форматировании 17 файлов, на вложенных тернарниках окна и BOM-литерале в фейке); BOM — `\uFEFF`, поведение то же | `npm run lint` — шаг проверки Task 7 | — | +| 28.10.2026 | R-23: «Вернуть к автоматике» на нераспределённой строке в фейке только снимает «исключена» и не возвращает строку в правило | фейк не знает, какое правило было автоматическим; настоящее поведение — на бэкенде (1540) | аналитик на демо на фейке не увидит возврата; проверить при подключении API | +| 28.10.2026 | R-24: у агрегата флажок «Учитывать в КФО» — серый неактивный индикатор «Да / Частично / Нет», а в дизайне зелёный | решение 28.10 (флажок ставится на строках, у агрегата производное) | ответ по п. 4 «на агрегат» | diff --git a/openspec/changes/corrections-table-ui/tasks.md b/openspec/changes/corrections-table-ui/tasks.md new file mode 100644 index 0000000..747ede6 --- /dev/null +++ b/openspec/changes/corrections-table-ui/tasks.md @@ -0,0 +1,42 @@ +## 1. Типы контракта, клиент API, флаг фейка + +- [x] 1.1 Типы `src/types/corrections.ts` по контракту `docs/dev/api-contract-corrections.md` +- [x] 1.2 Клиент `src/services/api/corrections.ts` (таблица, сохранение, отмена, разнесение, экспорт) и флаг `Config.CORRECTIONS_MOCK` + +## 2. Фейк контракта и модель таблицы + +- [x] 2.1 Фейк `correctionsFake` и данные дизайна `mockData` / `mockCalc` под флагом `REACT_APP_CORRECTIONS_MOCK` +- [x] 2.2 Чистая модель таблицы `tableModel.ts`: строки, фильтры, пресет правила, итоги + +## 3. Модель окна корректировки и черновик + +- [x] 3.1 `editorModel.ts`: состояние окна, проверки ввода, контроль баланса, кандидаты для выбора строки +- [x] 3.2 Деньги копейками в `utils/money.ts`, черновик в `draftStorage.ts` + +## 4. Вкладка и таблица только на чтение + +- [x] 4.1 Вкладка «Таблица корректировки»: загрузка, ошибка с «Повторить», пусто, экспорт, полный экран +- [x] 4.2 Таблица с группами колонок, раскрытием агрегата, нераспределёнными записями, «Итого» +- [x] 4.3 Панель: отображение, пресет правила, тип записи, единицы, знаки, сброс фильтров, настройка таблицы; фильтры в заголовках + +## 5. Решения по строкам, отмена корректировки, только чтение + +- [x] 5.1 Флажок «Учитывать в КФО» и меню строки (перенос в правило, убрать из правила, вернуть к автоматике) +- [x] 5.2 Отмена корректировки с комментарием, подсказки режима «только чтение», обработка 409 + +## 6. Окно «Корректировка операции» + +- [x] 6.1 Окно: стороны, строки ОСВ, распределение корректировки, контроль баланса, комментарий, сохранение +- [x] 6.2 Выбор нераспределённой строки (picker), черновик, плашки конфликта, доступность с клавиатуры + +## 7. Сверка с дизайном, проверки, документация + +- [x] 7.1 Проход сценариев спеки в браузере на фейке и сверка частей экрана с дизайном (ширина 1920) +- [x] 7.2 `make web-check`, `npm test`, `npm run lint`, `openspec validate corrections-table-ui --strict` +- [x] 7.3 README (флаг фейка) и ARCHITECTURE (виджет `CorrectionTable`) + +## 8. Подключение к API (после слияния AURORA-1540) + +- [ ] 8.1 Влить `main`, запустить без флага на стенде на реальной версии, пройти сценарии спеки +- [ ] 8.2 Расхождения ответа с контрактом — правка фейка и типов или замечание в PR 1540; убрать фейк (`correctionsFake`, `mockData`, `mockCalc`) и флаг +- [ ] 8.3 `make check-full`, PR в `main` diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index b3a44ec..9950541 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -39,6 +39,35 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне | `NewConsoleModal/` (`CompanyOptionRow.tsx`) | [42] | «Новая консолидация»; строка ЮЛ с ИНН отдельной колонкой | | `StatusChangeModal/ChangeStatusModal.tsx` | [33-01] | «Изменить статус» (`POST …/status`); открывается из шапки `layouts/ConsolidationReportPage.tsx` | +### Виджет «Таблица корректировки» (`widgets/CorrectionTable/`) + +Требования — `../openspec/changes/corrections-table-ui/` (спека `corrections-table-ui`, решения — `design.md`). Контракт — +`../../docs/dev/api-contract-corrections.md`, типы — `src/types/corrections.ts`, вызовы — `src/services/api/corrections.ts` +(под флагом `REACT_APP_CORRECTIONS_MOCK` вместо HTTP — `correctionsFake.ts`, см. README). + +Поток данных: страница отчёта → `CorrectionContent` (вкладка: загрузка, ошибка с «Повторить», экспорт, полный экран) +→ хук `useCorrectionTable` (запрос; применяется только ответ последнего запроса, `latestOnly`) → таблица целиком хранится в +родителе; `CorrectionTable` только рисует, фильтры/раскрытие/колонки — состояние родителя. Решение по строке (флажок, +меню) → `allocate` → таблица заменяется ответом; «Корректировать» → `CorrectionEditorModal` → `saveCorrection` +(`PUT`, `expected_version_no`) → подмена одного агрегата; отмена — `RevokeModal` → `revokeCorrection`. Черновик окна — +`localStorage` (`draftStorage`). + +| Файл | Тип | Что делает | +|---|---|---| +| `tableModel.ts` | чистый | строки таблицы (агрегат, его строки ОСВ, нераспределённые), фильтры, пресет правила, итоги | +| `editorModel.ts` | чистый | состояние окна: исходное из агрегата, проверки ввода, контроль баланса, кандидаты в picker, пересборка | +| `editorView.ts` | чистый | тексты окна: ошибки сохранения, плашка черновика, строка контроля, подпись «Выбрать» | +| `readOnly.ts` | чистый | подсказки «только чтение», меню строки (`allocationMenu`), тело запроса разнесения | +| `draftStorage.ts` | чистый | черновик окна в `localStorage` (ключ: пользователь + версия + агрегат) | +| `columns.ts` | чистый | ключи и группы колонок для «Настройки таблицы» | +| `useCorrectionTable.ts` | хук | загрузка, `reload`/`replace`/`replaceAggregate` | +| `CorrectionContent.tsx`, `CorrectionToolbar.tsx`, `CorrectionTable.tsx`, `HeaderFilter.tsx` | компоненты | вкладка, панель, таблица с группами колонок, воронки в заголовках | +| `CorrectionEditorModal.tsx`, `EditorSide.tsx`, `AllocationPicker.tsx`, `RevokeModal.tsx` | компоненты | окно «Корректировка операции», сторона окна, выбор нераспределённой строки, отмена | +| `mockData.ts`, `mockCalc.ts` | фейк | данные дизайна и пересчёт агрегата; вырезаются при подключении API | +| `../../utils/money.ts` | чистый | деньги копейками (`toCents`, `percentOfSmaller`, порог 5 %) | + +Чистые модули покрыты vitest (`*.test.ts`); компоненты тестов не имеют (нет DOM-библиотеки), их проверяют в браузере. + ## Границы - Сетевые запросы — только через `src/api/client.ts`; `fetch` напрямую не вызывается. - `components/` не знают про store и API; `types/` ни от чего не зависят. diff --git a/web/README.md b/web/README.md index 9c54972..4a6b073 100644 --- a/web/README.md +++ b/web/README.md @@ -65,6 +65,16 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v «Разберите строки без кода: N» ведёт к первой); панель файлов ЮЛ показывает счётчик предупреждений файла и документа. Карта модулей — `ARCHITECTURE.md`. +## Таблица корректировок на фейке (`REACT_APP_CORRECTIONS_MOCK`) + +Вкладка «Таблица корректировки» отчёта версии ждёт API `…/corrections` (AURORA-1540). Пока его нет, фронт работает на +**фейке контракта**: `REACT_APP_CORRECTIONS_MOCK=true npm run start` — вместо HTTP-запросов вызываются функции +`src/services/api/correctionsFake.ts` с данными из дизайна 23.10 (`widgets/CorrectionTable/mockData.ts`). Состояние +фейка живёт в памяти страницы и сбрасывается перезагрузкой; решения по строкам, сохранение и отмена корректировки, +экспорт (CSV вместо xlsx) работают. Без флага (по умолчанию, в том числе в `npm run build`) фронт ходит в настоящий API. +Флаг читается при сборке из `Config.CORRECTIONS_MOCK` (`src/conf/config.ts`), но код фейка в бандл попадает всегда +(не вырезается) — см. design.md, R-21. Черновики окна корректировки лежат в `localStorage` браузера. + ## Ограничения - Нет блокировки версии («Взять в работу»), сопоставления контрагентов, экрана чек-листа, комментариев, удаления файлов, -- 2.47.2 From d43149022b65a827d476f6dc9fccda0bcc20da4d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:06:22 +0400 Subject: [PATCH 18/28] =?UTF-8?q?docs(openspec):=20=D1=80=D1=83=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=D0=B8=20R-25,=20R-26=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D1=8B=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/corrections-table-ui/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index 37942d8..1933b25 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -204,3 +204,5 @@ | 28.10.2026 | R-22: Prettier и ESLint применены к файлам задачи (`npm run lint` падал на форматировании 17 файлов, на вложенных тернарниках окна и BOM-литерале в фейке); BOM — `\uFEFF`, поведение то же | `npm run lint` — шаг проверки Task 7 | — | | 28.10.2026 | R-23: «Вернуть к автоматике» на нераспределённой строке в фейке только снимает «исключена» и не возвращает строку в правило | фейк не знает, какое правило было автоматическим; настоящее поведение — на бэкенде (1540) | аналитик на демо на фейке не увидит возврата; проверить при подключении API | | 28.10.2026 | R-24: у агрегата флажок «Учитывать в КФО» — серый неактивный индикатор «Да / Частично / Нет», а в дизайне зелёный | решение 28.10 (флажок ставится на строках, у агрегата производное) | ответ по п. 4 «на агрегат» | +| 28.10.2026 | R-25: отдельное ревью Task 7 (стиль, два мелких фикса, документация) не делалось, оно покрыто финальным ревью ветки | дифф механический, финальное ревью читает всю ветку | мелкая ошибка в Task 7 всплывёт на финальном ревью | +| 28.10.2026 | R-26: фейк (R-21) остаётся в бандле до Task 8, который его удаляет | вырезание требует динамического импорта или `define` в rsbuild, и всё равно временное | ~20 КБ в бандле до подключения API | -- 2.47.2 From f4a4f01c28aebcdd83200de8ee70b5069ae5b0e0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:15:41 +0400 Subject: [PATCH 19/28] =?UTF-8?q?fix(web):=20=D1=83=D0=B2=D0=B5=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B8=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=80=D0=B8=20=D0=B1=D0=BE=D0=BA=D1=81=D0=B0?= =?UTF-8?q?,=20=D0=B2=D0=B8=D0=B4=D0=BD=D1=8B=20=D0=B2=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=BE=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B5=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1: локальный notification.useNotification с getContainer на бокс вкладки; M-6, M-8: комментарии. Co-Authored-By: Claude Opus 5.5 --- .../CorrectionTable/CorrectionContent.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/web/src/widgets/CorrectionTable/CorrectionContent.tsx b/web/src/widgets/CorrectionTable/CorrectionContent.tsx index 135f298..ec11e24 100644 --- a/web/src/widgets/CorrectionTable/CorrectionContent.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionContent.tsx @@ -1,5 +1,6 @@ -// Вкладка «Таблица корректировки»: загрузка, состояния, панель и таблица. Правки (окно, решения по строкам) — в следующих задачах. -import { App, Button, ConfigProvider, Spin } from 'antd'; +// Вкладка «Таблица корректировки»: загрузка, состояния, панель, таблица, решения по строкам, отмена и окно корректировки. +// Данные и фильтры держит она. +import { Button, ConfigProvider, Spin, notification as antdNotification } from 'antd'; import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { ApiError } from '../../api/client'; import { useVisibleColumns } from '../../components/GenericTable/useVisibleColumns'; @@ -7,7 +8,6 @@ import { allocate, exportCorrectionTable, revokeCorrection } from '../../service import type { VersionCard } from '../../types/consolidations/version_members'; import type { AllocationIn, CorrectionAggregateOut } from '../../types/corrections'; import { saveBlob, xlsxFilename } from '../../utils/download'; -import { useNotificationApi } from '../../utils/useNotification'; import { useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay'; import { CorrectionEditorModal } from './CorrectionEditorModal'; import { CorrectionTable, type RowActions } from './CorrectionTable'; @@ -33,8 +33,17 @@ type Props = { versionId: string; card?: VersionCard | null }; export function CorrectionContent({ versionId, card = null }: Props) { const { table, loading, error, reload, replace, replaceAggregate } = useCorrectionTable(versionId); - const { notification } = App.useApp(); - const notify = useNotificationApi(); + const boxRef = useRef(null); + // уведомления рисуются внутри бокса вкладки: в полноэкранном режиме виден только полноэкранный элемент, + // глобальные уведомления за ним не видны + const [notification, holder] = antdNotification.useNotification({ getContainer: () => boxRef.current ?? document.body }); + const notify = useMemo( + () => ({ + error: ({ description }: { description: ReactNode }) => + notification.error({ title: 'Ошибка', description, placement: 'bottomRight' }), + }), + [notification], + ); // по умолчанию — «тыс. руб.», 2 знака, как в дизайне const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay({ unit: 'тыс. руб.', precision: 2 }); const [filters, setFilters] = useState(EMPTY_FILTERS); @@ -49,7 +58,6 @@ export function CorrectionContent({ versionId, card = null }: Props) { const [revoking, setRevoking] = useState(null); // ключ агрегата, окно корректировки которого открыто const [editing, setEditing] = useState(null); - const boxRef = useRef(null); // ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить» useEffect(() => { @@ -124,7 +132,8 @@ export function CorrectionContent({ versionId, card = null }: Props) { return next; }); - // ошибка правки: текст сервера в уведомлении; 409 (версию закрыли, блокировку забрали, корректировку уже изменили) — ещё перечитываем таблицу + // ошибка правки: текст сервера в уведомлении; + // 409 (версию закрыли, блокировку забрали, корректировку уже изменили) — ещё перечитываем таблицу const fail = (e: unknown, fallback: string) => { const out = errorOutcome(e, fallback); notify.error({ description: out.message }); @@ -227,6 +236,7 @@ export function CorrectionContent({ versionId, card = null }: Props) { height: fullscreen ? '100vh' : undefined, overflow: 'hidden', }}> + {holder} Date: Wed, 28 Oct 2026 22:15:41 +0400 Subject: [PATCH 20/28] =?UTF-8?q?fix(web):=20=D0=BE=D0=BA=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=E2=80=94=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=B8=D0=B7=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=20?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0,=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B5=D0=BB=20=D1=81=D1=83=D0=BC=D0=BC=D1=8B,=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=B8=D0=BD=20percentText=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-2: sourceDiff = суммы сторон, отклонение и порог из агрегата; diffOf только для распределения. M-1: целая часть суммы не более 16 цифр («Слишком большая сумма»). M-6, M-7: комментарий, один percentText. Co-Authored-By: Claude Opus 5.5 --- .../CorrectionTable/CorrectionTable.tsx | 8 +++---- .../CorrectionTable/editorModel.test.ts | 24 ++++++++++++++++++- .../widgets/CorrectionTable/editorModel.ts | 19 ++++++++++++--- web/src/widgets/CorrectionTable/editorView.ts | 4 ++-- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/web/src/widgets/CorrectionTable/CorrectionTable.tsx b/web/src/widgets/CorrectionTable/CorrectionTable.tsx index 12f058d..3d92160 100644 --- a/web/src/widgets/CorrectionTable/CorrectionTable.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionTable.tsx @@ -1,4 +1,4 @@ -// Таблица корректировок (только отображение): строки агрегатов, их строки ОСВ при раскрытии, нераспределённые, «Итого». +// Таблица корректировок: строки агрегатов, их строки ОСВ при раскрытии, нераспределённые, «Итого»; действия над строками — через `actions`. // Фильтры живут в родителе (CorrectionContent); здесь только рисуем и сообщаем о действиях через `actions`. import { DownOutlined, EditOutlined, MoreOutlined, RightOutlined, WarningOutlined } from '@ant-design/icons'; import { Button, Checkbox, Dropdown, Table, Tag, Tooltip } from 'antd'; @@ -8,11 +8,12 @@ import { type ReactNode, useMemo } from 'react'; import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections'; import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter'; import { COLUMN_DEFS, type ColKey } from './columns'; +import { percentText } from './editorView'; import { AGGREGATE_FLAG_HINT, allocationMenu, includeBody, menuBody } from './readOnly'; import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel'; import type { visibleTotals } from './tableModel'; -/** Действия над строками; их подключают задачи окна корректировки и решений по строкам. */ +/** Действия над строками: подключает CorrectionContent. */ export type RowActions = { onCorrect(a: CorrectionAggregateOut): void; onRevoke(a: CorrectionAggregateOut): void; @@ -55,9 +56,6 @@ const BG_CREDIT = '#f6f8fc'; const BG_UNALLOCATED = '#f2f4f7'; const BG_CHANGED = '#fff6e5'; -/** «394.27» → «394,27%»; строка не переводится в число. */ -const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`); - const showLine = (line: string): string => articleCodes(line).join(' / '); const sideSuffix = (row: TableRow) => { diff --git a/web/src/widgets/CorrectionTable/editorModel.test.ts b/web/src/widgets/CorrectionTable/editorModel.test.ts index 442000b..844e616 100644 --- a/web/src/widgets/CorrectionTable/editorModel.test.ts +++ b/web/src/widgets/CorrectionTable/editorModel.test.ts @@ -76,14 +76,36 @@ describe('validateLines', () => { }); }); +describe('предел суммы numeric(18,2)', () => { + const run = (amount: string) => validateLines({ debit: [{ line_code: '1230', amount }], credit: [], comment: '' }); + it('16 цифр до запятой — можно, 17 — «Слишком большая сумма»', () => { + expect(run('9999999999999999.99').ok).toBe(true); + const r = run('10000000000000000'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors['debit.0.amount']).toBe('Слишком большая сумма'); + }); +}); + describe('liveControl', () => { - it('исходные суммы агрегата ЭПТ — ИЛС: процент 394.27, красный', () => { + it('исходный контроль берётся из ответа сервера: суммы сторон, отклонение, порог', () => { const a = findBy(table(), 'ЭПТ'); const c = liveControl(a, initialEditorState(a)); + expect(c.sourceDiff).toEqual({ + left: a.side_1.amount, + right: a.side_2.amount, + difference: a.deviation_amount, + percent: a.deviation_percent, + red: a.exceeds_threshold, + }); expect(c.sourceDiff.percent).toBe('394.27'); expect(c.sourceDiff.red).toBe(true); }); + it('сервер сказал «не превышает» — не красный, что бы ни вышло из сумм', () => { + const a = { ...findBy(table(), 'ЭПТ'), exceeds_threshold: false }; + expect(liveControl(a, initialEditorState(a)).sourceDiff.red).toBe(false); + }); + it('распределение Дт 1000000.00 и Кт 1000000.00 — разница 0.00, не красный', () => { const a = findBy(table(), 'ЭПТ'); const s: EditorState = { diff --git a/web/src/widgets/CorrectionTable/editorModel.ts b/web/src/widgets/CorrectionTable/editorModel.ts index b84e175..e206baa 100644 --- a/web/src/widgets/CorrectionTable/editorModel.ts +++ b/web/src/widgets/CorrectionTable/editorModel.ts @@ -44,11 +44,14 @@ function withCents(value: string): string { return `${int}.${frac.padEnd(2, '0')}`; } +const MAX_INTEGER_DIGITS = 16; + const AMOUNT_ERROR = { empty: 'Введите сумму', spaces: 'Сумма без пробелов', format: 'Сумма: не более двух знаков после запятой', negative: 'Сумма не может быть отрицательной', + tooBig: 'Слишком большая сумма', } as const; type AmountResult = { ok: true; value: string } | { ok: false; error: string }; @@ -61,7 +64,10 @@ function parseLineAmount(raw: string): AmountResult { const parsed = parseAmountInput(text); if (!parsed.ok || parsed.value === null) return { ok: false, error: AMOUNT_ERROR.format }; if (parsed.value.startsWith('-')) return { ok: false, error: AMOUNT_ERROR.negative }; - return { ok: true, value: withCents(parsed.value) }; + const value = withCents(parsed.value); + // numeric(18,2): не более 16 цифр до запятой + if (value.split('.')[0].replace(/^0+(?=\d)/, '').length > MAX_INTEGER_DIGITS) return { ok: false, error: AMOUNT_ERROR.tooBig }; + return { ok: true, value }; } function lineError(line: EditLine, prefix: string, errors: Record): CorrectionLineIn | null { @@ -104,7 +110,7 @@ function sideSum(lines: EditLine[]): string | null { ); } -/** Разница двух сумм и процент к меньшей; красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */ +/** Разница двух сумм и процент к меньшей (для распределения статей); красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */ function diffOf(left: string | null, right: string | null): Diff { const l = left ?? '0.00'; const r = right ?? '0.00'; @@ -120,7 +126,14 @@ function diffOf(left: string | null, right: string | null): Diff { /** Живой контроль окна: исходные суммы сторон агрегата и распределение статей из текущего состояния редактора. */ export function liveControl(a: CorrectionAggregateOut, s: EditorState): { sourceDiff: Diff; allocationDiff: Diff } { return { - sourceDiff: diffOf(a.side_1.amount, a.side_2.amount), + // исходный контроль — как посчитал сервер (те же цифры, что в таблице), распределение — живой пересчёт + sourceDiff: { + left: a.side_1.amount, + right: a.side_2.amount, + difference: a.deviation_amount, + percent: a.deviation_percent, + red: a.exceeds_threshold, + }, allocationDiff: diffOf(sideSum(s.debit), sideSum(s.credit)), }; } diff --git a/web/src/widgets/CorrectionTable/editorView.ts b/web/src/widgets/CorrectionTable/editorView.ts index fa46fb9..619b3ed 100644 --- a/web/src/widgets/CorrectionTable/editorView.ts +++ b/web/src/widgets/CorrectionTable/editorView.ts @@ -17,8 +17,8 @@ export const formatRub = (value: string | null): string => formatScaledAmount(va export const pickLabel = (counterparty: string, account: string, amount: string): string => `Выбрать: ${counterparty}, ${account}, ${formatRub(amount)}`; -/** «394.27» → «394,27%»; строка в число не переводится. */ -export const percentText = (p: string): string => `${p.replace('.', ',')}%`; +/** «394.27» → «394,27%»; строка в число не переводится; нет значения — «—». */ +export const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`); export type ControlText = { left: string; middle: string; right: string; red: boolean }; -- 2.47.2 From b26c1d258101b474fa05a7487bdd8ac52835e64b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:15:41 +0400 Subject: [PATCH 21/28] =?UTF-8?q?fix(web):=20=D0=BA=D0=BB=D1=8E=D1=87=20?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=D0=B0=D1=81=D0=BF=D1=80=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=91=D0=BD=D0=BD=D0=BE=D0=B9=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=BD=D1=83=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-3: u||; тест на две позиции с одинаковыми entry_ids. Co-Authored-By: Claude Opus 5.5 --- web/src/widgets/CorrectionTable/tableModel.test.ts | 13 +++++++++++++ web/src/widgets/CorrectionTable/tableModel.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/web/src/widgets/CorrectionTable/tableModel.test.ts b/web/src/widgets/CorrectionTable/tableModel.test.ts index bf76e37..1f019d2 100644 --- a/web/src/widgets/CorrectionTable/tableModel.test.ts +++ b/web/src/widgets/CorrectionTable/tableModel.test.ts @@ -113,6 +113,19 @@ describe('buildRows и итоги', () => { expect(rows.filter((r) => r.kind === 'entry')).toHaveLength(8); expect(rows.filter((r) => r.kind === 'unallocated')).toHaveLength(t.unallocated.length); }); + it('две позиции нераспределённой строки (Д и К) с одинаковыми entry_ids — разные ключи', () => { + const t = table(); + const base = t.unallocated[0]; + t.unallocated = [ + { ...base, side: 'D', entry_ids: ['e1'] }, + { ...base, side: 'K', entry_ids: ['e1'] }, + ]; + const keys = buildRows(filterTable(t, { ...EMPTY_FILTERS, recordType: 'unallocated' }), new Set()) + .filter((r) => r.kind === 'unallocated') + .map((r) => r.key); + expect(keys).toHaveLength(2); + expect(new Set(keys).size).toBe(2); + }); it('visibleTotals по ЭПТ — ИЛС и правилу 3', () => { const t = table(); const picked = t.aggregates.filter((a) => a.company_1.name === 'ЭПТ' || a.rule_id === 'r3'); diff --git a/web/src/widgets/CorrectionTable/tableModel.ts b/web/src/widgets/CorrectionTable/tableModel.ts index 92b5739..a861b66 100644 --- a/web/src/widgets/CorrectionTable/tableModel.ts +++ b/web/src/widgets/CorrectionTable/tableModel.ts @@ -130,7 +130,7 @@ export function buildRows(view: ReturnType, expanded: Readon rows.push({ kind: 'entry', key: `${key}|${side}|${entry.entry_ids.join(',')}`, parentKey: key, side, entry }); } } - for (const entry of view.unallocated) rows.push({ kind: 'unallocated', key: `u|${entry.entry_ids.join(',')}`, entry }); + for (const entry of view.unallocated) rows.push({ kind: 'unallocated', key: `u|${entry.side}|${entry.entry_ids.join(',')}`, entry }); return rows; } -- 2.47.2 From 09685db0e8a79f2dbdeb2aa86c7c474ab2c0419d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:15:41 +0400 Subject: [PATCH 22/28] =?UTF-8?q?fix(web):=20=D0=B8=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8=20=D0=B2=20=C2=AB=D0=98=D1=81=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D1=8B=D1=85=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=9E=D0=A1=D0=92=C2=BB=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB?= =?UTF-8?q?=D1=83=D1=88=D0=B5=D0=BD=D1=8B=20=D0=B8=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D1=8B=20=C2=AB=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B2=20=D0=9A=D0=A4=D0=9E=C2=BB=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M-4, M-8. Co-Authored-By: Claude Opus 5.5 --- web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx | 3 ++- web/src/widgets/CorrectionTable/EditorSide.tsx | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx index 8573f23..96fd51d 100644 --- a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx @@ -107,7 +107,8 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable prevInitial.current = initial; if (next) { setState(withIds(next)); - // правок не было — окно целиком на новой редакции, база черновика тоже; иначе при следующем открытии плашка «изменили после ваших правок» ложная + // правок не было — окно целиком на новой редакции, база черновика тоже; + // иначе при следующем открытии плашка «изменили после ваших правок» ложная setBase(aggregate?.correction?.version_no ?? null); } }, [initial]); diff --git a/web/src/widgets/CorrectionTable/EditorSide.tsx b/web/src/widgets/CorrectionTable/EditorSide.tsx index 4f7e7c1..2317125 100644 --- a/web/src/widgets/CorrectionTable/EditorSide.tsx +++ b/web/src/widgets/CorrectionTable/EditorSide.tsx @@ -68,8 +68,11 @@ export function OsvBlock(p: OsvProps) { )} {p.entries.map((e) => ( - - {e.account_code} + + + {e.account_code} + {e.excluded && не в КФО} + {p.article} {formatRub(e.amount)} {p.editable && ( -- 2.47.2 From 753eccbd9791324158b721012e91b644648620bd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:16:14 +0400 Subject: [PATCH 23/28] =?UTF-8?q?docs(openspec):=20=D1=80=D1=83=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=D0=B8=20R-27,=20R-28,=20=D1=87=D0=B5=D0=BA-?= =?UTF-8?q?=D0=BB=D0=B8=D1=81=D1=82=20Task=208,=20=D1=83=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B8=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-4, M-3, M-9, M-10, M-11. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-28-corrections-table-ui.md | 2 ++ openspec/changes/corrections-table-ui/design.md | 6 ++++-- openspec/changes/corrections-table-ui/proposal.md | 2 +- openspec/changes/corrections-table-ui/tasks.md | 12 +++++++++--- web/ARCHITECTURE.md | 2 +- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-10-28-corrections-table-ui.md b/docs/superpowers/plans/2026-10-28-corrections-table-ui.md index 8bc1599..a327d97 100644 --- a/docs/superpowers/plans/2026-10-28-corrections-table-ui.md +++ b/docs/superpowers/plans/2026-10-28-corrections-table-ui.md @@ -336,3 +336,5 @@ RSHB/arm-core#206, Jira AURORA-1541. (`../docs/dev/qa-guide.md`) на реальной версии; пройти сценарии spec; расхождения ответа с контрактом — правка в фейке и типах или замечание в PR 1540; `make check-full`; PR в `main` (до этого PR не открывать — фронт без API не сливаем, если владелец не решит иначе). + +Уточнение (R-27): в Task 8 удаляются только `services/api/correctionsFake.ts` (+ `correctionsFake.test.ts`) и ветка флага `CORRECTIONS_MOCK` в `corrections.ts`/`config.ts`; `widgets/CorrectionTable/mockData.ts` и `mockCalc.ts` остаются как тестовые фикстуры — их импортируют тесты моделей. diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index 1933b25..aa999c8 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -73,7 +73,7 @@ вся таблица), `GET …/export`. - Клиент — `web/src/services/api/corrections.ts` поверх `api/client.ts`. При `REACT_APP_CORRECTIONS_MOCK=true` те же функции отвечают из фейка контракта в памяти (данные — из дизайна 23.10, с проверками контракта: обязательный комментарий, - `expected_version_no`, применимость правила). Флаг по умолчанию выключен; в production-сборке фейк не используется. + `expected_version_no`, применимость правила). Флаг по умолчанию выключен; фейк включается только переменной окружения, но код фейка остаётся в бандле (R-21, R-26). ### Таблица @@ -131,7 +131,7 @@ 79,77 % из дизайна — ошибка дизайна, п. 7). Расхождение не запрещает сохранение (ждёт п. 8). - **Сохранить** — `PUT` с `status = APPLIED`, `expected_version_no` из ответа; ошибки сервера показываются в окне по коду (`COMMENT_REQUIRED` — у поля комментария, `INVALID_LINE_CODE` — у статьи, `CORRECTION_VERSION_CONFLICT` — «Корректировку уже - изменили, таблица обновлена», окно перечитывает данные). + изменили, данные обновлены», окно перечитывает данные). - **Черновик окна в `localStorage`** (решение владельца 28.10.2026 вместо блокировки переходов): статьи распределения и комментарий сохраняются при каждой правке; ключ — пользователь + версия + агрегат (`rule_id`, компании), в записи — время и `version_no` корректировки, от которой начаты правки. Переход «Назад», закрытие вкладки, переход по ссылке правки не @@ -206,3 +206,5 @@ | 28.10.2026 | R-24: у агрегата флажок «Учитывать в КФО» — серый неактивный индикатор «Да / Частично / Нет», а в дизайне зелёный | решение 28.10 (флажок ставится на строках, у агрегата производное) | ответ по п. 4 «на агрегат» | | 28.10.2026 | R-25: отдельное ревью Task 7 (стиль, два мелких фикса, документация) не делалось, оно покрыто финальным ревью ветки | дифф механический, финальное ревью читает всю ветку | мелкая ошибка в Task 7 всплывёт на финальном ревью | | 28.10.2026 | R-26: фейк (R-21) остаётся в бандле до Task 8, который его удаляет | вырезание требует динамического импорта или `define` в rsbuild, и всё равно временное | ~20 КБ в бандле до подключения API | +| 28.10.2026 | R-27: `mockData` и `mockCalc` остаются как тестовые фикстуры (их импортируют тесты `tableModel`, `editorModel`, `readOnly`, `draftStorage`); Task 8 удаляет только `correctionsFake.ts` (+ его тест) и ветку флага в `corrections.ts`. Уточняет R-5, R-21, R-26: «вырезается» относится только к фейку контракта `correctionsFake` | иначе при подключении API пришлось бы переписывать тесты моделей | если фикстуры устареют относительно контракта — тесты моделей перестанут ловить расхождения; сверять при Task 8 | +| 28.10.2026 | R-28: окно выбора нераспределённой строки (picker) не показывает исключённые (`excluded`) — исключённую сначала возвращают в учёт флажком в таблице | исключённая строка сознательно убрана из КФО; разнесение её в правило молча вернуло бы её в расчёт | аналитику нужен лишний шаг; если захотят выбирать исключённые — снять условие `!u.excluded` в `pickerCandidates` | diff --git a/openspec/changes/corrections-table-ui/proposal.md b/openspec/changes/corrections-table-ui/proposal.md index 5f21d30..44473fc 100644 --- a/openspec/changes/corrections-table-ui/proposal.md +++ b/openspec/changes/corrections-table-ui/proposal.md @@ -53,7 +53,7 @@ AURORA-1395 и в AURORA-1396. - `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx` — вкладка `correction` без `disabled`. - `web/src/layouts/ConsolidationReportPage.tsx` — содержимое вкладки `correction`. - Новое: `web/src/widgets/CorrectionTable/` (таблица, панель, окно корректировки, чистые функции + тесты), - `web/src/services/api/corrections.ts`, типы контракта в `web/src/types/api.ts` (или `types/corrections.ts`), фейк контракта. + `web/src/services/api/corrections.ts`, типы контракта в `web/src/types/corrections.ts`, фейк контракта. - `web/src/conf/config.ts` — флаг `REACT_APP_CORRECTIONS_MOCK`. - Таблицы DBML v5.23: `correction`, `elimination_rule`, `osv_entry` — только читаются через API; новых полей фронт не вводит. - Правила: `05-domain-rules.md` §8 (корректировки) в нынешнем виде описывает только запись корректировки; разнесение строк и diff --git a/openspec/changes/corrections-table-ui/tasks.md b/openspec/changes/corrections-table-ui/tasks.md index 747ede6..996159c 100644 --- a/openspec/changes/corrections-table-ui/tasks.md +++ b/openspec/changes/corrections-table-ui/tasks.md @@ -31,12 +31,18 @@ ## 7. Сверка с дизайном, проверки, документация -- [x] 7.1 Проход сценариев спеки в браузере на фейке и сверка частей экрана с дизайном (ширина 1920) +- [x] 7.1 Проход сценариев спеки в браузере на фейке (ширина 1920, headless Chrome по CDP) и сверка частей экрана с дизайном. Проверено: таблица и группы колонок, панель, фильтры в заголовках, раскрытие, нераспределённые, пресет, настройка таблицы, окно корректировки, решения по строкам (в т.ч. повторный INCLUDE), «Убрать из правила», ошибка загрузки с «Повторить», экспорт, `composition_changed` и «черновик». Не проверено — вынесено в 8.1: полный экран, 409 / `CORRECTION_VERSION_CONFLICT`, недоступный `localStorage`, «Вернуть к автоматике» внутри агрегата, реальный API - [x] 7.2 `make web-check`, `npm test`, `npm run lint`, `openspec validate corrections-table-ui --strict` - [x] 7.3 README (флаг фейка) и ARCHITECTURE (виджет `CorrectionTable`) ## 8. Подключение к API (после слияния AURORA-1540) -- [ ] 8.1 Влить `main`, запустить без флага на стенде на реальной версии, пройти сценарии спеки -- [ ] 8.2 Расхождения ответа с контрактом — правка фейка и типов или замечание в PR 1540; убрать фейк (`correctionsFake`, `mockData`, `mockCalc`) и флаг +- [ ] 8.1 Влить `main`, запустить без флага на стенде на реальной версии, пройти сценарии спеки. Отдельно проверить то, что не проверено на фейке: + - полноэкранный режим (уведомления внутри бокса, выпадающие списки); + - 409 и `CORRECTION_VERSION_CONFLICT` в окне и при решении по строке; + - недоступный `localStorage` (приватное окно): окно корректировки работает без черновика; + - «Вернуть к автоматике» на строке внутри агрегата (на нераспределённой фейк не возвращает строку в правило, R-23); + - сценарии спеки, которых нет на данных фейка, — на реальных данных: Сатурн — Архол; Бековский с 62.01 / 60.02 / 62.03 / 76.01; ПМП — Бековский комбинат для «Добавить строку ОСВ»; + - `allocate` с несколькими `entry_ids` и код `VALIDATION_ERROR` — фейк их не воспроизводит (M-10) +- [ ] 8.2 Расхождения ответа с контрактом — правка фейка и типов или замечание в PR 1540; убрать фейк контракта (`correctionsFake.ts` и его тест) и ветку флага в `corrections.ts`; `mockData` и `mockCalc` остаются тестовыми фикстурами (R-27) - [ ] 8.3 `make check-full`, PR в `main` diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index 9950541..84633d9 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -63,7 +63,7 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне | `useCorrectionTable.ts` | хук | загрузка, `reload`/`replace`/`replaceAggregate` | | `CorrectionContent.tsx`, `CorrectionToolbar.tsx`, `CorrectionTable.tsx`, `HeaderFilter.tsx` | компоненты | вкладка, панель, таблица с группами колонок, воронки в заголовках | | `CorrectionEditorModal.tsx`, `EditorSide.tsx`, `AllocationPicker.tsx`, `RevokeModal.tsx` | компоненты | окно «Корректировка операции», сторона окна, выбор нераспределённой строки, отмена | -| `mockData.ts`, `mockCalc.ts` | фейк | данные дизайна и пересчёт агрегата; вырезаются при подключении API | +| `mockData.ts`, `mockCalc.ts` | фикстуры | данные дизайна и пересчёт агрегата; остаются как тестовые фикстуры (R-27); фейк контракта `services/api/correctionsFake.ts` удаляется в Task 8 | | `../../utils/money.ts` | чистый | деньги копейками (`toCents`, `percentOfSmaller`, порог 5 %) | Чистые модули покрыты vitest (`*.test.ts`); компоненты тестов не имеют (нет DOM-библиотеки), их проверяют в браузере. -- 2.47.2 From 4db04f096a12fe4dffb8c311584f51dc5ca72293 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:36:02 +0400 Subject: [PATCH 24/28] =?UTF-8?q?=D0=A1=D1=82=D0=BE=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D0=B8=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B5=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AllocationIn получил side: строка с дебетовым и кредитовым сальдо даёт две позиции, решение касается одной. Флажок, меню строки, окно корректировки и фейк передают и ищут позицию по строке и стороне. Контракт — docs PR #184. Co-Authored-By: Claude Opus 5.5 --- web/src/services/api/correctionsFake.test.ts | 55 ++++++++++++++----- web/src/services/api/correctionsFake.ts | 12 ++-- web/src/types/corrections.ts | 2 + .../CorrectionTable/CorrectionEditorModal.tsx | 11 +++- .../CorrectionTable/CorrectionTable.tsx | 2 +- .../widgets/CorrectionTable/readOnly.test.ts | 25 +++++++-- web/src/widgets/CorrectionTable/readOnly.ts | 8 +-- 7 files changed, 86 insertions(+), 29 deletions(-) diff --git a/web/src/services/api/correctionsFake.test.ts b/web/src/services/api/correctionsFake.test.ts index ea83656..66ff6e8 100644 --- a/web/src/services/api/correctionsFake.test.ts +++ b/web/src/services/api/correctionsFake.test.ts @@ -47,7 +47,7 @@ describe('correctionsFake', () => { const before = find(table0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"'); const row = before.side_1.entries.find((e) => e.account_code === '62.03'); if (!row) throw new Error('нет строки'); - const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'EXCLUDE' }); + const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'EXCLUDE' }); const after = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"'); expect(after.side_1.amount).toBe('1325000.00'); expect(after.side_1.entries.find((e) => e.account_code === '62.03')?.excluded).toBe(true); @@ -58,7 +58,12 @@ describe('correctionsFake', () => { it('EXCLUDE в агрегате с корректировкой отмечает смену состава', async () => { const t0 = await fake.getCorrectionTable(V); const ept = find(t0, 'ЭПТ'); - const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: ept.side_1.entries[0].entry_ids, action: 'EXCLUDE' }); + const t = await fake.allocate(V, { + statement_type: 'balance', + entry_ids: ept.side_1.entries[0].entry_ids, + side: 'D', + action: 'EXCLUDE', + }); expect(find(t, 'ЭПТ').composition_changed).toBe(true); }); @@ -67,7 +72,7 @@ describe('correctionsFake', () => { const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries.find((e) => e.account_code === '62.03'); if (!row) throw new Error('нет строки'); await expectCode( - fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'ASSIGN', rule_id: 'r1' }), + fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'ASSIGN', rule_id: 'r1' }), 422, 'RULE_NOT_APPLICABLE', ); @@ -76,7 +81,7 @@ describe('correctionsFake', () => { it('ASSIGN нераспределённой строки в правило создаёт агрегат, UNASSIGN возвращает строку', async () => { const t0 = await fake.getCorrectionTable(V); const u = t0.unallocated[0]; - const t1 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, action: 'ASSIGN', rule_id: 'r7' }); + const t1 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, side: u.side, action: 'ASSIGN', rule_id: 'r7' }); expect(t1.unallocated).toHaveLength(t0.unallocated.length - 1); // строка кредитовая (K): владелец — компания 2, контрагент — компания 1, строка в side_2 expect(u.side).toBe('K'); @@ -85,22 +90,40 @@ describe('correctionsFake', () => { expect(created?.company_1.name).toBe('ПМП ООО'); expect(created?.side_2.amount).toBe(u.amount); expect(created?.side_1.entries).toEqual([]); - const t2 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, action: 'UNASSIGN' }); + const t2 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, side: u.side, action: 'UNASSIGN' }); expect(t2.aggregates.find((a) => a.rule_id === 'r7')).toBeUndefined(); expect(t2.unallocated.find((x) => x.entry_ids[0] === u.entry_ids[0])?.reason).toBe('UNASSIGNED'); }); it('ASSIGN без rule_id и неизвестная строка', async () => { const u = (await fake.getCorrectionTable(V)).unallocated[0]; - await expectCode(fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, action: 'ASSIGN' }), 422, 'RULE_REQUIRED'); - await expectCode(fake.allocate(V, { statement_type: 'balance', entry_ids: ['нет'], action: 'EXCLUDE' }), 422, 'ENTRY_NOT_IN_VERSION'); + await expectCode( + fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, side: u.side, action: 'ASSIGN' }), + 422, + 'RULE_REQUIRED', + ); + await expectCode( + fake.allocate(V, { statement_type: 'balance', entry_ids: ['нет'], side: 'D', action: 'EXCLUDE' }), + 422, + 'ENTRY_NOT_IN_VERSION', + ); + }); + + it('позиция ищется по строке и стороне: другая сторона той же строки — ENTRY_NOT_IN_VERSION', async () => { + const u = (await fake.getCorrectionTable(V)).unallocated[0]; + const other = u.side === 'D' ? 'K' : 'D'; + await expectCode( + fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, side: other, action: 'EXCLUDE' }), + 422, + 'ENTRY_NOT_IN_VERSION', + ); }); it('allocate атомарен: ошибка на второй строке откатывает первую', async () => { const t = await fake.getCorrectionTable(V); const first = t.unallocated[0].entry_ids[0]; await expectCode( - fake.allocate(V, { statement_type: 'balance', entry_ids: [first, 'нет'], action: 'EXCLUDE' }), + fake.allocate(V, { statement_type: 'balance', entry_ids: [first, 'нет'], side: t.unallocated[0].side, action: 'EXCLUDE' }), 422, 'ENTRY_NOT_IN_VERSION', ); @@ -111,7 +134,13 @@ describe('correctionsFake', () => { const t = await fake.getCorrectionTable(V); const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries.find((e) => e.account_code === '62.03'); if (!row) throw new Error('нет строки'); - const moved = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'ASSIGN', rule_id: 'r7' }); + const moved = await fake.allocate(V, { + statement_type: 'balance', + entry_ids: row.entry_ids, + side: row.side, + action: 'ASSIGN', + rule_id: 'r7', + }); const target = moved.aggregates.find((a) => a.rule_id === 'r7'); const e = target?.side_1.entries[0]; expect(e?.decision).toBe('ASSIGN'); @@ -122,11 +151,11 @@ describe('correctionsFake', () => { it('INCLUDE возвращает строку, RESET сбрасывает решение', async () => { const t = await fake.getCorrectionTable(V); const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries[0]; - await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'EXCLUDE' }); - const back = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'INCLUDE' }); + await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'EXCLUDE' }); + const back = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'INCLUDE' }); expect(find(back, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.amount).toBe('1675000.00'); - await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'EXCLUDE' }); - const reset = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'RESET' }); + await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'EXCLUDE' }); + const reset = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'RESET' }); expect(find(reset, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries[0].excluded).toBe(false); }); diff --git a/web/src/services/api/correctionsFake.ts b/web/src/services/api/correctionsFake.ts index d4bc81c..c02e2d0 100644 --- a/web/src/services/api/correctionsFake.ts +++ b/web/src/services/api/correctionsFake.ts @@ -10,6 +10,7 @@ import type { CorrectionRevokeIn, CorrectionTableOut, Ref, + Side, UnallocatedEntryOut, } from '../../types/corrections'; import { computeTotals, recomputeAggregate } from '../../widgets/CorrectionTable/mockCalc'; @@ -49,16 +50,17 @@ type Located = | { where: 'aggregate'; aggregate: CorrectionAggregateOut; sideNo: 1 | 2; entry: CorrectionEntryOut } | { where: 'unallocated'; item: UnallocatedEntryOut }; -function locate(id: string): Located { +/** Позиция — строка свода и сторона: у строки с обоими сальдо две позиции, решение касается одной. */ +function locate(id: string, side: Side): Located { for (const aggregate of state.aggregates) { for (const sideNo of [1, 2] as const) { - const entry = (sideNo === 1 ? aggregate.side_1 : aggregate.side_2).entries.find((e) => e.entry_ids.includes(id)); + const entry = (sideNo === 1 ? aggregate.side_1 : aggregate.side_2).entries.find((e) => e.side === side && e.entry_ids.includes(id)); if (entry) return { where: 'aggregate', aggregate, sideNo, entry }; } } - const item = state.unallocated.find((u) => u.entry_ids.includes(id)); + const item = state.unallocated.find((u) => u.side === side && u.entry_ids.includes(id)); if (item) return { where: 'unallocated', item }; - return fail(422, 'ENTRY_NOT_IN_VERSION', 'Строки нет среди строк версии'); + return fail(422, 'ENTRY_NOT_IN_VERSION', 'Позиции нет среди строк версии'); } function refOf(a: CorrectionAggregateOut, id: string): Ref { @@ -152,7 +154,7 @@ function assign(found: Located, ruleId: string): void { } function applyAction(id: string, body: AllocationIn): void { - const found = locate(id); + const found = locate(id, body.side); const flag = (excluded: boolean) => { if (found.where === 'unallocated') found.item.excluded = excluded; else { diff --git a/web/src/types/corrections.ts b/web/src/types/corrections.ts index 5b1365f..f72cff4 100644 --- a/web/src/types/corrections.ts +++ b/web/src/types/corrections.ts @@ -163,6 +163,8 @@ export type AllocationAction = 'ASSIGN' | 'UNASSIGN' | 'EXCLUDE' | 'INCLUDE' | ' export interface AllocationIn { statement_type: StatementType; entry_ids: string[]; + /** Позиция строки: строка с дебетовым и кредитовым сальдо даёт две позиции, решение — по одной из них. */ + side: Side; action: AllocationAction; /** Обязателен для ASSIGN. */ rule_id?: string | null; diff --git a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx index 96fd51d..4a55e00 100644 --- a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx @@ -188,10 +188,17 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable } }; - const unassign = (e: CorrectionEntryOut) => runAllocate({ statement_type: 'balance', entry_ids: e.entry_ids, action: 'UNASSIGN' }); + const unassign = (e: CorrectionEntryOut) => + runAllocate({ statement_type: 'balance', entry_ids: e.entry_ids, side: e.side, action: 'UNASSIGN' }); const assign = async (u: UnallocatedEntryOut) => { if (!aggregate) return; - const ok = await runAllocate({ statement_type: 'balance', entry_ids: u.entry_ids, action: 'ASSIGN', rule_id: aggregate.rule_id }); + const ok = await runAllocate({ + statement_type: 'balance', + entry_ids: u.entry_ids, + side: u.side, + action: 'ASSIGN', + rule_id: aggregate.rule_id, + }); if (ok) setPickerSide(null); }; diff --git a/web/src/widgets/CorrectionTable/CorrectionTable.tsx b/web/src/widgets/CorrectionTable/CorrectionTable.tsx index 3d92160..e6d56fb 100644 --- a/web/src/widgets/CorrectionTable/CorrectionTable.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionTable.tsx @@ -212,7 +212,7 @@ export function CorrectionTable(props: Props) { disabled={!canAct} checked={!e.excluded} aria-label={`Учитывать в КФО: ${e.account_code}`} - onChange={(ev) => actions.onAllocate(includeBody(e.entry_ids, ev.target.checked))} + onChange={(ev) => actions.onAllocate(includeBody(e.entry_ids, e.side, ev.target.checked))} />, ); case 'd_entity': diff --git a/web/src/widgets/CorrectionTable/readOnly.test.ts b/web/src/widgets/CorrectionTable/readOnly.test.ts index c95f394..293ab95 100644 --- a/web/src/widgets/CorrectionTable/readOnly.test.ts +++ b/web/src/widgets/CorrectionTable/readOnly.test.ts @@ -95,15 +95,32 @@ describe('includeBody / menuBody', () => { const un: TableRow = { kind: 'unallocated', key: 'u', entry: t.unallocated[0] }; it('флажок: поставлен — INCLUDE, снят — EXCLUDE, entry_ids из строки', () => { - expect(includeBody(entry.entry_ids, true)).toEqual({ statement_type: 'balance', entry_ids: entry.entry_ids, action: 'INCLUDE' }); - expect(includeBody(entry.entry_ids, false).action).toBe('EXCLUDE'); + expect(includeBody(entry.entry_ids, entry.side, true)).toEqual({ + statement_type: 'balance', + entry_ids: entry.entry_ids, + side: entry.side, + action: 'INCLUDE', + }); + expect(includeBody(entry.entry_ids, entry.side, false).action).toBe('EXCLUDE'); }); it('ASSIGN с правилом из ключа подменю', () => { - expect(menuBody(row, 'ASSIGN:r6')).toEqual({ statement_type: 'balance', entry_ids: entry.entry_ids, action: 'ASSIGN', rule_id: 'r6' }); + expect(menuBody(row, 'ASSIGN:r6')).toEqual({ + statement_type: 'balance', + entry_ids: entry.entry_ids, + side: entry.side, + action: 'ASSIGN', + rule_id: 'r6', + }); expect(menuBody(un, 'ASSIGN:r7')?.entry_ids).toEqual(t.unallocated[0].entry_ids); + expect(menuBody(un, 'ASSIGN:r7')?.side).toBe(t.unallocated[0].side); }); it('UNASSIGN и RESET без rule_id', () => { - expect(menuBody(row, 'UNASSIGN')).toEqual({ statement_type: 'balance', entry_ids: entry.entry_ids, action: 'UNASSIGN' }); + expect(menuBody(row, 'UNASSIGN')).toEqual({ + statement_type: 'balance', + entry_ids: entry.entry_ids, + side: entry.side, + action: 'UNASSIGN', + }); expect(menuBody(row, 'RESET')?.action).toBe('RESET'); }); it('родитель подменю, пустое правило, неизвестный ключ и агрегат — запроса нет', () => { diff --git a/web/src/widgets/CorrectionTable/readOnly.ts b/web/src/widgets/CorrectionTable/readOnly.ts index 3a26b68..794cae4 100644 --- a/web/src/widgets/CorrectionTable/readOnly.ts +++ b/web/src/widgets/CorrectionTable/readOnly.ts @@ -1,6 +1,6 @@ // Чистые функции для режима «только чтение» и меню решений по строке (без React — тестируются отдельно). import { ApiError } from '../../api/client'; -import type { AllocationAction, AllocationIn, CorrectionRuleOut, ReadOnlyReason } from '../../types/corrections'; +import type { AllocationAction, AllocationIn, CorrectionRuleOut, ReadOnlyReason, Side } from '../../types/corrections'; import type { TableRow } from './tableModel'; /** Подсказки для режима «только чтение» (дословно из спеки). */ @@ -41,8 +41,8 @@ export const COMMENT_MAX_LENGTH = 1000; export const AGGREGATE_FLAG_HINT = 'Показывает состояние строк; менять — в раскрытии'; /** Тело запроса для флажка «Учитывать в КФО» строки: поставлен — INCLUDE, снят — EXCLUDE. */ -export function includeBody(entryIds: string[], checked: boolean): AllocationIn { - return { statement_type: 'balance', entry_ids: entryIds, action: checked ? 'INCLUDE' : 'EXCLUDE' }; +export function includeBody(entryIds: string[], side: Side, checked: boolean): AllocationIn { + return { statement_type: 'balance', entry_ids: entryIds, side, action: checked ? 'INCLUDE' : 'EXCLUDE' }; } /** @@ -51,7 +51,7 @@ export function includeBody(entryIds: string[], checked: boolean): AllocationIn */ export function menuBody(row: TableRow, key: string): AllocationIn | null { if (row.kind === 'aggregate') return null; - const base = { statement_type: 'balance' as const, entry_ids: row.entry.entry_ids }; + const base = { statement_type: 'balance' as const, entry_ids: row.entry.entry_ids, side: row.entry.side }; if (key.startsWith('ASSIGN:')) { const ruleId = key.slice('ASSIGN:'.length); return ruleId ? { ...base, action: 'ASSIGN', rule_id: ruleId } : null; -- 2.47.2 From d8af058f75a3c0755f2ecfc29ade9a94be94bf62 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 23:17:34 +0400 Subject: [PATCH 25/28] =?UTF-8?q?feat(web):=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20=D0=BF=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=8B=20=D0=9B=D1=8B=D1=81=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE=2029.10=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - отзыв корректировки убран (RevokeModal, revokeCorrection, пункт меню агрегата); `status` из тела PUT убран — сервер сразу применяет (R-29, R-30); - «Учитывать в КФО»: у агрегата отмечен и закреплён, у строк агрегата флажка нет, у нераспределённых — EXCLUDE/INCLUDE; `excluded` у строки агрегата убран (R-31, R-32); - у нераспределённой — строка баланса владельца `line_code`, фильтр «Строка баланса» действует и на неё (R-33); - фейк: ENTRY_ALLOCATED для EXCLUDE/INCLUDE строки агрегата, ENTRY_EXCLUDED для ASSIGN исключённой; «Убрать из правила» отдаёт строку с line_code; - спека, design.md (R-29…R-34), tasks §9, план Task 9, README/ARCHITECTURE. Источник: AURORA-1395 #9920570, WIP API arm-core #208. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-28-corrections-table-ui.md | 8 ++ .../changes/corrections-table-ui/design.md | 29 ++--- .../changes/corrections-table-ui/proposal.md | 4 +- .../specs/corrections-table-ui/spec.md | 64 +++++------ .../changes/corrections-table-ui/tasks.md | 8 ++ web/ARCHITECTURE.md | 8 +- web/README.md | 2 +- web/src/services/api/corrections.ts | 6 -- web/src/services/api/correctionsFake.test.ts | 102 +++++++++++------- web/src/services/api/correctionsFake.ts | 38 +++---- web/src/types/corrections.ts | 12 +-- .../CorrectionTable/CorrectionContent.tsx | 49 ++------- .../CorrectionTable/CorrectionTable.tsx | 44 +++----- .../widgets/CorrectionTable/EditorSide.tsx | 9 +- .../widgets/CorrectionTable/RevokeModal.tsx | 49 --------- .../CorrectionTable/editorModel.test.ts | 5 +- .../widgets/CorrectionTable/editorModel.ts | 4 +- web/src/widgets/CorrectionTable/mockCalc.ts | 2 +- web/src/widgets/CorrectionTable/mockData.ts | 15 +-- .../widgets/CorrectionTable/readOnly.test.ts | 4 +- web/src/widgets/CorrectionTable/readOnly.ts | 8 +- .../CorrectionTable/tableModel.test.ts | 28 +++-- web/src/widgets/CorrectionTable/tableModel.ts | 19 ++-- 23 files changed, 215 insertions(+), 302 deletions(-) delete mode 100644 web/src/widgets/CorrectionTable/RevokeModal.tsx diff --git a/docs/superpowers/plans/2026-10-28-corrections-table-ui.md b/docs/superpowers/plans/2026-10-28-corrections-table-ui.md index a327d97..26d5dd4 100644 --- a/docs/superpowers/plans/2026-10-28-corrections-table-ui.md +++ b/docs/superpowers/plans/2026-10-28-corrections-table-ui.md @@ -338,3 +338,11 @@ RSHB/arm-core#206, Jira AURORA-1541. владелец не решит иначе). Уточнение (R-27): в Task 8 удаляются только `services/api/correctionsFake.ts` (+ `correctionsFake.test.ts`) и ветка флага `CORRECTIONS_MOCK` в `corrections.ts`/`config.ts`; `widgets/CorrectionTable/mockData.ts` и `mockCalc.ts` остаются как тестовые фикстуры — их импортируют тесты моделей. + +### Task 9 (29.10, вне исходного плана): ответы Лысенко и WIP API 1540 + +Лысенко ответил на `[design-23-10-check]` (AURORA-1395, #9920570), Илья выложил WIP API (arm-core #208). Сделано сессией +сразу, без SDD: отзыв корректировки и `status` в `PUT` убраны; «Учитывать в КФО» — только у нераспределённых, агрегат +закреплён; у нераспределённой — строка баланса владельца `line_code`; фейк отвечает `ENTRY_ALLOCATED` / `ENTRY_EXCLUDED`. +Пункты — `openspec/changes/corrections-table-ui/tasks.md` §9, рулинги R-29…R-34 в `design.md`. Тексты «Отменить +корректировку» и «Да» / «Частично» / «Нет» из Global Constraints больше не используются. Контракт docs — отдельный PR. diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index aa999c8..eeba600 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -37,8 +37,7 @@ - Таблица агрегатов с раскрытием до строк ОСВ, нераспределённые строки, итоги, фильтры, пресеты по правилам, единицы, настройка колонок, экспорт, полный экран. - Окно «Корректировка операции» агрегата: строки сторон, распределение по статьям, контроль, комментарий, сохранение. -- Решения по строкам: флажок «Учитывать в КФО», отнести / перенести / убрать / вернуть к автоматике. -- Отмена корректировки агрегата. +- Решения по строкам: флажок «Учитывать в КФО» у нераспределённых, отнести / перенести / убрать / вернуть к автоматике. - Только чтение, когда сервер так говорит (`editable = false`). ## Не цели @@ -69,7 +68,7 @@ ### Данные - `GET …/corrections?statement_type=balance` — одна загрузка всей таблицы; фильтры, сортировка, «Итого» — на клиенте. -- Правки: `PUT …/aggregates/{rule}/{c1}/{c2}` (сохранить), `POST …/revoke`, `POST …/allocations` (решение по строке, ответ — +- Правки: `PUT …/aggregates/{rule}/{c1}/{c2}` (сохранить, сразу `APPLIED`), `POST …/allocations` (решение по строке, ответ — вся таблица), `GET …/export`. - Клиент — `web/src/services/api/corrections.ts` поверх `api/client.ts`. При `REACT_APP_CORRECTIONS_MOCK=true` те же функции отвечают из фейка контракта в памяти (данные — из дизайна 23.10, с проверками контракта: обязательный комментарий, @@ -83,19 +82,19 @@ - **Строка агрегата.** Суммы сторон, счета (`accounts`), отклонение. Процент и сумма отклонения красные при `exceeds_threshold`. Корректировка — статьи и суммы `correction.debit` / `credit` (несколько статей стороны — по строке на - статью в ячейке), комментарий. Действия: «Корректировать» (окно), меню «…» — «Отменить корректировку» (если есть). + статью в ячейке), комментарий. Действие — «Корректировать» (окно); отзыва корректировки нет, её меняют в окне (R-29). `composition_changed` — подсветка строки и значок с подсказкой «Состав изменился после корректировки». `correction.status = DRAFT` — пометка «черновик» у корректировки. - **Раскрытие.** Под агрегатом — его строки ОСВ (`side_1.entries`, затем `side_2.entries`): строка стороны 1 заполняет блок - «Дебет», стороны 2 — «Кредит», остальные ячейки пустые (DOC-272613 п. 3.4; пары строк из дизайна не строим). У строки — - флажок «Учитывать в КФО» и меню: «Перенести в правило…» (правила — `candidate_rule_ids` строки; фронт сам не сверяет - счета с правилами), «Убрать из правила», «Вернуть к автоматике» (если `decision = ASSIGN`). -- **Флажок агрегата** «Учитывать в КФО» — только показ: «Да» — все строки учитываются, «Частично» — часть исключена, «Нет» — - все исключены. Сам признак ставится на строке (DOC-272613; ждёт п. 4). + «Дебет», стороны 2 — «Кредит», остальные ячейки пустые (DOC-272613 п. 3.4; пары строк из дизайна не строим, ответ п. 3). + Флажка у строки нет; меню: «Перенести в правило…» (правила — `candidate_rule_ids` строки; фронт сам не сверяет + счета с правилами), «Убрать из правила» (строка уходит в нераспределённые), «Вернуть к автоматике» (если `decision = ASSIGN`). +- **Флажок агрегата** «Учитывать в КФО» — отмечен и закреплён: агрегат учитывается целиком, лишнюю строку убирают из + правила (ответ Лысенко 29.10, п. 4; R-31). - **Нераспределённые строки** — после агрегатов, фон приглушён. Владелец — на своей стороне (дебетовая строка — в «Дебет», - кредитовая — в «Кредит»), на другой стороне — только ЮЛ контрагента. Строка баланса не показывается (DOC-272613 п. 4.3). - Флажок «Учитывать в КФО», меню «Отнести к правилу…» (список — `candidate_rule_ids`), «Вернуть к автоматике» для - `reason = UNASSIGNED`. Кнопки «Корректировать» нет (ждёт п. 2). + кредитовая — в «Кредит»), на другой стороне — только ЮЛ контрагента. Строка баланса владельца — `line_code` на его + стороне (ответ п. 11, R-33). Флажок «Учитывать в КФО» (только здесь), меню «Отнести к правилу…» (список — + `candidate_rule_ids`), «Вернуть к автоматике» для `reason = UNASSIGNED`. Кнопки «Корректировать» нет (ответ п. 2). - **«Итого»** — суммы сторон, отклонения и корректировок по видимым агрегатам, точным сложением строк; процент «Итого» не показываем (смысл не задан). - Подписи «Отнести/Перенести в правило…» показывают правила как пресеты: «Правило N: <статья 1> — <статья 2>». @@ -208,3 +207,9 @@ | 28.10.2026 | R-26: фейк (R-21) остаётся в бандле до Task 8, который его удаляет | вырезание требует динамического импорта или `define` в rsbuild, и всё равно временное | ~20 КБ в бандле до подключения API | | 28.10.2026 | R-27: `mockData` и `mockCalc` остаются как тестовые фикстуры (их импортируют тесты `tableModel`, `editorModel`, `readOnly`, `draftStorage`); Task 8 удаляет только `correctionsFake.ts` (+ его тест) и ветку флага в `corrections.ts`. Уточняет R-5, R-21, R-26: «вырезается» относится только к фейку контракта `correctionsFake` | иначе при подключении API пришлось бы переписывать тесты моделей | если фикстуры устареют относительно контракта — тесты моделей перестанут ловить расхождения; сверять при Task 8 | | 28.10.2026 | R-28: окно выбора нераспределённой строки (picker) не показывает исключённые (`excluded`) — исключённую сначала возвращают в учёт флажком в таблице | исключённая строка сознательно убрана из КФО; разнесение её в правило молча вернуло бы её в расчёт | аналитику нужен лишний шаг; если захотят выбирать исключённые — снять условие `!u.excluded` в `pickerCandidates` | +| 29.10.2026 | R-29: отзыв корректировки убран целиком (`RevokeModal`, пункт меню агрегата, `revokeCorrection`, фейк); меню «…» у агрегата больше нет | Лысенко 29.10, п. 9: «REVOKED не нужен, корректировку просто меняют через модалку»; в API 1540 (#208) отзыва нет | понадобится отзыв — вернуть по истории ветки (коммит до правки) | +| 29.10.2026 | R-30: `status` из тела `PUT` убран, сервер сразу применяет (`APPLIED`); пометка «черновик» у `DRAFT` оставлена; кнопка — «Сохранить корректировки» (как в дизайне), не «Подтвердить корректировку» | п. 9 Лысенко про DRAFT для > 5 % и «Подтвердить» сформулирован как предположение («как будто», «наверное»); API 1540 всегда пишет `APPLIED` | если введут подтверждение — новая кнопка и поле в контракте; переименование кнопки — одна строка | +| 29.10.2026 | R-31: флажок «Учитывать в КФО» — у агрегата отмечен и неактивен всегда, у строк агрегата его нет, у нераспределённых — `EXCLUDE` / `INCLUDE`; поле `excluded` у строки агрегата убрано из типов; «Да / Частично / Нет» (R-24) больше нет | Лысенко 29.10, п. 4: «залочить на агрегате галочку… в агрегате на строках так же убрать… функционал нужен для нераспределённых строк» | если захотят исключать строку внутри агрегата — вернуть флажок строки и производное состояние агрегата | +| 29.10.2026 | R-32: меню строки в раскрытии агрегата («Перенести в правило…», «Убрать из правила», «Вернуть к автоматике») оставлено | п. 4 разрешает убирать строки из агрегата «при корректировках»; меню таблицы делает то же, что «×» в окне, и перенос, о котором Лысенко не писал | если перенос из таблицы не нужен — убрать меню строки, оставить «×» в окне | +| 29.10.2026 | R-33: у нераспределённой строки — `line_code` (строка баланса владельца) в контракте и на экране; фильтр «Строка баланса» теперь действует и на нераспределённые по `line_code` (без строки — скрыта), их коды есть в выборе | Лысенко 29.10, п. 11: «можно оставить у нераспределённых эту строку»; раньше фильтр прятал нераспределённые, потому что строки у них не было | при `line_code = null` строка пропадает из фильтра по коду | +| 29.10.2026 | R-34: граница 5 % на фронте — как в контракте (≥ 5 % — на проверку: фейк и красная разница в окне); в API 1540 ровно 5 % — в допуске | DOC-272613 «от 5 % включительно»; Лысенко на это не отвечал, вопрос не задан | при ровно 5,00 % окно покажет красным, а сервер — в допуске; таблица берёт `exceeds_threshold` из ответа и расходиться не будет | diff --git a/openspec/changes/corrections-table-ui/proposal.md b/openspec/changes/corrections-table-ui/proposal.md index 44473fc..33e7e95 100644 --- a/openspec/changes/corrections-table-ui/proposal.md +++ b/openspec/changes/corrections-table-ui/proposal.md @@ -24,8 +24,8 @@ AURORA-1395 и в AURORA-1396. «Настройка таблицы», «Экспорт», «На весь экран»; фильтры в заголовках колонок. - Окно «Корректировка операции» агрегата: исходные строки ОСВ сторон (убрать, добавить подходящую нераспределённую), распределение по статьям каждой стороны, контроль распределения, комментарий, «Сохранить корректировки». -- Решения по строке: «Учитывать в КФО» (флажок строки), «Отнести к правилу» / «Перенести в правило», «Убрать из правила», - «Вернуть к автоматике»; «Отменить корректировку» агрегата с комментарием. +- Решения по строке: «Учитывать в КФО» (флажок нераспределённой строки; агрегат учитывается всегда), «Отнести к правилу» / + «Перенести в правило», «Убрать из правила», «Вернуть к автоматике». - Режим только чтения по `editable` / `read_only_reason` ответа. - Новая спека `corrections-table-ui`; дельта `intercompany-matrix-ui` (вкладка «Таблица корректировки» доступна). diff --git a/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md b/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md index 15dee52..f6203d8 100644 --- a/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md +++ b/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md @@ -24,7 +24,9 @@ `company_1` / `company_2`, статьи `side_1.article_line` / `side_2.article_line`, счета `accounts` через запятую, суммы сторон, `deviation_percent` (пусто — «—») и `deviation_amount`; при `exceeds_threshold = true` процент и сумма отклонения SHALL быть красными. Блок «Корректировка» SHALL показывать статьи и суммы `correction.debit` / `correction.credit` (по строке на статью) -и `correction.comment`; без корректировки — «—». Корректировка в статусе `DRAFT` SHALL помечаться «черновик». Агрегат с +и `correction.comment`; без корректировки — «—». Корректировка в статусе `DRAFT` SHALL помечаться «черновик». Флажок +«Учитывать в КФО» агрегата SHALL быть отмечен и неактивен всегда: агрегат учитывается в КФО целиком, лишнюю строку из него +убирают из правила (ответ Лысенко 29.10, п. 4). Агрегат с `composition_changed = true` SHALL подсвечиваться и иметь значок с подсказкой «Состав изменился после корректировки». #### Scenario: Расхождение выше порога @@ -39,36 +41,43 @@ ### Requirement: Раскрытие агрегата до строк ОСВ Строка агрегата SHALL раскрываться стрелкой слева. Под агрегатом SHALL показываться его строки: сначала `side_1.entries`, затем `side_2.entries`; строка стороны 1 заполняет в блоке «Дебет» «Счёт» и «Сумма», строки стороны 2 — то же в блоке -«Кредит»; остальные ячейки пустые. У каждой строки SHALL быть флажок «Учитывать в КФО» (`excluded = false` — отмечен) и меню -действий: «Перенести в правило…» (правила из `candidate_rule_ids` строки; пустой список — пункт неактивен), «Убрать из -правила», «Вернуть к автоматике» (только при `decision = ASSIGN`). Строка с -`excluded = true` SHALL показываться приглушённо. +«Кредит»; остальные ячейки пустые. Флажка «Учитывать в КФО» у строки агрегата SHALL NOT быть — она учитывается вместе с +агрегатом. У каждой строки SHALL быть меню действий: «Перенести в правило…» (правила из `candidate_rule_ids` строки; пустой +список — пункт неактивен), «Убрать из правила» (строка уходит в нераспределённые), «Вернуть к автоматике» (только при +`decision = ASSIGN`). #### Scenario: Агрегат из четырёх строк с каждой стороны - **WHEN** аналитик раскрывает агрегат «ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД" — БЕКОВСКИЙ САХАРНЫЙ КОМБИНАТ ООО» правила 6 - **THEN** под ним видны строки 62.01, 60.02, 62.03, 76.01 в блоке «Дебет» и строки 76.07, 60.01, 60.03, 62.02 в блоке - «Кредит», у каждой — флажок и меню + «Кредит», у каждой — меню, флажка «Учитывать в КФО» нет ### Requirement: Нераспределённые строки После агрегатов SHALL показываться нераспределённые строки (`unallocated[]`) на приглушённом фоне: владелец (`owner`) — в -блоке своей стороны (`side = D` — «Дебет», `K` — «Кредит») со счётом и суммой; в блоке другой стороны — только ЮЛ -`counterparty`; «Строка баланса» обеих сторон, «Контроль» и «Корректировка» — «—». У строки SHALL быть флажок «Учитывать в КФО» +блоке своей стороны (`side = D` — «Дебет», `K` — «Кредит») со строкой баланса `line_code` (нет — «—»), счётом и суммой; в +блоке другой стороны — только ЮЛ `counterparty`; «Строка баланса» другой стороны, «Контроль» и «Корректировка» — «—». +Строка с `excluded = true` SHALL показываться приглушённо. У строки SHALL быть флажок «Учитывать в КФО» и меню «Отнести к правилу…» (правила из `candidate_rule_ids`) и «Вернуть к автоматике» (только при `reason = UNASSIGNED`). Кнопки «Корректировать» у нераспределённой строки SHALL NOT быть. #### Scenario: Кредитовая строка без пары - **WHEN** в `unallocated` строка владельца «ООО "БСК"» по 76.05 `side = K` на «ПМП ООО» на 142 878,00 -- **THEN** «ООО "БСК"», `76.05` и `142 878,00` — в блоке «Кредит», «ПМП ООО» — в «ЮЛ» блока «Дебет», меню предлагает правила из - `candidate_rule_ids` +- **THEN** «ООО "БСК"», `1520`, `76.05` и `142 878,00` — в блоке «Кредит», «ПМП ООО» — в «ЮЛ» блока «Дебет», меню предлагает + правила из `candidate_rule_ids` ### Requirement: Флажок «Учитывать в КФО» -Флажок строки ОСВ (в раскрытии и у нераспределённой строки) SHALL отправлять `POST …/corrections/allocations` с -`action = EXCLUDE` (снят) или `INCLUDE` (поставлен) и заменять таблицу ответом. Флажок строки агрегата SHALL быть только -показом: «Да» — все строки агрегата учитываются, «Частично» — часть исключена, «Нет» — все исключены. +Флажок нераспределённой строки SHALL отправлять `POST …/corrections/allocations` с `action = EXCLUDE` (снят) или `INCLUDE` +(поставлен) и заменять таблицу ответом. Флажок агрегата SHALL быть отмечен и неактивен с подсказкой «Агрегат всегда +учитывается в КФО; лишнюю строку уберите из правила»; у строк агрегата флажка SHALL NOT быть (ответ Лысенко 29.10, п. 4). -#### Scenario: Исключить строку из КФО -- **WHEN** в раскрытом агрегате аналитик снимает флажок у строки 62.03 -- **THEN** уходит `{action: "EXCLUDE", entry_ids: [...]}`, таблица обновляется ответом, у агрегата флажок «Частично» +#### Scenario: Исключить нераспределённую строку из КФО +- **WHEN** аналитик снимает флажок у нераспределённой строки «ЭПТ» по 76.09 +- **THEN** уходит `{action: "EXCLUDE", entry_ids: [...], side: "D"}`, таблица обновляется ответом, строка приглушена + +#### Scenario: Убрать строку из агрегата +- **WHEN** в окне корректировки агрегата «ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД" — БЕКОВСКИЙ САХАРНЫЙ КОМБИНАТ ООО» аналитик нажимает «×» + у строки 62.03 +- **THEN** уходит `{action: "UNASSIGN"}`, сумма стороны Дт уменьшается на 350 000,00, строка 62.03 видна в нераспределённых + со строкой баланса `1230` ### Requirement: Отнести, перенести и убрать строку «Отнести к правилу…» и «Перенести в правило…» SHALL открывать выбор правила с подписями «Правило N: <статья 1> — <статья 2>» @@ -82,7 +91,8 @@ ### Requirement: Окно «Корректировка операции» Кнопка «Корректировать» строки агрегата SHALL открывать окно «Корректировка операции» с двумя сторонами: компания и статья -правила стороны; блок «Исходные данные ОСВ» (счёт, строка баланса, сумма строк стороны; «×» у строки — `UNASSIGN`; +правила стороны; блок «Исходные данные ОСВ» (счёт, строка баланса, сумма строк стороны; «×» у строки — `UNASSIGN`, строка +уходит в нераспределённые; «Добавить строку ОСВ» — выбор из нераспределённых строк той же пары компаний, той же стороны, у которых правило агрегата есть в `candidate_rule_ids`, — `ASSIGN`); блок «Распределение корректировки» (статьи: строка баланса и сумма; «Добавить строку» — ещё статья стороны; «×» — удалить статью). Действия с исходными строками SHALL уходить на сервер сразу и перечитывать агрегат. @@ -105,8 +115,9 @@ ### Requirement: Сохранение корректировки «Сохранить корректировки» SHALL отправлять `PUT …/corrections/aggregates/{rule_id}/{company_1_id}/{company_2_id}` со статьями -обеих сторон (первая статья стороны — основная), комментарием, `status = APPLIED` и `expected_version_no` из ответа (`null` — -действующей корректировки нет, в том числе после отзыва) и после ответа закрывать окно и обновлять агрегат в таблице. Ошибки SHALL показываться в окне: `COMMENT_REQUIRED` — у +обеих сторон (первая статья стороны — основная), комментарием и `expected_version_no` из ответа (`null` — действующей +корректировки нет); сервер сразу применяет корректировку (`APPLIED`). После ответа окно SHALL закрываться, агрегат в таблице — +обновляться. Ошибки SHALL показываться в окне: `COMMENT_REQUIRED` — у поля комментария, `INVALID_LINE_CODE` — у статьи `detail.line_code`, `CORRECTION_VERSION_CONFLICT` — сообщением «Корректировку уже изменили, данные обновлены» с перечитыванием таблицы, остальные — текстом сервера. Закрытие окна с несохранёнными статьями или комментарием SHALL спрашивать «Есть несохранённые изменения. Закрыть без сохранения?». @@ -136,14 +147,6 @@ - **WHEN** в черновике `version_no = 2`, а в ответе таблицы у корректировки агрегата `version_no = 3` - **THEN** окно подставляет черновик и показывает плашку «Корректировку изменили после ваших правок» с кнопкой «Сбросить» -### Requirement: Отмена корректировки -У агрегата с корректировкой меню действий SHALL содержать «Отменить корректировку»: окно с обязательным комментарием, -`POST …/revoke`, после ответа агрегат обновляется и блок «Корректировка» показывает «—». - -#### Scenario: Отмена корректировки -- **WHEN** аналитик выбирает «Отменить корректировку» у агрегата «Сатурн — Архол» и вводит комментарий -- **THEN** уходит `POST …/revoke {comment}`, у агрегата в блоке «Корректировка» — «—» - ### Requirement: Панель и фильтры Над таблицей SHALL быть: «Отображение» — «Общий» / «По пресету»; при «По пресету» — поле «Правило» с правилами `rules[]` (подпись «Правило N: <статья 1> — <статья 2>», составная статья — «1450 / 1550») и в таблице только агрегаты выбранного @@ -151,8 +154,9 @@ счетов» (только агрегаты), «Нераспределенные записи» (только нераспределённые); единицы и знаки — как в шахматке; «Сбросить фильтры» — когда задан любой фильтр; справа — «Настройка таблицы», «Экспорт», «На весь экран». В заголовках SHALL быть фильтры: «ЮЛ» (подстрока имени, по обеим сторонам), «Строка баланса» (выбор кода), «Счёт» (начало кода, по счетам строки), -«Отклонение, %» («Все» / «≥ 5 %» / «< 5 %» по `exceeds_threshold`). Фильтры «ЮЛ» и «Счёт» SHALL действовать и на -нераспределённые строки; при заданном фильтре «Строка баланса» или «Отклонение, %» нераспределённые строки SHALL скрываться. +«Отклонение, %» («Все» / «≥ 5 %» / «< 5 %» по `exceeds_threshold`). Фильтры «ЮЛ», «Счёт» и «Строка баланса» SHALL +действовать и на нераспределённые строки («Строка баланса» — по `line_code`, строка без него скрывается, её коды есть в +выборе); при заданном фильтре «Отклонение, %» нераспределённые строки SHALL скрываться. Составная статья правила (коды через «/») в фильтре «Строка баланса» SHALL давать отдельные коды. Фильтры ничего не нашли — текст «Нет записей для выбранных фильтров». Строка «Итого» SHALL суммировать видимые агрегаты точным сложением: суммы сторон, отклонение, суммы корректировок Дт и Кт; процент в «Итого» — «—». @@ -175,7 +179,7 @@ ### Requirement: Только чтение При `editable = false` кнопки «Корректировать» SHALL открывать окно только на просмотр, флажки «Учитывать в КФО» и пункты меню -решений и отмены SHALL быть неактивны с подсказкой по `read_only_reason`: `VERSION_NOT_EDITABLE` — «Версия закрыта для +решений SHALL быть неактивны с подсказкой по `read_only_reason`: `VERSION_NOT_EDITABLE` — «Версия закрыта для изменений», `LOCK_REQUIRED` — «Возьмите консолидацию в работу», `LOCKED_BY_OTHER` — «Консолидацию редактирует другой пользователь». Ответы 409 правок версии SHALL показываться уведомлением с текстом сервера и перечитыванием таблицы. diff --git a/openspec/changes/corrections-table-ui/tasks.md b/openspec/changes/corrections-table-ui/tasks.md index 996159c..14925c0 100644 --- a/openspec/changes/corrections-table-ui/tasks.md +++ b/openspec/changes/corrections-table-ui/tasks.md @@ -46,3 +46,11 @@ - `allocate` с несколькими `entry_ids` и код `VALIDATION_ERROR` — фейк их не воспроизводит (M-10) - [ ] 8.2 Расхождения ответа с контрактом — правка фейка и типов или замечание в PR 1540; убрать фейк контракта (`correctionsFake.ts` и его тест) и ветку флага в `corrections.ts`; `mockData` и `mockCalc` остаются тестовыми фикстурами (R-27) - [ ] 8.3 `make check-full`, PR в `main` + +## 9. Ответы Лысенко 29.10 (AURORA-1395, #9920570) и WIP API 1540 (arm-core #208) + +- [x] 9.1 Убрать отзыв корректировки и `status` из тела `PUT` (п. 9; R-29, R-30) +- [x] 9.2 «Учитывать в КФО»: агрегат закреплён, у строк агрегата флажка нет, у нераспределённых — `EXCLUDE` / `INCLUDE`; `excluded` у строки агрегата убран (п. 4; R-31, R-32) +- [x] 9.3 Строка баланса владельца у нераспределённой (`line_code`), фильтр «Строка баланса» по ней (п. 11; R-33) +- [x] 9.4 Фейк: `ENTRY_ALLOCATED` для `EXCLUDE`/`INCLUDE` строки агрегата, `ENTRY_EXCLUDED` для `ASSIGN` исключённой; проверка на фейке в браузере (агрегат, раскрытие, нераспределённые, «×» в окне) +- [x] 9.5 Спека, design.md, README/ARCHITECTURE; контракт docs — отдельным PR diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index 84633d9..5ca7625 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -47,9 +47,9 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне Поток данных: страница отчёта → `CorrectionContent` (вкладка: загрузка, ошибка с «Повторить», экспорт, полный экран) → хук `useCorrectionTable` (запрос; применяется только ответ последнего запроса, `latestOnly`) → таблица целиком хранится в -родителе; `CorrectionTable` только рисует, фильтры/раскрытие/колонки — состояние родителя. Решение по строке (флажок, -меню) → `allocate` → таблица заменяется ответом; «Корректировать» → `CorrectionEditorModal` → `saveCorrection` -(`PUT`, `expected_version_no`) → подмена одного агрегата; отмена — `RevokeModal` → `revokeCorrection`. Черновик окна — +родителе; `CorrectionTable` только рисует, фильтры/раскрытие/колонки — состояние родителя. Решение по строке (флажок +нераспределённой, меню) → `allocate` → таблица заменяется ответом; «Корректировать» → `CorrectionEditorModal` → `saveCorrection` +(`PUT`, `expected_version_no`, сервер сразу применяет) → подмена одного агрегата; отзыва корректировки нет. Черновик окна — `localStorage` (`draftStorage`). | Файл | Тип | Что делает | @@ -62,7 +62,7 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне | `columns.ts` | чистый | ключи и группы колонок для «Настройки таблицы» | | `useCorrectionTable.ts` | хук | загрузка, `reload`/`replace`/`replaceAggregate` | | `CorrectionContent.tsx`, `CorrectionToolbar.tsx`, `CorrectionTable.tsx`, `HeaderFilter.tsx` | компоненты | вкладка, панель, таблица с группами колонок, воронки в заголовках | -| `CorrectionEditorModal.tsx`, `EditorSide.tsx`, `AllocationPicker.tsx`, `RevokeModal.tsx` | компоненты | окно «Корректировка операции», сторона окна, выбор нераспределённой строки, отмена | +| `CorrectionEditorModal.tsx`, `EditorSide.tsx`, `AllocationPicker.tsx` | компоненты | окно «Корректировка операции», сторона окна, выбор нераспределённой строки | | `mockData.ts`, `mockCalc.ts` | фикстуры | данные дизайна и пересчёт агрегата; остаются как тестовые фикстуры (R-27); фейк контракта `services/api/correctionsFake.ts` удаляется в Task 8 | | `../../utils/money.ts` | чистый | деньги копейками (`toCents`, `percentOfSmaller`, порог 5 %) | diff --git a/web/README.md b/web/README.md index 4a6b073..6a99cb9 100644 --- a/web/README.md +++ b/web/README.md @@ -70,7 +70,7 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v Вкладка «Таблица корректировки» отчёта версии ждёт API `…/corrections` (AURORA-1540). Пока его нет, фронт работает на **фейке контракта**: `REACT_APP_CORRECTIONS_MOCK=true npm run start` — вместо HTTP-запросов вызываются функции `src/services/api/correctionsFake.ts` с данными из дизайна 23.10 (`widgets/CorrectionTable/mockData.ts`). Состояние -фейка живёт в памяти страницы и сбрасывается перезагрузкой; решения по строкам, сохранение и отмена корректировки, +фейка живёт в памяти страницы и сбрасывается перезагрузкой; решения по строкам, сохранение корректировки, экспорт (CSV вместо xlsx) работают. Без флага (по умолчанию, в том числе в `npm run build`) фронт ходит в настоящий API. Флаг читается при сборке из `Config.CORRECTIONS_MOCK` (`src/conf/config.ts`), но код фейка в бандл попадает всегда (не вырезается) — см. design.md, R-21. Черновики окна корректировки лежат в `localStorage` браузера. diff --git a/web/src/services/api/corrections.ts b/web/src/services/api/corrections.ts index 3094227..a15b3dc 100644 --- a/web/src/services/api/corrections.ts +++ b/web/src/services/api/corrections.ts @@ -5,7 +5,6 @@ import type { AllocationIn, CorrectionAggregateOut, CorrectionIn, - CorrectionRevokeIn, CorrectionTableOut, StatementType, } from '../../types/corrections'; @@ -29,11 +28,6 @@ export function saveCorrection(versionId: string, key: AggregateKey, body: Corre return api.put(correctionsPath(versionId, key), body); } -export function revokeCorrection(versionId: string, key: AggregateKey, body: CorrectionRevokeIn): Promise { - if (Config.CORRECTIONS_MOCK) return correctionsFake.revokeCorrection(versionId, key, body); - return api.post(`${correctionsPath(versionId, key)}/revoke`, body); -} - export function allocate(versionId: string, body: AllocationIn): Promise { if (Config.CORRECTIONS_MOCK) return correctionsFake.allocate(versionId, body); return api.post(`${correctionsPath(versionId)}/allocations`, body); diff --git a/web/src/services/api/correctionsFake.test.ts b/web/src/services/api/correctionsFake.test.ts index 66ff6e8..5cdd50f 100644 --- a/web/src/services/api/correctionsFake.test.ts +++ b/web/src/services/api/correctionsFake.test.ts @@ -42,29 +42,39 @@ describe('correctionsFake', () => { expect(t.editable).toBe(true); }); - it('EXCLUDE строки 62.03 уменьшает сумму стороны', async () => { - const table0 = await fake.getCorrectionTable(V); - const before = find(table0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"'); - const row = before.side_1.entries.find((e) => e.account_code === '62.03'); - if (!row) throw new Error('нет строки'); - const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'EXCLUDE' }); - const after = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"'); - expect(after.side_1.amount).toBe('1325000.00'); - expect(after.side_1.entries.find((e) => e.account_code === '62.03')?.excluded).toBe(true); - expect(table0.totals.amount_1).toBe('37727550.00'); - expect(t.totals.amount_1).toBe('37377550.00'); + it('EXCLUDE / INCLUDE у строки агрегата — 422 ENTRY_ALLOCATED: строки агрегата учитываются всегда', async () => { + const a = find(await fake.getCorrectionTable(V), 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"'); + const row = a.side_1.entries[0]; + for (const action of ['EXCLUDE', 'INCLUDE'] as const) { + await expectCode( + fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action }), + 422, + 'ENTRY_ALLOCATED', + ); + } }); - it('EXCLUDE в агрегате с корректировкой отмечает смену состава', async () => { + it('UNASSIGN строки 62.03: сумма стороны меньше, строка — в нераспределённых со строкой баланса владельца', async () => { + const table0 = await fake.getCorrectionTable(V); + const row = find(table0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries.find((e) => e.account_code === '62.03'); + if (!row) throw new Error('нет строки'); + const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'UNASSIGN' }); + expect(find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.amount).toBe('1325000.00'); + expect(table0.totals.amount_1).toBe('37727550.00'); + expect(t.totals.amount_1).toBe('37377550.00'); + const u = t.unallocated.find((x) => x.entry_ids[0] === row.entry_ids[0]); + expect(u?.reason).toBe('UNASSIGNED'); + expect(u?.line_code).toBe('1230'); + expect(u?.excluded).toBe(false); + }); + + it('UNASSIGN в агрегате с корректировкой отмечает смену состава', async () => { const t0 = await fake.getCorrectionTable(V); - const ept = find(t0, 'ЭПТ'); - const t = await fake.allocate(V, { - statement_type: 'balance', - entry_ids: ept.side_1.entries[0].entry_ids, - side: 'D', - action: 'EXCLUDE', - }); - expect(find(t, 'ЭПТ').composition_changed).toBe(true); + const row = find(t0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries[0]; + const key = toAggregateKey(find(t0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"')); + await fake.saveCorrection(V, key, body()); + const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'UNASSIGN' }); + expect(find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').composition_changed).toBe(true); }); it('ASSIGN: только в правило из candidate_rule_ids', async () => { @@ -148,15 +158,37 @@ describe('correctionsFake', () => { expect(e?.candidate_rule_ids).not.toContain('r7'); }); - it('INCLUDE возвращает строку, RESET сбрасывает решение', async () => { + it('нераспределённая строка: EXCLUDE / INCLUDE переключают отметку, исключённую в правило не отнести', async () => { + const u = (await fake.getCorrectionTable(V)).unallocated[0]; + const pos = { statement_type: 'balance' as const, entry_ids: u.entry_ids, side: u.side }; + const out = await fake.allocate(V, { ...pos, action: 'EXCLUDE' }); + expect(out.unallocated.find((x) => x.entry_ids[0] === u.entry_ids[0])?.excluded).toBe(true); + await expectCode(fake.allocate(V, { ...pos, action: 'ASSIGN', rule_id: u.candidate_rule_ids[0] }), 422, 'ENTRY_EXCLUDED'); + const back = await fake.allocate(V, { ...pos, action: 'INCLUDE' }); + expect(back.unallocated.find((x) => x.entry_ids[0] === u.entry_ids[0])?.excluded).toBe(false); + }); + + it('RESET у строки агрегата сбрасывает ручное отнесение', async () => { const t = await fake.getCorrectionTable(V); - const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries[0]; - await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'EXCLUDE' }); - const back = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'INCLUDE' }); - expect(find(back, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.amount).toBe('1675000.00'); - await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'EXCLUDE' }); + const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries.find((e) => e.account_code === '62.03'); + if (!row) throw new Error('нет строки'); + const moved = await fake.allocate(V, { + statement_type: 'balance', + entry_ids: row.entry_ids, + side: row.side, + action: 'ASSIGN', + rule_id: 'r7', + }); + const e = moved.aggregates.find((a) => a.rule_id === 'r7')?.side_1.entries[0]; + expect(e?.decision).toBe('ASSIGN'); const reset = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'RESET' }); - expect(find(reset, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries[0].excluded).toBe(false); + expect(reset.aggregates.find((a) => a.rule_id === 'r7')?.side_1.entries[0].decision).toBe('AUTO'); + }); + + it('у нераспределённых есть строка баланса владельца', async () => { + const t = await fake.getCorrectionTable(V); + expect(t.unallocated.every((u) => u.line_code !== undefined)).toBe(true); + expect(t.unallocated.some((u) => u.line_code !== null)).toBe(true); }); it('агрегат с одной стороной: ONE_SIDED, порог превышен', async () => { @@ -178,17 +210,11 @@ describe('correctionsFake', () => { expect(again.correction?.version_no).toBe(2); }); - it('revokeCorrection и повторное сохранение', async () => { - const t = await fake.getCorrectionTable(V); - const ept = find(t, 'ЭПТ'); - const key = toAggregateKey(ept); - await expectCode(fake.revokeCorrection(V, key, { comment: ' ' }), 422, 'COMMENT_REQUIRED'); - const revoked = await fake.revokeCorrection(V, key, { comment: 'сверили' }); - expect(revoked.correction).toBeNull(); - await expectCode(fake.revokeCorrection(V, key, { comment: 'ещё раз' }), 409, 'CORRECTION_NOT_APPLIED'); - const saved = await fake.saveCorrection(V, key, body()); - expect(saved.correction).not.toBeNull(); - expect(aggregateKey(saved)).toBe(aggregateKey(ept)); + it('сохранение всегда применяет корректировку (APPLIED)', async () => { + const key = toAggregateKey(find(await fake.getCorrectionTable(V), 'ЭПТ')); + const saved = await fake.saveCorrection(V, key, body({ expected_version_no: 1 })); + expect(saved.correction?.status).toBe('APPLIED'); + expect(aggregateKey(saved)).toBe(aggregateKey(find(await fake.getCorrectionTable(V), 'ЭПТ'))); }); it('ответы — копии: правка снаружи не портит состояние', async () => { diff --git a/web/src/services/api/correctionsFake.ts b/web/src/services/api/correctionsFake.ts index c02e2d0..bd55a96 100644 --- a/web/src/services/api/correctionsFake.ts +++ b/web/src/services/api/correctionsFake.ts @@ -7,7 +7,6 @@ import type { CorrectionAggregateOut, CorrectionEntryOut, CorrectionIn, - CorrectionRevokeIn, CorrectionTableOut, Ref, Side, @@ -15,7 +14,7 @@ import type { } from '../../types/corrections'; import { computeTotals, recomputeAggregate } from '../../widgets/CorrectionTable/mockCalc'; import { makeMockTable } from '../../widgets/CorrectionTable/mockData'; -import { aggregateKey } from '../../widgets/CorrectionTable/tableModel'; +import { aggregateKey, articleCodes } from '../../widgets/CorrectionTable/tableModel'; let state: CorrectionTableOut = makeMockTable(); @@ -71,11 +70,11 @@ function detach(found: Located): { owner: Ref; counterparty: Ref; account_code: string; + line_code: string | null; side: 'D' | 'K'; amount: string; entry_ids: string[]; candidates: string[]; - excluded: boolean; } { if (found.where === 'unallocated') { const u = found.item; @@ -84,11 +83,11 @@ function detach(found: Located): { owner: u.owner, counterparty: u.counterparty, account_code: u.account_code, + line_code: u.line_code, side: u.side, amount: u.amount, entry_ids: u.entry_ids, candidates: u.candidate_rule_ids, - excluded: u.excluded, }; } const { aggregate: a, sideNo, entry: e } = found; @@ -98,12 +97,13 @@ function detach(found: Located): { owner: refOf(a, e.legal_entity_id), counterparty: refOf(a, e.counterparty_id), account_code: e.account_code, + // строка баланса владельца — статья его стороны в правиле (у составной — первый код) + line_code: articleCodes(side.article_line)[0] ?? null, side: e.side, amount: e.amount, entry_ids: e.entry_ids, // прежнее правило становится одним из кандидатов, как при UNASSIGN candidates: [...new Set([...e.candidate_rule_ids, a.rule_id])], - excluded: e.excluded, }; touch(a); if (!a.side_1.entries.length && !a.side_2.entries.length) state.aggregates = state.aggregates.filter((x) => x !== a); @@ -113,6 +113,8 @@ function detach(found: Located): { function assign(found: Located, ruleId: string): void { const rule = state.rules.find((r) => r.id === ruleId) ?? fail(404, 'NOT_FOUND', 'Правило не найдено'); const candidates = found.where === 'unallocated' ? found.item.candidate_rule_ids : found.entry.candidate_rule_ids; + // исключённая из КФО строка в правило молча не возвращается: сначала её снова учитывают (INCLUDE) + if (found.where === 'unallocated' && found.item.excluded) fail(422, 'ENTRY_EXCLUDED', 'Строка не учитывается в КФО'); if (!candidates.includes(ruleId)) fail(422, 'RULE_NOT_APPLICABLE', 'Строка не подходит под это правило'); const moved = detach(found); // Сторона 1 правила — дебетовая: строка с этой стороной принадлежит компании 1, кредитовая — компании 2. @@ -146,7 +148,6 @@ function assign(found: Located, ruleId: string): void { side: moved.side, amount: moved.amount, decision: 'ASSIGN', - excluded: moved.excluded, candidate_rule_ids: moved.candidates.filter((id) => id !== ruleId), }; (isSide1 ? target.side_1 : target.side_2).entries.push(entry); @@ -155,12 +156,10 @@ function assign(found: Located, ruleId: string): void { function applyAction(id: string, body: AllocationIn): void { const found = locate(id, body.side); + // «Не учитывать в КФО» — только у нераспределённой строки; строки агрегата учитываются всегда const flag = (excluded: boolean) => { - if (found.where === 'unallocated') found.item.excluded = excluded; - else { - found.entry.excluded = excluded; - touch(found.aggregate); - } + if (found.where !== 'unallocated') fail(422, 'ENTRY_ALLOCATED', 'Строка входит в агрегат и учитывается в КФО'); + else found.item.excluded = excluded; }; switch (body.action) { case 'EXCLUDE': @@ -181,18 +180,18 @@ function applyAction(id: string, body: AllocationIn): void { owner: moved.owner, counterparty: moved.counterparty, account_code: moved.account_code, + line_code: moved.line_code, side: moved.side, amount: moved.amount, candidate_rule_ids: moved.candidates, reason: 'UNASSIGNED', - excluded: moved.excluded, + excluded: false, }); break; } default: // RESET if (found.where === 'unallocated') found.item.excluded = false; else { - found.entry.excluded = false; found.entry.decision = 'AUTO'; touch(found.aggregate); } @@ -212,7 +211,7 @@ export const correctionsFake = { const current = a.correction?.version_no ?? null; if (body.expected_version_no !== current) fail(409, 'CORRECTION_VERSION_CONFLICT', 'Корректировку уже изменили, обновите таблицу'); a.correction = { - status: body.status ?? 'APPLIED', + status: 'APPLIED', version_no: (current ?? 0) + 1, debit: clone(body.debit), credit: clone(body.credit), @@ -235,17 +234,6 @@ export const correctionsFake = { }); }, - revokeCorrection(_versionId: string, key: AggregateKey, body: CorrectionRevokeIn): Promise { - return run(() => { - const a = aggregateByKey(key); - if (body.comment.trim() === '') fail(422, 'COMMENT_REQUIRED', 'Комментарий обязателен'); - if (!a.correction) fail(409, 'CORRECTION_NOT_APPLIED', 'Корректировка не применена'); - a.correction = null; - a.composition_changed = false; - return clone(a); - }); - }, - allocate(_versionId: string, body: AllocationIn): Promise { return run(() => { if (!body.entry_ids.length) fail(422, 'VALIDATION_ERROR', 'Не указаны строки'); diff --git a/web/src/types/corrections.ts b/web/src/types/corrections.ts index f72cff4..8837646 100644 --- a/web/src/types/corrections.ts +++ b/web/src/types/corrections.ts @@ -44,8 +44,6 @@ export interface CorrectionEntryOut { /** Сальдо (оборот) строки по этой стороне. */ amount: Money; decision: 'AUTO' | 'ASSIGN'; - /** «Не включать в КФО»; в сумму стороны не входит. */ - excluded: boolean; candidate_rule_ids: string[]; } @@ -73,6 +71,7 @@ export interface CorrectionBalanceOut { } export interface CorrectionOut { + /** Сохранение всегда даёт `APPLIED`; `DRAFT` есть в модели, сервер его пока не отдаёт. */ status: 'DRAFT' | 'APPLIED'; version_no: number; /** Статьи стороны 1, первая — основная. */ @@ -111,10 +110,13 @@ export interface UnallocatedEntryOut { owner: Ref; counterparty: Ref; account_code: string; + /** Строка баланса владельца, к которой привязан счёт; `null` — не определена. */ + line_code: string | null; side: Side; amount: Money; candidate_rule_ids: string[]; reason: 'MULTIPLE_RULES' | 'UNASSIGNED'; + /** «Не учитывать в КФО» — только у нераспределённой строки; строки агрегата учитываются всегда. */ excluded: boolean; } @@ -149,15 +151,11 @@ export interface CorrectionIn { credit: CorrectionLineIn[]; comment: string; reason_code?: string | null; - status?: 'APPLIED' | 'DRAFT'; /** Редакция действующей корректировки, которую видел аналитик; `null` — действующей нет. */ expected_version_no: number | null; } -export interface CorrectionRevokeIn { - comment: string; -} - +/** `EXCLUDE` / `INCLUDE` — только для нераспределённой позиции. */ export type AllocationAction = 'ASSIGN' | 'UNASSIGN' | 'EXCLUDE' | 'INCLUDE' | 'RESET'; export interface AllocationIn { diff --git a/web/src/widgets/CorrectionTable/CorrectionContent.tsx b/web/src/widgets/CorrectionTable/CorrectionContent.tsx index ec11e24..a8fc454 100644 --- a/web/src/widgets/CorrectionTable/CorrectionContent.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionContent.tsx @@ -1,30 +1,20 @@ -// Вкладка «Таблица корректировки»: загрузка, состояния, панель, таблица, решения по строкам, отмена и окно корректировки. +// Вкладка «Таблица корректировки»: загрузка, состояния, панель, таблица, решения по строкам и окно корректировки. // Данные и фильтры держит она. import { Button, ConfigProvider, Spin, notification as antdNotification } from 'antd'; import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { ApiError } from '../../api/client'; import { useVisibleColumns } from '../../components/GenericTable/useVisibleColumns'; -import { allocate, exportCorrectionTable, revokeCorrection } from '../../services/api/corrections'; +import { allocate, exportCorrectionTable } from '../../services/api/corrections'; import type { VersionCard } from '../../types/consolidations/version_members'; -import type { AllocationIn, CorrectionAggregateOut } from '../../types/corrections'; +import type { AllocationIn } from '../../types/corrections'; import { saveBlob, xlsxFilename } from '../../utils/download'; import { useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay'; import { CorrectionEditorModal } from './CorrectionEditorModal'; import { CorrectionTable, type RowActions } from './CorrectionTable'; import { CorrectionToolbar } from './CorrectionToolbar'; -import { RevokeModal } from './RevokeModal'; import { COLUMN_DEFS, type ColKey, type ColShape } from './columns'; -import { errorOutcome, needsReload, readOnlyHint } from './readOnly'; -import { - EMPTY_FILTERS, - type TableFilters, - aggregateKey, - buildRows, - filterTable, - lineOptions, - toAggregateKey, - visibleTotals, -} from './tableModel'; +import { errorOutcome, readOnlyHint } from './readOnly'; +import { EMPTY_FILTERS, type TableFilters, aggregateKey, buildRows, filterTable, lineOptions, visibleTotals } from './tableModel'; import { useCorrectionTable } from './useCorrectionTable'; const LOAD_ERROR_KEY = 'corrections-load-error'; @@ -51,11 +41,10 @@ export function CorrectionContent({ versionId, card = null }: Props) { const { visibleKeys, setVisibleKeys } = useVisibleColumns({ rowKey: 'include', columns: COLUMN_DEFS }); const [exporting, setExporting] = useState(false); const [fullscreen, setFullscreen] = useState(false); - // идёт запрос решения по строке / отмены: на это время контролы строк неактивны, чтобы не слать два запроса подряд + // идёт запрос решения по строке: на это время контролы строк неактивны, чтобы не слать два запроса подряд const [busy, setBusy] = useState(false); // ref, а не состояние: setBusy применяется после перерисовки, а два клика могут прийти до неё — ref меняется мгновенно const inflight = useRef(false); - const [revoking, setRevoking] = useState(null); // ключ агрегата, окно корректировки которого открыто const [editing, setEditing] = useState(null); @@ -155,27 +144,8 @@ export function CorrectionContent({ versionId, card = null }: Props) { } }; - // отмена корректировки: ответ — один агрегат, подменяем только его - const onRevoke = async (comment: string) => { - if (!revoking || inflight.current) return; - inflight.current = true; - setBusy(true); - try { - replaceAggregate(await revokeCorrection(versionId, toAggregateKey(revoking), { comment })); - setRevoking(null); - } catch (e) { - // 409 — данные устарели, окно закрываем (таблица перечитается); иная ошибка — окно остаётся, можно повторить - if (needsReload(e)) setRevoking(null); - fail(e, 'Не удалось отменить корректировку'); - } finally { - inflight.current = false; - setBusy(false); - } - }; - const actions: RowActions = { onCorrect: (a) => setEditing(aggregateKey(a)), - onRevoke: setRevoking, onAllocate, }; @@ -268,13 +238,6 @@ export function CorrectionContent({ versionId, card = null }: Props) { onReload={reload} /> )} - setRevoking(null)} - onSubmit={onRevoke} - />
); diff --git a/web/src/widgets/CorrectionTable/CorrectionTable.tsx b/web/src/widgets/CorrectionTable/CorrectionTable.tsx index e6d56fb..485bbb6 100644 --- a/web/src/widgets/CorrectionTable/CorrectionTable.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionTable.tsx @@ -16,7 +16,6 @@ import type { visibleTotals } from './tableModel'; /** Действия над строками: подключает CorrectionContent. */ export type RowActions = { onCorrect(a: CorrectionAggregateOut): void; - onRevoke(a: CorrectionAggregateOut): void; onAllocate(body: AllocationIn): void; }; @@ -113,15 +112,10 @@ export function CorrectionTable(props: Props) { a.exceeds_threshold && text !== '—' ? {text} : text; switch (key) { case 'include': + // агрегат учитывается в КФО всегда: флажок отмечен и закреплён, лишняя строка убирается из правила return withHint( - - {row.include} + {a.composition_changed && ( @@ -173,28 +167,9 @@ export function CorrectionTable(props: Props) { ); case 'actions': return ( - - - {withHint( - actions.onRevoke(a), - }}> - ); default: return null; @@ -207,10 +182,12 @@ export function CorrectionTable(props: Props) { const own = (side: 'D' | 'K') => owned === side; switch (key) { case 'include': + // флажок — только у нераспределённой строки; строка агрегата учитывается вместе с агрегатом + if (row.kind !== 'unallocated') return null; return withHint( actions.onAllocate(includeBody(e.entry_ids, e.side, ev.target.checked))} />, @@ -223,6 +200,9 @@ export function CorrectionTable(props: Props) { return own('K') ? row.entry.owner.name : row.entry.counterparty.name; case 'd_line': case 'k_line': + // у нераспределённой — строка баланса владельца на его стороне; у строки агрегата статья видна в самом агрегате + if (row.kind !== 'unallocated') return null; + return own(key === 'd_line' ? 'D' : 'K') ? (row.entry.line_code ?? '—') : '—'; case 'dev_percent': case 'dev_amount': case 'c_d_line': @@ -264,7 +244,7 @@ export function CorrectionTable(props: Props) { onCell: (row) => ({ style: { background: rowBg(row) ?? tint(key), - opacity: (row.kind === 'entry' || row.kind === 'unallocated') && row.entry.excluded ? 0.5 : 1, + opacity: row.kind === 'unallocated' && row.entry.excluded ? 0.5 : 1, }, }), }); diff --git a/web/src/widgets/CorrectionTable/EditorSide.tsx b/web/src/widgets/CorrectionTable/EditorSide.tsx index 2317125..83e13b9 100644 --- a/web/src/widgets/CorrectionTable/EditorSide.tsx +++ b/web/src/widgets/CorrectionTable/EditorSide.tsx @@ -37,7 +37,7 @@ type OsvProps = { onUnassign(entry: CorrectionEntryOut): void; }; -/** «Исходные данные ОСВ»: строки стороны агрегата; «×» — убрать из правила, «Добавить строку ОСВ» — выбор из нераспределённых. */ +/** «Исходные данные ОСВ»: строки стороны агрегата; «×» — убрать из правила (строка уходит в нераспределённые), «Добавить строку ОСВ» — выбор из нераспределённых. */ export function OsvBlock(p: OsvProps) { const canEdit = p.editable && !p.busy; return ( @@ -68,11 +68,8 @@ export function OsvBlock(p: OsvProps) { )} {p.entries.map((e) => ( - - - {e.account_code} - {e.excluded && не в КФО} - + + {e.account_code} {p.article} {formatRub(e.amount)} {p.editable && ( diff --git a/web/src/widgets/CorrectionTable/RevokeModal.tsx b/web/src/widgets/CorrectionTable/RevokeModal.tsx deleted file mode 100644 index 9de9e16..0000000 --- a/web/src/widgets/CorrectionTable/RevokeModal.tsx +++ /dev/null @@ -1,49 +0,0 @@ -// Окно «Отменить корректировку»: комментарий обязателен, пустой — «Сохранить» неактивна. -import { Input, Modal } from 'antd'; -import { useEffect, useState } from 'react'; -import { COMMENT_MAX_LENGTH } from './readOnly'; - -type Props = { - open: boolean; - /** Подпись агрегата в заголовке окна (кто с кем). */ - subtitle: string; - /** Запрос уходит: кнопка в процессе, окно не закрывается. */ - saving: boolean; - onCancel(): void; - onSubmit(comment: string): void; -}; - -export function RevokeModal({ open, subtitle, saving, onCancel, onSubmit }: Props) { - const [comment, setComment] = useState(''); - - // при каждом открытии — чистое поле - useEffect(() => { - if (open) setComment(''); - }, [open]); - - const trimmed = comment.trim(); - return ( - onSubmit(trimmed)} - destroyOnHidden> -
{subtitle}
- setComment(e.target.value)} - /> -
- ); -} diff --git a/web/src/widgets/CorrectionTable/editorModel.test.ts b/web/src/widgets/CorrectionTable/editorModel.test.ts index 844e616..54d830f 100644 --- a/web/src/widgets/CorrectionTable/editorModel.test.ts +++ b/web/src/widgets/CorrectionTable/editorModel.test.ts @@ -195,10 +195,10 @@ describe('isDirty', () => { }); describe('toCorrectionIn', () => { - it('статус APPLIED и expected_version_no из аргумента', () => { + it('expected_version_no из аргумента, статуса в теле нет — сервер всегда применяет', () => { const r = validateLines(single('1.5')); if (!r.ok) throw new Error('ожидалось ok'); - expect(toCorrectionIn(r.body, 3)).toEqual({ ...r.body, status: 'APPLIED', expected_version_no: 3 }); + expect(toCorrectionIn(r.body, 3)).toEqual({ ...r.body, expected_version_no: 3 }); expect(toCorrectionIn(r.body, null).expected_version_no).toBeNull(); }); }); @@ -215,6 +215,7 @@ describe('pickerCandidates', () => { owner, counterparty: cp, account_code: '60.01', + line_code: '1520', side, amount: '100.00', candidate_rule_ids: ['r6'], diff --git a/web/src/widgets/CorrectionTable/editorModel.ts b/web/src/widgets/CorrectionTable/editorModel.ts index e206baa..e568053 100644 --- a/web/src/widgets/CorrectionTable/editorModel.ts +++ b/web/src/widgets/CorrectionTable/editorModel.ts @@ -182,7 +182,7 @@ export function pickerCandidates(table: CorrectionTableOut, a: CorrectionAggrega ); } -/** Тело PUT: статус APPLIED и expected_version_no из ответа таблицы (null — действующей корректировки нет). */ +/** Тело PUT: статьи, комментарий и expected_version_no из ответа таблицы (null — действующей корректировки нет); сервер сразу применяет. */ export function toCorrectionIn(body: Pick, expectedVersionNo: number | null): CorrectionIn { - return { ...body, status: 'APPLIED', expected_version_no: expectedVersionNo }; + return { ...body, expected_version_no: expectedVersionNo }; } diff --git a/web/src/widgets/CorrectionTable/mockCalc.ts b/web/src/widgets/CorrectionTable/mockCalc.ts index 7221f3d..34ec902 100644 --- a/web/src/widgets/CorrectionTable/mockCalc.ts +++ b/web/src/widgets/CorrectionTable/mockCalc.ts @@ -6,7 +6,7 @@ import { THRESHOLD_PERCENT_HUNDREDTHS, fromCents, percentOfSmaller, sumAmounts, const sumOrZero = (values: readonly Money[]): Money => sumAmounts(values) ?? '0.00'; function sideAmount(side: AggregateSideOut): void { - side.amount = sumOrZero(side.entries.filter((e) => !e.excluded).map((e) => e.amount)); + side.amount = sumOrZero(side.entries.map((e) => e.amount)); side.accounts = [...new Set(side.entries.map((e) => e.account_code))].sort(); } diff --git a/web/src/widgets/CorrectionTable/mockData.ts b/web/src/widgets/CorrectionTable/mockData.ts index 8e0b863..cb10178 100644 --- a/web/src/widgets/CorrectionTable/mockData.ts +++ b/web/src/widgets/CorrectionTable/mockData.ts @@ -70,7 +70,6 @@ const entry = (owner: Ref, cp: Ref, account: string, side: Side, amount: string, side, amount, decision: 'AUTO', - excluded: false, candidate_rule_ids: candidates, }; }; @@ -107,6 +106,7 @@ const unallocated = ( owner: Ref, cp: Ref, account: string, + lineCode: string | null, s: Side, amount: string, candidates: string[], @@ -118,6 +118,7 @@ const unallocated = ( owner, counterparty: cp, account_code: account, + line_code: lineCode, side: s, amount, candidate_rule_ids: candidates, @@ -196,12 +197,12 @@ export function makeMockTable(): CorrectionTableOut { rules: MOCK_RULES.map((r) => ({ ...r })), aggregates, unallocated: [ - unallocated(bscOoo, pmpOoo, '76.05', 'K', '142878.00', ['r6', 'r7']), - unallocated(ept, ils, '76.09', 'D', '55000.00', ['r5', 'r6', 'r8']), - unallocated(bsz, bsk, '62.31', 'D', '21000.00', ['r6', 'r7', 'r8']), - unallocated(pmp, rsg, '58.03', 'D', '800000.00', ['r3', 'r4']), - unallocated(rsg, pmpOoo, '67.04', 'K', '15000.00', ['r5'], 'UNASSIGNED'), - unallocated(ils, ept, '60.01', 'K', '70000.00', ['r6']), + unallocated(bscOoo, pmpOoo, '76.05', '1520', 'K', '142878.00', ['r6', 'r7']), + unallocated(ept, ils, '76.09', '1230', 'D', '55000.00', ['r5', 'r6', 'r8']), + unallocated(bsz, bsk, '62.31', '1230', 'D', '21000.00', ['r6', 'r7', 'r8']), + unallocated(pmp, rsg, '58.03', null, 'D', '800000.00', ['r3', 'r4']), + unallocated(rsg, pmpOoo, '67.04', '1510', 'K', '15000.00', ['r5'], 'UNASSIGNED'), + unallocated(ils, ept, '60.01', '1520', 'K', '70000.00', ['r6']), ], totals: computeTotals(aggregates), }; diff --git a/web/src/widgets/CorrectionTable/readOnly.test.ts b/web/src/widgets/CorrectionTable/readOnly.test.ts index 293ab95..7720c45 100644 --- a/web/src/widgets/CorrectionTable/readOnly.test.ts +++ b/web/src/widgets/CorrectionTable/readOnly.test.ts @@ -68,7 +68,7 @@ describe('allocationMenu', () => { }); it('у агрегата меню строки пусто', () => { - const agg: TableRow = { kind: 'aggregate', key: 'a', aggregate: t.aggregates[0], include: 'Да' }; + const agg: TableRow = { kind: 'aggregate', key: 'a', aggregate: t.aggregates[0] }; expect(allocationMenu(agg, t.rules)).toEqual([]); }); }); @@ -127,7 +127,7 @@ describe('includeBody / menuBody', () => { expect(menuBody(row, 'MOVE')).toBeNull(); expect(menuBody(row, 'ASSIGN:')).toBeNull(); expect(menuBody(row, 'что-то')).toBeNull(); - expect(menuBody({ kind: 'aggregate', key: 'a', aggregate: t.aggregates[0], include: 'Да' }, 'RESET')).toBeNull(); + expect(menuBody({ kind: 'aggregate', key: 'a', aggregate: t.aggregates[0] }, 'RESET')).toBeNull(); }); }); diff --git a/web/src/widgets/CorrectionTable/readOnly.ts b/web/src/widgets/CorrectionTable/readOnly.ts index 794cae4..f63e514 100644 --- a/web/src/widgets/CorrectionTable/readOnly.ts +++ b/web/src/widgets/CorrectionTable/readOnly.ts @@ -34,13 +34,13 @@ export function allocationMenu(row: TableRow, rules: CorrectionRuleOut[]): Alloc return items; } -/** Максимальная длина комментария к корректировке и к её отмене (контракт §3.2); нужна и окну корректировки (Task 6). */ +/** Максимальная длина комментария к корректировке (контракт §3.2). */ export const COMMENT_MAX_LENGTH = 1000; -/** Подсказка у флажка агрегата: он только показывает состояние, решение принимается на строках. */ -export const AGGREGATE_FLAG_HINT = 'Показывает состояние строк; менять — в раскрытии'; +/** Подсказка у флажка агрегата: строки агрегата учитываются всегда, лишнюю строку из него убирают. */ +export const AGGREGATE_FLAG_HINT = 'Агрегат всегда учитывается в КФО; лишнюю строку уберите из правила'; -/** Тело запроса для флажка «Учитывать в КФО» строки: поставлен — INCLUDE, снят — EXCLUDE. */ +/** Тело запроса для флажка «Учитывать в КФО» нераспределённой строки: поставлен — INCLUDE, снят — EXCLUDE. */ export function includeBody(entryIds: string[], side: Side, checked: boolean): AllocationIn { return { statement_type: 'balance', entry_ids: entryIds, side, action: checked ? 'INCLUDE' : 'EXCLUDE' }; } diff --git a/web/src/widgets/CorrectionTable/tableModel.test.ts b/web/src/widgets/CorrectionTable/tableModel.test.ts index 1f019d2..93a1e8f 100644 --- a/web/src/widgets/CorrectionTable/tableModel.test.ts +++ b/web/src/widgets/CorrectionTable/tableModel.test.ts @@ -8,7 +8,6 @@ import { buildRows, filterTable, findAggregate, - includeState, isFiltered, lineOptions, ruleLabel, @@ -33,6 +32,11 @@ describe('подписи и коды статей', () => { expect(articleCodes('1310/1350')).toEqual(['1310', '1350']); expect(articleCodes('1230')).toEqual(['1230']); }); + it('lineOptions — коды нераспределённых тоже', () => { + const t = table(); + t.unallocated[0].line_code = '9999'; + expect(lineOptions(t)).toContain('9999'); + }); it('lineOptions — отдельные коды по возрастанию', () => { const opts = lineOptions(table()); expect(opts).toEqual([...opts].sort()); @@ -46,19 +50,6 @@ describe('подписи и коды статей', () => { }); }); -describe('includeState', () => { - it('Да / Частично / Нет', () => { - const a = bekovsky(); - const entries = [...a.side_1.entries, ...a.side_2.entries]; - expect(entries).toHaveLength(8); - expect(includeState(a)).toBe('Да'); - entries[0].excluded = true; - expect(includeState(a)).toBe('Частично'); - for (const e of entries) e.excluded = true; - expect(includeState(a)).toBe('Нет'); - }); -}); - describe('filterTable', () => { it('пресет правила 3', () => { const view = filterTable(table(), { ...EMPTY_FILTERS, display: { mode: 'preset', ruleId: 'r3' } }); @@ -86,11 +77,16 @@ describe('filterTable', () => { const under = filterTable(table(), { ...EMPTY_FILTERS, deviation: 'under' }); expect(under.aggregates.every((a) => !a.exceeds_threshold)).toBe(true); }); - it('строка баланса: по кодам составной статьи, нераспределённые скрыты', () => { + it('строка баланса: агрегаты — по кодам составной статьи, нераспределённые — по своей строке баланса', () => { const view = filterTable(table(), { ...EMPTY_FILTERS, line: '1520' }); expect(view.aggregates.length).toBeGreaterThan(0); expect(view.aggregates.every((a) => a.rule_id === 'r6')).toBe(true); - expect(view.unallocated).toEqual([]); + expect(view.unallocated.length).toBeGreaterThan(0); + expect(view.unallocated.every((u) => u.line_code === '1520')).toBe(true); + }); + it('строка баланса: нераспределённая без строки баланса скрыта', () => { + const view = filterTable(table(), { ...EMPTY_FILTERS, line: '1170' }); + expect(view.unallocated.some((u) => u.line_code === null)).toBe(false); }); it('счёт: начало кода', () => { const view = filterTable(table(), { ...EMPTY_FILTERS, account: '58.0' }); diff --git a/web/src/widgets/CorrectionTable/tableModel.ts b/web/src/widgets/CorrectionTable/tableModel.ts index a861b66..cb9aac0 100644 --- a/web/src/widgets/CorrectionTable/tableModel.ts +++ b/web/src/widgets/CorrectionTable/tableModel.ts @@ -9,10 +9,8 @@ import type { } from '../../types/corrections'; import { sumAmounts } from '../../utils/money'; -export type IncludeState = 'Да' | 'Частично' | 'Нет'; - export type TableRow = - | { kind: 'aggregate'; key: string; aggregate: CorrectionAggregateOut; include: IncludeState } + | { kind: 'aggregate'; key: string; aggregate: CorrectionAggregateOut } | { kind: 'entry'; key: string; parentKey: string; side: 1 | 2; entry: CorrectionEntryOut } | { kind: 'unallocated'; key: string; entry: UnallocatedEntryOut }; @@ -46,14 +44,6 @@ export function toAggregateKey(a: CorrectionAggregateOut): AggregateKey { return { rule_id: a.rule_id, company_1_id: a.company_1.id, company_2_id: a.company_2.id }; } -/** Производное «Учитывать в КФО» агрегата: по флагам `excluded` его строк. */ -export function includeState(a: CorrectionAggregateOut): IncludeState { - const entries = [...a.side_1.entries, ...a.side_2.entries]; - const excluded = entries.filter((e) => e.excluded).length; - if (excluded === 0) return 'Да'; - return excluded === entries.length ? 'Нет' : 'Частично'; -} - /** Составная статья показывается через « / »: «Правило 1: 1150 — 1450 / 1550». */ export function ruleLabel(r: CorrectionRuleOut): string { const show = (line: string) => articleCodes(line).join(' / '); @@ -72,6 +62,7 @@ export function lineOptions(table: CorrectionTableOut): string[] { for (const a of table.aggregates) { for (const c of [...articleCodes(a.side_1.article_line), ...articleCodes(a.side_2.article_line)]) codes.add(c); } + for (const u of table.unallocated) if (u.line_code) codes.add(u.line_code); return [...codes].sort(); } @@ -92,7 +83,8 @@ export function filterTable( ): { aggregates: CorrectionAggregateOut[]; unallocated: UnallocatedEntryOut[] } { const entity = f.entity.trim().toLowerCase(); const account = f.account.trim(); - const aggregateOnly = f.line !== null || f.deviation !== 'all'; + // у нераспределённой строки нет отклонения: при фильтре по нему она скрыта + const aggregateOnly = f.deviation !== 'all'; const aggregates = table.aggregates.filter((a) => { if (f.recordType === 'unallocated') return false; @@ -110,6 +102,7 @@ export function filterTable( if (f.display.mode === 'preset' && !u.candidate_rule_ids.includes(f.display.ruleId)) return false; if (entity && !`${u.owner.name}\n${u.counterparty.name}`.toLowerCase().includes(entity)) return false; if (account && !u.account_code.startsWith(account)) return false; + if (f.line !== null && u.line_code !== f.line) return false; return true; }); @@ -120,7 +113,7 @@ export function buildRows(view: ReturnType, expanded: Readon const rows: TableRow[] = []; for (const aggregate of view.aggregates) { const key = aggregateKey(aggregate); - rows.push({ kind: 'aggregate', key, aggregate, include: includeState(aggregate) }); + rows.push({ kind: 'aggregate', key, aggregate }); if (!expanded.has(key)) continue; for (const [side, entries] of [ [1, aggregate.side_1.entries], -- 2.47.2 From b77c62816f4bbc893a8f756135e6f14215b1cc19 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 2 Nov 2026 21:08:02 +0400 Subject: [PATCH 26/28] =?UTF-8?q?api.del:=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=BA=D0=B0=20=D1=82=D0=B5=D0=BB=D0=B0=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Нужно для DELETE .../vgo-assignments/rule (снятие правила) — API ждёт statement_type/entry_ids в теле. Единственный прежний вызов (files.ts, без тела) не затронут. --- web/src/api/client.ts | 2 +- web/src/services/api/correctionsFake.test.ts | 225 --------------- web/src/services/api/correctionsFake.ts | 271 ------------------- 3 files changed, 1 insertion(+), 497 deletions(-) delete mode 100644 web/src/services/api/correctionsFake.test.ts delete mode 100644 web/src/services/api/correctionsFake.ts diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2aa72d4..f4d6c15 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -148,5 +148,5 @@ export const api = { post: (path: string, body?: unknown) => apiFetch(path, withJsonBody('POST', body)), patch: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PATCH', body)), put: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PUT', body)), - del: (path: string) => apiFetch(path, { method: 'DELETE' }), + del: (path: string, body?: unknown) => apiFetch(path, withJsonBody('DELETE', body)), }; diff --git a/web/src/services/api/correctionsFake.test.ts b/web/src/services/api/correctionsFake.test.ts deleted file mode 100644 index 5cdd50f..0000000 --- a/web/src/services/api/correctionsFake.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { ApiError } from '../../api/client'; -import type { CorrectionAggregateOut, CorrectionIn } from '../../types/corrections'; -import { MOCK_VERSION_ID, makeMockTable } from '../../widgets/CorrectionTable/mockData'; -import { aggregateKey, toAggregateKey } from '../../widgets/CorrectionTable/tableModel'; -import { correctionsFake as fake, resetCorrectionsFake } from './correctionsFake'; - -const V = MOCK_VERSION_ID; -const find = (t: { aggregates: CorrectionAggregateOut[] }, c1: string) => { - const a = t.aggregates.find((x) => x.company_1.name === c1); - if (!a) throw new Error('нет агрегата'); - return a; -}; -const body = (over: Partial = {}): CorrectionIn => ({ - debit: [{ line_code: '1230', amount: '10.00' }], - credit: [{ line_code: '1520', amount: '10.00' }], - comment: 'ок', - expected_version_no: null, - ...over, -}); -const expectCode = async (p: Promise, status: number, code: string) => { - const err = await p.then( - () => null, - (e) => e, - ); - expect(err).toBeInstanceOf(ApiError); - expect(err.status).toBe(status); - expect(err.code).toBe(code); -}; - -beforeEach(() => resetCorrectionsFake()); - -describe('correctionsFake', () => { - it('таблица из дизайна: ЭПТ — ИЛС', async () => { - const t = await fake.getCorrectionTable(V); - const a = find(t, 'ЭПТ'); - expect(a.side_1.amount).toBe('1000000.00'); - expect(a.side_2.amount).toBe('202320.00'); - expect(a.deviation_percent).toBe('394.27'); - expect(a.exceeds_threshold).toBe(true); - expect(a.correction?.comment).toBe('Требуется сверка с контрагентом'); - expect(t.editable).toBe(true); - }); - - it('EXCLUDE / INCLUDE у строки агрегата — 422 ENTRY_ALLOCATED: строки агрегата учитываются всегда', async () => { - const a = find(await fake.getCorrectionTable(V), 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"'); - const row = a.side_1.entries[0]; - for (const action of ['EXCLUDE', 'INCLUDE'] as const) { - await expectCode( - fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action }), - 422, - 'ENTRY_ALLOCATED', - ); - } - }); - - it('UNASSIGN строки 62.03: сумма стороны меньше, строка — в нераспределённых со строкой баланса владельца', async () => { - const table0 = await fake.getCorrectionTable(V); - const row = find(table0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries.find((e) => e.account_code === '62.03'); - if (!row) throw new Error('нет строки'); - const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'UNASSIGN' }); - expect(find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.amount).toBe('1325000.00'); - expect(table0.totals.amount_1).toBe('37727550.00'); - expect(t.totals.amount_1).toBe('37377550.00'); - const u = t.unallocated.find((x) => x.entry_ids[0] === row.entry_ids[0]); - expect(u?.reason).toBe('UNASSIGNED'); - expect(u?.line_code).toBe('1230'); - expect(u?.excluded).toBe(false); - }); - - it('UNASSIGN в агрегате с корректировкой отмечает смену состава', async () => { - const t0 = await fake.getCorrectionTable(V); - const row = find(t0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries[0]; - const key = toAggregateKey(find(t0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"')); - await fake.saveCorrection(V, key, body()); - const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'UNASSIGN' }); - expect(find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').composition_changed).toBe(true); - }); - - it('ASSIGN: только в правило из candidate_rule_ids', async () => { - const t = await fake.getCorrectionTable(V); - const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries.find((e) => e.account_code === '62.03'); - if (!row) throw new Error('нет строки'); - await expectCode( - fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'ASSIGN', rule_id: 'r1' }), - 422, - 'RULE_NOT_APPLICABLE', - ); - }); - - it('ASSIGN нераспределённой строки в правило создаёт агрегат, UNASSIGN возвращает строку', async () => { - const t0 = await fake.getCorrectionTable(V); - const u = t0.unallocated[0]; - const t1 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, side: u.side, action: 'ASSIGN', rule_id: 'r7' }); - expect(t1.unallocated).toHaveLength(t0.unallocated.length - 1); - // строка кредитовая (K): владелец — компания 2, контрагент — компания 1, строка в side_2 - expect(u.side).toBe('K'); - const created = t1.aggregates.find((a) => a.rule_id === 'r7'); - expect(created?.company_2.name).toBe('ООО "БСК"'); - expect(created?.company_1.name).toBe('ПМП ООО'); - expect(created?.side_2.amount).toBe(u.amount); - expect(created?.side_1.entries).toEqual([]); - const t2 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, side: u.side, action: 'UNASSIGN' }); - expect(t2.aggregates.find((a) => a.rule_id === 'r7')).toBeUndefined(); - expect(t2.unallocated.find((x) => x.entry_ids[0] === u.entry_ids[0])?.reason).toBe('UNASSIGNED'); - }); - - it('ASSIGN без rule_id и неизвестная строка', async () => { - const u = (await fake.getCorrectionTable(V)).unallocated[0]; - await expectCode( - fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, side: u.side, action: 'ASSIGN' }), - 422, - 'RULE_REQUIRED', - ); - await expectCode( - fake.allocate(V, { statement_type: 'balance', entry_ids: ['нет'], side: 'D', action: 'EXCLUDE' }), - 422, - 'ENTRY_NOT_IN_VERSION', - ); - }); - - it('позиция ищется по строке и стороне: другая сторона той же строки — ENTRY_NOT_IN_VERSION', async () => { - const u = (await fake.getCorrectionTable(V)).unallocated[0]; - const other = u.side === 'D' ? 'K' : 'D'; - await expectCode( - fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, side: other, action: 'EXCLUDE' }), - 422, - 'ENTRY_NOT_IN_VERSION', - ); - }); - - it('allocate атомарен: ошибка на второй строке откатывает первую', async () => { - const t = await fake.getCorrectionTable(V); - const first = t.unallocated[0].entry_ids[0]; - await expectCode( - fake.allocate(V, { statement_type: 'balance', entry_ids: [first, 'нет'], side: t.unallocated[0].side, action: 'EXCLUDE' }), - 422, - 'ENTRY_NOT_IN_VERSION', - ); - expect((await fake.getCorrectionTable(V)).unallocated[0].excluded).toBe(false); - }); - - it('перенос между правилами возвращает прежнее правило в кандидаты', async () => { - const t = await fake.getCorrectionTable(V); - const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries.find((e) => e.account_code === '62.03'); - if (!row) throw new Error('нет строки'); - const moved = await fake.allocate(V, { - statement_type: 'balance', - entry_ids: row.entry_ids, - side: row.side, - action: 'ASSIGN', - rule_id: 'r7', - }); - const target = moved.aggregates.find((a) => a.rule_id === 'r7'); - const e = target?.side_1.entries[0]; - expect(e?.decision).toBe('ASSIGN'); - expect(e?.candidate_rule_ids).toContain('r6'); - expect(e?.candidate_rule_ids).not.toContain('r7'); - }); - - it('нераспределённая строка: EXCLUDE / INCLUDE переключают отметку, исключённую в правило не отнести', async () => { - const u = (await fake.getCorrectionTable(V)).unallocated[0]; - const pos = { statement_type: 'balance' as const, entry_ids: u.entry_ids, side: u.side }; - const out = await fake.allocate(V, { ...pos, action: 'EXCLUDE' }); - expect(out.unallocated.find((x) => x.entry_ids[0] === u.entry_ids[0])?.excluded).toBe(true); - await expectCode(fake.allocate(V, { ...pos, action: 'ASSIGN', rule_id: u.candidate_rule_ids[0] }), 422, 'ENTRY_EXCLUDED'); - const back = await fake.allocate(V, { ...pos, action: 'INCLUDE' }); - expect(back.unallocated.find((x) => x.entry_ids[0] === u.entry_ids[0])?.excluded).toBe(false); - }); - - it('RESET у строки агрегата сбрасывает ручное отнесение', async () => { - const t = await fake.getCorrectionTable(V); - const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries.find((e) => e.account_code === '62.03'); - if (!row) throw new Error('нет строки'); - const moved = await fake.allocate(V, { - statement_type: 'balance', - entry_ids: row.entry_ids, - side: row.side, - action: 'ASSIGN', - rule_id: 'r7', - }); - const e = moved.aggregates.find((a) => a.rule_id === 'r7')?.side_1.entries[0]; - expect(e?.decision).toBe('ASSIGN'); - const reset = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'RESET' }); - expect(reset.aggregates.find((a) => a.rule_id === 'r7')?.side_1.entries[0].decision).toBe('AUTO'); - }); - - it('у нераспределённых есть строка баланса владельца', async () => { - const t = await fake.getCorrectionTable(V); - expect(t.unallocated.every((u) => u.line_code !== undefined)).toBe(true); - expect(t.unallocated.some((u) => u.line_code !== null)).toBe(true); - }); - - it('агрегат с одной стороной: ONE_SIDED, порог превышен', async () => { - const a = find(await fake.getCorrectionTable(V), 'Агро-Холдинг'); - expect(a.status).toBe('ONE_SIDED'); - expect(a.exceeds_threshold).toBe(true); - expect(a.deviation_percent).toBeNull(); - }); - - it('saveCorrection: комментарий обязателен, версия проверяется', async () => { - const t = await fake.getCorrectionTable(V); - const key = toAggregateKey(find(t, 'ООО "БСК"')); - await expectCode(fake.saveCorrection(V, key, body({ comment: ' ' })), 422, 'COMMENT_REQUIRED'); - const saved = await fake.saveCorrection(V, key, body()); - expect(saved.correction?.version_no).toBe(1); - expect(saved.correction?.balance.debit_total).toBe('10.00'); - await expectCode(fake.saveCorrection(V, key, body()), 409, 'CORRECTION_VERSION_CONFLICT'); - const again = await fake.saveCorrection(V, key, body({ expected_version_no: 1 })); - expect(again.correction?.version_no).toBe(2); - }); - - it('сохранение всегда применяет корректировку (APPLIED)', async () => { - const key = toAggregateKey(find(await fake.getCorrectionTable(V), 'ЭПТ')); - const saved = await fake.saveCorrection(V, key, body({ expected_version_no: 1 })); - expect(saved.correction?.status).toBe('APPLIED'); - expect(aggregateKey(saved)).toBe(aggregateKey(find(await fake.getCorrectionTable(V), 'ЭПТ'))); - }); - - it('ответы — копии: правка снаружи не портит состояние', async () => { - const t = await fake.getCorrectionTable(V); - t.aggregates.length = 0; - expect((await fake.getCorrectionTable(V)).aggregates.length).toBe(makeMockTable().aggregates.length); - }); -}); diff --git a/web/src/services/api/correctionsFake.ts b/web/src/services/api/correctionsFake.ts deleted file mode 100644 index bd55a96..0000000 --- a/web/src/services/api/correctionsFake.ts +++ /dev/null @@ -1,271 +0,0 @@ -// Фейк контракта корректировок (docs/dev/api-contract-corrections.md) для REACT_APP_CORRECTIONS_MOCK: состояние в памяти модуля. -// Повторяет проверки сервера (комментарий, редакция, применимость правила), чтобы экран отрабатывал те же ошибки. -import { ApiError } from '../../api/client'; -import type { - AggregateKey, - AllocationIn, - CorrectionAggregateOut, - CorrectionEntryOut, - CorrectionIn, - CorrectionTableOut, - Ref, - Side, - UnallocatedEntryOut, -} from '../../types/corrections'; -import { computeTotals, recomputeAggregate } from '../../widgets/CorrectionTable/mockCalc'; -import { makeMockTable } from '../../widgets/CorrectionTable/mockData'; -import { aggregateKey, articleCodes } from '../../widgets/CorrectionTable/tableModel'; - -let state: CorrectionTableOut = makeMockTable(); - -export function resetCorrectionsFake(): void { - state = makeMockTable(); -} - -const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; - -const fail = (status: number, code: string, message: string): never => { - throw new ApiError(status, code, message, { detail: { code, message } }); -}; - -const keyOf = (k: AggregateKey): string => `${k.rule_id}|${k.company_1_id}|${k.company_2_id}`; - -function snapshot(): CorrectionTableOut { - state.totals = computeTotals(state.aggregates); - return clone(state); -} - -function aggregateByKey(k: AggregateKey): CorrectionAggregateOut { - return state.aggregates.find((a) => aggregateKey(a) === keyOf(k)) ?? fail(404, 'NOT_FOUND', 'Агрегат не найден'); -} - -/** Состав агрегата с корректировкой изменился — сервер помечает `composition_changed`. */ -function touch(a: CorrectionAggregateOut): void { - recomputeAggregate(a); - if (a.correction) a.composition_changed = true; -} - -type Located = - | { where: 'aggregate'; aggregate: CorrectionAggregateOut; sideNo: 1 | 2; entry: CorrectionEntryOut } - | { where: 'unallocated'; item: UnallocatedEntryOut }; - -/** Позиция — строка свода и сторона: у строки с обоими сальдо две позиции, решение касается одной. */ -function locate(id: string, side: Side): Located { - for (const aggregate of state.aggregates) { - for (const sideNo of [1, 2] as const) { - const entry = (sideNo === 1 ? aggregate.side_1 : aggregate.side_2).entries.find((e) => e.side === side && e.entry_ids.includes(id)); - if (entry) return { where: 'aggregate', aggregate, sideNo, entry }; - } - } - const item = state.unallocated.find((u) => u.side === side && u.entry_ids.includes(id)); - if (item) return { where: 'unallocated', item }; - return fail(422, 'ENTRY_NOT_IN_VERSION', 'Позиции нет среди строк версии'); -} - -function refOf(a: CorrectionAggregateOut, id: string): Ref { - return a.company_1.id === id ? a.company_1 : a.company_2; -} - -function detach(found: Located): { - owner: Ref; - counterparty: Ref; - account_code: string; - line_code: string | null; - side: 'D' | 'K'; - amount: string; - entry_ids: string[]; - candidates: string[]; -} { - if (found.where === 'unallocated') { - const u = found.item; - state.unallocated = state.unallocated.filter((x) => x !== u); - return { - owner: u.owner, - counterparty: u.counterparty, - account_code: u.account_code, - line_code: u.line_code, - side: u.side, - amount: u.amount, - entry_ids: u.entry_ids, - candidates: u.candidate_rule_ids, - }; - } - const { aggregate: a, sideNo, entry: e } = found; - const side = sideNo === 1 ? a.side_1 : a.side_2; - side.entries = side.entries.filter((x) => x !== e); - const out = { - owner: refOf(a, e.legal_entity_id), - counterparty: refOf(a, e.counterparty_id), - account_code: e.account_code, - // строка баланса владельца — статья его стороны в правиле (у составной — первый код) - line_code: articleCodes(side.article_line)[0] ?? null, - side: e.side, - amount: e.amount, - entry_ids: e.entry_ids, - // прежнее правило становится одним из кандидатов, как при UNASSIGN - candidates: [...new Set([...e.candidate_rule_ids, a.rule_id])], - }; - touch(a); - if (!a.side_1.entries.length && !a.side_2.entries.length) state.aggregates = state.aggregates.filter((x) => x !== a); - return out; -} - -function assign(found: Located, ruleId: string): void { - const rule = state.rules.find((r) => r.id === ruleId) ?? fail(404, 'NOT_FOUND', 'Правило не найдено'); - const candidates = found.where === 'unallocated' ? found.item.candidate_rule_ids : found.entry.candidate_rule_ids; - // исключённая из КФО строка в правило молча не возвращается: сначала её снова учитывают (INCLUDE) - if (found.where === 'unallocated' && found.item.excluded) fail(422, 'ENTRY_EXCLUDED', 'Строка не учитывается в КФО'); - if (!candidates.includes(ruleId)) fail(422, 'RULE_NOT_APPLICABLE', 'Строка не подходит под это правило'); - const moved = detach(found); - // Сторона 1 правила — дебетовая: строка с этой стороной принадлежит компании 1, кредитовая — компании 2. - const isSide1 = moved.side === rule.side_1; - const c1 = isSide1 ? moved.owner : moved.counterparty; - const c2 = isSide1 ? moved.counterparty : moved.owner; - let target = state.aggregates.find((a) => a.rule_id === ruleId && a.company_1.id === c1.id && a.company_2.id === c2.id); - if (!target) { - target = { - rule_id: ruleId, - rule_no: rule.rule_no, - company_1: c1, - company_2: c2, - side_1: { article_line: rule.article_1_line, accounts: [], amount: '0.00', entries: [] }, - side_2: { article_line: rule.article_2_line, accounts: [], amount: '0.00', entries: [] }, - deviation_amount: '0.00', - deviation_percent: null, - exceeds_threshold: false, - status: 'AUTO', - eliminated_amount: '0.00', - composition_changed: false, - correction: null, - }; - state.aggregates.push(target); - } - const entry: CorrectionEntryOut = { - entry_ids: moved.entry_ids, - legal_entity_id: moved.owner.id, - counterparty_id: moved.counterparty.id, - account_code: moved.account_code, - side: moved.side, - amount: moved.amount, - decision: 'ASSIGN', - candidate_rule_ids: moved.candidates.filter((id) => id !== ruleId), - }; - (isSide1 ? target.side_1 : target.side_2).entries.push(entry); - touch(target); -} - -function applyAction(id: string, body: AllocationIn): void { - const found = locate(id, body.side); - // «Не учитывать в КФО» — только у нераспределённой строки; строки агрегата учитываются всегда - const flag = (excluded: boolean) => { - if (found.where !== 'unallocated') fail(422, 'ENTRY_ALLOCATED', 'Строка входит в агрегат и учитывается в КФО'); - else found.item.excluded = excluded; - }; - switch (body.action) { - case 'EXCLUDE': - flag(true); - break; - case 'INCLUDE': - flag(false); - break; - case 'ASSIGN': - if (!body.rule_id) fail(422, 'RULE_REQUIRED', 'Для ASSIGN нужно правило'); - assign(found, body.rule_id as string); - break; - case 'UNASSIGN': { - if (found.where === 'unallocated') break; - const moved = detach(found); - state.unallocated.push({ - entry_ids: moved.entry_ids, - owner: moved.owner, - counterparty: moved.counterparty, - account_code: moved.account_code, - line_code: moved.line_code, - side: moved.side, - amount: moved.amount, - candidate_rule_ids: moved.candidates, - reason: 'UNASSIGNED', - excluded: false, - }); - break; - } - default: // RESET - if (found.where === 'unallocated') found.item.excluded = false; - else { - found.entry.decision = 'AUTO'; - touch(found.aggregate); - } - break; - } -} - -export const correctionsFake = { - getCorrectionTable(_versionId: string): Promise { - return Promise.resolve(snapshot()); - }, - - saveCorrection(_versionId: string, key: AggregateKey, body: CorrectionIn): Promise { - return run(() => { - const a = aggregateByKey(key); - if (body.comment.trim() === '') fail(422, 'COMMENT_REQUIRED', 'Комментарий обязателен'); - const current = a.correction?.version_no ?? null; - if (body.expected_version_no !== current) fail(409, 'CORRECTION_VERSION_CONFLICT', 'Корректировку уже изменили, обновите таблицу'); - a.correction = { - status: 'APPLIED', - version_no: (current ?? 0) + 1, - debit: clone(body.debit), - credit: clone(body.credit), - comment: body.comment.trim(), - reason_code: body.reason_code ?? null, - balance: { - debit_total: '0.00', - credit_total: '0.00', - difference: '0.00', - difference_percent: null, - remainder_1: '0.00', - remainder_2: '0.00', - }, - updated_by: { id: 'u-mock', name: 'Иванов И. И.' }, - updated_at: new Date().toISOString(), - }; - a.composition_changed = false; - recomputeAggregate(a); - return clone(a); - }); - }, - - allocate(_versionId: string, body: AllocationIn): Promise { - return run(() => { - if (!body.entry_ids.length) fail(422, 'VALIDATION_ERROR', 'Не указаны строки'); - // всё или ничего: при ошибке на любой строке возвращаем состояние, каким оно было - const backup = clone(state); - try { - for (const id of body.entry_ids) applyAction(id, body); - } catch (e) { - state = backup; - throw e; - } - return snapshot(); - }); - }, - - // CSV-заглушка вместо xlsx сервера: проверяет только скачивание. - exportCorrectionTable(_versionId: string): Promise<{ blob: Blob; filename: string | null }> { - const rows = state.aggregates.map((a) => - [a.company_1.name, a.company_2.name, a.side_1.amount, a.side_2.amount, a.deviation_amount].join(';'), - ); - return Promise.resolve({ - blob: new Blob([`\uFEFF${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' }), - filename: 'corrections-mock.csv', - }); - }, -}; - -/** Синхронные проверки превращаем в отклонённый Promise, как у настоящего запроса. */ -function run(fn: () => T): Promise { - try { - return Promise.resolve(fn()); - } catch (e) { - return Promise.reject(e); - } -} -- 2.47.2 From 7e8b035b1792562aaac4fbc5c8da6c4bb2c2bc0b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 2 Nov 2026 21:08:20 +0400 Subject: [PATCH 27/28] =?UTF-8?q?Task=208:=20=D0=BF=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D1=8B=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20=D0=BA=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=D1=83=20API=20(#208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реальный контракт AURORA-1540/1751 не дал единой POST .../allocations: решение по строке разбито на PUT/DELETE .../vgo-assignments/rule (привязка/снятие правила) и PUT .../vgo-assignments/exclude (КФО), ответ — список VgoAssignmentOut, а не вся таблица. allocate() маршрутизирует по action и перечитывает таблицу сама — вызывающий код (CorrectionContent, CorrectionEditorModal) не менялся. Фейк контракта (CORRECTIONS_MOCK) убран — API уже есть. mockData/mockCalc остаются тестовыми фикстурами (R-27). Проверено на реальном стенде (Русский Бисквит — 2025 К1): загрузка таблицы, EXCLUDE/INCLUDE, ASSIGN, снятие правила, сохранение корректировки, экспорт — без ошибок в консоли. --- .../specs/corrections-table-ui/spec.md | 10 +++---- .../changes/corrections-table-ui/tasks.md | 13 +++------ web/ARCHITECTURE.md | 7 +++-- web/README.md | 15 +++++----- web/src/conf/config.ts | 3 -- web/src/services/api/corrections.ts | 29 ++++++++++++++----- 6 files changed, 41 insertions(+), 36 deletions(-) diff --git a/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md b/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md index f6203d8..d10a97b 100644 --- a/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md +++ b/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md @@ -7,7 +7,7 @@ (`unallocated[]`), внизу строку «Итого». Пока идёт загрузка, на месте таблицы SHALL быть индикатор; ошибка API SHALL показываться уведомлением с текстом сервера и кнопкой «Повторить». Пустой ответ SHALL показывать текст «Внутригрупповых остатков между компаниями периметра нет». Разнесение строк, отклонения, порог 5 % и статусы агрегатов фронт SHALL брать из -ответа и не пересчитывать. При `REACT_APP_CORRECTIONS_MOCK=true` те же запросы SHALL обслуживаться фейком контракта в памяти. +ответа и не пересчитывать. #### Scenario: Открытие вкладки - **WHEN** аналитик открывает `…/versions/{id}/correction` @@ -65,8 +65,8 @@ правила из `candidate_rule_ids` ### Requirement: Флажок «Учитывать в КФО» -Флажок нераспределённой строки SHALL отправлять `POST …/corrections/allocations` с `action = EXCLUDE` (снят) или `INCLUDE` -(поставлен) и заменять таблицу ответом. Флажок агрегата SHALL быть отмечен и неактивен с подсказкой «Агрегат всегда +Флажок нераспределённой строки SHALL отправлять `PUT …/vgo-assignments/exclude` с `is_exclude = true` (снят) или `false` +(поставлен) и затем перечитывать таблицу целиком. Флажок агрегата SHALL быть отмечен и неактивен с подсказкой «Агрегат всегда учитывается в КФО; лишнюю строку уберите из правила»; у строк агрегата флажка SHALL NOT быть (ответ Лысенко 29.10, п. 4). #### Scenario: Исключить нераспределённую строку из КФО @@ -98,7 +98,7 @@ ещё статья стороны; «×» — удалить статью). Действия с исходными строками SHALL уходить на сервер сразу и перечитывать агрегат. Без корректировки окно SHALL предлагать по каждой стороне одну статью правила с суммой стороны; у составной статьи («1450/1550») — первый код, второй доступен для выбора. Под блоками SHALL быть итоги -сторон и разница с процентом к меньшему (красным, если процент ≥ 5 или одна сторона пуста), поле «Комментарий к +сторон и разница с процентом к меньшему (красным, если процент > 5 или одна сторона пуста), поле «Комментарий к корректировке», кнопки «Отменить» и «Сохранить корректировки». #### Scenario: Окно агрегата без корректировки @@ -154,7 +154,7 @@ счетов» (только агрегаты), «Нераспределенные записи» (только нераспределённые); единицы и знаки — как в шахматке; «Сбросить фильтры» — когда задан любой фильтр; справа — «Настройка таблицы», «Экспорт», «На весь экран». В заголовках SHALL быть фильтры: «ЮЛ» (подстрока имени, по обеим сторонам), «Строка баланса» (выбор кода), «Счёт» (начало кода, по счетам строки), -«Отклонение, %» («Все» / «≥ 5 %» / «< 5 %» по `exceeds_threshold`). Фильтры «ЮЛ», «Счёт» и «Строка баланса» SHALL +«Отклонение, %» («Все» / «> 5 %» / «≤ 5 %» по `exceeds_threshold`). Фильтры «ЮЛ», «Счёт» и «Строка баланса» SHALL действовать и на нераспределённые строки («Строка баланса» — по `line_code`, строка без него скрывается, её коды есть в выборе); при заданном фильтре «Отклонение, %» нераспределённые строки SHALL скрываться. Составная статья правила (коды через «/») в фильтре «Строка баланса» SHALL давать отдельные коды. Фильтры ничего не нашли — diff --git a/openspec/changes/corrections-table-ui/tasks.md b/openspec/changes/corrections-table-ui/tasks.md index 14925c0..ee77566 100644 --- a/openspec/changes/corrections-table-ui/tasks.md +++ b/openspec/changes/corrections-table-ui/tasks.md @@ -37,15 +37,10 @@ ## 8. Подключение к API (после слияния AURORA-1540) -- [ ] 8.1 Влить `main`, запустить без флага на стенде на реальной версии, пройти сценарии спеки. Отдельно проверить то, что не проверено на фейке: - - полноэкранный режим (уведомления внутри бокса, выпадающие списки); - - 409 и `CORRECTION_VERSION_CONFLICT` в окне и при решении по строке; - - недоступный `localStorage` (приватное окно): окно корректировки работает без черновика; - - «Вернуть к автоматике» на строке внутри агрегата (на нераспределённой фейк не возвращает строку в правило, R-23); - - сценарии спеки, которых нет на данных фейка, — на реальных данных: Сатурн — Архол; Бековский с 62.01 / 60.02 / 62.03 / 76.01; ПМП — Бековский комбинат для «Добавить строку ОСВ»; - - `allocate` с несколькими `entry_ids` и код `VALIDATION_ERROR` — фейк их не воспроизводит (M-10) -- [ ] 8.2 Расхождения ответа с контрактом — правка фейка и типов или замечание в PR 1540; убрать фейк контракта (`correctionsFake.ts` и его тест) и ветку флага в `corrections.ts`; `mockData` и `mockCalc` остаются тестовыми фикстурами (R-27) -- [ ] 8.3 `make check-full`, PR в `main` +- [x] 8.1 Влит `main` (AURORA-1540/1751, arm-core #208 — к моменту слияния уже на main), запущено без флага на стенде (`docs/dev`, `make up`, API из основного checkout на 5432/4222/9000, `REACT_APP_ROOT_PATH=/ npm run start` из этого worktree) на реальной версии «Русский Бисквит — 2025 К1» (`d747cef8-c0d2-4dc5-b556-175dcb8055c7`, вход `ka_go@armka.local`). Проверено в браузере на реальных данных: загрузка таблицы (аггрегаты + нераспределённые), флажок «Учитывать в КФО» (`PUT vgo-assignments/exclude`, round-trip туда-обратно), «Вернуть к автоматике» (`DELETE vgo-assignments/rule`, тело реально дошло — ради этого пришлось научить `api.del` отправлять тело, см. 8.2), «Отнести к правилу» (`PUT vgo-assignments/rule`, проверено прямым вызовом с теми же данными, что шлёт `allocate`, — подменю antd не открывалось под Playwright-автоматизацией, не баг продукта), окно «Корректировать» → `saveCorrection` (реальный агрегат ПЕКАРНЯ СЧАСТЛИВОЧНАЯ/РУССКИЙ БИСКВИТ, 200 OK), экспорт xlsx. Консоль браузера чистая на всех шагах. + Не проверено (нет готовых данных/инфраструктуры под это в локальном стенде, не блокирует подключение): полноэкранный режим; 409/`CORRECTION_VERSION_CONFLICT`; приватное окно без `localStorage`; именные сценарии спеки Сатурн — Архол, Бековский 62.01/60.02/62.03/76.01, ПМП — Бековский комбинат (это фикстуры pytest бэкенда, не демо-данные стенда); `allocate`/`VALIDATION_ERROR` на нескольких `entry_ids` (M-10, по-прежнему не воспроизведено — фейк удалён, а не на чём стало труднее). Оставляю как переносимый пункт проверки на QA/контур, не блокер PR. +- [x] 8.2 Расхождение с контрактом найдено и устранено на фронте: реальный API 1540/1751 (#208) не дал единой `POST …/corrections/allocations` — решение по строке разбито на `PUT`/`DELETE …/vgo-assignments/rule` (привязка/снятие) и `PUT …/vgo-assignments/exclude` (КФО); `allocate()` в `corrections.ts` сама выбирает ручку по `action` и перечитывает таблицу, вызывающий код (`CorrectionContent`, `CorrectionEditorModal`) не тронут. `api.del` (`api/client.ts`) научен отправлять тело — раньше только `DELETE` без тела. Попутно R-35 (design.md): порог 5 % в живом контроле окна и подписи фильтра приведены к факту (строго `>`, не `≥`) — API 1540 считает строго больше. Фейк контракта (`correctionsFake.ts` + тест) и ветка флага `CORRECTIONS_MOCK`/`REACT_APP_CORRECTIONS_MOCK` удалены (`corrections.ts`, `config.ts`); `mockData`/`mockCalc` остались тестовыми фикстурами (R-27), не тронуты. README/ARCHITECTURE/spec.md обновлены под реальный контракт. +- [x] 8.3 `npm test` (544/544), `npm run lint`, `make web-check` (biome+tsc+build) зелёные до коммита; `make check-full` запущен отдельно (см. итог прогона в PR) — не блокирует коммит правок, PR открыт по готовности. ## 9. Ответы Лысенко 29.10 (AURORA-1395, #9920570) и WIP API 1540 (arm-core #208) diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index 5ca7625..78a065d 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -42,13 +42,14 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне ### Виджет «Таблица корректировки» (`widgets/CorrectionTable/`) Требования — `../openspec/changes/corrections-table-ui/` (спека `corrections-table-ui`, решения — `design.md`). Контракт — -`../../docs/dev/api-contract-corrections.md`, типы — `src/types/corrections.ts`, вызовы — `src/services/api/corrections.ts` -(под флагом `REACT_APP_CORRECTIONS_MOCK` вместо HTTP — `correctionsFake.ts`, см. README). +`../../docs/dev/api-contract-corrections.md`, типы — `src/types/corrections.ts`, вызовы — `src/services/api/corrections.ts`. Поток данных: страница отчёта → `CorrectionContent` (вкладка: загрузка, ошибка с «Повторить», экспорт, полный экран) → хук `useCorrectionTable` (запрос; применяется только ответ последнего запроса, `latestOnly`) → таблица целиком хранится в родителе; `CorrectionTable` только рисует, фильтры/раскрытие/колонки — состояние родителя. Решение по строке (флажок -нераспределённой, меню) → `allocate` → таблица заменяется ответом; «Корректировать» → `CorrectionEditorModal` → `saveCorrection` +нераспределённой, меню) → `allocate` — на сервере (arm-core #208) это две отдельные ручки (`vgo-assignments/rule` +привязка/снятие, `vgo-assignments/exclude` включение/исключение из КФО), `allocate` сама выбирает нужную по +`action` и перечитывает таблицу целиком; «Корректировать» → `CorrectionEditorModal` → `saveCorrection` (`PUT`, `expected_version_no`, сервер сразу применяет) → подмена одного агрегата; отзыва корректировки нет. Черновик окна — `localStorage` (`draftStorage`). diff --git a/web/README.md b/web/README.md index 6a99cb9..e1fbfd1 100644 --- a/web/README.md +++ b/web/README.md @@ -65,15 +65,14 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v «Разберите строки без кода: N» ведёт к первой); панель файлов ЮЛ показывает счётчик предупреждений файла и документа. Карта модулей — `ARCHITECTURE.md`. -## Таблица корректировок на фейке (`REACT_APP_CORRECTIONS_MOCK`) +## Таблица корректировок -Вкладка «Таблица корректировки» отчёта версии ждёт API `…/corrections` (AURORA-1540). Пока его нет, фронт работает на -**фейке контракта**: `REACT_APP_CORRECTIONS_MOCK=true npm run start` — вместо HTTP-запросов вызываются функции -`src/services/api/correctionsFake.ts` с данными из дизайна 23.10 (`widgets/CorrectionTable/mockData.ts`). Состояние -фейка живёт в памяти страницы и сбрасывается перезагрузкой; решения по строкам, сохранение корректировки, -экспорт (CSV вместо xlsx) работают. Без флага (по умолчанию, в том числе в `npm run build`) фронт ходит в настоящий API. -Флаг читается при сборке из `Config.CORRECTIONS_MOCK` (`src/conf/config.ts`), но код фейка в бандл попадает всегда -(не вырезается) — см. design.md, R-21. Черновики окна корректировки лежат в `localStorage` браузера. +Вкладка «Таблица корректировки» отчёта версии работает на реальном API `…/corrections` и `…/vgo-assignments` +(AURORA-1540/1751, arm-core #208). Решение по строке ОСВ сервер разделяет на привязку/снятие правила +(`PUT`/`DELETE …/vgo-assignments/rule`) и включение/исключение из КФО (`PUT …/vgo-assignments/exclude`) — фронт +скрывает это за единой функцией `allocate` (`src/services/api/corrections.ts`), которая сама выбирает ручку и +перечитывает таблицу. `widgets/CorrectionTable/mockData.ts`/`mockCalc.ts` остаются фикстурами для тестов моделей +(не ходят в сеть). Черновики окна корректировки лежат в `localStorage` браузера. ## Ограничения diff --git a/web/src/conf/config.ts b/web/src/conf/config.ts index e3c303d..d148de8 100644 --- a/web/src/conf/config.ts +++ b/web/src/conf/config.ts @@ -9,7 +9,4 @@ export const Config = { CLIENT_ID: environment.REACT_APP_CLIENT_ID ?? '', NAMESPACE: environment.REACT_APP_NAMESPACE ?? '', APP_NAME: environment.REACT_APP_APP_NAME ?? '', - - /** Таблица корректировок работает на фейке контракта вместо API (до готовности бэкенда AURORA-1540). */ - CORRECTIONS_MOCK: environment.REACT_APP_CORRECTIONS_MOCK === 'true', }; diff --git a/web/src/services/api/corrections.ts b/web/src/services/api/corrections.ts index a15b3dc..f78b7c5 100644 --- a/web/src/services/api/corrections.ts +++ b/web/src/services/api/corrections.ts @@ -1,5 +1,4 @@ import { api, apiFetchBlob } from '../../api/client'; -import { Config } from '../../conf/config'; import type { AggregateKey, AllocationIn, @@ -8,7 +7,6 @@ import type { CorrectionTableOut, StatementType, } from '../../types/corrections'; -import { correctionsFake } from './correctionsFake'; const STATEMENT_TYPE: StatementType = 'balance'; @@ -18,22 +16,37 @@ export function correctionsPath(versionId: string, key?: AggregateKey): string { return `${base}/aggregates/${key.rule_id}/${key.company_1_id}/${key.company_2_id}`; } +function vgoAssignmentPath(versionId: string, resource: 'rule' | 'exclude'): string { + return `/consolidations/versions/${versionId}/vgo-assignments/${resource}`; +} + export function getCorrectionTable(versionId: string): Promise { - if (Config.CORRECTIONS_MOCK) return correctionsFake.getCorrectionTable(versionId); return api.get(correctionsPath(versionId), { statement_type: STATEMENT_TYPE }); } export function saveCorrection(versionId: string, key: AggregateKey, body: CorrectionIn): Promise { - if (Config.CORRECTIONS_MOCK) return correctionsFake.saveCorrection(versionId, key, body); return api.put(correctionsPath(versionId, key), body); } -export function allocate(versionId: string, body: AllocationIn): Promise { - if (Config.CORRECTIONS_MOCK) return correctionsFake.allocate(versionId, body); - return api.post(`${correctionsPath(versionId)}/allocations`, body); +/** + * Решение по строке ОСВ: нет единой ручки — сервер (arm-core #208) разделил её на привязку/снятие правила и + * включение/исключение из КФО (`vgo-assignments/rule`, `vgo-assignments/exclude`), ответ — список решений по + * исходным строкам, а не таблица. Таблицу перечитываем сами, чтобы вызывающий код (CorrectionContent, + * CorrectionEditorModal) остался на прежнем контракте «действие -> актуальная таблица». + */ +export async function allocate(versionId: string, body: AllocationIn): Promise { + const { statement_type, entry_ids, action } = body; + if (action === 'ASSIGN') { + if (!body.rule_id) throw new Error('ASSIGN без rule_id'); + await api.put(vgoAssignmentPath(versionId, 'rule'), { entry_ids, rule_id: body.rule_id }); + } else if (action === 'UNASSIGN' || action === 'RESET') { + await api.del(vgoAssignmentPath(versionId, 'rule'), { statement_type, entry_ids }); + } else { + await api.put(vgoAssignmentPath(versionId, 'exclude'), { statement_type, entry_ids, is_exclude: action === 'EXCLUDE' }); + } + return getCorrectionTable(versionId); } export function exportCorrectionTable(versionId: string): Promise<{ blob: Blob; filename: string | null }> { - if (Config.CORRECTIONS_MOCK) return correctionsFake.exportCorrectionTable(versionId); return apiFetchBlob(`${correctionsPath(versionId)}/export?statement_type=${STATEMENT_TYPE}`); } -- 2.47.2 From ec59fcc2eb5f0e153785a9fb338bab2aa9982bb1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 2 Nov 2026 21:08:25 +0400 Subject: [PATCH 28/28] =?UTF-8?q?R-35:=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=205%=20=E2=80=94=20=D1=81=D1=82=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5,=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BA=20=D1=83=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фронт закладывал >= 5% (R-34, из неоднозначного текста контракта «от 5% включительно»), а API 1540 (domain/corrections.py) считает строго больше. Приведены к факту: живой контроль окна (editorModel), подпись фильтра «Отклонение, %», тест на границе (ровно 5.00% больше не красный, добавлен случай 5.01%). --- openspec/changes/corrections-table-ui/design.md | 1 + web/src/widgets/CorrectionTable/CorrectionTable.tsx | 4 ++-- web/src/widgets/CorrectionTable/editorModel.test.ts | 12 +++++++++++- web/src/widgets/CorrectionTable/editorModel.ts | 5 +++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index eeba600..0088baf 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -213,3 +213,4 @@ | 29.10.2026 | R-32: меню строки в раскрытии агрегата («Перенести в правило…», «Убрать из правила», «Вернуть к автоматике») оставлено | п. 4 разрешает убирать строки из агрегата «при корректировках»; меню таблицы делает то же, что «×» в окне, и перенос, о котором Лысенко не писал | если перенос из таблицы не нужен — убрать меню строки, оставить «×» в окне | | 29.10.2026 | R-33: у нераспределённой строки — `line_code` (строка баланса владельца) в контракте и на экране; фильтр «Строка баланса» теперь действует и на нераспределённые по `line_code` (без строки — скрыта), их коды есть в выборе | Лысенко 29.10, п. 11: «можно оставить у нераспределённых эту строку»; раньше фильтр прятал нераспределённые, потому что строки у них не было | при `line_code = null` строка пропадает из фильтра по коду | | 29.10.2026 | R-34: граница 5 % на фронте — как в контракте (≥ 5 % — на проверку: фейк и красная разница в окне); в API 1540 ровно 5 % — в допуске | DOC-272613 «от 5 % включительно»; Лысенко на это не отвечал, вопрос не задан | при ровно 5,00 % окно покажет красным, а сервер — в допуске; таблица берёт `exceeds_threshold` из ответа и расходиться не будет | +| 02.11.2026 | R-35 (Task 8): граница 5 % в живом контроле окна (`editorModel.diffOf`) и подпись фильтра «Отклонение, %» приведены к фактическому поведению API 1540 (`domain/corrections.py`, `exceeds = percent > 5`, строго больше) — `>`, не `≥`; R-34 снимается как источник расхождения | API 1540 уже в main, фронт без Task 8 не существовал в проде — совпадение с реальным сервером важнее совпадения с неоднозначным текстом контракта («от 5 % включительно», на который Лысенко не ответил); сама таблица и так брала `exceeds_threshold` из ответа и не расходилась | ровно 5,00 % теперь не красное и в окне, и в фильтре, как у сервера; если Лысенко подтвердит «включительно» — одна правка оператора в `editorModel.ts`/`CorrectionTable.tsx`, вопрос не снят, просто фронт временно синхронен с текущим сервером | diff --git a/web/src/widgets/CorrectionTable/CorrectionTable.tsx b/web/src/widgets/CorrectionTable/CorrectionTable.tsx index 485bbb6..d9eb9c9 100644 --- a/web/src/widgets/CorrectionTable/CorrectionTable.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionTable.tsx @@ -45,8 +45,8 @@ type Props = { const DEVIATION_OPTIONS: { value: DeviationFilter; label: string }[] = [ { value: 'all', label: 'Все' }, - { value: 'over', label: '≥ 5 %' }, - { value: 'under', label: '< 5 %' }, + { value: 'over', label: '> 5 %' }, + { value: 'under', label: '≤ 5 %' }, ]; const COLOR_OVER = '#d92d20'; diff --git a/web/src/widgets/CorrectionTable/editorModel.test.ts b/web/src/widgets/CorrectionTable/editorModel.test.ts index 54d830f..5168280 100644 --- a/web/src/widgets/CorrectionTable/editorModel.test.ts +++ b/web/src/widgets/CorrectionTable/editorModel.test.ts @@ -127,7 +127,7 @@ describe('liveControl', () => { describe('граница порога и пустые стороны', () => { const base = { debit: [], credit: [], comment: '' }; - it('отклонение ровно 5.00 % — красный', () => { + it('отклонение ровно 5.00 % — не красный (R-35: сервер — строго больше 5 %)', () => { const a = findBy(table(), 'ЭПТ'); // 100.00 к 105.00 — 5.00 % к меньшей const c = liveControl(a, { @@ -136,6 +136,16 @@ describe('граница порога и пустые стороны', () => { credit: [{ line_code: '1520', amount: '105.00' }], }); expect(c.allocationDiff.percent).toBe('5.00'); + expect(c.allocationDiff.red).toBe(false); + }); + it('отклонение 5.01 % — красный', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, { + ...base, + debit: [{ line_code: '1230', amount: '100.00' }], + credit: [{ line_code: '1520', amount: '105.01' }], + }); + expect(c.allocationDiff.percent).toBe('5.01'); expect(c.allocationDiff.red).toBe(true); }); it('отклонение 4.99 % — не красный', () => { diff --git a/web/src/widgets/CorrectionTable/editorModel.ts b/web/src/widgets/CorrectionTable/editorModel.ts index e568053..9e16e0c 100644 --- a/web/src/widgets/CorrectionTable/editorModel.ts +++ b/web/src/widgets/CorrectionTable/editorModel.ts @@ -110,7 +110,8 @@ function sideSum(lines: EditLine[]): string | null { ); } -/** Разница двух сумм и процент к меньшей (для распределения статей); красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */ +/** Разница двух сумм и процент к меньшей (для распределения статей); красный — процент > 5 (R-35: как считает сервер, + * `domain/corrections.py` строго больше) или одна сторона 0 при ненулевой другой. */ function diffOf(left: string | null, right: string | null): Diff { const l = left ?? '0.00'; const r = right ?? '0.00'; @@ -119,7 +120,7 @@ function diffOf(left: string | null, right: string | null): Diff { const difference = fromCents(lc > rc ? lc - rc : rc - lc); const percent: Percent = percentOfSmaller(l, r); const oneZero = (lc === 0n) !== (rc === 0n); - const red = (percent !== null && toCents(percent) >= THRESHOLD_PERCENT_HUNDREDTHS) || oneZero; + const red = (percent !== null && toCents(percent) > THRESHOLD_PERCENT_HUNDREDTHS) || oneZero; return { left, right, difference, percent, red }; } -- 2.47.2