AURORA-1541: таблица корректировки — подключена к реальному API (#206) #215

Merged
aep merged 29 commits from feat/206-corrections-ui into main 2026-11-02 20:12:17 +03:00
23 changed files with 215 additions and 302 deletions
Showing only changes of commit d8af058f75 - Show all commits

View File

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

View File

@ -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` из ответа и расходиться не будет |

View File

@ -24,8 +24,8 @@ AURORA-1395 и в AURORA-1396.
«Настройка таблицы», «Экспорт», «На весь экран»; фильтры в заголовках колонок.
- Окно «Корректировка операции» агрегата: исходные строки ОСВ сторон (убрать, добавить подходящую нераспределённую),
распределение по статьям каждой стороны, контроль распределения, комментарий, «Сохранить корректировки».
- Решения по строке: «Учитывать в КФО» (флажок строки), «Отнести к правилу» / «Перенести в правило», «Убрать из правила»,
«Вернуть к автоматике»; «Отменить корректировку» агрегата с комментарием.
- Решения по строке: «Учитывать в КФО» (флажок нераспределённой строки; агрегат учитывается всегда), «Отнести к правилу» /
«Перенести в правило», «Убрать из правила», «Вернуть к автоматике».
- Режим только чтения по `editable` / `read_only_reason` ответа.
- Новая спека `corrections-table-ui`; дельта `intercompany-matrix-ui` (вкладка «Таблица корректировки» доступна).

View File

@ -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 показываться уведомлением с текстом сервера и перечитыванием таблицы.

View File

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

View File

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

View File

@ -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` браузера.

View File

@ -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<CorrectionAggregateOut>(correctionsPath(versionId, key), body);
}
export function revokeCorrection(versionId: string, key: AggregateKey, body: CorrectionRevokeIn): Promise<CorrectionAggregateOut> {
if (Config.CORRECTIONS_MOCK) return correctionsFake.revokeCorrection(versionId, key, body);
return api.post<CorrectionAggregateOut>(`${correctionsPath(versionId, key)}/revoke`, body);
}
export function allocate(versionId: string, body: AllocationIn): Promise<CorrectionTableOut> {
if (Config.CORRECTIONS_MOCK) return correctionsFake.allocate(versionId, body);
return api.post<CorrectionTableOut>(`${correctionsPath(versionId)}/allocations`, body);

View File

@ -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 () => {

View File

@ -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<CorrectionAggregateOut> {
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<CorrectionTableOut> {
return run(() => {
if (!body.entry_ids.length) fail(422, 'VALIDATION_ERROR', 'Не указаны строки');

View File

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

View File

@ -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<ColShape>({ 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<CorrectionAggregateOut | null>(null);
// ключ агрегата, окно корректировки которого открыто
const [editing, setEditing] = useState<string | null>(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}
/>
)}
<RevokeModal
open={revoking !== null}
subtitle={revoking ? `${revoking.company_1.name} — ${revoking.company_2.name}` : ''}
saving={busy}
onCancel={() => setRevoking(null)}
onSubmit={onRevoke}
/>
</div>
</ConfigProvider>
);

View File

@ -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 !== '—' ? <span style={{ color: COLOR_OVER }}>{text}</span> : text;
switch (key) {
case 'include':
// агрегат учитывается в КФО всегда: флажок отмечен и закреплён, лишняя строка убирается из правила
return withHint(
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Checkbox
disabled
aria-label='Учитывать в КФО (состояние агрегата)'
checked={row.include !== 'Нет'}
indeterminate={row.include === 'Частично'}
/>
<span style={{ fontSize: 12 }}>{row.include}</span>
<Checkbox disabled checked aria-label='Учитывать в КФО: агрегат учитывается всегда' />
{a.composition_changed && (
<Tooltip title='Состав изменился после корректировки'>
<WarningOutlined style={{ color: '#dc6803' }} />
@ -173,28 +167,9 @@ export function CorrectionTable(props: Props) {
);
case 'actions':
return (
<span style={{ display: 'inline-flex', gap: 4 }}>
<Button size='small' icon={<EditOutlined />} onClick={() => actions.onCorrect(a)}>
Корректировать
</Button>
{withHint(
<Dropdown
trigger={['click']}
disabled={!canAct}
menu={{
items: [{ key: 'revoke', label: 'Отменить корректировку', disabled: !a.correction }],
onClick: () => actions.onRevoke(a),
}}>
<Button
size='small'
type='text'
icon={<MoreOutlined />}
aria-label={`Действия: ${a.company_1.name} — ${a.company_2.name}`}
disabled={!canAct}
/>
</Dropdown>,
)}
</span>
<Button size='small' icon={<EditOutlined />} onClick={() => actions.onCorrect(a)}>
Корректировать
</Button>
);
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(
<Checkbox
disabled={!canAct}
checked={!e.excluded}
checked={!row.entry.excluded}
aria-label={`Учитывать в КФО: ${e.account_code}`}
onChange={(ev) => 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,
},
}),
});

View File

@ -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) {
</tr>
)}
{p.entries.map((e) => (
<tr key={e.entry_ids.join(',')} style={{ opacity: e.excluded ? 0.5 : 1 }}>
<td style={TD}>
{e.account_code}
{e.excluded && <span style={{ marginLeft: 6, fontSize: 12, color: '#667085' }}>не в КФО</span>}
</td>
<tr key={e.entry_ids.join(',')}>
<td style={TD}>{e.account_code}</td>
<td style={TD}>{p.article}</td>
<td style={{ ...TD, textAlign: 'right' }}>{formatRub(e.amount)}</td>
{p.editable && (

View File

@ -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 (
<Modal
open={open}
title='Отменить корректировку'
okText='Сохранить'
cancelText='Отменить'
okButtonProps={{ disabled: trimmed === '' }}
confirmLoading={saving}
mask={{ closable: false }}
onCancel={saving ? undefined : onCancel}
onOk={() => onSubmit(trimmed)}
destroyOnHidden>
<div style={{ marginBottom: 8, color: '#667085' }}>{subtitle}</div>
<Input.TextArea
autoFocus
rows={4}
value={comment}
maxLength={COMMENT_MAX_LENGTH}
placeholder='Комментарий к отмене (обязателен)'
aria-label='Комментарий к отмене корректировки'
onChange={(e) => setComment(e.target.value)}
/>
</Modal>
);
}

View File

@ -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'],

View File

@ -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<CorrectionIn, 'debit' | 'credit' | 'comment'>, expectedVersionNo: number | null): CorrectionIn {
return { ...body, status: 'APPLIED', expected_version_no: expectedVersionNo };
return { ...body, expected_version_no: expectedVersionNo };
}

View File

@ -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();
}

View File

@ -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),
};

View File

@ -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();
});
});

View File

@ -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' };
}

View File

@ -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' });

View File

@ -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<typeof filterTable>, 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],