diff --git a/docs/superpowers/plans/2026-10-28-corrections-table-ui.md b/docs/superpowers/plans/2026-10-28-corrections-table-ui.md new file mode 100644 index 0000000..26d5dd4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-corrections-table-ui.md @@ -0,0 +1,348 @@ +# Таблица корректировки (UI) — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** вкладка «Таблица корректировки» страницы отчёта версии: таблица агрегатов ВГО со строками ОСВ, нераспределённые +строки, решения по строкам, окно корректировки агрегата, экспорт — по дизайну 23.10 и аналитике Лысенко. + +**Architecture:** только фронт `web/`. Данные — по контракту `../docs/dev/api-contract-corrections.md` (+ `.openapi.yaml`); +пока бэкенда (AURORA-1540) нет, клиент `services/api/corrections.ts` при `REACT_APP_CORRECTIONS_MOCK=true` отвечает из фейка +в памяти. Вся логика (строки таблицы, фильтры, итоги, окно, черновик) — чистые функции с тестами vitest; компоненты тонкие и +проверяются в браузере против дизайна. + +**Tech Stack:** React 18, TS strict, antd 6, react-router 7, vitest 3, biome (+ ESLint/Prettier `npm run lint`). + +**Spec:** `openspec/changes/corrections-table-ui/` — `design.md` (главное), `proposal.md`, `specs/corrections-table-ui/spec.md`, +дельта `specs/intercompany-matrix-ui/spec.md`. Контракт — `/Users/andrey.epifancev/projects/awp-ca/.wt/corrections-ui/docs/dev/api-contract-corrections.md`. +Дизайн — `/Users/andrey.epifancev/projects/awp-ca/ДКБ_таблица_корректировок_23_10_26_1.html` (SPA; открывать в браузере). + +**Ветка:** `feat/206-corrections-ui`, worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/corrections-ui/arm-core`, issue +RSHB/arm-core#206, Jira AURORA-1541. + +## Global Constraints + +- Только `web/`; бэкенд, DBML, `api/` не трогать. +- Деньги — строки контракта в рублях; показ — `formatScaledAmount` (`utils/money.ts`) через `useNumberDisplay`; сложение — + только `sumAmounts`; никаких `Number(...)`/`parseFloat` над суммами. +- Фронт не считает разнесение, отклонения, порог и статусы — берёт из ответа (`exceeds_threshold`, `deviation_*`, `status`, + `candidate_rule_ids`). Исключение — живой контроль в окне (суммы несохранённых статей) и «Итого» видимых строк. +- Только `statement_type = 'balance'`. +- Тексты (дословно): вкладка «Таблица корректировки»; «Учитывать в КФО»; группы «Дебет», «Кредит», «Контроль», + «Корректировка»; «Отклонение, %», «Отклонение, сумма»; «Корректировать»; «Корректировка операции»; «Исходные данные ОСВ»; + «Добавить строку ОСВ»; «Распределение корректировки»; «Добавить строку»; «Комментарий к корректировке»; «Отменить»; + «Сохранить корректировки»; «Отображение» — «Общий» / «По пресету»; «Правило»; «Тип записи» — «Все» / «Пары счетов» / + «Нераспределенные записи»; «Сбросить фильтры»; «Настройка таблицы»; «Экспорт»; «На весь экран»; «Итого»; + «Внутригрупповых остатков между компаниями периметра нет»; «Нет записей для выбранных фильтров»; «Состав изменился после + корректировки»; «черновик»; «Перенести в правило…»; «Отнести к правилу…»; «Убрать из правила»; «Вернуть к автоматике»; + «Отменить корректировку»; «Да» / «Частично» / «Нет»; «Восстановлены несохранённые правки от ЧЧ:ММ»; «Сбросить»; + «Корректировку изменили после ваших правок»; «Есть несохранённые изменения. Закрыть без сохранения?»; «Остаться»; + «Корректировку уже изменили, данные обновлены»; подсказки только чтения: `VERSION_NOT_EDITABLE` — «Версия закрыта для + изменений», `LOCK_REQUIRED` — «Возьмите консолидацию в работу», `LOCKED_BY_OTHER` — «Консолидацию редактирует другой + пользователь». +- Подпись правила: `Правило N: <статья 1> — <статья 2>`, составная статья `"1450/1550"` показывается `1450 / 1550`. +- Порог для красного в окне (живой контроль): процент к меньшей стороне `≥ 5` или одна сторона 0 при ненулевой другой. +- `localStorage`/`sessionStorage` — только в `try/catch`; недоступно — работа без черновика. +- Стиль: табы, одинарные кавычки, ширина 140 (biome); комментарии в коде — по-русски; файлы виджета — в + `web/src/widgets/CorrectionTable/`. +- Проверки перед коммитом задачи: из `web/` — `npm test`, `npx tsc --noEmit -p .`, `npx biome check src`; в Task 7 — ещё + `npm run build` и `make web-check` из корня репозитория. + +## Review Focus + +- Ответ на устаревший запрос (аналитик быстро щёлкнул два флажка) перетирает свежую таблицу — `useCorrectionTable` применяет + только ответ последнего запроса (счётчик запросов, как `usePagedRows`); тест в Task 4 на функцию выбора ответа. +- Сумма статьи «1 000 000,5» / «1000000,567» / «-5» / пусто в окне: принимается только `parseAmountInput`-валидное ≥ 0 с ≤ 2 + знаками, иначе поле с ошибкой и «Сохранить» неактивна — тест в Task 3 (`validateLines`). +- Черновик из `localStorage` от другой формы данных (старый формат, мусор) не ломает окно — `loadDraft` возвращает `null` — + тест в Task 3. +- Агрегат исчез из ответа после переноса строк, пока окно открыто (все строки убраны) — окно показывает «—» по сторонам и + не падает; `findAggregate` возвращает `null` — тест в Task 2. +- Фильтр «ЮЛ» с латиницей/регистром/кавычками («ооо "бск"») находит «ООО "БСК"» — сравнение без регистра — тест в Task 2. + +--- + +### Task 1: Типы контракта, клиент API, флаг фейка + +**Files:** +- Create: `web/src/types/corrections.ts` +- Create: `web/src/services/api/corrections.ts` +- Modify: `web/src/conf/config.ts` +- Test: `web/src/services/api/corrections.test.ts` + +**Interfaces:** +- Produces (`types/corrections.ts`) — типы 1:1 с `components.schemas` OpenAPI: `StatementType`, `Side = 'D' | 'K'`, + `Ref`, `CorrectionTableOut`, `CorrectionRuleOut`, `CorrectionAggregateOut`, `AggregateSideOut`, `CorrectionEntryOut`, + `UnallocatedEntryOut`, `CorrectionOut`, `CorrectionLineOut`, `CorrectionBalanceOut`, `CorrectionTotalsOut`, + `CorrectionLineIn`, `CorrectionIn`, `CorrectionRevokeIn`, `AllocationAction = 'ASSIGN' | 'UNASSIGN' | 'EXCLUDE' | 'INCLUDE' | 'RESET'`, + `AllocationIn`, `ReadOnlyReason`, `AggregateKey = { rule_id: string; company_1_id: string; company_2_id: string }`. +- Produces (`services/api/corrections.ts`): + `getCorrectionTable(versionId: string): Promise`; + `saveCorrection(versionId: string, key: AggregateKey, body: CorrectionIn): Promise`; + `revokeCorrection(versionId: string, key: AggregateKey, body: CorrectionRevokeIn): Promise`; + `allocate(versionId: string, body: AllocationIn): Promise`; + `exportCorrectionTable(versionId: string): Promise<{ blob: Blob; filename: string | null }>` (форма ответа — как у + `apiFetchBlob`; если она другая — вернуть то, что даёт `apiFetchBlob`, и записать в Interfaces); + `correctionsPath(versionId: string, key?: AggregateKey): string`. + При `Config.CORRECTIONS_MOCK` все функции делегируют в `correctionsFake` (Task 2); флаг читается в рантайме, фейк + остаётся в бандле — это допустимо (данные фейка — небольшие, без персональных данных). +- Produces (`conf/config.ts`): `Config.CORRECTIONS_MOCK: boolean` = `environment.REACT_APP_CORRECTIONS_MOCK === 'true'`. + +- [ ] **Step 1: node_modules в worktree.** `ln -s /Users/andrey.epifancev/projects/awp-ca/arm-core/web/node_modules web/node_modules` + (тот же `package-lock.json`, что у `main`; в git не попадает). Проверка: `cd web && npx vitest --version` печатает `3.1.4`. +- [ ] **Step 2: Failing test** `corrections.test.ts`: + `correctionsPath('v1')` → `'/consolidations/versions/v1/corrections'`; + `correctionsPath('v1', {rule_id: 'r', company_1_id: 'a', company_2_id: 'b'})` → `'/consolidations/versions/v1/corrections/aggregates/r/a/b'`. +- [ ] **Step 3: Run** `cd web && npx vitest run src/services/api/corrections.test.ts` — FAIL (нет модуля). +- [ ] **Step 4: Implement** типы, `correctionsPath`, функции поверх `api.get/put/post` и `apiFetchBlob` + (`?statement_type=balance` в GET и export), флаг в `Config`. До Task 2 ветка фейка — `throw new Error('фейк в Task 2')`. +- [ ] **Step 5: Run** тест — PASS; `npx tsc --noEmit -p .` и `npx biome check src` — чисто. +- [ ] **Step 6: Commit** `feat(web): типы и клиент API таблицы корректировок (#206)`. + +### Task 2: Фейк контракта и модель таблицы + +**Files:** +- Create: `web/src/widgets/CorrectionTable/mockData.ts` — данные из дизайна 23.10 +- Create: `web/src/services/api/correctionsFake.ts`, Test: `web/src/services/api/correctionsFake.test.ts` +- Create: `web/src/widgets/CorrectionTable/tableModel.ts`, Test: `web/src/widgets/CorrectionTable/tableModel.test.ts` +- Modify: `web/src/services/api/corrections.ts` (ветка фейка) + +**Interfaces:** +- Consumes: типы Task 1. +- Produces (`correctionsFake.ts`): объект `correctionsFake` с теми же сигнатурами, что функции Task 1, состояние в памяти + модуля, `resetCorrectionsFake(): void` для тестов. Правила фейка: агрегат пересчитывается из своих строк (суммы сторон + `sumAmounts` по не-`excluded`, `deviation_amount`, `deviation_percent` к меньшей с двумя знаками, `exceeds_threshold` при + `≥ 5` или одна сторона 0), `composition_changed = true` при изменении состава агрегата с корректировкой; `ASSIGN` — + только в правило из `candidate_rule_ids`, иначе `ApiError` 422 `RULE_NOT_APPLICABLE`; пустой комментарий — 422 + `COMMENT_REQUIRED`; `expected_version_no` не совпал — 409 `CORRECTION_VERSION_CONFLICT`; `revoke` без корректировки — 409 + `CORRECTION_NOT_APPLIED`; `editable = true`. Деление для процента — BigInt-копейки с округлением половины вверх. + `mockData.ts`: 8 правил (как `api/seed/core/elimination_rules_v1.yaml`, статьи `1150`/`1450/1550`, `1170`/`1310/1350`, + `1170`/`1410`, `1240`/`1510`, `1230`/`1510`, `1230`/`1520`, `1230`/`1410`, `1230`/`1510`), агрегаты и нераспределённые строки + из дизайна (суммы в рублях: «ЭПТ — ИЛС» 1000000.00 / 202320.00 с корректировкой 1230 / 1520 и комментарием «Требуется + сверка с контрагентом»; «ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД" — БЕКОВСКИЙ САХАРНЫЙ КОМБИНАТ ООО» правила 6 — четыре строки с + каждой стороны; правило 3 — «Пензенский Мир Продуктов — Регион-Сахар» 29742550.00 / 29242550.00; нераспределённые — не + меньше пяти, включая кредитовую 76.05 «ООО "БСК"» → «ПМП ООО» 142878.00 с `candidate_rule_ids` правил 6 и 7). +- Produces (`tableModel.ts`): + ```ts + type TableRow = + | { kind: 'aggregate'; key: string; aggregate: CorrectionAggregateOut; include: 'Да' | 'Частично' | 'Нет' } + | { kind: 'entry'; key: string; parentKey: string; side: 1 | 2; entry: CorrectionEntryOut } + | { kind: 'unallocated'; key: string; entry: UnallocatedEntryOut }; + type DisplayMode = { mode: 'all' } | { mode: 'preset'; ruleId: string }; + type RecordType = 'all' | 'posting' | 'unallocated'; + type DeviationFilter = 'all' | 'over' | 'under'; + type TableFilters = { display: DisplayMode; recordType: RecordType; entity: string; line: string | null; account: string; deviation: DeviationFilter }; + const EMPTY_FILTERS: TableFilters; + aggregateKey(a: CorrectionAggregateOut): string // `${rule_id}|${company_1.id}|${company_2.id}` + toAggregateKey(a: CorrectionAggregateOut): AggregateKey + includeState(a: CorrectionAggregateOut): 'Да' | 'Частично' | 'Нет' + ruleLabel(r: CorrectionRuleOut): string // «Правило 6: 1230 — 1520», «Правило 1: 1150 — 1450 / 1550» + articleCodes(line: string): string[] // '1450/1550' → ['1450','1550'] + lineOptions(table: CorrectionTableOut): string[] // отдельные коды статей агрегатов, по возрастанию + filterTable(table: CorrectionTableOut, f: TableFilters): { aggregates: CorrectionAggregateOut[]; unallocated: UnallocatedEntryOut[] } + buildRows(view: ReturnType, expanded: ReadonlySet): TableRow[] + visibleTotals(aggregates: CorrectionAggregateOut[]): { amount_1: string | null; amount_2: string | null; deviation_amount: string | null; correction_debit: string | null; correction_credit: string | null } + isFiltered(f: TableFilters): boolean + findAggregate(table: CorrectionTableOut, key: string): CorrectionAggregateOut | null + ``` + Правила `filterTable` — design.md «Панель»: пресет — агрегаты правила + нераспределённые с этим правилом в + `candidate_rule_ids`; `recordType`; `entity` — подстрока без регистра в именах обеих сторон (у нераспределённых — `owner`, + `counterparty`); `account` — начало кода любого счёта строки (`accounts` сторон / `account_code`); `line` и `deviation` — + только агрегаты (по `articleCodes` статей сторон и по `exceeds_threshold`), при любом из них нераспределённые скрываются. + +- [ ] **Step 1: Failing tests `tableModel.test.ts`** (на `mockData`): + `ruleLabel` правила 1 = `'Правило 1: 1150 — 1450 / 1550'`; `articleCodes('1310/1350')` = `['1310','1350']`; + `includeState` — все не исключены → `'Да'`, одна из восьми исключена → `'Частично'`, все → `'Нет'`; + пресет правила 3 → только агрегаты правила 3 и нераспределённые, где правило 3 в `candidate_rule_ids`; + `recordType: 'unallocated'` → `aggregates = []`; + `entity: 'ооо "бск"'` → агрегаты и нераспределённые «ООО "БСК"» (Review Focus); + `deviation: 'over'` → только `exceeds_threshold`, `unallocated = []`; + `buildRows` с раскрытым агрегатом правила 6 «Бековский завод — комбинат» → после агрегата 4 строки `side: 1`, затем 4 `side: 2`; + `visibleTotals` по агрегатам «ЭПТ — ИЛС» и правила 3 → `amount_1 = '30742550.00'`; + `findAggregate(table, 'нет')` → `null` (Review Focus). +- [ ] **Step 2: Failing tests `correctionsFake.test.ts`**: `getCorrectionTable` после `resetCorrectionsFake` отдаёт агрегат + «ЭПТ — ИЛС» с `deviation_percent = '394.27'`, `exceeds_threshold = true`; `allocate EXCLUDE` строки 62.03 агрегата правила 6 + уменьшает `side_1.amount` на её сумму; `allocate ASSIGN` в правило не из `candidate_rule_ids` — `ApiError` с + `code = 'RULE_NOT_APPLICABLE'`; `saveCorrection` с `comment: ' '` — `COMMENT_REQUIRED`; повторный `saveCorrection` с + `expected_version_no: null`, когда корректировка есть, — `CORRECTION_VERSION_CONFLICT`; `revokeCorrection` → `correction = null`, + затем `saveCorrection` с `expected_version_no: null` проходит. +- [ ] **Step 3: Run** `cd web && npx vitest run src/widgets/CorrectionTable src/services/api/correctionsFake.test.ts` — FAIL. +- [ ] **Step 4: Implement** `mockData.ts`, `correctionsFake.ts`, `tableModel.ts`; в `corrections.ts` ветка флага вызывает + `correctionsFake`. `ApiError` создавать так же, как это делает `api/client.ts` (посмотреть конструктор). +- [ ] **Step 5: Run** тесты — PASS; tsc и biome — чисто. +- [ ] **Step 6: Commit** `feat(web): фейк контракта корректировок и модель таблицы (#206)`. + +### Task 3: Модель окна корректировки и черновик + +**Files:** +- Create: `web/src/widgets/CorrectionTable/editorModel.ts`, Test: `web/src/widgets/CorrectionTable/editorModel.test.ts` +- Create: `web/src/widgets/CorrectionTable/draftStorage.ts`, Test: `web/src/widgets/CorrectionTable/draftStorage.test.ts` + +**Interfaces:** +- Consumes: типы Task 1, `articleCodes`, `aggregateKey` (Task 2), `parseAmountInput`, `sumAmounts` (`utils/money.ts`). +- Produces (`editorModel.ts`): + ```ts + type EditLine = { line_code: string; amount: string }; // amount — как ввёл пользователь + type EditorState = { debit: EditLine[]; credit: EditLine[]; comment: string }; + initialEditorState(a: CorrectionAggregateOut): EditorState // есть корректировка — её статьи (суммы как в API); + // нет — по стороне одна статья: первый код articleCodes(article_line), сумма стороны + validateLines(s: EditorState): { ok: true; body: Pick } | { ok: false; errors: Record } + // ключ ошибки `debit.0.amount`; пустая сторона ок, обе пустые — ошибка «Добавьте статью»; + // код — 4 цифры; сумма — parseAmountInput, ≥ 0, ≤ 2 знаков + liveControl(a: CorrectionAggregateOut, s: EditorState): { sourceDiff: Diff; allocationDiff: Diff } + type Diff = { left: string | null; right: string | null; difference: string | null; percent: string | null; red: boolean } + // percent к меньшей стороне, 2 знака; red — percent ≥ 5 или одна сторона 0 при ненулевой другой + isDirty(initial: EditorState, current: EditorState): boolean + pickerCandidates(table: CorrectionTableOut, a: CorrectionAggregateOut, side: 1 | 2): UnallocatedEntryOut[] + // та же пара: side 1 — owner = company_1, counterparty = company_2, entry.side = rule.side_1; + // side 2 — наоборот и противоположная сторона; правило агрегата в candidate_rule_ids; не excluded + toCorrectionIn(body: …, expectedVersionNo: number | null): CorrectionIn // status 'APPLIED' + ``` +- Produces (`draftStorage.ts`): + ```ts + type Draft = { state: EditorState; savedAt: string; baseVersionNo: number | null }; + draftKey(userId: string, versionId: string, aggregateKey: string): string // 'armka:correction-draft:::' + saveDraft(key: string, draft: Draft, storage?: Storage | null): void + loadDraft(key: string, storage?: Storage | null): Draft | null // мусор/старый формат/исключение — null + clearDraft(key: string, storage?: Storage | null): void + isStale(draft: Draft, a: CorrectionAggregateOut): boolean // baseVersionNo ≠ (a.correction?.version_no ?? null) + ``` + `storage` по умолчанию — `window.localStorage`, доступ в `try/catch`. + +- [ ] **Step 1: Failing tests `editorModel.test.ts`**: `initialEditorState` агрегата правила 3 без корректировки → + `debit = [{line_code: '1170', amount: '29742550.00'}]`, `credit = [{line_code: '1410', amount: '29242550.00'}]`; + агрегат правила 1 без корректировки → кредит `line_code = '1450'`; + `validateLines` с суммами `'1 000 000,5'`, `'1000000,567'`, `'-5'`, `''` → ошибки у `amount` (Review Focus), с `'500000,5'` → + `'500000.50'` в `body`; `liveControl` агрегата «ЭПТ — ИЛС» с исходным состоянием → `sourceDiff.percent = '394.27'`, + `red = true`; при Дт 1000000.00 и Кт 1000000.00 распределения → `allocationDiff.difference = '0.00'`, `red = false`; + `pickerCandidates` для агрегата «Пензенский Мир Продуктов — Бековский сахарный комбинат» правила 6, сторона 2 → только + кредитовые нераспределённые строки комбината на ПМП с правилом 6. +- [ ] **Step 2: Failing tests `draftStorage.test.ts`** (`storage` — простая заглушка `Map` с `getItem/setItem/removeItem`): + `saveDraft` → `loadDraft` возвращает то же; `setItem` строки `'{"x":1}'` → `loadDraft` = `null` (Review Focus); заглушка, + бросающая на `getItem`, → `null` без исключения; `isStale` при `baseVersionNo = 2` и `correction.version_no = 3` → `true`. +- [ ] **Step 3: Run** `cd web && npx vitest run src/widgets/CorrectionTable` — FAIL. +- [ ] **Step 4: Implement** оба модуля. +- [ ] **Step 5: Run** — PASS; tsc и biome — чисто. +- [ ] **Step 6: Commit** `feat(web): модель окна корректировки и черновик (#206)`. + +### Task 4: Вкладка и таблица только на чтение + +**Files:** +- Create: `web/src/widgets/CorrectionTable/useCorrectionTable.ts`, Test: `web/src/widgets/CorrectionTable/useCorrectionTable.test.ts` +- Create: `web/src/widgets/CorrectionTable/CorrectionTable.tsx` (таблица), `CorrectionToolbar.tsx` (панель), + `CorrectionContent.tsx` (вкладка: загрузка, состояния, панель + таблица) +- Modify: `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx` (у `correction` убрать `disabled`/`tooltip`) +- Modify: `web/src/layouts/ConsolidationReportPage.tsx` (`TAB_COMPONENTS.correction`, `isValidTab` с `correction`) + +**Interfaces:** +- Consumes: Task 1–2 (`getCorrectionTable`, `filterTable`, `buildRows`, `visibleTotals`, `ruleLabel`, `lineOptions`, + `isFiltered`, `EMPTY_FILTERS`), `useNumberDisplay`, `ColumnSettings`/`useVisibleColumns` (`components/GenericTable`), + полноэкранный режим — как `toggleFullscreen` в `ConsolidatedChessContent.tsx`. +- Produces: + `latestOnly(): { next(): number; isLatest(id: number): boolean }` (в `useCorrectionTable.ts`, чистая, тестируется); + `useCorrectionTable(versionId: string): { table: CorrectionTableOut | null; loading: boolean; error: string | null; reload(): void; replace(t: CorrectionTableOut): void; replaceAggregate(a: CorrectionAggregateOut): void }` + — применяет только ответ последнего запроса (Review Focus); + `CorrectionContent({ versionId }: { versionId: string })`; + `CorrectionTable` props: `{ rows: TableRow[]; totals; format; expanded; onToggle(key); rules; editable; readOnlyHint: string | null; actions: RowActions }`, + где `RowActions` — колбэки Task 5–6 (`onCorrect(a)`, `onRevoke(a)`, `onAllocate(body)`), в этой задаче — заглушки. + +- [ ] **Step 1: Failing test** `useCorrectionTable.test.ts`: `latestOnly` — после `next()` = 1 и `next()` = 2 `isLatest(1) = false`, + `isLatest(2) = true`. +- [ ] **Step 2: Run** — FAIL. +- [ ] **Step 3: Implement** хук, вкладку и таблицу: колонки и тексты — Global Constraints и spec «Колонки и строка агрегата», + «Раскрытие», «Нераспределённые строки», «Панель и фильтры»; суммы — `format` из `useNumberDisplay` (по умолчанию «тыс. руб.», + 2 знака, как в дизайне); красное — при `exceeds_threshold`; `composition_changed` — фон строки и значок с подсказкой; + `DRAFT` — метка «черновик»; «Итого» — `visibleTotals`, процент «—»; флажки и меню пока неактивны. Экспорт — `exportCorrectionTable` + + `saveBlob(blob, filename ?? xlsxFilename('Таблица_корректировки', …))`. Вкладка доступна, адрес `…/correction`. +- [ ] **Step 4: Run** тест — PASS; `npx tsc --noEmit -p .`, `npx biome check src` — чисто. +- [ ] **Step 5: Проверка в браузере** (фейк): `cd web && REACT_APP_CORRECTIONS_MOCK=true npm run start`, бэкенд ядра для + карточки версии — как в `../docs/dev/qa-guide.md`; открыть `…/versions//correction`; сверить с дизайном (открыть + `ДКБ_таблица_корректировок_23_10_26_1.html` рядом): колонки, раскрытие агрегата правила 6, нераспределённые строки, пресет + правила 3, фильтры, «Итого», единицы. Снимки — в `/private/tmp/claude-501/` (scratchpad сессии), в репозиторий не класть. +- [ ] **Step 6: Commit** `feat(web): вкладка «Таблица корректировки» — таблица и панель (#206)`. + +### Task 5: Решения по строкам, отмена корректировки, только чтение + +**Files:** +- Create: `web/src/widgets/CorrectionTable/RowActions.tsx` (флажок «Учитывать в КФО», меню строки, выбор правила) +- Create: `web/src/widgets/CorrectionTable/RevokeModal.tsx` +- Create: `web/src/widgets/CorrectionTable/readOnly.ts`, Test: `web/src/widgets/CorrectionTable/readOnly.test.ts` +- Modify: `CorrectionTable.tsx`, `CorrectionContent.tsx` + +**Interfaces:** +- Consumes: `allocate`, `revokeCorrection` (Task 1), `replace`/`replaceAggregate`/`reload` (Task 4), `ruleLabel`. +- Produces: `readOnlyHint(reason: ReadOnlyReason | null): string | null` (тексты — Global Constraints); + `allocationMenu(row: TableRow, rules: CorrectionRuleOut[]): { key: AllocationAction | 'MOVE'; label: string; disabled: boolean }[]` + (тестируется: у строки агрегата с `decision = 'AUTO'` нет «Вернуть к автоматике»; пустой `candidate_rule_ids` — «Перенести в + правило…» неактивен; у нераспределённой `reason = 'UNASSIGNED'` есть «Вернуть к автоматике»). + +- [ ] **Step 1: Failing tests** `readOnly.test.ts`: `readOnlyHint('LOCK_REQUIRED')` = «Возьмите консолидацию в работу», `null` → `null`; + три случая `allocationMenu` выше. +- [ ] **Step 2: Run** — FAIL. +- [ ] **Step 3: Implement**: флажок строки → `allocate({statement_type: 'balance', entry_ids, action: checked ? 'INCLUDE' : 'EXCLUDE'})`, + ответ — `replace`; меню → `ASSIGN` (выбор правила из `candidate_rule_ids`, подписи `ruleLabel`), `UNASSIGN`, `RESET`; + «Отменить корректировку» в меню агрегата → `RevokeModal` (комментарий обязателен, пустой — «Сохранить» неактивна) → + `revokeCorrection` → `replaceAggregate`. Ошибки API — уведомление с `ApiError.message`; 409 правок версии — ещё `reload()`. + `editable = false` — всё неактивно с подсказкой `readOnlyHint`. +- [ ] **Step 4: Run** тесты — PASS; tsc, biome — чисто. +- [ ] **Step 5: Проверка в браузере** (фейк): исключить строку 62.03 → у агрегата «Частично», сумма стороны меньше; + «Отнести к правилу…» у кредитовой 76.05 «ООО "БСК"» → строка в агрегате; «Отменить корректировку» у «Сатурн — Архол». + Флаг фейка `editable = false` (временно в тесте руками) — подсказки. +- [ ] **Step 6: Commit** `feat(web): решения по строкам и отмена корректировки (#206)`. + +### Task 6: Окно «Корректировка операции» + +**Files:** +- Create: `web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx` +- Modify: `CorrectionContent.tsx` (открытие окна из «Корректировать») + +**Interfaces:** +- Consumes: Task 3 целиком, `allocate`, `saveCorrection`, `findAggregate`, `toAggregateKey`, `useCurrentUser().id`, + `replace`/`replaceAggregate`/`reload`. +- Produces: `CorrectionEditorModal({ versionId, table, aggregateKey, editable, onClose, onTable, onAggregate })`. + +- [ ] **Step 1: Implement** по spec «Окно…», «Сохранение…», «Черновик окна…»: состояние — `initialEditorState` или черновик + (`loadDraft(draftKey(...))`, плашки «Восстановлены несохранённые правки от ЧЧ:ММ» / «Корректировку изменили после ваших + правок» по `isStale`, кнопка «Сбросить»); `saveDraft` при каждой правке; «×» исходной строки и «Добавить строку ОСВ» + (`pickerCandidates`) — `allocate` сразу, таблица — `onTable`, агрегат окна — `findAggregate` (нет — по сторонам «—», Review + Focus); контроль — `liveControl`; «Сохранить корректировки» — `validateLines` → `saveCorrection(toCorrectionIn(body, + aggregate.correction?.version_no ?? null))` → `clearDraft`, `onAggregate`, закрыть; ошибки: `COMMENT_REQUIRED` — у + комментария, `INVALID_LINE_CODE` — у статьи `detail.line_code`, `CORRECTION_VERSION_CONFLICT` — сообщение «Корректировку уже + изменили, данные обновлены» + `reload()`; «Отменить» при `isDirty` — `Modal.confirm` «Есть несохранённые изменения. Закрыть + без сохранения?» («Остаться» / закрыть с `clearDraft`). `editable = false` — всё только на просмотр, без «Сохранить + корректировки». +- [ ] **Step 2: Run** `npx tsc --noEmit -p .`, `npx biome check src`, `npm test` — чисто. +- [ ] **Step 3: Проверка в браузере** (фейк): окно «ЭПТ — ИЛС» (суммы, 394,27 %, красное); правило 3 без корректировки — + подстановка 1170 / 1410; добавить статью 1550 на стороне Кт, сохранить без комментария → ошибка у поля; с комментарием → + таблица показывает две статьи; правка суммы → «Назад» в браузере → вернуться → плашка восстановления; «Отменить» → + вопрос. Сверка вида окна с дизайном (`editor0.png`, `editor-unalloc.png` — снимки в + `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/d22b6541-bb55-46fc-a4fe-8bba06387616/scratchpad/mockup-1023/`). +- [ ] **Step 4: Commit** `feat(web): окно «Корректировка операции» с черновиком (#206)`. + +### Task 7: Сверка с дизайном, проверки, документация + +**Files:** +- Modify: `web/README.md` (раздел про флаг `REACT_APP_CORRECTIONS_MOCK`), `web/ARCHITECTURE.md` (виджет `CorrectionTable`) +- Modify: `openspec/changes/corrections-table-ui/tasks.md` (создать по факту выполненного, все пункты отмечены) + +- [ ] **Step 1:** полный проход сценариев spec `corrections-table-ui` в браузере на фейке, сверка каждой части экрана с + дизайном в Playwright (оба окна рядом, одинаковая ширина 1920); расхождения — исправить или записать в отчёт с причиной. +- [ ] **Step 2:** из корня репозитория `make web-check` — PASS; из `web/` — `npm test`, `npm run lint` — PASS. +- [ ] **Step 3:** `openspec validate corrections-table-ui --strict` — valid. +- [ ] **Step 4: Commit** `docs(web): таблица корректировок — README, ARCHITECTURE, tasks (#206)`. + +### Task 8 (после слияния AURORA-1540): подключение к API + +Выполняется отдельно, когда эндпоинты 1540 в `main`. Влить `main` в ветку; запустить без флага на стенде +(`../docs/dev/qa-guide.md`) на реальной версии; пройти сценарии spec; расхождения ответа с контрактом — правка в фейке и +типах или замечание в PR 1540; `make check-full`; PR в `main` (до этого PR не открывать — фронт без API не сливаем, если +владелец не решит иначе). + +Уточнение (R-27): в Task 8 удаляются только `services/api/correctionsFake.ts` (+ `correctionsFake.test.ts`) и ветка флага `CORRECTIONS_MOCK` в `corrections.ts`/`config.ts`; `widgets/CorrectionTable/mockData.ts` и `mockCalc.ts` остаются как тестовые фикстуры — их импортируют тесты моделей. + +### Task 9 (29.10, вне исходного плана): ответы Лысенко и WIP API 1540 + +Лысенко ответил на `[design-23-10-check]` (AURORA-1395, #9920570), Илья выложил WIP API (arm-core #208). Сделано сессией +сразу, без SDD: отзыв корректировки и `status` в `PUT` убраны; «Учитывать в КФО» — только у нераспределённых, агрегат +закреплён; у нераспределённой — строка баланса владельца `line_code`; фейк отвечает `ENTRY_ALLOCATED` / `ENTRY_EXCLUDED`. +Пункты — `openspec/changes/corrections-table-ui/tasks.md` §9, рулинги R-29…R-34 в `design.md`. Тексты «Отменить +корректировку» и «Да» / «Частично» / «Нет» из Global Constraints больше не используются. Контракт docs — отдельный PR. diff --git a/openspec/changes/corrections-table-ui/.openspec.yaml b/openspec/changes/corrections-table-ui/.openspec.yaml new file mode 100644 index 0000000..1bb6456 --- /dev/null +++ b/openspec/changes/corrections-table-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-28 diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md new file mode 100644 index 0000000..0088baf --- /dev/null +++ b/openspec/changes/corrections-table-ui/design.md @@ -0,0 +1,216 @@ +# Дизайн: экран «Таблица корректировки» (AURORA-1541, arm-core #206) + +## Зачем + +Кредитный аналитик на шаге консолидации видит, где внутригрупповые обороты двух компаний не сошлись, разбирается, из каких +строк ОСВ сложилась каждая сторона, переносит строки между правилами и вносит корректировку — сколько и из каких статей ББ +исключить. Без экрана корректировки ВГО делаются только вне системы, и тестирование корректировок (AURORA-1542, с 03.11) +начать нельзя. + +## Что считается готовым (слова владельца, 28.10.2026) + +- К сроку 02.11 — **рабочий экран на настоящем API**, не демо на моках. Если бэкенд (AURORA-1540) или аналитика не успевают — + мы сами помогаем ребятам доделать. +- Моки нужны, чтобы фронт не стоял, пока нет API, а не как результат. + +**Объём плана — только `web/`.** Бэкенд (DBML, `vgo_assignment`, разнесение, эндпоинты, аудит) — AURORA-1540 Цыганкова; +помогаем ему на ревью его PR, а не задачами этого плана. Последняя задача плана — подключение к настоящему API, когда 1540 +слит: флаг `REACT_APP_CORRECTIONS_MOCK` выключается, если эндпоинты есть в `main`, вкладка открывается на стенде на +реальной версии и проходит сценарии спеки; расхождения ответа с контрактом правятся в той ветке, где ошибка (фейк или +бэкенд). Сверяем фейк с реальным ответом мы (владелец / этот агент) при подключении. + +## Источники и их вес + +1. Аналитика Лысенко — DOC-272613, ответы 1–7 (AURORA-1395, 27.10), DOC-274230. **Приоритетна над нашими правилами и + черновиками** (решение владельца 28.10). +2. Дизайн от 23.10.2026 — `ДКБ_таблица_корректировок_23_10_26_1.html` (вложение к DOC-272613; в корне workspace). Как лежат + строки в дизайне — визуализация; логика — по аналитике. +3. ТЗ 6.8 / Прил. 9 — ниже аналитики, иногда игнорируется. +4. Контракт API — `../docs/dev/api-contract-corrections.md` и `.openapi.yaml` (docs #183, вложение в AURORA-1540). + +Открытые вопросы аналитику (комментарий `[design-23-10-check]` в AURORA-1395, п. 1–12 + 3 по ТЗ; в AURORA-1396 — ОФР) не +блокируют работу: зависящие места сделаны так, чтобы ответ менял поведение точечно. + +## Цели + +- Вкладка «Таблица корректировки» страницы отчёта версии, по виду — как дизайн 23.10. +- Таблица агрегатов с раскрытием до строк ОСВ, нераспределённые строки, итоги, фильтры, пресеты по правилам, единицы, + настройка колонок, экспорт, полный экран. +- Окно «Корректировка операции» агрегата: строки сторон, распределение по статьям, контроль, комментарий, сохранение. +- Решения по строкам: флажок «Учитывать в КФО» у нераспределённых, отнести / перенести / убрать / вернуть к автоматике. +- Только чтение, когда сервер так говорит (`editable = false`). + +## Не цели + +- Бэкенд и DBML (AURORA-1540), правила §8 в `05-domain-rules.md`. +- ОФР (нет в дизайне; AURORA-1396), импорт xlsx (ТЗ 6.8, ждёт ответа). +- Корректировка строки ОСВ и нераспределённой строки (аналитика: только агрегат; ждёт п. 2). +- Переход из шахматки с фокусом на паре (AURORA-1653), учёт корректировок в шахматке (AURORA-1643). + +## Анти-цели (провал, даже если «работает») + +- Фронт сам считает разнесение строк, отклонения, порог 5 % или статусы агрегата — это делает сервер; фронт только + показывает, форматирует и суммирует видимые строки для «Итого». +- Деньги через `number`/`float` с потерей копеек. Суммы — строки контракта, сложение — точное десятичное. +- Экран, в котором можно что-то нажать и молча потерять изменения окна корректировки. +- Кнопки правки у версии, которую сервер считает нередактируемой. + +## Ограничения + +- `web/` arm-core: React 18 + TS strict, antd 6, react-router 7, biome (140, одинарные кавычки), vitest. +- Деньги в API — рубли строками с двумя знаками; показ в руб. / тыс. руб. и 0–2 знака — как в шахматке. +- Состояние экрана — локальные хуки (стор Redux пуст по соглашению шаблона). +- Пользователь слаб во фронте — решения в PR объясняются простыми словами. +- Каждый экран сверяется с дизайном в Playwright и проходится руками до сдачи. + +## Как устроено + +### Данные + +- `GET …/corrections?statement_type=balance` — одна загрузка всей таблицы; фильтры, сортировка, «Итого» — на клиенте. +- Правки: `PUT …/aggregates/{rule}/{c1}/{c2}` (сохранить, сразу `APPLIED`), `POST …/allocations` (решение по строке, ответ — + вся таблица), `GET …/export`. +- Клиент — `web/src/services/api/corrections.ts` поверх `api/client.ts`. При `REACT_APP_CORRECTIONS_MOCK=true` те же функции + отвечают из фейка контракта в памяти (данные — из дизайна 23.10, с проверками контракта: обязательный комментарий, + `expected_version_no`, применимость правила). Флаг по умолчанию выключен; фейк включается только переменной окружения, но код фейка остаётся в бандле (R-21, R-26). + +### Таблица + +Колонки как в дизайне: «Учитывать в КФО»; Дебет — ЮЛ, Строка баланса, Счёт, Сумма; Кредит — то же; Контроль — +Отклонение %, Отклонение сумма; Корректировка — Строка баланса, Сумма (Дт), Строка баланса, Сумма (Кт), Комментарий; +колонка действий. + +- **Строка агрегата.** Суммы сторон, счета (`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; пары строк из дизайна не строим, ответ п. 3). + Флажка у строки нет; меню: «Перенести в правило…» (правила — `candidate_rule_ids` строки; фронт сам не сверяет + счета с правилами), «Убрать из правила» (строка уходит в нераспределённые), «Вернуть к автоматике» (если `decision = ASSIGN`). +- **Флажок агрегата** «Учитывать в КФО» — отмечен и закреплён: агрегат учитывается целиком, лишнюю строку убирают из + правила (ответ Лысенко 29.10, п. 4; R-31). +- **Нераспределённые строки** — после агрегатов, фон приглушён. Владелец — на своей стороне (дебетовая строка — в «Дебет», + кредитовая — в «Кредит»), на другой стороне — только ЮЛ контрагента. Строка баланса владельца — `line_code` на его + стороне (ответ п. 11, R-33). Флажок «Учитывать в КФО» (только здесь), меню «Отнести к правилу…» (список — + `candidate_rule_ids`), «Вернуть к автоматике» для `reason = UNASSIGNED`. Кнопки «Корректировать» нет (ответ п. 2). +- **«Итого»** — суммы сторон, отклонения и корректировок по видимым агрегатам, точным сложением строк; процент «Итого» не + показываем (смысл не задан). +- Подписи «Отнести/Перенести в правило…» показывают правила как пресеты: «Правило N: <статья 1> — <статья 2>». + +### Панель + +- «Отображение»: «Общий» / «По пресету»; при «По пресету» — выбор правила (подпись с номером — снимает одинаковые подписи + правил 5 и 8 из дизайна, п. 12); в таблице только агрегаты этого правила и те нераспределённые строки, у которых это + правило есть в `candidate_rule_ids` (их можно отнести к нему). +- «Тип записи»: «Все» / «Пары счетов» (агрегаты) / «Нераспределенные записи». +- Фильтры «ЮЛ» и «Счёт» действуют и на нераспределённые строки; «Строка баланса» и «Отклонение» — только на агрегаты, при + заданном одном из них нераспределённые строки скрываются (у них нет статьи и отклонения). +- Составная статья правила (`"1450/1550"`, `"1310/1350"`, правила 1 и 2) показывается как «1450 / 1550»; в фильтре + «Строка баланса» это два кода; в окне корректировки подставляется первый код, аналитик может выбрать второй. +- Единицы и знаки — тот же компонент, что в шахматке. +- Фильтры в заголовках: ЮЛ (подстрока, обе стороны), Строка баланса (список), Счёт (начало кода), Отклонение % + («Все» / «≥ 5 %» / «< 5 %», по `exceeds_threshold`); «Сбросить фильтры» — когда что-то задано. +- «Настройка таблицы» — `GenericTable/ColumnSettings` (видимость групп и колонок); «Экспорт» — файл сервера; «На весь экран» — + как в шахматке. + +### Окно «Корректировка операции» + +По дизайну: шапка сторон (компания, статья правила), блоки «Исходные данные ОСВ» и «Распределение корректировки» для +каждой стороны, итоги, комментарий, «Отменить» / «Сохранить корректировки». + +- **Исходные данные ОСВ** — строки стороны агрегата. «×» у строки — «Убрать из правила» (`UNASSIGN`), «Добавить строку ОСВ» — + выбор из нераспределённых строк, которые подходят под эту сторону: та же пара компаний (владелец и контрагент), сторона + строки = сторона правила, правило агрегата есть в `candidate_rule_ids` (ответ 3(а)). Обе операции идут на сервер сразу + (`POST …/allocations`), окно перечитывает агрегат. +- **Распределение** — статьи стороны: строка баланса (код статьи) и сумма; «Добавить строку» — дочерняя статья. Без + корректировки окно предлагает по стороне одну статью правила с суммой стороны (как в дизайне). +- **Контроль** — суммы исходных строк сторон и суммы распределения сторон, разница и процент к меньшему (как в таблице; + 79,77 % из дизайна — ошибка дизайна, п. 7). Расхождение не запрещает сохранение (ждёт п. 8). +- **Сохранить** — `PUT` с `status = APPLIED`, `expected_version_no` из ответа; ошибки сервера показываются в окне по коду + (`COMMENT_REQUIRED` — у поля комментария, `INVALID_LINE_CODE` — у статьи, `CORRECTION_VERSION_CONFLICT` — «Корректировку уже + изменили, данные обновлены», окно перечитывает данные). +- **Черновик окна в `localStorage`** (решение владельца 28.10.2026 вместо блокировки переходов): статьи распределения и + комментарий сохраняются при каждой правке; ключ — пользователь + версия + агрегат (`rule_id`, компании), в записи — время + и `version_no` корректировки, от которой начаты правки. Переход «Назад», закрытие вкладки, переход по ссылке правки не + теряют. При открытии окна этого агрегата черновик подставляется с плашкой «Восстановлены несохранённые правки от ЧЧ:ММ» и + кнопкой «Сбросить»; если `version_no` на сервере уже другой — плашка «Корректировку изменили после ваших правок» + (аналитик решает: оставить или сбросить). Черновик удаляется после успешного сохранения и после «Отменить» с + подтверждением «Есть несохранённые изменения. Закрыть без сохранения?». Действия со строками в черновик не входят (они уже + на сервере). Доступ к хранилищу — в `try/catch`: недоступно (приватный режим) — работаем без черновика. + +### Только чтение + +`editable = false` — «Корректировать», флажки и меню решений неактивны; подсказка по `read_only_reason`: +`VERSION_NOT_EDITABLE` — «Версия закрыта для изменений», `LOCK_REQUIRED` — «Возьмите консолидацию в работу», +`LOCKED_BY_OTHER` — «Консолидацию редактирует другой пользователь». Окно корректировки открывается на просмотр. + +### Состояния + +Загрузка — индикатор на месте таблицы; ошибка — уведомление с текстом сервера и «Повторить»; пусто — «Внутригрупповых +остатков между компаниями периметра нет»; фильтры ничего не нашли — «Нет записей для выбранных фильтров» (текст дизайна). + +## Что оставлено исполнителю + +Разбиение на компоненты и хуки, имена файлов внутри `widgets/CorrectionTable/`, способ раскрытия (antd `expandable` или +собственные строки), вид меню действий, точная раскладка окна в пределах дизайна, состав данных фейка. + +## Decisions made without discussion + +| Дата | Решение | Почему | Цена ошибки | +|---|---|---|---| +| 28.10.2026 | Контракт API пишем сами (docs #183), markdown + OpenAPI; корректировка в контракте — только на агрегат | API ещё нет; аналитика приоритетна | при ответе «можно на строку» (п. 2) — новый эндпоинт и кнопки на строках | +| 28.10.2026 | Раскрытие агрегата — строки ОСВ под своей стороной, не пары из дизайна | DOC-272613 п. 3.4; пары нигде не хранятся | ответ по п. 3 «пары нужны» — доработка раскрытия и контракта | +| 28.10.2026 | «Учитывать в КФО» ставится на строке; у агрегата — производное «Да / Частично / Нет» | DOC-272613 (EXCLUDE на `osv_entry`) | ответ по п. 4 «на агрегат» — поменять флажок агрегата на действие | +| 28.10.2026 | Действия со строками в окне корректировки идут на сервер сразу, статьи и комментарий — по «Сохранить» | разнесение и корректировка — разные записи (`vgo_assignment` / `correction`); аналитик видит новые суммы до ввода корректировки | отмена окна не откатывает перенос строк — аналитику нужно вернуть строку вручную | +| 28.10.2026 | Сохранение сразу применяет (`APPLIED`), черновик не показываем | в дизайне одна кнопка | ответ по п. 9 «нужен черновик» — вторая кнопка | +| 28.10.2026 | «Отменить корректировку» — в меню агрегата, с комментарием | `REVOKED` в аналитике есть, в дизайне нет места | ответ по п. 9 — перенести кнопку | +| 28.10.2026 | Процент в окне — к меньшему, как в таблице | аналитика; в дизайне две формулы | — | +| 28.10.2026 | Без корректировки окно предлагает суммы сторон целиком | так в дизайне; в аналитике подсказка не описана | ответ по п. 8 — поменять подсказку на min | +| 28.10.2026 | Фильтр «Отклонение» — по `exceeds_threshold` (≥ 5 % / < 5 %), а не диапазон чисел | порог — главный вопрос аналитика; диапазон в дизайне не описан | доработка фильтра | +| 28.10.2026 | «Итого» без процента | смысл процента по итогам не задан | добавить колонку | +| 28.10.2026 | Фейк контракта под флагом `REACT_APP_CORRECTIONS_MOCK`, данные из дизайна | фронт не ждёт бэкенд; тот же код ходит в настоящий API | фейк расходится с бэкендом — ловится при подключении API и ревью AURORA-1540 | +| 28.10.2026 | Только `statement_type = balance`, переключателя ББ/ОФР нет | в дизайне его нет; ОФР ждёт AURORA-1396 | добавить переключатель | +| 28.10.2026 | Ключ черновика — пользователь + версия + агрегат; черновик без срока жизни, удаляется сохранением или «Отменить» | на общем компьютере у разных пользователей разные черновики | старые черновики копятся в `localStorage` — объём мал | +| 28.10.2026 | План — только `web/`, бэкенду помогаем на ревью 1540; последняя задача — подключение к API и снятие флага | слова владельца «поможем ребятам на ревью» | если 1540 не успеет к 02.11, экран сдаётся на фейке | +| 28.10.2026 | В контракт добавлен `candidate_rule_ids` у строки агрегата | иначе фронту пришлось бы сверять счета с правилами — это разнесение, анти-цель | правка контракта до начала 1540 — дёшево | +| 28.10.2026 | `expected_version_no = null`, когда действующей корректировки нет (и после отзыва) | иначе после отзыва фронту неоткуда взять номер | — | +| 28.10.2026 | Составная статья правил 1 и 2 — в окне первый код, можно выбрать второй | `line_code` — один код; особый расчёт правил 1 и 2 (DOC-274230) — на сервере | при ответе «нужно распределение по обеим» — аналитик добавляет вторую статью руками | +| 28.10.2026 | «По пресету» показывает и подходящие нераспределённые строки; «Строка баланса» и «Отклонение» скрывают нераспределённые | пресет — рабочее место по правилу, строки для отнесения нужны рядом | поменять функцию фильтров | +| 28.10.2026 | R-1: список правил в плане — ориентир; при расхождении с api/seed/core/elimination_rules_v1.yaml (в плане статья 1230/1510 встречается дважды) номера и статьи берутся из yaml, тесты плана про правила 1, 3, 6, 7 остаются верными | seed — источник истины | поправка mockData и тестов | +| 28.10.2026 | R-2: в фейк добавлен `widgets/CorrectionTable/mockCalc.ts` (пересчёт агрегата и итогов через BigInt-копейки) — общий для `mockData` и `correctionsFake`, иначе циклический импорт | план не называл файл; без него циклический импорт | перенос функций, без поведения | +| 28.10.2026 | R-3: в `mockData` сверх дизайна добавлены агрегат правила 6 «ООО "БСК"» — «ПМП ООО» (310000.00 / 300000.00) и агрегат правила 4 «Агро-Холдинг» без второй стороны (ONE_SIDED); `status` в фейке — ONE_SIDED / DISCREPANCY / AUTO, MANUAL не выдаётся | тест фильтра `ооо "бск"` из плана требует агрегат с таким ЮЛ; ONE_SIDED нужен для проверки отображения | поправка данных фейка, на прод не влияет | +| 28.10.2026 | R-4: в окне корректировки сумма с пробелами («1 000 000,5») отклоняется с ошибкой «Сумма без пробелов», хотя `parseAmountInput` пробелы принимает | Review Focus плана прямо требует ошибку для этого ввода | если заказчик хочет пробелы — снять одну проверку в `validateLines` | +| 28.10.2026 | R-5: `percentOfSmaller`, `toCents`, `fromCents` и порог 5 % (`THRESHOLD_PERCENT_HUNDREDTHS`) вынесены в `utils/money.ts`; `editorModel` и `mockCalc` импортируют оттуда, зависимости `editorModel` → `mockCalc` нет | фейк `mockCalc` вырезается при подключении API, экран не должен от него зависеть (ревью Task 3, T3.1) | поведение то же; тесты перенесены в `utils/money.test.ts` | +| 28.10.2026 | R-6: тексты ошибок валидации окна и ключ ошибки `lines` для «Добавьте статью» выбраны исполнителем | бриф их не задаёт; тексты в spec не указаны | правка строк | +| 28.10.2026 | R-7: флажки «Учитывать в КФО» и меню строк неактивны всегда (`actionsEnabled=false`) до Task 5–6, подсказка «только чтение» подключена | бриф Task 4: решения по строкам — в Task 5 | забыть включить флаг в Task 5 | +| 28.10.2026 | R-8: ошибка загрузки показывается уведомлением с «Повторить» (по спеке) и кнопкой на месте таблицы | после закрытия уведомления аналитик не остаётся без выхода | две кнопки повтора | +| 28.10.2026 | R-9: «Итого» показывается только при наличии видимых агрегатов (при «Нераспределенные записи» его нет) | сценарий спеки | показать «Итого» с пустыми значениями | +| 28.10.2026 | R-10: `ROW_ACTIONS_READY`/`actionsEnabled` удалены; контролы строк неактивны при `!editable` (с подсказкой `readOnlyHint`) и на время запроса (`busy`, без подсказки) | включение R-7 в Task 5; блокировка на время запроса не даёт отправить два решения подряд | аналитик не видит, почему контрол замер, пока идёт запрос (виден спиннер таблицы) | +| 28.10.2026 | R-11: любая ошибка 409 при решении/отмене — уведомление с текстом сервера и `reload()` (`needsReload`) | 409 = версия закрылась, блокировку забрали, корректировку уже изменили/отозвали: локальная таблица устарела | лишний запрос при 409 без смысла для таблицы; комментарий окна отмены теряется при 409 и перезагрузке — допустимо, корректировка уже изменена; перечитывание и при 404 (`needsReload`) | +| 28.10.2026 | R-12: после `allocate` таблица заменяется ответом целиком (`replace`), после `revoke` подменяется один агрегат (`replaceAggregate`); окно отмены при не-409 ошибке остаётся открытым, при 409 закрывается | `allocate` меняет и нераспределённые, `revoke` отдаёт агрегат; при сетевой ошибке комментарий не должен пропасть | после revoke нераспределённые не обновляются (по контракту revoke их не трогает) | +| 28.10.2026 | R-13: меню строки вынесено в чистую `allocationMenu` (ключ `MOVE` — «выбрать правило», уходит как `ASSIGN`); правило-кандидат, которого нет в `rules`, пункт «в правило» не включает | тестируемость без React; фронт не выдумывает правил | при рассинхроне справочника правил пункт неактивен | + +| 28.10.2026 | R-14: окно получило пропс `onReload` сверх сигнатуры плана и рендерится только пока открыто (`key` = ключ агрегата) | при `CORRECTION_VERSION_CONFLICT`/409/404 окну нужно перечитать таблицу; черновик читается один раз при открытии | окно без `onReload` не обновит таблицу при конфликте | +| 28.10.2026 | R-15: черновик пишется только при `editable` и если состояние отличается от исходного (суммы — копейками); вернули к исходному — черновик удаляется; при конфликте редакций правки остаются в окне, черновик сохраняется с прежним `baseVersionNo`, плашка «Корректировку изменили после ваших правок» | в режиме чтения черновик бессмыслен; совпавший с исходным — мусор в `localStorage` | при ручном возврате к исходному плашка восстановления пропадает | +| 28.10.2026 | R-16: ключ строки статьи в окне — устойчивый числовой `id` из счётчика компонента (в `EditorState` ids нет); наружу (черновик, PUT) уходит `EditorState` без id | ключ-индекс при удалении строки переносил бы введённый текст на соседнюю | id не попадают в черновик, после перезагрузки выдаются заново | +| 28.10.2026 | R-17: после `UNASSIGN`/`ASSIGN` в окне суммы статей без своих правок (не dirty) пересобираются из нового агрегата (`initialEditorState`), при правках — остаются как есть | у агрегата без корректировки статья предлагается с суммой стороны, она изменилась; затирать ввод аналитика нельзя | при правках статьи не подстраиваются под новые суммы сторон — их видно по строке контроля | +| 28.10.2026 | R-18: суммы в окне показываются в рублях (`1 000 000,00`) независимо от единиц панели, а в полях ввода — как есть (`1000000.00`, пробелы запрещены R-4); у строки контроля без процента (`percent = null`, одна сторона 0) процент не пишется, у равенства — «= 0,00» | единицы панели — про таблицу; дизайн показывает «(100%)» там, где процент к меньшей стороне не определён, фронт его не выдумывает | расхождение с дизайном в тексте контроля | +| 28.10.2026 | R-19: агрегат исчез из ответа — окно открыто, стороны «—», «Сохранить» неактивна, пояснение в плашке; ключ PUT берётся из ответа, поэтому без агрегата сохранить нельзя | Review Focus плана | — | +| 28.10.2026 | R-20: в окне нет названий статей рядом с кодом («дт 1230 Дебиторская задолженность» в дизайне) | в ответе API названий статей нет, а фронт их не выдумывает | добавить поле `article_name` в контракт и показать | +| 28.10.2026 | R-21: код фейка (`correctionsFake`, `mockData`, `mockCalc`, ~20 КБ исходников) остаётся в прод-бандле при сборке без флага; сам не вырезал | `Config.CORRECTIONS_MOCK` — обычное чтение `process.env.REACT_APP_CORRECTIONS_MOCK`, импорты статические: `npm run build` без флага даёт строку «Требуется сверка» в `index_script`; вырезание требует динамического импорта фейка или `define`-константы в rsbuild — не мелкая правка; Task 8 фейк всё равно удаляет | лишние ~8 КБ gzip в бандле до Task 8; вопрос владельцу: делать ли вырезание сейчас | +| 28.10.2026 | R-22: Prettier и ESLint применены к файлам задачи (`npm run lint` падал на форматировании 17 файлов, на вложенных тернарниках окна и BOM-литерале в фейке); BOM — `\uFEFF`, поведение то же | `npm run lint` — шаг проверки Task 7 | — | +| 28.10.2026 | R-23: «Вернуть к автоматике» на нераспределённой строке в фейке только снимает «исключена» и не возвращает строку в правило | фейк не знает, какое правило было автоматическим; настоящее поведение — на бэкенде (1540) | аналитик на демо на фейке не увидит возврата; проверить при подключении API | +| 28.10.2026 | R-24: у агрегата флажок «Учитывать в КФО» — серый неактивный индикатор «Да / Частично / Нет», а в дизайне зелёный | решение 28.10 (флажок ставится на строках, у агрегата производное) | ответ по п. 4 «на агрегат» | +| 28.10.2026 | R-25: отдельное ревью Task 7 (стиль, два мелких фикса, документация) не делалось, оно покрыто финальным ревью ветки | дифф механический, финальное ревью читает всю ветку | мелкая ошибка в Task 7 всплывёт на финальном ревью | +| 28.10.2026 | R-26: фейк (R-21) остаётся в бандле до Task 8, который его удаляет | вырезание требует динамического импорта или `define` в rsbuild, и всё равно временное | ~20 КБ в бандле до подключения API | +| 28.10.2026 | R-27: `mockData` и `mockCalc` остаются как тестовые фикстуры (их импортируют тесты `tableModel`, `editorModel`, `readOnly`, `draftStorage`); Task 8 удаляет только `correctionsFake.ts` (+ его тест) и ветку флага в `corrections.ts`. Уточняет R-5, R-21, R-26: «вырезается» относится только к фейку контракта `correctionsFake` | иначе при подключении API пришлось бы переписывать тесты моделей | если фикстуры устареют относительно контракта — тесты моделей перестанут ловить расхождения; сверять при Task 8 | +| 28.10.2026 | R-28: окно выбора нераспределённой строки (picker) не показывает исключённые (`excluded`) — исключённую сначала возвращают в учёт флажком в таблице | исключённая строка сознательно убрана из КФО; разнесение её в правило молча вернуло бы её в расчёт | аналитику нужен лишний шаг; если захотят выбирать исключённые — снять условие `!u.excluded` в `pickerCandidates` | +| 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` из ответа и расходиться не будет | +| 02.11.2026 | R-35 (Task 8): граница 5 % в живом контроле окна (`editorModel.diffOf`) и подпись фильтра «Отклонение, %» приведены к фактическому поведению API 1540 (`domain/corrections.py`, `exceeds = percent > 5`, строго больше) — `>`, не `≥`; R-34 снимается как источник расхождения | API 1540 уже в main, фронт без Task 8 не существовал в проде — совпадение с реальным сервером важнее совпадения с неоднозначным текстом контракта («от 5 % включительно», на который Лысенко не ответил); сама таблица и так брала `exceeds_threshold` из ответа и не расходилась | ровно 5,00 % теперь не красное и в окне, и в фильтре, как у сервера; если Лысенко подтвердит «включительно» — одна правка оператора в `editorModel.ts`/`CorrectionTable.tsx`, вопрос не снят, просто фронт временно синхронен с текущим сервером | diff --git a/openspec/changes/corrections-table-ui/proposal.md b/openspec/changes/corrections-table-ui/proposal.md new file mode 100644 index 0000000..33e7e95 --- /dev/null +++ b/openspec/changes/corrections-table-ui/proposal.md @@ -0,0 +1,61 @@ +## Why + +AURORA-1541: вкладка «Таблица корректировки» страницы отчёта версии — заглушка «Будет в следующих срезах». Аналитику нужен +экран, где видно расхождения ВГО по правилам и парам компаний, строки ОСВ, из которых они сложились, нераспределённые +строки, и где вносится корректировка агрегата. Срок задачи — 02.11.2026, к 03.11 начинается тестирование корректировок +(AURORA-1542). + +Основа — аналитика Лысенко (DOC-272613 «Корректировка данных агрегатом», ответы на вопросы 1–7 от 27.10.2026 в AURORA-1395, +DOC-274230 «Правила пар счетов») и дизайн от 23.10.2026 (вложение к DOC-272613). Где дизайн расходится с аналитикой, +действует аналитика (решение владельца 28.10.2026); вопросы по расхождениям — комментарий `[design-23-10-check]` в +AURORA-1395 и в AURORA-1396. + +## What Changes + +Срез 5 (`09-delivery-plan.md`, «ВГО: автоматическое сопоставление, шахматка, корректировки»). Репозиторий — arm-core, +только `web/`. Бэкенд — AURORA-1540 (Цыганков) по контракту `../docs/dev/api-contract-corrections.md` + +`api-contract-corrections.openapi.yaml` (docs #183); пока API нет, экран работает на встроенном фейке контракта под флагом +`REACT_APP_CORRECTIONS_MOCK`. + +- Вкладка «Таблица корректировки» (`…/versions/{id}/correction`) становится доступной. +- Таблица агрегатов «правило × компания 1 × компания 2»: блоки Дебет / Кредит / Контроль / Корректировка, раскрытие до строк + ОСВ под своей стороной, нераспределённые строки после агрегатов, строка «Итого» по видимым строкам. +- Панель: «Отображение» (Общий / По пресету + выбор правила), «Тип записи», единицы и знаки, «Сбросить фильтры», + «Настройка таблицы», «Экспорт», «На весь экран»; фильтры в заголовках колонок. +- Окно «Корректировка операции» агрегата: исходные строки ОСВ сторон (убрать, добавить подходящую нераспределённую), + распределение по статьям каждой стороны, контроль распределения, комментарий, «Сохранить корректировки». +- Решения по строке: «Учитывать в КФО» (флажок нераспределённой строки; агрегат учитывается всегда), «Отнести к правилу» / + «Перенести в правило», «Убрать из правила», «Вернуть к автоматике». +- Режим только чтения по `editable` / `read_only_reason` ответа. +- Новая спека `corrections-table-ui`; дельта `intercompany-matrix-ui` (вкладка «Таблица корректировки» доступна). + +## Non-goals + +- Бэкенд, DBML (`correction` как есть в v5.23; `vgo_assignment` — новая версия DBML в рамках AURORA-1540). +- ОФР: в дизайне его нет, вопрос в AURORA-1396; экран запрашивает только `statement_type = balance`. +- Импорт xlsx (вопрос по ТЗ 6.8 в `[design-23-10-check]`). +- Корректировка строки ОСВ и нераспределённой строки — по аналитике корректируется только агрегат (ждёт п. 2). +- Переход из ячейки шахматки в таблицу с фокусом на паре — AURORA-1653. +- Учёт корректировок в шахматке — AURORA-1643. + +## Capabilities + +### New Capabilities + +- `corrections-table-ui`: экран «Таблица корректировки» версии консолидации. + +### Modified Capabilities + +- `intercompany-matrix-ui`: MODIFIED «Вкладки страницы отчёта по версии» — «Таблица корректировки» доступна. + +## Impact + +- `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx` — вкладка `correction` без `disabled`. +- `web/src/layouts/ConsolidationReportPage.tsx` — содержимое вкладки `correction`. +- Новое: `web/src/widgets/CorrectionTable/` (таблица, панель, окно корректировки, чистые функции + тесты), + `web/src/services/api/corrections.ts`, типы контракта в `web/src/types/corrections.ts`, фейк контракта. +- `web/src/conf/config.ts` — флаг `REACT_APP_CORRECTIONS_MOCK`. +- Таблицы DBML v5.23: `correction`, `elimination_rule`, `osv_entry` — только читаются через API; новых полей фронт не вводит. +- Правила: `05-domain-rules.md` §8 (корректировки) в нынешнем виде описывает только запись корректировки; разнесение строк и + `vgo_assignment` в §8 нет — ASSUMPTION: до обновления §8 источник — DOC-272613 и ответы 1–7 (обновление §8 и DBML — + в рамках AURORA-1540 / AURORA-1751). diff --git a/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md b/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md new file mode 100644 index 0000000..d10a97b --- /dev/null +++ b/openspec/changes/corrections-table-ui/specs/corrections-table-ui/spec.md @@ -0,0 +1,188 @@ +## ADDED Requirements + +### Requirement: Загрузка таблицы корректировок +Вкладка `correction` страницы отчёта версии SHALL загружать таблицу одним запросом +`GET /consolidations/versions/{id}/corrections?statement_type=balance` (контракт `../docs/dev/api-contract-corrections.md`) +и показывать её целиком: строки агрегатов (`aggregates[]`) в порядке ответа, затем нераспределённые строки +(`unallocated[]`), внизу строку «Итого». Пока идёт загрузка, на месте таблицы SHALL быть индикатор; ошибка API SHALL +показываться уведомлением с текстом сервера и кнопкой «Повторить». Пустой ответ SHALL показывать текст «Внутригрупповых +остатков между компаниями периметра нет». Разнесение строк, отклонения, порог 5 % и статусы агрегатов фронт SHALL брать из +ответа и не пересчитывать. + +#### Scenario: Открытие вкладки +- **WHEN** аналитик открывает `…/versions/{id}/correction` +- **THEN** выполняется один запрос таблицы, показываются агрегаты, после них нераспределённые строки и строка «Итого» + +#### Scenario: Ошибка загрузки +- **WHEN** сервер вернул 500 +- **THEN** показано уведомление с текстом ошибки и кнопка «Повторить», повторный запрос выполняется по нажатию + +### Requirement: Колонки и строка агрегата +Таблица SHALL иметь колонки «Учитывать в КФО»; группу «Дебет» — «ЮЛ», «Строка баланса», «Счёт», «Сумма»; группу «Кредит» — +те же четыре; группу «Контроль» — «Отклонение, %», «Отклонение, сумма»; группу «Корректировка» — «Строка баланса», «Сумма» +(Дт), «Строка баланса», «Сумма» (Кт), «Комментарий»; колонку действий. Строка агрегата SHALL показывать: компании +`company_1` / `company_2`, статьи `side_1.article_line` / `side_2.article_line`, счета `accounts` через запятую, суммы сторон, +`deviation_percent` (пусто — «—») и `deviation_amount`; при `exceeds_threshold = true` процент и сумма отклонения SHALL быть +красными. Блок «Корректировка» SHALL показывать статьи и суммы `correction.debit` / `correction.credit` (по строке на статью) +и `correction.comment`; без корректировки — «—». Корректировка в статусе `DRAFT` SHALL помечаться «черновик». Флажок +«Учитывать в КФО» агрегата SHALL быть отмечен и неактивен всегда: агрегат учитывается в КФО целиком, лишнюю строку из него +убирают из правила (ответ Лысенко 29.10, п. 4). Агрегат с +`composition_changed = true` SHALL подсвечиваться и иметь значок с подсказкой «Состав изменился после корректировки». + +#### Scenario: Расхождение выше порога +- **WHEN** у агрегата «ЭПТ — ИЛС» `amount 1000000.00 / 202320.00`, `deviation_percent = "394.27"`, `exceeds_threshold = true`, + выбраны «тыс. руб.» и 2 знака +- **THEN** в строке видно `1 000,00`, `202,32`, красные `394,27%` и `797,68` + +#### Scenario: Корректировка по двум статьям стороны +- **WHEN** у корректировки агрегата `credit = [{1520, 500000.00}, {1550, 500000.00}]` +- **THEN** в ячейках «Строка баланса» и «Сумма» стороны Кт видно две строки: `1520` / `500,00` и `1550` / `500,00` + +### Requirement: Раскрытие агрегата до строк ОСВ +Строка агрегата SHALL раскрываться стрелкой слева. Под агрегатом SHALL показываться его строки: сначала `side_1.entries`, +затем `side_2.entries`; строка стороны 1 заполняет в блоке «Дебет» «Счёт» и «Сумма», строки стороны 2 — то же в блоке +«Кредит»; остальные ячейки пустые. Флажка «Учитывать в КФО» у строки агрегата SHALL 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` — «Кредит») со строкой баланса `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** «ООО "БСК"», `1520`, `76.05` и `142 878,00` — в блоке «Кредит», «ПМП ООО» — в «ЮЛ» блока «Дебет», меню предлагает + правила из `candidate_rule_ids` + +### Requirement: Флажок «Учитывать в КФО» +Флажок нераспределённой строки SHALL отправлять `PUT …/vgo-assignments/exclude` с `is_exclude = true` (снят) или `false` +(поставлен) и затем перечитывать таблицу целиком. Флажок агрегата SHALL быть отмечен и неактивен с подсказкой «Агрегат всегда +учитывается в КФО; лишнюю строку уберите из правила»; у строк агрегата флажка SHALL NOT быть (ответ Лысенко 29.10, п. 4). + +#### 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>» +и отправлять `action = ASSIGN` с `rule_id`; «Убрать из правила» — `action = UNASSIGN`; «Вернуть к автоматике» — +`action = RESET`. Ответ (вся таблица) SHALL заменять показанную таблицу; ошибка 422 `RULE_NOT_APPLICABLE` SHALL показываться +уведомлением с текстом сервера. + +#### Scenario: Перенос строки в другое правило +- **WHEN** аналитик выбирает у строки «Перенести в правило…» и «Правило 7: 1230 — 1410» +- **THEN** уходит `{action: "ASSIGN", rule_id: <правило 7>, entry_ids: [...]}`, строка видна в агрегате правила 7 + +### Requirement: Окно «Корректировка операции» +Кнопка «Корректировать» строки агрегата SHALL открывать окно «Корректировка операции» с двумя сторонами: компания и статья +правила стороны; блок «Исходные данные ОСВ» (счёт, строка баланса, сумма строк стороны; «×» у строки — `UNASSIGN`, строка +уходит в нераспределённые; +«Добавить строку ОСВ» — выбор из нераспределённых строк той же пары компаний, той же стороны, у которых правило агрегата есть +в `candidate_rule_ids`, — `ASSIGN`); блок «Распределение корректировки» (статьи: строка баланса и сумма; «Добавить строку» — +ещё статья стороны; «×» — удалить статью). Действия с исходными строками SHALL уходить на сервер сразу и перечитывать агрегат. +Без корректировки окно SHALL предлагать по каждой стороне одну статью правила с суммой стороны; у составной статьи +(«1450/1550») — первый код, второй доступен для выбора. Под блоками SHALL быть итоги +сторон и разница с процентом к меньшему (красным, если процент > 5 или одна сторона пуста), поле «Комментарий к +корректировке», кнопки «Отменить» и «Сохранить корректировки». + +#### Scenario: Окно агрегата без корректировки +- **WHEN** аналитик нажимает «Корректировать» у агрегата «Пензенский Мир Продуктов — Регион-Сахар» правила 3 (29 742 550,00 / + 29 242 550,00) +- **THEN** в распределении стороны Дт — статья `1170` с суммой `29 742 550,00`, стороны Кт — `1410` с `29 242 550,00`, разница + `500 000,00 (1,71%)` + +#### Scenario: Добавить строку ОСВ +- **WHEN** в окне агрегата пары «Пензенский Мир Продуктов — Бековский сахарный комбинат» правила 6 аналитик нажимает + «Добавить строку ОСВ» на стороне Кт +- **THEN** список содержит только нераспределённые кредитовые строки «Бековский сахарный комбинат ООО» на «Пензенский Мир + Продуктов», подходящие под правило 6; выбор строки отправляет `ASSIGN` и обновляет суммы стороны + +### Requirement: Сохранение корректировки +«Сохранить корректировки» SHALL отправлять `PUT …/corrections/aggregates/{rule_id}/{company_1_id}/{company_2_id}` со статьями +обеих сторон (первая статья стороны — основная), комментарием и `expected_version_no` из ответа (`null` — действующей +корректировки нет); сервер сразу применяет корректировку (`APPLIED`). После ответа окно SHALL закрываться, агрегат в таблице — +обновляться. Ошибки SHALL показываться в окне: `COMMENT_REQUIRED` — у +поля комментария, `INVALID_LINE_CODE` — у статьи `detail.line_code`, `CORRECTION_VERSION_CONFLICT` — сообщением «Корректировку +уже изменили, данные обновлены» с перечитыванием таблицы, остальные — текстом сервера. Закрытие окна с несохранёнными +статьями или комментарием SHALL спрашивать «Есть несохранённые изменения. Закрыть без сохранения?». + +#### Scenario: Сохранить без комментария +- **WHEN** аналитик сохраняет корректировку с пустым комментарием и сервер отвечает 422 `COMMENT_REQUIRED` +- **THEN** окно остаётся открытым, у поля комментария — текст ошибки + +#### Scenario: Закрыть с несохранённым +- **WHEN** аналитик изменил сумму статьи и нажал «Отменить» +- **THEN** появляется вопрос «Есть несохранённые изменения. Закрыть без сохранения?»; «Остаться» оставляет окно с правками + +### Requirement: Черновик окна корректировки +Статьи распределения и комментарий открытого окна SHALL сохраняться в `localStorage` при каждой правке под ключом +«пользователь + версия + агрегат» вместе со временем и `version_no` корректировки, от которой начаты правки. При открытии окна +агрегата с черновиком окно SHALL подставлять черновик и показывать плашку «Восстановлены несохранённые правки от ЧЧ:ММ» с +кнопкой «Сбросить»; если `version_no` действующей корректировки отличается от сохранённого в черновике — плашку +«Корректировку изменили после ваших правок». Черновик SHALL удаляться после успешного сохранения, после «Сбросить» и после +«Отменить» с подтверждением. Недоступное хранилище SHALL NOT ломать окно — оно работает без черновика. + +#### Scenario: Ушёл со страницы и вернулся +- **WHEN** аналитик изменил сумму статьи в окне агрегата, нажал «Назад» в браузере, потом снова открыл вкладку и окно этого + агрегата +- **THEN** в окне его сумма и плашка «Восстановлены несохранённые правки от …» + +#### Scenario: Корректировку изменили, пока аналитик отсутствовал +- **WHEN** в черновике `version_no = 2`, а в ответе таблицы у корректировки агрегата `version_no = 3` +- **THEN** окно подставляет черновик и показывает плашку «Корректировку изменили после ваших правок» с кнопкой «Сбросить» + +### Requirement: Панель и фильтры +Над таблицей SHALL быть: «Отображение» — «Общий» / «По пресету»; при «По пресету» — поле «Правило» с правилами `rules[]` +(подпись «Правило N: <статья 1> — <статья 2>», составная статья — «1450 / 1550») и в таблице только агрегаты выбранного +правила и нераспределённые строки, у которых оно есть в `candidate_rule_ids`; «Тип записи» — «Все», «Пары +счетов» (только агрегаты), «Нераспределенные записи» (только нераспределённые); единицы и знаки — как в шахматке; «Сбросить +фильтры» — когда задан любой фильтр; справа — «Настройка таблицы», «Экспорт», «На весь экран». В заголовках SHALL быть +фильтры: «ЮЛ» (подстрока имени, по обеим сторонам), «Строка баланса» (выбор кода), «Счёт» (начало кода, по счетам строки), +«Отклонение, %» («Все» / «> 5 %» / «≤ 5 %» по `exceeds_threshold`). Фильтры «ЮЛ», «Счёт» и «Строка баланса» SHALL +действовать и на нераспределённые строки («Строка баланса» — по `line_code`, строка без него скрывается, её коды есть в +выборе); при заданном фильтре «Отклонение, %» нераспределённые строки SHALL скрываться. +Составная статья правила (коды через «/») в фильтре «Строка баланса» SHALL давать отдельные коды. Фильтры ничего не нашли — +текст «Нет записей для выбранных фильтров». Строка «Итого» SHALL суммировать видимые агрегаты точным сложением: суммы сторон, отклонение, суммы +корректировок Дт и Кт; процент в «Итого» — «—». + +#### Scenario: Пресет правила 3 +- **WHEN** выбраны «По пресету» и «Правило 3: 1170 — 1410» +- **THEN** в таблице только агрегаты правила 3, «Итого» — по ним + +#### Scenario: Только нераспределённые +- **WHEN** «Тип записи» — «Нераспределенные записи» +- **THEN** агрегатов и «Итого» по агрегатам нет, видны только нераспределённые строки + +### Requirement: Экспорт +«Экспорт» SHALL скачивать файл `GET …/corrections/export?statement_type=balance` с именем из ответа сервера; ошибка — +уведомлением с текстом сервера. + +#### Scenario: Выгрузка +- **WHEN** аналитик нажимает «Экспорт» +- **THEN** браузер сохраняет xlsx, кнопка на время запроса показывает загрузку + +### Requirement: Только чтение +При `editable = false` кнопки «Корректировать» SHALL открывать окно только на просмотр, флажки «Учитывать в КФО» и пункты меню +решений SHALL быть неактивны с подсказкой по `read_only_reason`: `VERSION_NOT_EDITABLE` — «Версия закрыта для +изменений», `LOCK_REQUIRED` — «Возьмите консолидацию в работу», `LOCKED_BY_OTHER` — «Консолидацию редактирует другой +пользователь». Ответы 409 правок версии SHALL показываться уведомлением с текстом сервера и перечитыванием таблицы. + +#### Scenario: Верифицированная версия +- **WHEN** сервер вернул `editable = false`, `read_only_reason = "VERSION_NOT_EDITABLE"` +- **THEN** флажки неактивны, в окне корректировки нет «Сохранить корректировки», подсказка — «Версия закрыта для изменений» diff --git a/openspec/changes/corrections-table-ui/specs/intercompany-matrix-ui/spec.md b/openspec/changes/corrections-table-ui/specs/intercompany-matrix-ui/spec.md new file mode 100644 index 0000000..217f9c0 --- /dev/null +++ b/openspec/changes/corrections-table-ui/specs/intercompany-matrix-ui/spec.md @@ -0,0 +1,34 @@ +## MODIFIED Requirements + +### Requirement: Вкладки страницы отчёта по версии +Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз +и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`: +`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка», `correction` — «Таблица корректировки» (спека `corrections-table-ui`). +Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки +(навигация, не локальное состояние) заменой текущей записи истории браузера, без новой записи: кнопка «Назад» в шапке и +кнопка «Назад» браузера ведут туда, откуда открыт отчёт, а не по переключениям вкладок (AURORA-1851). Активная вкладка +подсвечена зелёным и выделена жирным. Неизвестный ключ вкладки SHALL +заменяться на `osv` без новой записи в истории браузера. Вкладки «Гудвилл», «КФО», «КФО Общий», +«Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». При смене `version_id` всё +состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново. + +#### Scenario: Переход на шахматку +- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка» +- **THEN** адрес становится `…/versions/{id}/chess`, шапка версии остаётся, под вкладками показана шахматка + +#### Scenario: Переход на таблицу корректировки +- **WHEN** открыта шахматка версии по адресу `…/versions/{id}/chess` и нажата вкладка «Таблица корректировки» +- **THEN** адрес становится `…/versions/{id}/correction`, шапка версии остаётся, под вкладками показана таблица корректировок + +#### Scenario: Прежний адрес сводной ОСВ +- **WHEN** открыт адрес `…/versions/{id}/osv` +- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна + +#### Scenario: Неизвестная вкладка +- **WHEN** открыт адрес `…/versions/{id}/abc` +- **THEN** адрес заменяется на `…/versions/{id}/osv`, показана сводная ОСВ; кнопка «Назад» браузера не возвращает на `…/abc` + +#### Scenario: Назад после переключения вкладок +- **WHEN** аналитик открыл отчёт версии кнопкой «К отчётности» в списке консолидаций, переключился «Шахматка» → «ОСВ» → + «Шахматка» и нажал «Назад» в шапке +- **THEN** одним нажатием открыт список консолидаций ГК diff --git a/openspec/changes/corrections-table-ui/tasks.md b/openspec/changes/corrections-table-ui/tasks.md new file mode 100644 index 0000000..ee77566 --- /dev/null +++ b/openspec/changes/corrections-table-ui/tasks.md @@ -0,0 +1,51 @@ +## 1. Типы контракта, клиент API, флаг фейка + +- [x] 1.1 Типы `src/types/corrections.ts` по контракту `docs/dev/api-contract-corrections.md` +- [x] 1.2 Клиент `src/services/api/corrections.ts` (таблица, сохранение, отмена, разнесение, экспорт) и флаг `Config.CORRECTIONS_MOCK` + +## 2. Фейк контракта и модель таблицы + +- [x] 2.1 Фейк `correctionsFake` и данные дизайна `mockData` / `mockCalc` под флагом `REACT_APP_CORRECTIONS_MOCK` +- [x] 2.2 Чистая модель таблицы `tableModel.ts`: строки, фильтры, пресет правила, итоги + +## 3. Модель окна корректировки и черновик + +- [x] 3.1 `editorModel.ts`: состояние окна, проверки ввода, контроль баланса, кандидаты для выбора строки +- [x] 3.2 Деньги копейками в `utils/money.ts`, черновик в `draftStorage.ts` + +## 4. Вкладка и таблица только на чтение + +- [x] 4.1 Вкладка «Таблица корректировки»: загрузка, ошибка с «Повторить», пусто, экспорт, полный экран +- [x] 4.2 Таблица с группами колонок, раскрытием агрегата, нераспределёнными записями, «Итого» +- [x] 4.3 Панель: отображение, пресет правила, тип записи, единицы, знаки, сброс фильтров, настройка таблицы; фильтры в заголовках + +## 5. Решения по строкам, отмена корректировки, только чтение + +- [x] 5.1 Флажок «Учитывать в КФО» и меню строки (перенос в правило, убрать из правила, вернуть к автоматике) +- [x] 5.2 Отмена корректировки с комментарием, подсказки режима «только чтение», обработка 409 + +## 6. Окно «Корректировка операции» + +- [x] 6.1 Окно: стороны, строки ОСВ, распределение корректировки, контроль баланса, комментарий, сохранение +- [x] 6.2 Выбор нераспределённой строки (picker), черновик, плашки конфликта, доступность с клавиатуры + +## 7. Сверка с дизайном, проверки, документация + +- [x] 7.1 Проход сценариев спеки в браузере на фейке (ширина 1920, headless Chrome по CDP) и сверка частей экрана с дизайном. Проверено: таблица и группы колонок, панель, фильтры в заголовках, раскрытие, нераспределённые, пресет, настройка таблицы, окно корректировки, решения по строкам (в т.ч. повторный INCLUDE), «Убрать из правила», ошибка загрузки с «Повторить», экспорт, `composition_changed` и «черновик». Не проверено — вынесено в 8.1: полный экран, 409 / `CORRECTION_VERSION_CONFLICT`, недоступный `localStorage`, «Вернуть к автоматике» внутри агрегата, реальный API +- [x] 7.2 `make web-check`, `npm test`, `npm run lint`, `openspec validate corrections-table-ui --strict` +- [x] 7.3 README (флаг фейка) и ARCHITECTURE (виджет `CorrectionTable`) + +## 8. Подключение к API (после слияния AURORA-1540) + +- [x] 8.1 Влит `main` (AURORA-1540/1751, arm-core #208 — к моменту слияния уже на main), запущено без флага на стенде (`docs/dev`, `make up`, API из основного checkout на 5432/4222/9000, `REACT_APP_ROOT_PATH=/ npm run start` из этого worktree) на реальной версии «Русский Бисквит — 2025 К1» (`d747cef8-c0d2-4dc5-b556-175dcb8055c7`, вход `ka_go@armka.local`). Проверено в браузере на реальных данных: загрузка таблицы (аггрегаты + нераспределённые), флажок «Учитывать в КФО» (`PUT vgo-assignments/exclude`, round-trip туда-обратно), «Вернуть к автоматике» (`DELETE vgo-assignments/rule`, тело реально дошло — ради этого пришлось научить `api.del` отправлять тело, см. 8.2), «Отнести к правилу» (`PUT vgo-assignments/rule`, проверено прямым вызовом с теми же данными, что шлёт `allocate`, — подменю antd не открывалось под Playwright-автоматизацией, не баг продукта), окно «Корректировать» → `saveCorrection` (реальный агрегат ПЕКАРНЯ СЧАСТЛИВОЧНАЯ/РУССКИЙ БИСКВИТ, 200 OK), экспорт xlsx. Консоль браузера чистая на всех шагах. + Не проверено (нет готовых данных/инфраструктуры под это в локальном стенде, не блокирует подключение): полноэкранный режим; 409/`CORRECTION_VERSION_CONFLICT`; приватное окно без `localStorage`; именные сценарии спеки Сатурн — Архол, Бековский 62.01/60.02/62.03/76.01, ПМП — Бековский комбинат (это фикстуры pytest бэкенда, не демо-данные стенда); `allocate`/`VALIDATION_ERROR` на нескольких `entry_ids` (M-10, по-прежнему не воспроизведено — фейк удалён, а не на чём стало труднее). Оставляю как переносимый пункт проверки на QA/контур, не блокер PR. +- [x] 8.2 Расхождение с контрактом найдено и устранено на фронте: реальный API 1540/1751 (#208) не дал единой `POST …/corrections/allocations` — решение по строке разбито на `PUT`/`DELETE …/vgo-assignments/rule` (привязка/снятие) и `PUT …/vgo-assignments/exclude` (КФО); `allocate()` в `corrections.ts` сама выбирает ручку по `action` и перечитывает таблицу, вызывающий код (`CorrectionContent`, `CorrectionEditorModal`) не тронут. `api.del` (`api/client.ts`) научен отправлять тело — раньше только `DELETE` без тела. Попутно R-35 (design.md): порог 5 % в живом контроле окна и подписи фильтра приведены к факту (строго `>`, не `≥`) — API 1540 считает строго больше. Фейк контракта (`correctionsFake.ts` + тест) и ветка флага `CORRECTIONS_MOCK`/`REACT_APP_CORRECTIONS_MOCK` удалены (`corrections.ts`, `config.ts`); `mockData`/`mockCalc` остались тестовыми фикстурами (R-27), не тронуты. README/ARCHITECTURE/spec.md обновлены под реальный контракт. +- [x] 8.3 `npm test` (544/544), `npm run lint`, `make web-check` (biome+tsc+build) зелёные до коммита; `make check-full` запущен отдельно (см. итог прогона в PR) — не блокирует коммит правок, PR открыт по готовности. + +## 9. Ответы Лысенко 29.10 (AURORA-1395, #9920570) и WIP API 1540 (arm-core #208) + +- [x] 9.1 Убрать отзыв корректировки и `status` из тела `PUT` (п. 9; R-29, R-30) +- [x] 9.2 «Учитывать в КФО»: агрегат закреплён, у строк агрегата флажка нет, у нераспределённых — `EXCLUDE` / `INCLUDE`; `excluded` у строки агрегата убран (п. 4; R-31, R-32) +- [x] 9.3 Строка баланса владельца у нераспределённой (`line_code`), фильтр «Строка баланса» по ней (п. 11; R-33) +- [x] 9.4 Фейк: `ENTRY_ALLOCATED` для `EXCLUDE`/`INCLUDE` строки агрегата, `ENTRY_EXCLUDED` для `ASSIGN` исключённой; проверка на фейке в браузере (агрегат, раскрытие, нераспределённые, «×» в окне) +- [x] 9.5 Спека, design.md, README/ARCHITECTURE; контракт docs — отдельным PR diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index b3a44ec..78a065d 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -39,6 +39,36 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне | `NewConsoleModal/` (`CompanyOptionRow.tsx`) | [42] | «Новая консолидация»; строка ЮЛ с ИНН отдельной колонкой | | `StatusChangeModal/ChangeStatusModal.tsx` | [33-01] | «Изменить статус» (`POST …/status`); открывается из шапки `layouts/ConsolidationReportPage.tsx` | +### Виджет «Таблица корректировки» (`widgets/CorrectionTable/`) + +Требования — `../openspec/changes/corrections-table-ui/` (спека `corrections-table-ui`, решения — `design.md`). Контракт — +`../../docs/dev/api-contract-corrections.md`, типы — `src/types/corrections.ts`, вызовы — `src/services/api/corrections.ts`. + +Поток данных: страница отчёта → `CorrectionContent` (вкладка: загрузка, ошибка с «Повторить», экспорт, полный экран) +→ хук `useCorrectionTable` (запрос; применяется только ответ последнего запроса, `latestOnly`) → таблица целиком хранится в +родителе; `CorrectionTable` только рисует, фильтры/раскрытие/колонки — состояние родителя. Решение по строке (флажок +нераспределённой, меню) → `allocate` — на сервере (arm-core #208) это две отдельные ручки (`vgo-assignments/rule` +привязка/снятие, `vgo-assignments/exclude` включение/исключение из КФО), `allocate` сама выбирает нужную по +`action` и перечитывает таблицу целиком; «Корректировать» → `CorrectionEditorModal` → `saveCorrection` +(`PUT`, `expected_version_no`, сервер сразу применяет) → подмена одного агрегата; отзыва корректировки нет. Черновик окна — +`localStorage` (`draftStorage`). + +| Файл | Тип | Что делает | +|---|---|---| +| `tableModel.ts` | чистый | строки таблицы (агрегат, его строки ОСВ, нераспределённые), фильтры, пресет правила, итоги | +| `editorModel.ts` | чистый | состояние окна: исходное из агрегата, проверки ввода, контроль баланса, кандидаты в picker, пересборка | +| `editorView.ts` | чистый | тексты окна: ошибки сохранения, плашка черновика, строка контроля, подпись «Выбрать» | +| `readOnly.ts` | чистый | подсказки «только чтение», меню строки (`allocationMenu`), тело запроса разнесения | +| `draftStorage.ts` | чистый | черновик окна в `localStorage` (ключ: пользователь + версия + агрегат) | +| `columns.ts` | чистый | ключи и группы колонок для «Настройки таблицы» | +| `useCorrectionTable.ts` | хук | загрузка, `reload`/`replace`/`replaceAggregate` | +| `CorrectionContent.tsx`, `CorrectionToolbar.tsx`, `CorrectionTable.tsx`, `HeaderFilter.tsx` | компоненты | вкладка, панель, таблица с группами колонок, воронки в заголовках | +| `CorrectionEditorModal.tsx`, `EditorSide.tsx`, `AllocationPicker.tsx` | компоненты | окно «Корректировка операции», сторона окна, выбор нераспределённой строки | +| `mockData.ts`, `mockCalc.ts` | фикстуры | данные дизайна и пересчёт агрегата; остаются как тестовые фикстуры (R-27); фейк контракта `services/api/correctionsFake.ts` удаляется в Task 8 | +| `../../utils/money.ts` | чистый | деньги копейками (`toCents`, `percentOfSmaller`, порог 5 %) | + +Чистые модули покрыты vitest (`*.test.ts`); компоненты тестов не имеют (нет DOM-библиотеки), их проверяют в браузере. + ## Границы - Сетевые запросы — только через `src/api/client.ts`; `fetch` напрямую не вызывается. - `components/` не знают про store и API; `types/` ни от чего не зависят. diff --git a/web/README.md b/web/README.md index 9c54972..e1fbfd1 100644 --- a/web/README.md +++ b/web/README.md @@ -65,6 +65,15 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v «Разберите строки без кода: N» ведёт к первой); панель файлов ЮЛ показывает счётчик предупреждений файла и документа. Карта модулей — `ARCHITECTURE.md`. +## Таблица корректировок + +Вкладка «Таблица корректировки» отчёта версии работает на реальном API `…/corrections` и `…/vgo-assignments` +(AURORA-1540/1751, arm-core #208). Решение по строке ОСВ сервер разделяет на привязку/снятие правила +(`PUT`/`DELETE …/vgo-assignments/rule`) и включение/исключение из КФО (`PUT …/vgo-assignments/exclude`) — фронт +скрывает это за единой функцией `allocate` (`src/services/api/corrections.ts`), которая сама выбирает ручку и +перечитывает таблицу. `widgets/CorrectionTable/mockData.ts`/`mockCalc.ts` остаются фикстурами для тестов моделей +(не ходят в сеть). Черновики окна корректировки лежат в `localStorage` браузера. + ## Ограничения - Нет блокировки версии («Взять в работу»), сопоставления контрагентов, экрана чек-листа, комментариев, удаления файлов, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2aa72d4..f4d6c15 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -148,5 +148,5 @@ export const api = { post: (path: string, body?: unknown) => apiFetch(path, withJsonBody('POST', body)), patch: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PATCH', body)), put: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PUT', body)), - del: (path: string) => apiFetch(path, { method: 'DELETE' }), + del: (path: string, body?: unknown) => apiFetch(path, withJsonBody('DELETE', body)), }; diff --git a/web/src/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx index 5df7e85..55c1d2e 100644 --- a/web/src/layouts/ConsolidationReportPage.tsx +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -16,6 +16,7 @@ import { statusColorMapping } from '../widgets/ConsoleTable/consts'; import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent'; import { ChessContent } from '../widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent'; +import { CorrectionContent } from '../widgets/CorrectionTable/CorrectionContent'; import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; function ReportContent() { @@ -31,6 +32,8 @@ function ReportContent() { osv: , chess: , + + correction: , }; const ActiveTab = TAB_COMPONENTS[tab as string] ?? null; @@ -61,7 +64,7 @@ function ReportContent() { }, [version_id]); useEffect(() => { - const isValidTab = !tab || tab === 'osv' || tab === 'chess'; + const isValidTab = !tab || tab === 'osv' || tab === 'chess' || tab === 'correction'; if (!isValidTab) { const pathParts = pathname.split('/'); diff --git a/web/src/services/api/corrections.test.ts b/web/src/services/api/corrections.test.ts new file mode 100644 index 0000000..a834b81 --- /dev/null +++ b/web/src/services/api/corrections.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { correctionsPath } from './corrections'; + +describe('correctionsPath', () => { + it('таблица версии', () => { + expect(correctionsPath('v1')).toBe('/consolidations/versions/v1/corrections'); + }); + it('агрегат по ключу (rule_id, company_1_id, company_2_id)', () => { + expect(correctionsPath('v1', { rule_id: 'r', company_1_id: 'a', company_2_id: 'b' })).toBe( + '/consolidations/versions/v1/corrections/aggregates/r/a/b', + ); + }); +}); diff --git a/web/src/services/api/corrections.ts b/web/src/services/api/corrections.ts new file mode 100644 index 0000000..f78b7c5 --- /dev/null +++ b/web/src/services/api/corrections.ts @@ -0,0 +1,52 @@ +import { api, apiFetchBlob } from '../../api/client'; +import type { + AggregateKey, + AllocationIn, + CorrectionAggregateOut, + CorrectionIn, + CorrectionTableOut, + StatementType, +} from '../../types/corrections'; + +const STATEMENT_TYPE: StatementType = 'balance'; + +export function correctionsPath(versionId: string, key?: AggregateKey): string { + const base = `/consolidations/versions/${versionId}/corrections`; + if (!key) return base; + return `${base}/aggregates/${key.rule_id}/${key.company_1_id}/${key.company_2_id}`; +} + +function vgoAssignmentPath(versionId: string, resource: 'rule' | 'exclude'): string { + return `/consolidations/versions/${versionId}/vgo-assignments/${resource}`; +} + +export function getCorrectionTable(versionId: string): Promise { + return api.get(correctionsPath(versionId), { statement_type: STATEMENT_TYPE }); +} + +export function saveCorrection(versionId: string, key: AggregateKey, body: CorrectionIn): Promise { + return api.put(correctionsPath(versionId, key), body); +} + +/** + * Решение по строке ОСВ: нет единой ручки — сервер (arm-core #208) разделил её на привязку/снятие правила и + * включение/исключение из КФО (`vgo-assignments/rule`, `vgo-assignments/exclude`), ответ — список решений по + * исходным строкам, а не таблица. Таблицу перечитываем сами, чтобы вызывающий код (CorrectionContent, + * CorrectionEditorModal) остался на прежнем контракте «действие -> актуальная таблица». + */ +export async function allocate(versionId: string, body: AllocationIn): Promise { + const { statement_type, entry_ids, action } = body; + if (action === 'ASSIGN') { + if (!body.rule_id) throw new Error('ASSIGN без rule_id'); + await api.put(vgoAssignmentPath(versionId, 'rule'), { entry_ids, rule_id: body.rule_id }); + } else if (action === 'UNASSIGN' || action === 'RESET') { + await api.del(vgoAssignmentPath(versionId, 'rule'), { statement_type, entry_ids }); + } else { + await api.put(vgoAssignmentPath(versionId, 'exclude'), { statement_type, entry_ids, is_exclude: action === 'EXCLUDE' }); + } + return getCorrectionTable(versionId); +} + +export function exportCorrectionTable(versionId: string): Promise<{ blob: Blob; filename: string | null }> { + return apiFetchBlob(`${correctionsPath(versionId)}/export?statement_type=${STATEMENT_TYPE}`); +} diff --git a/web/src/types/corrections.ts b/web/src/types/corrections.ts new file mode 100644 index 0000000..8837646 --- /dev/null +++ b/web/src/types/corrections.ts @@ -0,0 +1,176 @@ +// Типы таблицы корректировок 1:1 с components.schemas контракта (docs/dev/api-contract-corrections.openapi.yaml). +// Деньги и проценты — строки с двумя знаками, в рублях; перевод в тыс. руб. и округление — на фронте. + +export type StatementType = 'balance' | 'income'; + +export type Side = 'D' | 'K'; + +/** Сумма в рублях, два знака (`"1000000.00"`). */ +export type Money = string; + +/** Неотрицательная сумма для ввода, не более двух знаков. */ +export type MoneyIn = string; + +/** Процент с двумя знаками; `null` — не определён. */ +export type Percent = string | null; + +export interface Ref { + id: string; + name: string; +} + +export type ReadOnlyReason = 'VERSION_NOT_EDITABLE' | 'LOCK_REQUIRED' | 'LOCKED_BY_OTHER'; + +export interface CorrectionRuleOut { + id: string; + rule_no: number; + /** Код статьи; составная — коды через «/» (`"1450/1550"`). */ + article_1_line: string; + article_2_line: string; + side_1: Side; + measure: 'closing' | 'turnover'; + company_1_accounts: string[]; + company_2_accounts: string[]; + company_1_excluded: string[]; + company_2_excluded: string[]; +} + +export interface CorrectionEntryOut { + entry_ids: string[]; + legal_entity_id: string; + counterparty_id: string; + account_code: string; + side: Side; + /** Сальдо (оборот) строки по этой стороне. */ + amount: Money; + decision: 'AUTO' | 'ASSIGN'; + candidate_rule_ids: string[]; +} + +export interface AggregateSideOut { + article_line: string; + accounts: string[]; + amount: Money; + entries: CorrectionEntryOut[]; +} + +export interface CorrectionLineOut { + line_code: string; + amount: Money; +} + +export interface CorrectionBalanceOut { + debit_total: Money; + credit_total: Money; + difference: Money; + difference_percent: Percent; + /** Сумма стороны 1 минус debit_total. */ + remainder_1: Money; + /** Сумма стороны 2 минус credit_total. */ + remainder_2: Money; +} + +export interface CorrectionOut { + /** Сохранение всегда даёт `APPLIED`; `DRAFT` есть в модели, сервер его пока не отдаёт. */ + status: 'DRAFT' | 'APPLIED'; + version_no: number; + /** Статьи стороны 1, первая — основная. */ + debit: CorrectionLineOut[]; + /** Статьи стороны 2, первая — основная. */ + credit: CorrectionLineOut[]; + comment: string; + reason_code: string | null; + balance: CorrectionBalanceOut; + updated_by: Ref | null; + updated_at: string; +} + +export type AggregateStatus = 'AUTO' | 'DISCREPANCY' | 'ONE_SIDED' | 'MANUAL'; + +export interface CorrectionAggregateOut { + rule_id: string; + rule_no: number; + company_1: Ref; + company_2: Ref; + side_1: AggregateSideOut; + side_2: AggregateSideOut; + deviation_amount: Money; + deviation_percent: Percent; + /** deviation_percent ≥ 5, или одна сторона пуста при ненулевой другой. */ + exceeds_threshold: boolean; + status: AggregateStatus; + eliminated_amount: Money; + /** Состав или суммы изменились после применённой корректировки. */ + composition_changed: boolean; + correction: CorrectionOut | null; +} + +export interface UnallocatedEntryOut { + entry_ids: string[]; + owner: Ref; + counterparty: Ref; + account_code: string; + /** Строка баланса владельца, к которой привязан счёт; `null` — не определена. */ + line_code: string | null; + side: Side; + amount: Money; + candidate_rule_ids: string[]; + reason: 'MULTIPLE_RULES' | 'UNASSIGNED'; + /** «Не учитывать в КФО» — только у нераспределённой строки; строки агрегата учитываются всегда. */ + excluded: boolean; +} + +export interface CorrectionTotalsOut { + amount_1: Money; + amount_2: Money; + deviation_amount: Money; + correction_debit: Money; + correction_credit: Money; +} + +export interface CorrectionTableOut { + version_id: string; + statement_type: StatementType; + rules_version_no: number; + /** Может ли текущий пользователь править таблицу. */ + editable: boolean; + read_only_reason: ReadOnlyReason | null; + rules: CorrectionRuleOut[]; + aggregates: CorrectionAggregateOut[]; + unallocated: UnallocatedEntryOut[]; + totals: CorrectionTotalsOut; +} + +export interface CorrectionLineIn { + line_code: string; + amount: MoneyIn; +} + +export interface CorrectionIn { + debit: CorrectionLineIn[]; + credit: CorrectionLineIn[]; + comment: string; + reason_code?: string | null; + /** Редакция действующей корректировки, которую видел аналитик; `null` — действующей нет. */ + expected_version_no: number | null; +} + +/** `EXCLUDE` / `INCLUDE` — только для нераспределённой позиции. */ +export type AllocationAction = 'ASSIGN' | 'UNASSIGN' | 'EXCLUDE' | 'INCLUDE' | 'RESET'; + +export interface AllocationIn { + statement_type: StatementType; + entry_ids: string[]; + /** Позиция строки: строка с дебетовым и кредитовым сальдо даёт две позиции, решение — по одной из них. */ + side: Side; + action: AllocationAction; + /** Обязателен для ASSIGN. */ + rule_id?: string | null; +} + +/** Ключ агрегата в пути запроса: `(rule_id, company_1_id, company_2_id)`. */ +export interface AggregateKey { + rule_id: string; + company_1_id: string; + company_2_id: string; +} diff --git a/web/src/utils/consolidations.test.ts b/web/src/utils/consolidations.test.ts index 9dd568e..240bc2d 100644 --- a/web/src/utils/consolidations.test.ts +++ b/web/src/utils/consolidations.test.ts @@ -281,6 +281,15 @@ describe('resolveLeaveAfterFetch (T7.3)', () => { }); }); +describe('reportTabNavigation: вкладка «Таблица корректировки» (#206, T4.13)', () => { + it('шахматка → таблица корректировки: меняется последний сегмент, запись истории заменяется', () => { + expect(reportTabNavigation('/groups/g/versions/v/chess', 'chess', 'correction')).toEqual({ + to: '/groups/g/versions/v/correction', + options: { replace: true }, + }); + }); +}); + describe('reportTabNavigation: адрес без search (T7.4)', () => { it('?holder=&missing= не переносятся на другую вкладку', () => { expect(reportTabNavigation('/groups/g/versions/v/osv', 'osv', 'chess').to).toBe('/groups/g/versions/v/chess'); diff --git a/web/src/utils/money.test.ts b/web/src/utils/money.test.ts index 88a6ff0..f36637b 100644 --- a/web/src/utils/money.test.ts +++ b/web/src/utils/money.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { formatAmount, formatScaledAmount, parseAmountInput, sumAmounts } from './money'; +import { + THRESHOLD_PERCENT_HUNDREDTHS, + formatAmount, + formatScaledAmount, + fromCents, + parseAmountInput, + percentOfSmaller, + sumAmounts, + toCents, +} from './money'; const NBSP = ' '; @@ -115,3 +124,28 @@ describe('formatScaledAmount', () => { expect(formatScaledAmount('1e5', rub(2))).toBe('1e5'); }); }); + +describe('percentOfSmaller', () => { + it('округляет половину вверх', () => { + expect(percentOfSmaller('9.00', '8.00')).toBe('12.50'); + expect(percentOfSmaller('1.00', '3.00')).toBe('200.00'); + // отклонение 0.01 к 8.00 = 0.125 % -> 0.13 (половина вверх) + expect(percentOfSmaller('8.01', '8.00')).toBe('0.13'); + expect(percentOfSmaller('0.00', '5.00')).toBeNull(); + }); +}); + +describe('toCents / fromCents', () => { + it('копейки и обратно, без float', () => { + expect(toCents('-1.5')).toBe(-150n); + expect(toCents('9007199254740993.01')).toBe(900719925474099301n); + expect(fromCents(-5n)).toBe('-0.05'); + expect(fromCents(0n)).toBe('0.00'); + }); + it('не сумма — ошибка', () => { + expect(() => toCents('1e5')).toThrow(); + }); + it('порог — 5 % в сотых', () => { + expect(THRESHOLD_PERCENT_HUNDREDTHS).toBe(500n); + }); +}); diff --git a/web/src/utils/money.ts b/web/src/utils/money.ts index f1e6082..f9c3ae7 100644 --- a/web/src/utils/money.ts +++ b/web/src/utils/money.ts @@ -114,3 +114,30 @@ export function sumAmounts(values: readonly (string | null | undefined)[]): stri const abs = negative ? -cents : cents; return `${negative ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`; } + +/** Порог отклонения в сотых процента: 5 % = 500. Выше или равно — «красный» (см. контракт, `exceeds_threshold`). */ +export const THRESHOLD_PERCENT_HUNDREDTHS = 500n; + +/** Строка суммы (`"-12.5"`, до двух знаков) → копейки BigInt. Не сумма — ошибка. */ +export function toCents(value: string): bigint { + const match = MONEY.exec(value); + if (!match) throw new Error(`Не сумма: ${value}`); + const cents = BigInt(match[2]) * 100n + BigInt((match[3] ?? '').padEnd(2, '0')); + return match[1] ? -cents : cents; +} + +/** Копейки BigInt → строка суммы с двумя знаками (`"-0.05"`). */ +export function fromCents(cents: bigint): string { + const abs = cents < 0n ? -cents : cents; + return `${cents < 0n ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`; +} + +/** Процент отклонения к меньшей сумме, два знака, половина вверх; `null`, если меньшая сумма не положительна. */ +export function percentOfSmaller(a: string, b: string): string | null { + const x = toCents(a); + const y = toCents(b); + const min = x < y ? x : y; + if (min <= 0n) return null; + const diff = x < y ? y - x : x - y; + return fromCents((diff * 20000n + min) / (2n * min)); +} diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index 2a810cb..219bf56 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -18,12 +18,7 @@ import { pairsViewSearch } from './counterPairs'; const TAB_CONFIG = [ { key: 'osv', label: 'ОСВ' }, { key: 'chess', label: 'Шахматка' }, - { - key: 'correction', - label: 'Таблица корректировки', - disabled: true, - tooltip: 'Будет в следующих срезах', - }, + { key: 'correction', label: 'Таблица корректировки' }, { key: 'goodwill', label: 'Гудвилл', diff --git a/web/src/widgets/CorrectionTable/AllocationPicker.tsx b/web/src/widgets/CorrectionTable/AllocationPicker.tsx new file mode 100644 index 0000000..4d04c6f --- /dev/null +++ b/web/src/widgets/CorrectionTable/AllocationPicker.tsx @@ -0,0 +1,64 @@ +// Выбор нераспределённой строки для «Добавить строку ОСВ»: клик по строке отправляет ASSIGN (в окне, через onPick). +import { Button, Modal, Table } from 'antd'; +import type { UnallocatedEntryOut } from '../../types/corrections'; +import { formatRub, pickLabel } from './editorView'; + +type Props = { + open: boolean; + sideLabel: 'Дт' | 'Кт'; + article: string; + candidates: UnallocatedEntryOut[]; + busy: boolean; + onPick(entry: UnallocatedEntryOut): void; + onClose(): void; +}; + +export function AllocationPicker({ open, sideLabel, article, candidates, busy, onPick, onClose }: Props) { + return ( + (document.fullscreenElement as HTMLElement) || document.body} + onCancel={onClose} + destroyOnHidden> + u.entry_ids.join(',')} + dataSource={candidates} + locale={{ emptyText: 'Нет подходящих нераспределённых записей' }} + onRow={(u) => ({ + onClick: () => { + if (!busy) onPick(u); + }, + style: { cursor: busy ? 'default' : 'pointer' }, + })} + columns={[ + { title: 'Контрагент', dataIndex: ['counterparty', 'name'] }, + { title: 'Счёт', dataIndex: 'account_code', width: 120 }, + { title: 'Строка баланса', width: 140, render: () => article }, + { title: 'Сумма', dataIndex: 'amount', align: 'right', width: 160, render: (v: string) => formatRub(v) }, + { + // кнопка — путь с клавиатуры (Tab, Enter); клик по строке остаётся для мыши + title: '', + width: 110, + render: (_: unknown, u: UnallocatedEntryOut) => ( + + ), + }, + ]} + /> + + ); +} diff --git a/web/src/widgets/CorrectionTable/CorrectionContent.tsx b/web/src/widgets/CorrectionTable/CorrectionContent.tsx new file mode 100644 index 0000000..a8fc454 --- /dev/null +++ b/web/src/widgets/CorrectionTable/CorrectionContent.tsx @@ -0,0 +1,244 @@ +// Вкладка «Таблица корректировки»: загрузка, состояния, панель, таблица, решения по строкам и окно корректировки. +// Данные и фильтры держит она. +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 } from '../../services/api/corrections'; +import type { VersionCard } from '../../types/consolidations/version_members'; +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 { COLUMN_DEFS, type ColKey, type ColShape } from './columns'; +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'; + +type Props = { versionId: string; card?: VersionCard | null }; + +export function CorrectionContent({ versionId, card = null }: Props) { + const { table, loading, error, reload, replace, replaceAggregate } = useCorrectionTable(versionId); + const boxRef = useRef(null); + // уведомления рисуются внутри бокса вкладки: в полноэкранном режиме виден только полноэкранный элемент, + // глобальные уведомления за ним не видны + const [notification, holder] = antdNotification.useNotification({ getContainer: () => boxRef.current ?? document.body }); + const notify = useMemo( + () => ({ + error: ({ description }: { description: ReactNode }) => + notification.error({ title: 'Ошибка', description, placement: 'bottomRight' }), + }), + [notification], + ); + // по умолчанию — «тыс. руб.», 2 знака, как в дизайне + const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay({ unit: 'тыс. руб.', precision: 2 }); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [expanded, setExpanded] = useState>(new Set()); + const { visibleKeys, setVisibleKeys } = useVisibleColumns({ rowKey: 'include', columns: COLUMN_DEFS }); + const [exporting, setExporting] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + // идёт запрос решения по строке: на это время контролы строк неактивны, чтобы не слать два запроса подряд + const [busy, setBusy] = useState(false); + // ref, а не состояние: setBusy применяется после перерисовки, а два клика могут прийти до неё — ref меняется мгновенно + const inflight = useRef(false); + // ключ агрегата, окно корректировки которого открыто + const [editing, setEditing] = useState(null); + + // ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить» + useEffect(() => { + if (!error) { + notification.destroy(LOAD_ERROR_KEY); + return; + } + notification.error({ + key: LOAD_ERROR_KEY, + title: 'Ошибка', + description: error, + placement: 'bottomRight', + duration: 0, + actions: ( + + ), + }); + }, [error, notification, reload]); + + // ушли с вкладки — уведомление об ошибке закрываем, оно относится к этому экрану + useEffect(() => () => notification.destroy(LOAD_ERROR_KEY), [notification]); + + useEffect(() => { + const onChange = () => { + if (!document.fullscreenElement) setFullscreen(false); + }; + document.addEventListener('fullscreenchange', onChange); + return () => document.removeEventListener('fullscreenchange', onChange); + }, []); + + const toggleFullscreen = async () => { + if (!boxRef.current) return; + try { + if (!document.fullscreenElement) { + await boxRef.current.requestFullscreen(); + setFullscreen(true); + } else { + await document.exitFullscreen(); + setFullscreen(false); + } + } catch (e) { + notify.error({ description: e instanceof Error ? e.message : 'Не удалось переключить полноэкранный режим' }); + } + }; + + const download = async () => { + setExporting(true); + try { + const { blob, filename } = await exportCorrectionTable(versionId); + const info = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null; + saveBlob(blob, filename ?? xlsxFilename('Таблица_корректировки', info)); + } catch (e) { + notify.error({ description: e instanceof ApiError ? e.message : 'Не удалось выгрузить таблицу' }); + } finally { + setExporting(false); + } + }; + + // фильтры, строки и «Итого» пересчитываются только при смене таблицы, фильтров или раскрытия + const view = useMemo(() => (table ? filterTable(table, filters) : null), [table, filters]); + const rows = useMemo(() => (view ? buildRows(view, expanded) : []), [view, expanded]); + const totals = useMemo(() => (view && view.aggregates.length > 0 ? visibleTotals(view.aggregates) : null), [view]); + const lines = useMemo(() => (table ? lineOptions(table) : []), [table]); + + const toggle = (key: string) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + // ошибка правки: текст сервера в уведомлении; + // 409 (версию закрыли, блокировку забрали, корректировку уже изменили) — ещё перечитываем таблицу + const fail = (e: unknown, fallback: string) => { + const out = errorOutcome(e, fallback); + notify.error({ description: out.message }); + if (out.reload) reload(); + }; + + // решение по строкам: сервер отвечает всей таблицей (в ней меняются и агрегаты, и нераспределённые) — подменяем её целиком + const onAllocate = async (body: AllocationIn) => { + if (inflight.current) return; + inflight.current = true; + setBusy(true); + try { + replace(await allocate(versionId, body)); + } catch (e) { + fail(e, 'Не удалось сохранить решение по строке'); + } finally { + inflight.current = false; + setBusy(false); + } + }; + + const actions: RowActions = { + onCorrect: (a) => setEditing(aggregateKey(a)), + onAllocate, + }; + + const noData = table !== null && table.aggregates.length === 0 && table.unallocated.length === 0; + const emptyText = noData ? 'Внутригрупповых остатков между компаниями периметра нет' : 'Нет записей для выбранных фильтров'; + + let body: ReactNode; + if (table) { + body = ( + + + + ); + } else if (loading) { + body = ( +
+ +
+ ); + } else { + body = ( +
+ {error ?? 'Таблица не загружена'} + +
+ ); + } + + return ( + (document.fullscreenElement as HTMLElement) || document.body}> +
+ {holder} + setFilters(EMPTY_FILTERS)} + unit={unit} + onUnitChange={setUnit} + precision={precision} + onPrecisionChange={setPrecision} + visibleKeys={visibleKeys as ColKey[]} + onVisibleKeysChange={setVisibleKeys} + onExport={download} + exporting={exporting} + exportDisabled={!table} + fullscreen={fullscreen} + onToggleFullscreen={toggleFullscreen} + /> + {body} + {editing !== null && table && ( + setEditing(null)} + onTable={replace} + onAggregate={replaceAggregate} + onReload={reload} + /> + )} +
+
+ ); +} diff --git a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx new file mode 100644 index 0000000..4a55e00 --- /dev/null +++ b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx @@ -0,0 +1,404 @@ +// Окно «Корректировка операции»: исходные строки ОСВ и распределение корректировки по двум сторонам агрегата, живой контроль, +// комментарий. Правки статей и комментария пишутся в черновик (localStorage); действия со строками ОСВ уходят на сервер сразу. +import { Alert, App, Button, Input, Modal } from 'antd'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCurrentUser } from '../../auth/MeContext'; +import { allocate, saveCorrection } from '../../services/api/corrections'; +import type { CorrectionAggregateOut, CorrectionEntryOut, CorrectionTableOut, Side, UnallocatedEntryOut } from '../../types/corrections'; +import { AllocationPicker } from './AllocationPicker'; +import { ControlRow, DistributionBlock, type KeyedLine, OsvBlock, SideHeader } from './EditorSide'; +import { type Draft, clearDraft, draftKey, loadDraft, saveDraft } from './draftStorage'; +import { + type EditorState, + initialEditorState, + isDirty, + liveControl, + pickerCandidates, + rebaseState, + toCorrectionIn, + validateLines, +} from './editorModel'; +import { DISCARD_QUESTION, controlText, draftNotice, saveErrorOutcome } from './editorView'; +import { COMMENT_MAX_LENGTH, errorOutcome, readOnlyHint } from './readOnly'; +import { articleCodes, findAggregate, toAggregateKey } from './tableModel'; + +type Props = { + versionId: string; + table: CorrectionTableOut; + /** Ключ агрегата из `aggregateKey(...)`. */ + aggregateKey: string; + editable: boolean; + onClose(): void; + /** После действия со строкой сервер отвечает всей таблицей. */ + onTable(t: CorrectionTableOut): void; + /** После сохранения сервер отвечает одним агрегатом. */ + onAggregate(a: CorrectionAggregateOut): void; + /** Данные устарели (конфликт редакций, 409/404): перечитать таблицу. */ + onReload(): void; +}; + +type KeyedState = { debit: KeyedLine[]; credit: KeyedLine[]; comment: string }; +type SideNo = 1 | 2; + +const EMPTY: EditorState = { debit: [], credit: [], comment: '' }; +const container = () => (document.fullscreenElement as HTMLElement) || document.body; + +/** Сторона в тексте: у правила сторона 1 — `side_1` (Д/К), сторона 2 — противоположная. */ +const sideText = (s: Side): 'Дт' | 'Кт' => (s === 'D' ? 'Дт' : 'Кт'); + +export function CorrectionEditorModal({ versionId, table, aggregateKey, editable, onClose, onTable, onAggregate, onReload }: Props) { + const user = useCurrentUser(); + const { modal } = App.useApp(); + const aggregate = findAggregate(table, aggregateKey); + const key = useMemo(() => draftKey(user.id, versionId, aggregateKey), [user.id, versionId, aggregateKey]); + + // Счётчик id строк: у «Распределения» нет собственных id, а ключ-индекс при удалении строки путал бы поля соседей. + const nextId = useRef(1); + const withIds = (s: EditorState): KeyedState => ({ + comment: s.comment, + debit: s.debit.map((l) => ({ ...l, id: nextId.current++ })), + credit: s.credit.map((l) => ({ ...l, id: nextId.current++ })), + }); + const plain = (s: KeyedState): EditorState => ({ + comment: s.comment, + debit: s.debit.map(({ line_code, amount }) => ({ line_code, amount })), + credit: s.credit.map(({ line_code, amount }) => ({ line_code, amount })), + }); + + const initial = useMemo(() => (aggregate ? initialEditorState(aggregate) : EMPTY), [aggregate]); + + // Начальное состояние: черновик (если не только чтение и он отличается от исходного) или исходные данные агрегата. + const [start] = useState(() => { + const draft: Draft | null = editable && aggregate ? loadDraft(key) : null; + const useDraft = draft !== null && isDirty(initial, draft.state); + return { + state: withIds(useDraft && draft ? draft.state : initial), + savedAt: useDraft && draft ? draft.savedAt : null, + base: useDraft && draft ? draft.baseVersionNo : (aggregate?.correction?.version_no ?? null), + }; + }); + const [state, setStateRaw] = useState(start.state); + // ref обновляется там же, где состояние (а не при рендере): после await обработчик читает свежие правки, а не замыкание + const stateRef = useRef(state); + const setState = (next: KeyedState) => { + stateRef.current = next; + setStateRaw(next); + }; + // savedAt — время черновика, пока плашка видна; base — редакция корректировки, от которой начаты правки + const [savedAt, setSavedAt] = useState(start.savedAt); + const [base, setBase] = useState(start.base); + const [saving, setSaving] = useState(false); + const [allocating, setAllocating] = useState(false); + const inflight = useRef(false); // ref: два клика приходят раньше, чем применится setSaving + const confirming = useRef(false); + const [commentError, setCommentError] = useState(null); + const [lineError, setLineError] = useState<{ lineCode: string; message: string } | null>(null); + const [formError, setFormError] = useState(null); + const [pickerSide, setPickerSide] = useState(null); + + const busy = saving || allocating; + + // Агрегат пересобрался (перечитали таблицу, перенос строки ОСВ): без своих правок окно берёт новые суммы, с правками — ввод остаётся. + const prevInitial = useRef(initial); + // biome-ignore lint/correctness/useExhaustiveDependencies: реагируем только на смену исходного состояния агрегата + useEffect(() => { + if (prevInitial.current === initial) return; + const next = rebaseState(prevInitial.current, initial, plain(stateRef.current)); + prevInitial.current = initial; + if (next) { + setState(withIds(next)); + // правок не было — окно целиком на новой редакции, база черновика тоже; + // иначе при следующем открытии плашка «изменили после ваших правок» ложная + setBase(aggregate?.correction?.version_no ?? null); + } + }, [initial]); + + // без агрегата окно только на просмотр: правок и «несохранённого» нет + const edit = editable && aggregate !== null; + const dirty = edit && isDirty(initial, plain(state)); + + /** Любая правка статей/комментария: в состояние и в черновик (совпало с исходным — черновик удаляем). */ + const commit = (next: KeyedState) => { + setState(next); + if (!edit || !aggregate) return; + const p = plain(next); + if (isDirty(initial, p)) saveDraft(key, { state: p, savedAt: new Date().toISOString(), baseVersionNo: base }); + else { + clearDraft(key); + setSavedAt(null); + } + }; + const editLines = (side: SideNo, fn: (lines: KeyedLine[]) => KeyedLine[]) => { + setLineError(null); + setFormError(null); + const name = side === 1 ? 'debit' : 'credit'; + commit({ ...state, [name]: fn(state[name]) }); + }; + + const resetDraft = () => { + clearDraft(key); + setState(withIds(initial)); + setSavedAt(null); + setBase(aggregate?.correction?.version_no ?? null); + setCommentError(null); + setLineError(null); + setFormError(null); + }; + + const requestClose = () => { + if (saving || confirming.current) return; + if (!dirty) { + onClose(); + return; + } + confirming.current = true; + modal.confirm({ + title: DISCARD_QUESTION, + okText: 'Закрыть', + cancelText: 'Остаться', + getContainer: container, + onOk: () => { + clearDraft(key); + onClose(); + }, + afterClose: () => { + confirming.current = false; + }, + }); + }; + + // Действие со строкой ОСВ: сразу на сервер, таблица подменяется ответом, окно перечитывает агрегат из неё. + const runAllocate = async (body: Parameters[1]): Promise => { + if (inflight.current) return false; + inflight.current = true; + setAllocating(true); + setFormError(null); + try { + const t = await allocate(versionId, body); + onTable(t); + return true; + } catch (e) { + const out = errorOutcome(e, 'Не удалось выполнить действие со строкой'); + setFormError(out.message); + if (out.reload) onReload(); + return false; + } finally { + inflight.current = false; + setAllocating(false); + } + }; + + const unassign = (e: CorrectionEntryOut) => + runAllocate({ statement_type: 'balance', entry_ids: e.entry_ids, side: e.side, action: 'UNASSIGN' }); + const assign = async (u: UnallocatedEntryOut) => { + if (!aggregate) return; + const ok = await runAllocate({ + statement_type: 'balance', + entry_ids: u.entry_ids, + side: u.side, + action: 'ASSIGN', + rule_id: aggregate.rule_id, + }); + if (ok) setPickerSide(null); + }; + + const validation = validateLines(plain(state)); + + const save = async () => { + if (!edit || !aggregate || inflight.current || !validation.ok) return; + inflight.current = true; + setSaving(true); + setFormError(null); + setCommentError(null); + setLineError(null); + try { + const out = await saveCorrection( + versionId, + toAggregateKey(aggregate), + toCorrectionIn(validation.body, aggregate.correction?.version_no ?? null), + ); + clearDraft(key); + onAggregate(out); + onClose(); + } catch (e) { + const err = saveErrorOutcome(e); + if (err.kind === 'comment') setCommentError(err.message); + else if (err.kind === 'line' && err.lineCode) setLineError({ lineCode: err.lineCode, message: err.message }); + else setFormError(err.message); + if (err.kind === 'conflict') { + // правки остаются в окне; плашка «Корректировку изменили после ваших правок» (base остаётся прежним) + const p = plain(stateRef.current); + const at = new Date().toISOString(); + saveDraft(key, { state: p, savedAt: at, baseVersionNo: base }); + setSavedAt(at); + } + if (err.reload) onReload(); + } finally { + inflight.current = false; + setSaving(false); + } + }; + + const lineErrors = (side: SideNo) => (i: number) => { + const name = side === 1 ? 'debit' : 'credit'; + const errs = validation.ok ? {} : validation.errors; + const line = state[name][i]; + return { + line_code: + errs[`${name}.${i}.line_code`] ?? + (lineError && line && line.line_code.trim() === lineError.lineCode ? lineError.message : undefined), + amount: errs[`${name}.${i}.amount`], + }; + }; + + const rule = aggregate ? table.rules.find((r) => r.id === aggregate.rule_id) : undefined; + const sideLabels: Record = rule + ? { 1: sideText(rule.side_1), 2: sideText(rule.side_1 === 'D' ? 'K' : 'D') } + : { 1: 'Дт', 2: 'Кт' }; + const sideData = (n: SideNo) => { + const first = n === 1; + const pick = (a: T, b: T): T => (first ? a : b); + const side = aggregate ? pick(aggregate.side_1, aggregate.side_2) : null; + const company = aggregate ? pick(aggregate.company_1, aggregate.company_2).name : null; + return { + company, + article: side ? articleCodes(side.article_line).join(' / ') : '', + codes: side ? articleCodes(side.article_line) : [], + entries: side ? side.entries : [], + }; + }; + const s1 = sideData(1); + const s2 = sideData(2); + + const control = aggregate ? liveControl(aggregate, plain(state)) : null; + const source = control ? controlText(control.sourceDiff) : null; + const allocation = control ? controlText(control.allocationDiff) : null; + const notice = draftNotice(savedAt, base, aggregate); + const hint = editable ? null : readOnlyHint(table.read_only_reason); + + return ( + + + {edit && ( + + )} + + }> +
+ {hint && } + {!aggregate && ( + + )} + {notice && ( + + Сбросить + + ) : undefined + } + /> + )} + {formError && } + +
+
+ + + setPickerSide(1)} + onUnassign={unassign} + /> + setPickerSide(2)} + onUnassign={unassign} + /> +
+ +
+ {([1, 2] as const).map((n) => ( + editLines(n, (ls) => [...ls, { id: nextId.current++, line_code: '', amount: '' }])} + onRemoveLine={(id) => editLines(n, (ls) => ls.filter((l) => l.id !== id))} + onChangeLine={(id, patch) => editLines(n, (ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l)))} + /> + ))} +
+ + {!validation.ok && validation.errors.lines && ( +
+ {validation.errors.lines} +
+ )} +
+ +
+
Комментарий к корректировке
+ { + setCommentError(null); + commit({ ...state, comment: e.target.value }); + }} + /> + {commentError && ( + + )} +
+
+ + {pickerSide !== null && aggregate && ( + setPickerSide(null)} + /> + )} +
+ ); +} diff --git a/web/src/widgets/CorrectionTable/CorrectionTable.tsx b/web/src/widgets/CorrectionTable/CorrectionTable.tsx new file mode 100644 index 0000000..d9eb9c9 --- /dev/null +++ b/web/src/widgets/CorrectionTable/CorrectionTable.tsx @@ -0,0 +1,393 @@ +// Таблица корректировок: строки агрегатов, их строки ОСВ при раскрытии, нераспределённые, «Итого»; действия над строками — через `actions`. +// Фильтры живут в родителе (CorrectionContent); здесь только рисуем и сообщаем о действиях через `actions`. +import { DownOutlined, EditOutlined, MoreOutlined, RightOutlined, WarningOutlined } from '@ant-design/icons'; +import { Button, Checkbox, Dropdown, Table, Tag, Tooltip } from 'antd'; +import type { MenuProps } from 'antd'; +import type { ColumnGroupType, ColumnType } from 'antd/es/table'; +import { type ReactNode, useMemo } from 'react'; +import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections'; +import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter'; +import { COLUMN_DEFS, type ColKey } from './columns'; +import { percentText } from './editorView'; +import { AGGREGATE_FLAG_HINT, allocationMenu, includeBody, menuBody } from './readOnly'; +import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel'; +import type { visibleTotals } from './tableModel'; + +/** Действия над строками: подключает CorrectionContent. */ +export type RowActions = { + onCorrect(a: CorrectionAggregateOut): void; + onAllocate(body: AllocationIn): void; +}; + +type Props = { + rows: TableRow[]; + totals: ReturnType | null; + /** Форматтер сумм (единицы и знаки из панели). */ + format: (raw: string | null) => string; + expanded: ReadonlySet; + onToggle: (key: string) => void; + rules: CorrectionRuleOut[]; + /** Сервер разрешил правки. */ + editable: boolean; + /** Подсказка, почему правки закрыты; `null` — открыты. */ + readOnlyHint: string | null; + /** Запрос решения уже идёт: контролы строк на это время неактивны (без подсказки). */ + busy: boolean; + actions: RowActions; + filters: TableFilters; + onFiltersChange: (next: TableFilters) => void; + lineOptions: string[]; + visibleKeys: ColKey[]; + /** Высота области прокрутки таблицы. */ + scrollY: string; + emptyText: string; +}; + +const DEVIATION_OPTIONS: { value: DeviationFilter; label: string }[] = [ + { value: 'all', label: 'Все' }, + { value: 'over', label: '> 5 %' }, + { value: 'under', label: '≤ 5 %' }, +]; + +const COLOR_OVER = '#d92d20'; +const BG_DEBIT = '#f4faf5'; +const BG_CREDIT = '#f6f8fc'; +const BG_UNALLOCATED = '#f2f4f7'; +const BG_CHANGED = '#fff6e5'; + +const showLine = (line: string): string => articleCodes(line).join(' / '); + +const sideSuffix = (row: TableRow) => { + // одна и та же функция отдаёт, какая сторона строки ОСВ/нераспределённой заполнена: 'D' — Дебет, 'K' — Кредит + if (row.kind === 'entry') return row.side === 1 ? 'D' : 'K'; + if (row.kind === 'unallocated') return row.entry.side; + return null; +}; + +export function CorrectionTable(props: Props) { + const { rows, totals, format, expanded, onToggle, rules, editable, readOnlyHint, busy, actions, filters, onFiltersChange } = props; + const canAct = editable && !busy; + const ruleById = useMemo(() => new Map(rules.map((r) => [r.id, r])), [rules]); + + const rowBg = (row: TableRow): string | undefined => { + if (row.kind === 'unallocated') return BG_UNALLOCATED; + if (row.kind === 'aggregate' && row.aggregate.composition_changed) return BG_CHANGED; + return undefined; + }; + + // меню решений по строке ОСВ / нераспределённой строке: пункты даёт allocationMenu, здесь только переводим в формат antd + const entryMenu = (row: Extract): MenuProps['items'] => + allocationMenu(row, rules).map((item) => { + if (item.key !== 'MOVE') return { key: item.key, label: item.label, disabled: item.disabled }; + const children = row.entry.candidate_rule_ids.flatMap((id) => { + const r = ruleById.get(id); + return r ? [{ key: `ASSIGN:${id}`, label: ruleLabel(r) }] : []; + }); + return { key: 'MOVE', label: item.label, disabled: item.disabled, children }; + }); + + const onEntryMenu = (row: Extract, key: string) => { + const body = menuBody(row, key); + if (body) actions.onAllocate(body); + }; + + // почему контрол неактивен: подсказка нужна только когда сервер закрыл правки (editable = false); пока идёт запрос — без неё + const withHint = (node: ReactNode, hint: string | null = readOnlyHint) => + hint ? ( + + {node} + + ) : ( + node + ); + + const lines = (list: CorrectionLineOut[] | undefined, pick: (l: CorrectionLineOut) => string): ReactNode => + list && list.length > 0 ? list.map((l, i) =>
{pick(l)}
) : '—'; + + const cell = (key: ColKey, row: TableRow): ReactNode => { + if (row.kind === 'aggregate') { + const a = row.aggregate; + // красным — только значение выше порога; прочерк («нет значения») не красим + const redIf = (text: string): ReactNode => + a.exceeds_threshold && text !== '—' ? {text} : text; + switch (key) { + case 'include': + // агрегат учитывается в КФО всегда: флажок отмечен и закреплён, лишняя строка убирается из правила + return withHint( + + + {a.composition_changed && ( + + + + )} + , + AGGREGATE_FLAG_HINT, + ); + case 'd_entity': + return a.company_1.name; + case 'k_entity': + return a.company_2.name; + case 'd_line': + return showLine(a.side_1.article_line); + case 'k_line': + return showLine(a.side_2.article_line); + case 'd_account': + return a.side_1.accounts.join(', '); + case 'k_account': + return a.side_2.accounts.join(', '); + case 'd_amount': + return format(a.side_1.amount); + case 'k_amount': + return format(a.side_2.amount); + case 'dev_percent': + return redIf(percentText(a.deviation_percent)); + case 'dev_amount': + return redIf(format(a.deviation_amount)); + case 'c_d_line': + return lines(a.correction?.debit, (l) => l.line_code); + case 'c_d_amount': + return lines(a.correction?.debit, (l) => format(l.amount)); + case 'c_k_line': + return lines(a.correction?.credit, (l) => l.line_code); + case 'c_k_amount': + return lines(a.correction?.credit, (l) => format(l.amount)); + case 'comment': + return a.correction ? ( + + {a.correction.status === 'DRAFT' && ( + + черновик + + )} + {a.correction.comment || '—'} + + ) : ( + '—' + ); + case 'actions': + return ( + + ); + default: + return null; + } + } + + // строка ОСВ в раскрытии или нераспределённая: заполнена только сторона владельца + const e = row.entry; + const owned = sideSuffix(row); // 'D' | 'K' + const own = (side: 'D' | 'K') => owned === side; + switch (key) { + case 'include': + // флажок — только у нераспределённой строки; строка агрегата учитывается вместе с агрегатом + if (row.kind !== 'unallocated') return null; + return withHint( + actions.onAllocate(includeBody(e.entry_ids, e.side, ev.target.checked))} + />, + ); + case 'd_entity': + if (row.kind !== 'unallocated') return null; + return own('D') ? row.entry.owner.name : row.entry.counterparty.name; + case 'k_entity': + if (row.kind !== 'unallocated') return null; + 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': + case 'c_d_amount': + case 'c_k_line': + case 'c_k_amount': + case 'comment': + return row.kind === 'unallocated' ? '—' : null; + case 'd_account': + return own('D') ? e.account_code : null; + case 'k_account': + return own('K') ? e.account_code : null; + case 'd_amount': + return own('D') ? format(e.amount) : null; + case 'k_amount': + return own('K') ? format(e.amount) : null; + case 'actions': + return withHint( + onEntryMenu(row, k) }}> +
+ + + + + + {p.editable && + + + {p.entries.length === 0 && ( + + + + )} + {p.entries.map((e) => ( + + + + + {p.editable && ( + + )} + + ))} + +
СчётСтрока балансаСумма} +
+ Нет исходных строк ОСВ для этой операции. +
{e.account_code}{p.article}{formatRub(e.amount)} +
+ + ); +} + +type DistProps = { + articleCodes: string[]; + lines: KeyedLine[]; + lineErrors: LineErrors; + editable: boolean; + /** Сторона в подписях для скринридера: «дебет» / «кредит». */ + sideName: string; + onAddLine(): void; + onRemoveLine(id: number): void; + onChangeLine(id: number, patch: Partial>): void; +}; + +/** Блок «Распределение корректировки» стороны — отдельная карточка под контрольной строкой исходных данных. */ +export function DistributionBlock(p: DistProps) { + return ( +
+
+ Распределение корректировки + {p.editable && ( + + )} +
+
+ Строка баланса + Сумма + + {p.lines.map((line, i) => { + const err = p.lineErrors(i); + return ; + })} +
+
+ ); +} + +type RowProps = Pick & { + line: KeyedLine; + /** Номер строки в стороне с нуля. */ + index: number; + sideName: string; + err: { line_code?: string; amount?: string }; +}; + +function LineRow({ line, index, sideName, err, articleCodes, editable, onRemoveLine, onChangeLine }: RowProps) { + const where = `${sideName}, ${index + 1}`; + const codeErrId = `line-${line.id}-code-error`; + const amountErrId = `line-${line.id}-amount-error`; + return ( + <> +
+ ({ value: c }))} + disabled={!editable} + style={{ width: '100%' }} + onChange={(v) => onChangeLine(line.id, { line_code: v })}> + + + {err.line_code && ( + + )} +
+
+ onChangeLine(line.id, { amount: e.target.value })} + /> + {err.amount && ( + + )} +
+
+ {editable && ( +
+ + ); +} + +/** Строка контроля: сумма слева, «≠ разница (процент)» посередине (красная при превышении порога), сумма справа. */ +export function ControlRow({ left, middle, right, red }: { left: string; middle: string; right: string; red: boolean }) { + return ( +
+ {left} + {middle} + {right} +
+ ); +} diff --git a/web/src/widgets/CorrectionTable/HeaderFilter.tsx b/web/src/widgets/CorrectionTable/HeaderFilter.tsx new file mode 100644 index 0000000..453aba9 --- /dev/null +++ b/web/src/widgets/CorrectionTable/HeaderFilter.tsx @@ -0,0 +1,57 @@ +import { FilterFilled, FilterOutlined } from '@ant-design/icons'; +import { Input, Popover, Select } from 'antd'; +import type { ReactNode } from 'react'; + +type Props = { + label: string; + /** Фильтр задан — значок выделяется. */ + active: boolean; + children: ReactNode; +}; + +/** Подпись колонки со значком-воронкой: по клику открывается поле фильтра. */ +export function HeaderFilter({ label, active, children }: Props) { + return ( + + {label} + {children}}> + {/* стопим всплытие, чтобы клик не запускал выбор заголовка */} + + + + ); +} + +export function TextFilter({ value, placeholder, onChange }: { value: string; placeholder: string; onChange: (v: string) => void }) { + return onChange(e.target.value)} />; +} + +export function SelectFilter({ + value, + options, + allowClear, + onChange, +}: { + value: V | null; + options: { value: V; label: string }[]; + allowClear?: boolean; + onChange: (v: V | null) => void; +}) { + return ( +