AURORA-1541: таблица корректировки — подключена к реальному API (#206) #215
@ -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.
|
||||
|
||||
@ -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` из ответа и расходиться не будет |
|
||||
|
||||
@ -24,8 +24,8 @@ AURORA-1395 и в AURORA-1396.
|
||||
«Настройка таблицы», «Экспорт», «На весь экран»; фильтры в заголовках колонок.
|
||||
- Окно «Корректировка операции» агрегата: исходные строки ОСВ сторон (убрать, добавить подходящую нераспределённую),
|
||||
распределение по статьям каждой стороны, контроль распределения, комментарий, «Сохранить корректировки».
|
||||
- Решения по строке: «Учитывать в КФО» (флажок строки), «Отнести к правилу» / «Перенести в правило», «Убрать из правила»,
|
||||
«Вернуть к автоматике»; «Отменить корректировку» агрегата с комментарием.
|
||||
- Решения по строке: «Учитывать в КФО» (флажок нераспределённой строки; агрегат учитывается всегда), «Отнести к правилу» /
|
||||
«Перенести в правило», «Убрать из правила», «Вернуть к автоматике».
|
||||
- Режим только чтения по `editable` / `read_only_reason` ответа.
|
||||
- Новая спека `corrections-table-ui`; дельта `intercompany-matrix-ui` (вкладка «Таблица корректировки» доступна).
|
||||
|
||||
|
||||
@ -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 показываться уведомлением с текстом сервера и перечитыванием таблицы.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 %) |
|
||||
|
||||
|
||||
@ -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` браузера.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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', 'Не указаны строки');
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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'],
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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' };
|
||||
}
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user