diff --git a/docs/superpowers/plans/2026-10-08-demo-prototype.md b/docs/superpowers/plans/2026-10-08-demo-prototype.md new file mode 100644 index 0000000..dbaec23 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-demo-prototype.md @@ -0,0 +1,2890 @@ +# Демо-прототип arm-web: от консолидации до сводной ОСВ — план реализации + +> **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:** аналитик на локальном стенде проходит путь «создание консолидации → загрузка ОСВ и PDF → проверка и подтверждение +документов → сводная ОСВ» в arm-web без обращений к API старого АРМ; `make check` зелёный. + +**Architecture:** SPA поверх API arm-core (`/api/v1`). Все запросы — через `src/api/client.ts` (`apiFetch`, новый +`apiFetchBlob`). Чистые функции (деньги, подписи, сетка формы, дерево ОСВ, разбор ответов) — в `src/utils/` и рядом с +виджетами, покрыты vitest. Экраны держат состояние в локальных хуках; существующие Redux-слайсы остаются. + +**Tech Stack:** React 18, TypeScript strict, rsbuild 1.6, antd 6, react-router 7, Redux Toolkit, pdfjs-dist 5, biome 1.9, +vitest (новая devDependency). + +**Spec:** `openspec/changes/demo-prototype/` (proposal.md, specs/*/spec.md, design.md). Контракт API — +`../docs/dev/frontend-api-guide.md` §5.5–§5.7 и `../arm-core/api/src/schemas/`. + +**Рабочая папка:** `/Users/andrey.epifancev/projects/awp-ca/.wt/web-proto/arm-web` (worktree, ветка +`feat/3-demo-prototype`). Перед любой Bash-командой cwd — эта папка (хук guard-main блокирует запись, если cwd — checkout +на `main`). Коммиты — по-русски, с трейлерами: + +``` +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 +``` + +## Global Constraints + +- Все запросы к бэку — только через `src/api/client.ts` (`apiFetch` / `api.*` / `apiFetchBlob`); прямой `fetch` запрещён. +- Эндпоинты — только существующие в arm-core (`/api/v1/...`); `/fsl/...`, `/osv/by-file-id`, `POST /files` не использовать. +- Суммы — строки; `parseFloat`/`Number()` над суммами запрещены; в `PATCH` сумма уходит строкой. +- Подписи и сообщения UI — по-русски; `console.log` в коде не остаётся. +- Код — по biome проекта: одинарные кавычки, табы, ширина 140; TS strict, `noUnusedLocals`, `noUnusedParameters`. +- `any` в новом коде не вводить (biome `noExplicitAny` — warn, но в новых файлах ноль предупреждений). +- `make check` = install + lint (biome) + typecheck (tsc) + test (vitest) + build; зелёный в конце каждой задачи, + начиная с Task 2. +- Пакеты `raisa-*` — только `optionalDependencies`; `package-lock.json` не коммитится. +- Worker pdf.js — из пакета `pdfjs-dist` сборкой, без CDN. +- Маршруты: `/groups/:group_id` (консолидации), `/documents/:document_id` (документ), `/groups/:group_id/versions/:version_id/osv` (свод). + +## Review Focus + +1. Сумма из API в нестандартной записи (`"1E+2"`, `"-0.00"`, `"100"`, `null`) — экран не падает, показывает читаемо + (`formatAmount` возвращает исходную строку, если формат не распознан; `null` → «—»). Тест — Task 3. +2. Ответ 422 при загрузке бывает двух форм (`UploadResult` без `detail` и `{detail:{code:'ENTITY_NOT_IN_PERIMETER'}}`) — + первая показывается списком отклонённых, вторая — текстом ошибки. Тест — Task 4 (`uploadFiles`). +3. Опрос файлов не продолжается вечно и не плодит параллельных запросов при закрытии/повторном открытии панели — + `hasPendingFiles` + очистка интервала. Тест чистой части — Task 4; ручная проверка — Task 6. +4. Документ без формы (DISCLOSURE/OTHER, ББ с `ERROR`) или ещё не `PROCESSED` — экран документа показывает карточку и + понятный текст, а не белый экран (409 `DOCUMENT_NOT_STATEMENT`, 404). Тест чистой части — Task 7 (`documentViewKind`). +5. Ячейка формы с вводом «1 234,5», «-», «12,3,4», пустым — валидация до запроса; пустое — `null`. Тест — Task 3 + (`parseAmountInput`). + +--- + +### Task 1: Проверка стенда — круг PDF «ядро + scan-gateway + эмулятор» (без кода) + +**Модель:** sonnet. + +**Files:** не меняются. Результат — запись в ledger SDD (`.superpowers/sdd/demo-prototype/ledger.md`) и, если круг +сломан, issue в соответствующем репозитории. + +**Interfaces:** +- Produces: факт «PDF `aep_6.pdf` на стенде даёт документы BALANCE и INCOME со статусом NEEDS_CHECK» (или «сломан: …» + + ссылка на issue) — от него зависит, что показывать в Task 8 и демо. + +- [ ] **Step 1: Поднять стенд по гайду** + +Прочитать `../docs/dev/qa-guide.md` §4 и §6.8 и выполнить их команды (инфраструктура + профиль `services` с +tabular-parser, scan-gateway, scan-emulator; arm-core с `NATS_ENABLED=true`; `make seed-dev`). Команды arm-core — +`make -C ../arm-core …` (каталог `../arm-core` — симлинк на основной checkout; в нём **ничего не коммитить и не править**; +если гайд требует `.env` — создать его, `.env` в `.gitignore` arm-core). + +Проверка: `curl -s localhost:8000/healthcheck` → `"nats": "ok"`. + +- [ ] **Step 2: Получить токен и id ЮЛ** + +```bash +H="Authorization: Bearer $(curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' -d '{"email":"ka_go@armka.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')" +curl -s "localhost:8000/api/v1/directories/legal-entities?q=%D0%A1%D1%87%D0%B0%D1%81%D1%82%D0%BB%D0%B8%D0%B2&limit=5" -H "$H" | python3 -m json.tool | head -40 +``` + +Взять `id` «ООО "Пекарня Счастливочная"» → `LE`. + +- [ ] **Step 3: Загрузить aep_6.pdf и дождаться результата** + +```bash +curl -s -X POST "localhost:8000/api/v1/legal-entities/$LE/files" -H "$H" -F "files=@../fixtures/ocr/aep_6.pdf" | python3 -m json.tool +# через 30–60 с: +curl -s "localhost:8000/api/v1/legal-entities/$LE/files?limit=5" -H "$H" | python3 -c ' +import sys,json +for f in json.load(sys.stdin)["items"]: + print(f["status"], f["original_name"], f["job"] and f["job"]["status"], [(d["document_type"], d["status"], d["id"]) for d in f["documents"]])' +``` + +Ожидание: файл `PROCESSED`, два документа `BALANCE` и `INCOME` со статусом `NEEDS_CHECK`. Затем +`curl -s localhost:8000/api/v1/documents//statement -H "$H" | head -c 600` — 200 и `lines` непуст. + +- [ ] **Step 4: Записать результат** + +В ledger: «T1: круг PDF работает / сломан», id документов, ИНН из `header.inn.value` (для открытого вопроса design.md). +Если сломан — `tea issues create --login home --repo RSHB/ --title "…" --description "…"` +с шагами воспроизведения и выводом; в ledger — номер issue и решение: в Task 8 скан показывается только для PDF, а на демо +форма ББ берётся из `../fixtures/statements-xlsx/mezheninovskaya` (`qa-guide` §6.9). Код не меняется. + +Стенд оставить запущенным для Task 10. + +--- + +### Task 2: Чистка наследия старого АРМ, vitest и зелёный `make check` + +**Модель:** sonnet. + +**Files:** +- Delete: `src/layouts/FileViewer.tsx`, `src/components/FileUploaderModel.tsx`, `src/components/FilesList.tsx`, + `src/widgets/PDFResultViewer/PDFDataViewer.tsx`, `src/widgets/PDFResultViewer/types.ts`, + `src/widgets/PDFResultViewer/styles.module.css`, `src/services/api/mocks.ts` +- Move: `src/widgets/PDFResultViewer/IFrameBlock.tsx` → `src/widgets/DocumentReview/PdfPages.tsx` (содержимое правится в Task 8; + здесь — только перенос и минимальные правки для tsc) +- Modify: `src/App.tsx` (убрать маршрут `/files/:file_id` и импорт `FileViewer`), `src/layouts/ConsolidationsList.tsx` + (убрать импорты `FileItem`, `FileUploaderModal`), `src/services/api/consolidations.ts` (убрать импорт моков), + все файлы с ошибками tsc/biome, `package.json`, `Makefile` +- Create: `src/utils/smoke.test.ts` (временный тест, удаляется в Task 3) + +**Interfaces:** +- Produces: `npm test` (= `vitest run`), `make test`, `make check` включает `test`; дерево без старых эндпоинтов. + +- [ ] **Step 1: Удалить наследие и перенести рендер PDF** + +```bash +git rm -q src/layouts/FileViewer.tsx src/components/FileUploaderModel.tsx src/components/FilesList.tsx \ + src/widgets/PDFResultViewer/PDFDataViewer.tsx src/widgets/PDFResultViewer/types.ts \ + src/widgets/PDFResultViewer/styles.module.css src/services/api/mocks.ts +mkdir -p src/widgets/DocumentReview +git mv src/widgets/PDFResultViewer/IFrameBlock.tsx src/widgets/DocumentReview/PdfPages.tsx +grep -rn "FileViewer\|FileUploaderModel\|FilesList\|PDFDataViewer\|PDFResultViewer\|mocks" src +``` + +Убрать найденные импорты/маршруты. В `PdfPages.tsx` временно убрать импорт `./styles.module.css` и заменить +`className={styles.x}` на inline-стили или удалить (полная переработка — Task 8). Grep в конце пуст. + +- [ ] **Step 2: Подключить vitest** + +```bash +npm install --save-dev vitest@^3 +``` + +`package.json` → `"scripts"`: добавить `"test": "vitest run"`. `Makefile`: добавить цель и включить её в `check`: + +```make +.PHONY: install lint typecheck test build check start + +test: + npm test + +check: install lint typecheck test build +``` + +`src/utils/smoke.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; + +describe('vitest', () => { + it('запускается', () => { + expect(1 + 1).toBe(2); + }); +}); +``` + +Run: `npm test` → 1 passed. + +- [ ] **Step 3: Автоформат biome** + +```bash +npx biome check --write src +npx biome check src 2>&1 | tail -5 +``` + +Оставшиеся ошибки lint — править вручную по одной (типичные: неиспользуемые импорты, `==`, отсутствующие ключи в +списках, пустые `catch {}` — заменить на обработку с `message.error`). + +- [ ] **Step 4: Ошибки tsc** + +```bash +npx tsc --noEmit 2>&1 | grep "error TS" | sed 's/(.*//' | sort | uniq -c | sort -rn +``` + +Править по файлам. Правила: +- ответы `api.get` типизировать дженериком (`api.get(…)`), не `as any`; +- неявные `any` в параметрах — явные типы; +- `useParams()` возвращает `string | undefined` — ранний выход, если параметра нет; +- `useAppSelector` с `unknown` — типизировать `initialState` слайсов (`files`, `company_groups`, `consolidations`) и + `createAsyncThunk<Возврат, Аргумент>`; +- неиспользуемые импорты/переменные — удалить; +- `src/types/consolidations/version_members.ts`: `is_head_entity: boolean`, `entity_type: 'ЮЛ' | 'ИП' | null`, + `osv_accounts: string[]`, в `VersionCardRow` поля `bb_status`, `ofr_status` — `FileRussianStatus | undefined`, + `entity_type: 'ЮЛ' | 'ИП' | null`. + +Run: `npx tsc --noEmit` → без вывода. + +- [ ] **Step 5: `make check` и коммит** + +Run: `make check` → biome без ошибок, tsc чисто, vitest 1 passed, build ready. + +```bash +git add -A +git commit -m "chore: убрано наследие старого АРМ, vitest, зелёный make check (#3)" +``` + +--- + +### Task 3: Клиент API для бинарных ответов, деньги, подписи, dev-proxy + +**Модель:** haiku. + +**Files:** +- Modify: `src/api/client.ts` (поле `body` в `ApiError`, `apiFetchBlob`), `rsbuild.config.ts` (proxy) +- Create: `src/utils/money.ts`, `src/utils/money.test.ts`, `src/utils/download.ts`, `src/utils/download.test.ts`, + `src/utils/labels.ts`, `src/utils/labels.test.ts` +- Delete: `src/utils/smoke.test.ts` + +**Interfaces:** +- Produces: + - `class ApiError { status: number; code: string | undefined; message: string; body: unknown }` (4-й параметр + конструктора `body: unknown = undefined`). + - `apiFetchBlob(path: string): Promise<{ blob: Blob; filename: string | null }>` + - `formatAmount(value: string | null | undefined): string` — `null`/`undefined`/`''` → `'—'`. + - `parseAmountInput(input: string): { ok: true; value: string | null } | { ok: false }` + - `filenameFromContentDisposition(header: string | null): string | null` + - `exportFilename(groupName: string, year: number, quarter: string): string` + - `saveBlob(blob: Blob, filename: string): void` + - `documentTypeLabel(type: DocumentType, account: string | null): string` + - `periodLabel(year: number | null, quarter: Quarter | null, isCumulative: boolean | null): string` + - `documentStatusLabel(status: DocumentStatus): string` + - `fileStatusLabel(status: FileStatus): string` + - Типы-литералы в `src/types/api.ts` (создаются в Task 4, но `labels.ts` нужен раньше — поэтому объявить их здесь в + `src/types/api.ts` минимально: `DocumentType`, `DocumentStatus`, `FileStatus`, `Quarter`; Task 4 дополняет файл). + +- [ ] **Step 1: Тесты денег** + +`src/utils/money.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { formatAmount, parseAmountInput } from './money'; + +const NBSP = ' '; + +describe('formatAmount', () => { + it('разделяет разряды и держит два знака', () => { + expect(formatAmount('4454151.93')).toBe(`4${NBSP}454${NBSP}151.93`); + expect(formatAmount('100')).toBe('100.00'); + expect(formatAmount('123.5')).toBe('123.50'); + }); + it('отрицательные', () => { + expect(formatAmount('-1234567.1')).toBe(`-1${NBSP}234${NBSP}567.10`); + }); + it('пустое — прочерк', () => { + expect(formatAmount(null)).toBe('—'); + expect(formatAmount(undefined)).toBe('—'); + expect(formatAmount('')).toBe('—'); + }); + it('нераспознанная запись возвращается как есть', () => { + expect(formatAmount('1E+2')).toBe('1E+2'); + }); + it('больше двух знаков не округляет, а показывает как есть', () => { + expect(formatAmount('1.005')).toBe('1.005'); + }); +}); + +describe('parseAmountInput', () => { + it('пробелы и запятая', () => { + expect(parseAmountInput(' 1 234,5 ')).toEqual({ ok: true, value: '1234.5' }); + expect(parseAmountInput(`1${NBSP}000`)).toEqual({ ok: true, value: '1000' }); + }); + it('пустое — null', () => { + expect(parseAmountInput('')).toEqual({ ok: true, value: null }); + expect(parseAmountInput(' ')).toEqual({ ok: true, value: null }); + }); + it('отрицательное', () => { + expect(parseAmountInput('-15.25')).toEqual({ ok: true, value: '-15.25' }); + }); + it('мусор отклоняется', () => { + expect(parseAmountInput('12,3,4')).toEqual({ ok: false }); + expect(parseAmountInput('-')).toEqual({ ok: false }); + expect(parseAmountInput('1.234')).toEqual({ ok: false }); + expect(parseAmountInput('abc')).toEqual({ ok: false }); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что падают** + +Run: `npx vitest run src/utils/money.test.ts` → FAIL (модуля нет). + +- [ ] **Step 3: Реализация денег** + +`src/utils/money.ts`: + +```ts +const NBSP = ' '; +const PLAIN = /^(-?)(\d+)(?:\.(\d+))?$/; +const INPUT = /^-?\d+(\.\d{1,2})?$/; + +/** Сумма из API (строка Decimal) → «1 234 567.80». Без арифметики над float; нераспознанное — как есть. */ +export function formatAmount(value: string | null | undefined): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + const match = PLAIN.exec(value); + if (!match) { + return value; + } + const [, sign, intPart, frac = ''] = match; + if (frac.length > 2) { + return value; + } + const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, NBSP); + return `${sign}${grouped}.${frac.padEnd(2, '0')}`; +} + +/** Ввод аналитика → строка суммы для API; пустое — null; неверный формат — ok: false. */ +export function parseAmountInput(input: string): { ok: true; value: string | null } | { ok: false } { + const normalized = input.replace(/[\s ]/g, '').replace(',', '.'); + if (normalized === '') { + return { ok: true, value: null }; + } + return INPUT.test(normalized) ? { ok: true, value: normalized } : { ok: false }; +} +``` + +Примечание: `replace(',', '.')` меняет только первую запятую — «12,3,4» → «12.3,4», регулярка отклоняет. Так и задумано. + +Run: `npx vitest run src/utils/money.test.ts` → PASS. + +- [ ] **Step 4: Тесты скачивания и подписей** + +`src/utils/download.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { exportFilename, filenameFromContentDisposition } from './download'; + +describe('filenameFromContentDisposition', () => { + it('RFC 5987 filename*', () => { + expect(filenameFromContentDisposition("attachment; filename*=UTF-8''%D0%A1%D0%B2%D0%BE%D0%B4.xlsx")).toBe('Свод.xlsx'); + }); + it('filename в кавычках', () => { + expect(filenameFromContentDisposition('attachment; filename="osv.xlsx"')).toBe('osv.xlsx'); + }); + it('filename* важнее filename', () => { + expect(filenameFromContentDisposition("attachment; filename=\"a.xlsx\"; filename*=UTF-8''b.xlsx")).toBe('b.xlsx'); + }); + it('нет заголовка или имени', () => { + expect(filenameFromContentDisposition(null)).toBeNull(); + expect(filenameFromContentDisposition('inline')).toBeNull(); + }); +}); + +describe('exportFilename', () => { + it('безопасное имя', () => { + expect(exportFilename('ГК «Русский бисквит»/Север', 2024, 'Q3')).toBe('Сводная_ОСВ_ГК_«Русский_бисквит»_Север_2024_Q3.xlsx'); + }); +}); +``` + +`src/utils/labels.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { documentStatusLabel, documentTypeLabel, fileStatusLabel, periodLabel } from './labels'; + +describe('labels', () => { + it('тип документа', () => { + expect(documentTypeLabel('OSV', '60.01')).toBe('ОСВ 60.01'); + expect(documentTypeLabel('OSV', null)).toBe('ОСВ'); + expect(documentTypeLabel('BALANCE', null)).toBe('Бух. баланс'); + expect(documentTypeLabel('INCOME', null)).toBe('ОФР'); + expect(documentTypeLabel('DISCLOSURE', null)).toBe('Расшифровка'); + expect(documentTypeLabel('OTHER', null)).toBe('Прочее'); + }); + it('период', () => { + expect(periodLabel(2024, 'Q1', false)).toBe('2024 К1'); + expect(periodLabel(2024, 'Q3', true)).toBe('2024 · 9 мес.'); + expect(periodLabel(2024, 'Q4', true)).toBe('2024 · 12 мес.'); + expect(periodLabel(2024, null, null)).toBe('период не определён'); + expect(periodLabel(null, null, null)).toBe('период не определён'); + }); + it('статусы', () => { + expect(documentStatusLabel('NEEDS_CHECK')).toBe('Требуется проверка'); + expect(documentStatusLabel('RECOGNIZED')).toBe('Требуется проверка'); + expect(documentStatusLabel('CONFIRMED')).toBe('Загружен'); + expect(documentStatusLabel('ERROR')).toBe('Ошибка'); + expect(fileStatusLabel('UPLOADED')).toBe('Обработка'); + expect(fileStatusLabel('PROCESSING')).toBe('Обработка'); + expect(fileStatusLabel('PROCESSED')).toBe('Обработан'); + expect(fileStatusLabel('ERROR')).toBe('Ошибка'); + expect(fileStatusLabel('SUPERSEDED')).toBe('Заменён'); + }); +}); +``` + +Run: `npx vitest run src/utils` → FAIL (модулей нет). + +- [ ] **Step 5: Реализация скачивания, подписей и базовых типов** + +`src/types/api.ts` (Task 4 дополнит): + +```ts +// DTO API arm-core (/api/v1). Повторяют arm-core/api/src/schemas/; Decimal и даты — строки. + +export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4'; +export type DocumentType = 'BALANCE' | 'INCOME' | 'OSV' | 'DISCLOSURE' | 'OTHER'; +export type DocumentStatus = 'RECOGNIZED' | 'NEEDS_CHECK' | 'CONFIRMED' | 'ERROR'; +export type FileStatus = 'UPLOADED' | 'PROCESSING' | 'PROCESSED' | 'ERROR' | 'SUPERSEDED'; +``` + +`src/utils/download.ts`: + +```ts +/** Имя файла из Content-Disposition: `filename*=UTF-8''…` важнее `filename=`. */ +export function filenameFromContentDisposition(header: string | null): string | null { + if (!header) { + return null; + } + const star = /filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/.exec(header); + if (star) { + try { + return decodeURIComponent(star[1].trim()); + } catch { + return star[1].trim(); + } + } + const plain = /filename\s*=\s*"?([^";]+)"?/.exec(header); + return plain ? plain[1].trim() : null; +} + +/** Запасное имя экспорта свода, если Content-Disposition недоступен. */ +export function exportFilename(groupName: string, year: number, quarter: string): string { + const safe = groupName.replace(/[\\/:*?"<>|\s]+/g, '_'); + return `Сводная_ОСВ_${safe}_${year}_${quarter}.xlsx`; +} + +/** Сохранить blob файлом через временную ссылку. */ +export function saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} +``` + +`src/utils/labels.ts`: + +```ts +import type { DocumentStatus, DocumentType, FileStatus, Quarter } from '../types/api'; + +const TYPE_LABELS: Record = { + BALANCE: 'Бух. баланс', + INCOME: 'ОФР', + OSV: 'ОСВ', + DISCLOSURE: 'Расшифровка', + OTHER: 'Прочее', +}; + +const MONTHS: Record = { Q1: 3, Q2: 6, Q3: 9, Q4: 12 }; + +export function documentTypeLabel(type: DocumentType, account: string | null): string { + return type === 'OSV' && account ? `ОСВ ${account}` : TYPE_LABELS[type]; +} + +export function periodLabel(year: number | null, quarter: Quarter | null, isCumulative: boolean | null): string { + if (year === null || quarter === null) { + return 'период не определён'; + } + return isCumulative ? `${year} · ${MONTHS[quarter]} мес.` : `${year} К${quarter.slice(1)}`; +} + +export function documentStatusLabel(status: DocumentStatus): string { + switch (status) { + case 'CONFIRMED': + return 'Загружен'; + case 'ERROR': + return 'Ошибка'; + default: + return 'Требуется проверка'; + } +} + +export function fileStatusLabel(status: FileStatus): string { + switch (status) { + case 'UPLOADED': + case 'PROCESSING': + return 'Обработка'; + case 'PROCESSED': + return 'Обработан'; + case 'ERROR': + return 'Ошибка'; + case 'SUPERSEDED': + return 'Заменён'; + } +} +``` + +Run: `npx vitest run src/utils` → PASS. Удалить `src/utils/smoke.test.ts`. + +- [ ] **Step 6: `ApiError.body` и `apiFetchBlob`** + +В `src/api/client.ts`: + +1. Конструктор `ApiError` получает 4-й параметр `public body: unknown = undefined`. +2. `ErrorInfo` расширить полем `body: unknown`; `parseErrorBody(body)` возвращает `{ code, message, body }` во всех + ветках; `readErrorInfo` в `catch` возвращает `body: undefined`. +3. Во всех `throw new ApiError(status, info.code, info.message)` передать `info.body` четвёртым аргументом. +4. Вынести общую часть в `async function send(path: string, init: RequestInit): Promise` — заголовки, токен, + сетевые ошибки, 401, `!response.ok` (всё, что сейчас в `apiFetch` до разбора тела); `apiFetch` вызывает `send` и + разбирает JSON как сейчас. +5. Добавить: + +```ts +/** Бинарный ответ (скан, xlsx) с токеном и тем же разбором ошибок; имя — из Content-Disposition, если доступно. */ +export async function apiFetchBlob(path: string): Promise<{ blob: Blob; filename: string | null }> { + const response = await send(path, {}); + const blob = await response.blob(); + return { blob, filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')) }; +} +``` + +(импорт `filenameFromContentDisposition` из `../utils/download`). + +- [ ] **Step 7: Dev-proxy** + +`rsbuild.config.ts`, в `server` добавить: + +```ts + proxy: { + // dev: запросы /api идут на arm-core с того же источника — без CORS, Content-Disposition читается (design D-5) + '/api': process.env.ARM_CORE_URL ?? 'http://localhost:8000', + }, +``` + +Проверка: `make check` → зелёный. + +- [ ] **Step 8: Коммит** + +```bash +git add -A +git commit -m "feat: apiFetchBlob, деньги строками, подписи статусов, dev-proxy (#3)" +``` + +--- + +### Task 4: Типы DTO и сервисы API + +**Модель:** haiku. + +**Files:** +- Modify: `src/types/api.ts` (дополнить), `src/services/api/files.ts` (переписать), `src/services/api/consolidations.ts`, + `src/services/api/directories/company_groups.ts` +- Create: `src/services/api/documents.ts`, `src/services/api/osv.ts`, `src/services/api/files.test.ts`, + `src/services/api/osv.test.ts` + +**Interfaces:** +- Consumes: `ApiError(status, code, message, body)`, `api`, `apiFetch`, `apiFetchBlob` (Task 3). +- Produces (точные сигнатуры): + - `uploadFiles(legalEntityId: string, files: File[], versionId: string | null): Promise` + - `isUploadResult(body: unknown): body is UploadResult` + - `summarizeUpload(result: UploadResult): { accepted: string[]; rejected: { name: string; reason: string }[] }` + - `listLegalEntityFiles(legalEntityId: string): Promise` (limit 100) + - `hasPendingFiles(files: FileOut[]): boolean` + - `fetchFileContent(fileId: string): Promise<{ blob: Blob; filename: string | null }>` (inline) + - `getDocument(id: string): Promise` + - `getAllEntries(id: string): Promise` (все страницы по 2000) + - `confirmDocument(id: string): Promise` + - `getStatement(id: string): Promise` + - `patchStatement(id: string, body: StatementPatch): Promise` + - `acceptChecks(id: string, comment: string): Promise` + - `buildOsvQuery(filters: OsvFilters, page?: { limit: number; offset: number }): string` + - `getConsolidatedOsv(versionId: string, filters: OsvFilters, limit: number, offset: number): Promise>` + - `getOsvSummary(versionId: string): Promise` + - `exportOsv(versionId: string, filters: OsvFilters): Promise<{ blob: Blob; filename: string | null }>` + - `getVersionCard(versionId: string): Promise` (переименование `getVersionData`; старое имя удалить и + поправить вызовы) + - `createConsolidation(body: ConsolidationCreate): Promise` + - `fetchRelatedGroups(groupId: string): Promise`, `fetchRelatedGroup(id: string): Promise`, + `fetchAllLegalEntities(groupId: string): Promise` (все страницы) + +- [ ] **Step 1: Дополнить `src/types/api.ts`** + +Добавить после базовых типов (сверить поля с `../arm-core/api/src/schemas/{files,documents,statements,consolidations,directories}.py`; +расхождение — поле как в схеме): + +```ts +export interface Page { + items: T[]; + total: number; + limit: number; + offset: number; +} + +export interface AcceptedOut { + file_id: string; + job_id: string; + name: string; +} +export interface RejectedOut { + name: string; + code: string; + message: string; + file_id?: string | null; +} +export interface UploadResult { + accepted: AcceptedOut[]; + rejected: RejectedOut[]; +} + +export type RecognitionStatus = string; // pending | processing | done | error | timeout | dlq — только для показа +export interface JobBrief { + id: string; + status: RecognitionStatus; + error_code: string | null; + error_message: string | null; + mapping_error: string | null; + warnings: string[]; + mapping_warnings: string[]; + finished_at: string | null; +} +export type CheckResult = 'MATCH' | 'MISMATCH' | 'UNKNOWN'; +export interface DocumentBrief { + id: string; + document_type: DocumentType; + osv_account_code: string | null; + period_year: number | null; + period_quarter: Quarter | null; + is_cumulative: boolean | null; + status: DocumentStatus; + period_check: CheckResult | null; + counterparty_check: CheckResult | null; + locator: string | null; + comments_count: number; +} +export type FileType = 'XLS' | 'XLSX' | 'XLSB' | 'PDF' | 'PNG' | 'JPG'; +export interface FileOut { + id: string; + legal_entity_id: string; + original_name: string | null; + file_type: FileType; + status: FileStatus; + version_no: number; + size_bytes: number | null; + uploaded_at: string | null; + job: JobBrief | null; + documents: DocumentBrief[]; +} +export interface DocumentOut extends DocumentBrief { + file_id: string; + legal_entity_id: string; + confirmed_by: string | null; + confirmed_at: string | null; + warnings: string[]; +} +export interface EntryOut { + id: string; + row_number: number | null; + level: number | null; + parent_entry_id: string | null; + row_value: string | null; + group_name: string | null; + account_code: string | null; + counterparty_name: string | null; + counterparty_inn: string | null; + opening_debit: string | null; + opening_credit: string | null; + turnover_debit: string | null; + turnover_credit: string | null; + closing_debit: string | null; + closing_credit: string | null; +} + +export interface HeaderFieldOut { + value: string | null; + original_value: string | null; + confidence?: string | null; + verified: boolean | null; + modified: boolean | null; +} +export interface StatementHeaderOut { + inn: HeaderFieldOut; + report_date: HeaderFieldOut; + units: HeaderFieldOut; + organization_name: string | null; + okud: string | null; + okpo: string | null; + okopf: string | null; + okfs: string | null; + address: string | null; +} +export interface ColumnOut { + id: string; + column_index: number; + label: string | null; + period_date: string | null; + is_current: boolean | null; +} +export interface ValueOut { + id: string; + column_id: string; + column_index: number; + amount: string | null; + string_value: string | null; + confidence: string | null; + is_recognized: boolean | null; + is_modified: boolean; + original_amount: string | null; +} +export interface LineOut { + id: string; + line_code: string | null; + line_name: string | null; + values: ValueOut[]; +} +export interface FailedCheckOut { + column_index: number; + relation: string; + expected: string; + actual: string; +} +export interface ChecksOut { + failed: FailedCheckOut[]; + soft_failed: FailedCheckOut[]; + cross_form: FailedCheckOut | null; + accepted: { by: string; at: string; comment: string } | null; +} +export interface StatementOut { + document: { + id: string; + document_type: DocumentType; + status: DocumentStatus; + period_year: number | null; + period_quarter: Quarter | null; + reporting_date: string | null; + counterparty_check: CheckResult | null; + locator: string | null; + file_id: string; + }; + edition: string | null; + header: StatementHeaderOut; + columns: ColumnOut[]; + lines: LineOut[]; + checks: ChecksOut; +} +export interface StatementPatch { + header?: { inn?: string; report_date?: string; units?: string }; + values?: { line_id: string; column_id: string; amount: string | null }[]; +} + +export type CounterpartyType = 'GSK' | 'GK' | 'EXTERNAL'; +export type MatchKind = 'INN' | 'NAME' | 'ALIAS' | 'MANUAL' | 'PROPAGATED'; +export interface ConsolidatedRowOut { + entry_ids: string[]; + legal_entity_id: string; + legal_entity_name: string; + legal_entity_inn: string; + account_code: string | null; + account_root: string | null; + period: string; + is_cumulative: boolean; + counterparty_name: string | null; + counterparty_inn: string | null; + counterparty_id: string | null; + counterparty_entity_name: string | null; + counterparty_type: CounterpartyType; + match_kind: MatchKind | null; + opening_debit: string | null; + opening_credit: string | null; + turnover_debit: string | null; + turnover_credit: string | null; + closing_debit: string | null; + closing_credit: string | null; + warnings: string[]; +} +export interface GapOut { + legal_entity_id: string; + legal_entity_name: string; + account_code: string; + missing_quarters: Quarter[]; +} +export interface OsvSummaryOut { + rows_total: number; + unmapped_total: number; + auto_matched_total: number; + documents_total: number; + gaps: GapOut[]; +} +export interface OsvFilters { + legal_entity_id?: string; + account_root?: string; + counterparty_type?: CounterpartyType; + unmapped?: boolean; + q?: string; +} + +export interface ConsolidationCreate { + company_group_id: string; + related_group_id: string; + year: number; + quarter: Quarter; + legal_entity_ids: string[]; +} +export interface RelatedGroupOut { + id: string; + name: string; + kind: 'gsk' | 'gsz'; +} +export interface RelatedGroupDetail extends RelatedGroupOut { + members: { legal_entity_id: string }[]; +} +export interface LegalEntityBrief { + id: string; + name: string; + inn: string; +} +``` + +Перед фиксацией `RelatedGroupDetail.members` открыть `../arm-core/api/src/schemas/directories.py` (класс ответа +`GET /directories/related-groups/{id}`) и взять точное имя поля id ЮЛ в элементе `members` — тип поправить по схеме. + +`VersionCard` оставить в `src/types/consolidations/version_members.ts` (`IVersionCard`), экспортировать алиасом: +`export type VersionCard = IVersionCard;` в конце `version_members.ts`. + +- [ ] **Step 2: Тесты сервисов** + +`src/services/api/files.test.ts`: + +```ts +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApiError } from '../../api/client'; +import type { FileOut, UploadResult } from '../../types/api'; +import { hasPendingFiles, isUploadResult, summarizeUpload, uploadFiles } from './files'; + +const result: UploadResult = { + accepted: [{ file_id: 'f1', job_id: 'j1', name: 'osv.xlsx' }], + rejected: [{ name: 'dup.xlsx', code: 'DUPLICATE_FILE', message: 'Файл уже загружен', file_id: 'f0' }], +}; + +describe('isUploadResult', () => { + it('отличает UploadResult от detail-ошибки', () => { + expect(isUploadResult(result)).toBe(true); + expect(isUploadResult({ detail: { code: 'ENTITY_NOT_IN_PERIMETER', message: 'x' } })).toBe(false); + expect(isUploadResult(null)).toBe(false); + }); +}); + +describe('summarizeUpload', () => { + it('имена и причины', () => { + expect(summarizeUpload(result)).toEqual({ + accepted: ['osv.xlsx'], + rejected: [{ name: 'dup.xlsx', reason: 'Файл уже загружен' }], + }); + }); +}); + +describe('hasPendingFiles', () => { + const file = (status: FileOut['status']): FileOut => ({ + id: status, + legal_entity_id: 'le', + original_name: null, + file_type: 'PDF', + status, + version_no: 1, + size_bytes: null, + uploaded_at: null, + job: null, + documents: [], + }); + it('UPLOADED/PROCESSING — ждём', () => { + expect(hasPendingFiles([file('PROCESSED'), file('PROCESSING')])).toBe(true); + expect(hasPendingFiles([file('UPLOADED')])).toBe(true); + }); + it('остальные — нет', () => { + expect(hasPendingFiles([file('PROCESSED'), file('ERROR')])).toBe(false); + expect(hasPendingFiles([])).toBe(false); + }); +}); + +describe('uploadFiles', () => { + afterEach(() => vi.restoreAllMocks()); + + it('422 с UploadResult возвращается как результат', async () => { + const client = await import('../../api/client'); + vi.spyOn(client, 'apiFetch').mockRejectedValue(new ApiError(422, undefined, 'x', { accepted: [], rejected: result.rejected })); + await expect(uploadFiles('le', [new File(['a'], 'dup.xlsx')], null)).resolves.toEqual({ accepted: [], rejected: result.rejected }); + }); + + it('422 с detail пробрасывается', async () => { + const client = await import('../../api/client'); + const error = new ApiError(422, 'ENTITY_NOT_IN_PERIMETER', 'ЮЛ вне периметра', { detail: { code: 'ENTITY_NOT_IN_PERIMETER' } }); + vi.spyOn(client, 'apiFetch').mockRejectedValue(error); + await expect(uploadFiles('le', [new File(['a'], 'a.xlsx')], 'v1')).rejects.toBe(error); + }); + + it('версия попадает в query, лишних полей формы нет', async () => { + const client = await import('../../api/client'); + const spy = vi.spyOn(client, 'apiFetch').mockResolvedValue(result); + await uploadFiles('le', [new File(['a'], 'a.xlsx'), new File(['b'], 'b.pdf')], 'v1'); + const [path, init] = spy.mock.calls[0]; + expect(path).toBe('/legal-entities/le/files?consolidation_version_id=v1'); + const form = init?.body as FormData; + expect(form.getAll('files')).toHaveLength(2); + expect([...form.keys()].every((k) => k === 'files')).toBe(true); + }); + + it('без версии — без query', async () => { + const client = await import('../../api/client'); + const spy = vi.spyOn(client, 'apiFetch').mockResolvedValue(result); + await uploadFiles('le', [new File(['a'], 'a.xlsx')], null); + expect(spy.mock.calls[0][0]).toBe('/legal-entities/le/files'); + }); +}); +``` + +`src/services/api/osv.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { buildOsvQuery } from './osv'; + +describe('buildOsvQuery', () => { + it('только заданные фильтры', () => { + expect(buildOsvQuery({})).toBe(''); + expect(buildOsvQuery({ legal_entity_id: 'le', q: ' бисквит ' })).toBe('?legal_entity_id=le&q=%D0%B1%D0%B8%D1%81%D0%BA%D0%B2%D0%B8%D1%82'); + }); + it('unmapped только если true, пустой q пропускается', () => { + expect(buildOsvQuery({ unmapped: false, q: ' ' })).toBe(''); + expect(buildOsvQuery({ unmapped: true })).toBe('?unmapped=true'); + }); + it('страница', () => { + expect(buildOsvQuery({ counterparty_type: 'GSK' }, { limit: 500, offset: 1000 })).toBe('?counterparty_type=GSK&limit=500&offset=1000'); + }); +}); +``` + +Run: `npx vitest run src/services` → FAIL. + +Примечание к моку: `vi.spyOn(client, 'apiFetch')` работает, только если `files.ts` вызывает `apiFetch` через импорт +модуля, который vitest может перехватить. Если spy не срабатывает (ESM live binding), заменить на +`vi.mock('../../api/client', async (orig) => ({ ...(await orig()), apiFetch: vi.fn() }))` +в начале файла и в тестах задавать `vi.mocked(apiFetch).mockResolvedValue(...)`. Поведение тестов не меняется. + +- [ ] **Step 3: Реализация `files.ts`** + +```ts +import { ApiError, apiFetch, apiFetchBlob } from '../../api/client'; +import type { FileOut, Page, UploadResult } from '../../types/api'; + +export function isUploadResult(body: unknown): body is UploadResult { + return ( + typeof body === 'object' && + body !== null && + Array.isArray((body as UploadResult).accepted) && + Array.isArray((body as UploadResult).rejected) + ); +} + +/** Загрузка файлов ЮЛ одним запросом; 422 «ни один не принят» возвращается как результат, прочие ошибки — throw. */ +export async function uploadFiles(legalEntityId: string, files: File[], versionId: string | null): Promise { + const form = new FormData(); + for (const file of files) { + form.append('files', file); + } + const query = versionId ? `?consolidation_version_id=${encodeURIComponent(versionId)}` : ''; + try { + return await apiFetch(`/legal-entities/${legalEntityId}/files${query}`, { method: 'POST', body: form }); + } catch (e: unknown) { + if (e instanceof ApiError && e.status === 422 && isUploadResult(e.body)) { + return e.body; + } + throw e; + } +} + +export function summarizeUpload(result: UploadResult): { accepted: string[]; rejected: { name: string; reason: string }[] } { + return { + accepted: result.accepted.map((a) => a.name), + rejected: result.rejected.map((r) => ({ name: r.name, reason: r.message })), + }; +} + +export async function listLegalEntityFiles(legalEntityId: string): Promise { + const page = await apiFetch>(`/legal-entities/${legalEntityId}/files?limit=100`); + return page.items; +} + +export function hasPendingFiles(files: FileOut[]): boolean { + return files.some((f) => f.status === 'UPLOADED' || f.status === 'PROCESSING'); +} + +export function fetchFileContent(fileId: string): Promise<{ blob: Blob; filename: string | null }> { + return apiFetchBlob(`/files/${fileId}/content?disposition=inline`); +} +``` + +- [ ] **Step 4: `documents.ts`, `osv.ts`, правки `consolidations.ts` и `company_groups.ts`** + +`src/services/api/documents.ts`: + +```ts +import { api } from '../../api/client'; +import type { DocumentOut, EntryOut, Page, StatementOut, StatementPatch } from '../../types/api'; + +const ENTRIES_PAGE = 2000; + +export const getDocument = (id: string) => api.get(`/documents/${id}`); +export const confirmDocument = (id: string) => api.post(`/documents/${id}/confirm`); +export const getStatement = (id: string) => api.get(`/documents/${id}/statement`); +export const patchStatement = (id: string, body: StatementPatch) => api.patch(`/documents/${id}/statement`, body); +export const acceptChecks = (id: string, comment: string) => + api.post(`/documents/${id}/statement/checks/accept`, { comment }); + +/** Все строки ОСВ-документа (страницами по 2000). */ +export async function getAllEntries(id: string): Promise { + const all: EntryOut[] = []; + for (let offset = 0; ; offset += ENTRIES_PAGE) { + const page = await api.get>(`/documents/${id}/entries?limit=${ENTRIES_PAGE}&offset=${offset}`); + all.push(...page.items); + if (all.length >= page.total || page.items.length === 0) { + return all; + } + } +} +``` + +`src/services/api/osv.ts`: + +```ts +import { api, apiFetchBlob } from '../../api/client'; +import type { ConsolidatedRowOut, OsvFilters, OsvSummaryOut, Page } from '../../types/api'; + +export function buildOsvQuery(filters: OsvFilters, page?: { limit: number; offset: number }): string { + const params = new URLSearchParams(); + if (filters.legal_entity_id) params.set('legal_entity_id', filters.legal_entity_id); + if (filters.account_root) params.set('account_root', filters.account_root); + if (filters.counterparty_type) params.set('counterparty_type', filters.counterparty_type); + if (filters.unmapped) params.set('unmapped', 'true'); + const q = filters.q?.trim(); + if (q) params.set('q', q); + if (page) { + params.set('limit', String(page.limit)); + params.set('offset', String(page.offset)); + } + const s = params.toString(); + return s ? `?${s}` : ''; +} + +const base = (versionId: string) => `/consolidations/versions/${versionId}/osv`; + +export const getConsolidatedOsv = (versionId: string, filters: OsvFilters, limit: number, offset: number) => + api.get>(`${base(versionId)}${buildOsvQuery(filters, { limit, offset })}`); +export const getOsvSummary = (versionId: string) => api.get(`${base(versionId)}/summary`); +export const exportOsv = (versionId: string, filters: OsvFilters) => apiFetchBlob(`${base(versionId)}/export${buildOsvQuery(filters)}`); +``` + +`URLSearchParams` кодирует пробел как `+`, а кириллицу — `%D0…`; тест выше ждёт именно это (`q` обрезан). Если тест на +`q` расходится только в кодировке — поправить ожидание в тесте на вывод `URLSearchParams`, не реализацию. + +`src/services/api/consolidations.ts` — заменить содержимое: + +```ts +import { api } from '../../api/client'; +import type { ConsolidationCreate } from '../../types/api'; +import type { IConsolidation } from '../../types/consolidations/consolidations'; +import type { VersionCard } from '../../types/consolidations/version_members'; + +export const getConsolidations = (groupId: string) => api.get(`/company-groups/${groupId}/consolidations`); +export const getVersionCard = (versionId: string) => api.get(`/consolidations/versions/${versionId}`); +export const createConsolidation = (body: ConsolidationCreate) => api.post('/consolidations', body); +``` + +`src/services/api/directories/company_groups.ts` — заменить содержимое: + +```ts +import { api } from '../../../api/client'; +import type { LegalEntityBrief, Page, RelatedGroupDetail, RelatedGroupOut } from '../../../types/api'; +import type { ICompanyGroup } from '../../../types/companyGroups/companyGroups'; + +const LE_PAGE = 200; + +export const fetchCompanyGroups = () => api.get('/directories/company-groups'); +export const fetchOneCompanyGroup = (id: string) => api.get(`/directories/company-groups/${id}`); +export const fetchRelatedGroups = (groupId: string) => + api.get(`/directories/company-groups/${groupId}/related-groups`); +export const fetchRelatedGroup = (id: string) => api.get(`/directories/related-groups/${id}`); + +/** Все незакрытые ЮЛ ГК — дочитываем страницы, пока не получим total. */ +export async function fetchAllLegalEntities(groupId: string): Promise { + const all: LegalEntityBrief[] = []; + for (let offset = 0; ; offset += LE_PAGE) { + const page = await api.get>( + `/directories/legal-entities?company_group_id=${groupId}&closed=false&limit=${LE_PAGE}&offset=${offset}`, + ); + all.push(...page.items); + if (all.length >= page.total || page.items.length === 0) { + return all; + } + } +} +``` + +Перед фиксацией сверить: (а) `GET /directories/company-groups` — массив или `Page` (`../arm-core/api/src/api/endpoints/directories/`); +если `Page` — `fetchCompanyGroups` возвращает `page.items`; (б) максимальный `limit` у legal-entities (`MAX_PAGE_LIMIT` в +`schemas/common.py`) — `LE_PAGE` не больше него. Поправить вызовы `getVersionData` → `getVersionCard`, +`fetchGSKByComGroup` → `fetchRelatedGroups`, `fetchGroupMembers` → `fetchAllLegalEntities` (поиск grep по `src`). + +- [ ] **Step 5: Прогон и коммит** + +Run: `npx vitest run` → PASS; `make check` → зелёный. + +```bash +git add -A +git commit -m "feat: типы DTO и сервисы API для файлов, документов, форм и свода (#3)" +``` + +--- +### Task 5: Модалка «Новая консолидация», переход к версии и «К отчётности» + +**Модель:** sonnet. + +**Files:** +- Modify: `src/widgets/NewConsoleModal/NewConsoleModal.tsx`, `src/widgets/Sidebar/Sidebar.tsx`, + `src/layouts/ConsolidationsList.tsx`, `src/widgets/ConsoleTable/ConsoleTable.tsx` +- Create: `src/widgets/NewConsoleModal/preselect.ts`, `src/widgets/NewConsoleModal/preselect.test.ts`, + `src/widgets/ConsoleLegalEntities/normalize.test.ts` + +**Interfaces:** +- Consumes: `fetchRelatedGroups`, `fetchRelatedGroup`, `fetchAllLegalEntities`, `createConsolidation` (Task 4), + `ApiError` (Task 3), `setSelectedVersion` (`store/slices/consolidations/consolidationsSlice.ts`). +- Produces: `preselectMembers(memberIds: string[], entities: LegalEntityBrief[]): string[]`; URL + `/groups/:group_id?version=` открывает версию; маршрут свода `/groups/:group_id/versions/:version_id/osv` + (страница — Task 9). + +- [ ] **Step 1: Тесты предотметки и нормализации карточки** + +`src/widgets/NewConsoleModal/preselect.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { preselectMembers } from './preselect'; + +const entities = [ + { id: 'a', name: 'А', inn: '1' }, + { id: 'b', name: 'Б', inn: '2' }, +]; + +describe('preselectMembers', () => { + it('отмечает только тех членов ГСК, что есть среди ЮЛ ГК', () => { + expect(preselectMembers(['b', 'x'], entities)).toEqual(['b']); + }); + it('пустая ГСК — ничего', () => { + expect(preselectMembers([], entities)).toEqual([]); + }); +}); +``` + +`src/widgets/ConsoleLegalEntities/normalize.test.ts` (поведение существующей `normalizeData`): + +```ts +import { describe, expect, it } from 'vitest'; +import type { IVersionCard } from '../../types/consolidations/version_members'; +import { normalizeData } from './normalize'; + +const card = { + id: 'v1', + members: [ + { + id: 'le1', + name: 'АО "Русский бисквит"', + inn: '3528052718', + is_head_entity: true, + entity_type: 'ЮЛ', + osv_accounts: ['62', '60'], + checklist: [ + { document_type: 'BALANCE', account: null, status: 'NEEDS_CHECK', missing_quarters: [], document_ids: ['d1'] }, + { document_type: 'INCOME', account: null, status: 'MISSING', missing_quarters: [], document_ids: [] }, + { document_type: 'OSV', account: '62', status: 'CONFIRMED', missing_quarters: [], document_ids: ['d2'] }, + { document_type: 'OSV', account: '60', status: 'PROCESSING', missing_quarters: [], document_ids: [] }, + { document_type: 'OSV', account: '58', status: 'MISSING', missing_quarters: ['Q1'], document_ids: [] }, + ], + }, + ], +} as unknown as IVersionCard; + +describe('normalizeData', () => { + it('строка ЮЛ со статусами ББ/ОФР и загруженными ОСВ', () => { + expect(normalizeData(card)).toEqual([ + { + id: 'le1', + name: 'АО "Русский бисквит"', + inn: '3528052718', + version_id: 'v1', + entity_type: 'ЮЛ', + bb_status: 'Требуется проверка', + ofr_status: 'Отсутствует', + uploaded_osvs: '60, 62', + }, + ]); + }); + it('null — пусто', () => { + expect(normalizeData(null)).toEqual([]); + }); +}); +``` + +Run: `npx vitest run src/widgets` → preselect FAIL (модуля нет); normalize — PASS или FAIL. Если normalize падает +только из-за `console.log`/типов — убрать `console.log` из `normalize.ts`, логику не менять; если падает по значениям — +сначала сверить ожидание с правилом «ОСВ: не MISSING → загружено» в `normalize.ts` и поправить **тест** (поведение +существующей функции сохраняем). + +- [ ] **Step 2: `preselect.ts`** + +```ts +import type { LegalEntityBrief } from '../../types/api'; + +/** Члены ГСК, присутствующие среди ЮЛ ГК, — начальные отметки периметра. */ +export function preselectMembers(memberIds: string[], entities: LegalEntityBrief[]): string[] { + const present = new Set(entities.map((e) => e.id)); + return memberIds.filter((id) => present.has(id)); +} +``` + +Run: `npx vitest run src/widgets` → PASS. + +- [ ] **Step 3: Модалка** + +В `NewConsoleModal.tsx`: +1. Удалить закомментированные `handleGroupChange`/`handleSubGroupChange`, `any` заменить на `RelatedGroupOut[]` и + `LegalEntityBrief[]`. `FormValues` → `ConsolidationCreate` из `types/api`. +2. `handleGroupChange(value)`: параллельно `fetchRelatedGroups(value)` (оставить `kind === 'gsk'`) и + `fetchAllLegalEntities(value)`; ошибки — `message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить данные ГК')`. +3. Новый `handleRelatedGroupChange(value)` на `Select` ГСК (`onChange`): `fetchRelatedGroup(value)` → + `form.setFieldsValue({ legal_entity_ids: preselectMembers(detail.members.map((m) => m.legal_entity_id), legEntities) })`; + защита от гонок — тот же приём `reqRef`, что для подгрупп. (Имя поля id в `members` — как зафиксировано в Task 4.) +4. `handleOk`: + +```ts + const [submitting, setSubmitting] = useState(false); + + const handleOk = async () => { + let values: ConsolidationCreate; + try { + values = await form.validateFields(); + } catch { + return; // ошибки полей antd показывает сам + } + setSubmitting(true); + try { + await onOk(values); + form.resetFields(); + resetLocal(); + } catch (e: unknown) { + message.error(e instanceof ApiError ? e.message : 'Не удалось создать консолидацию'); + } finally { + setSubmitting(false); + } + }; +``` + + `Modal`: `okText="Создать"`, `cancelText="Отмена"`, `confirmLoading={submitting}`; `Empty` — «Нет ЮЛ в ГК» / + «Ничего не найдено»; `initialValues={{ legal_entity_ids: [] }}` (было `legEntIds` — опечатка). +5. Тип пропса `onOk: (values: ConsolidationCreate) => Promise`; модалка переименовывается в `NewConsolidationModal` + (экспорт и импорт в `Sidebar.tsx`). + +- [ ] **Step 4: Создание → переход к версии** + +`Sidebar.tsx`, `onOk`: + +```ts + onOk={async (values) => { + const card = await createConsolidation(values); + setModalOpen(false); + message.success(`Создана консолидация ${values.year} ${values.quarter}`); + navigate(`/groups/${values.company_group_id}?version=${card.id}`); + }} +``` + +(ошибку ловит модалка — Step 3). Удалить неиспользуемые импорты, найденные tsc. + +`layouts/ConsolidationsList.tsx`: +- `const [searchParams] = useSearchParams(); const versionParam = searchParams.get('version');` +- эффект загрузки зависит от `[dispatch, group_id, versionParam]`; после `dispatch(getConsolidationsListThunk(group_id))` + — `if (versionParam) dispatch(setSelectedVersion(versionParam));` +- ранний выход, если `group_id` нет; ошибки `fetchOneCompanyGroup` — `message.error(...)`; +- удалить неиспользуемые `legalEntities`, `legEntId`, `drawerContent`, импорты. + +- [ ] **Step 5: «К отчётности»** + +`ConsoleTable.tsx`: `const { group_id } = useParams();` и кнопка +`onClick={() => navigate(`/groups/${group_id}/versions/${row.id}/osv`)}` (было `/cons/...`). Кнопку «Взять в работу» +скрыть (вне объёма — блокировка; `alert` не оставлять). Пункты `RowActions` с `alert("Не реализовано")` — убрать из меню +или показывать `message.info('Будет позже')`. + +- [ ] **Step 6: Проверка и коммит** + +Run: `make check` → зелёный. Ручная проверка (стенд из Task 1, `make start`): создать консолидацию 2024 Q3 для «Русский +бисквит» — члены ГСК отмечены; после «Создать» открыта ГК с выделенной версией; повторное создание — текст +`CONSOLIDATION_EXISTS` в модалке. + +```bash +git add -A +git commit -m "feat: модалка консолидации — предотметка ГСК, все ЮЛ, ошибки, переход к версии (#3)" +``` + +--- + +### Task 6: Панель «Файлы ЮЛ» — загрузка с итогом, файлы и документы, опрос + +**Модель:** sonnet. + +**Files:** +- Rewrite: `src/widgets/FilesDrawer/FilesDrawer.tsx`, `src/widgets/FilesDrawer/UploadDocumentsModal.tsx`, + `src/widgets/FilesDrawer/useDocumentsPolling.ts` → `src/widgets/FilesDrawer/useLegalEntityFiles.ts` +- Create: `src/widgets/FilesDrawer/FilesList.tsx` +- Delete: `src/store/slices/files/` (заменён локальным хуком), запись `files` в `src/store/store.ts` +- Modify: `src/layouts/ConsolidationsList.tsx` (перечитывание карточки при закрытии панели), + `src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx` (проп `reloadKey`), + `openspec/changes/demo-prototype/design.md` (решение о слайсе `files`) + +**Interfaces:** +- Consumes: `uploadFiles`, `summarizeUpload`, `listLegalEntityFiles`, `hasPendingFiles` (Task 4); `documentTypeLabel`, + `periodLabel`, `documentStatusLabel`, `fileStatusLabel` (Task 3). +- Produces: `useLegalEntityFiles(legalEntityId: string | null, open: boolean): { files: FileOut[]; loading: boolean; reload: () => Promise }`; + ссылки `/documents/:document_id`. + +- [ ] **Step 1: Хук списка с опросом** + +`src/widgets/FilesDrawer/useLegalEntityFiles.ts`: + +```ts +import { message } from 'antd'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ApiError } from '../../api/client'; +import { hasPendingFiles, listLegalEntityFiles } from '../../services/api/files'; +import type { FileOut } from '../../types/api'; + +const POLL_MS = 5000; + +/** Файлы ЮЛ; пока панель открыта и есть UPLOADED/PROCESSING — перечитываем раз в 5 с. */ +export function useLegalEntityFiles(legalEntityId: string | null, open: boolean) { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + const requestRef = useRef(0); + + const reload = useCallback(async () => { + if (!legalEntityId) return; + const requestId = ++requestRef.current; + setLoading(true); + try { + const items = await listLegalEntityFiles(legalEntityId); + if (requestId === requestRef.current) setFiles(items); + } catch (e: unknown) { + if (requestId === requestRef.current) message.error(e instanceof ApiError ? e.message : 'Не удалось получить файлы'); + } finally { + if (requestId === requestRef.current) setLoading(false); + } + }, [legalEntityId]); + + useEffect(() => { + if (open) { + void reload(); + } else { + requestRef.current++; // ответы после закрытия не применяем + setFiles([]); + } + }, [open, reload]); + + const pending = hasPendingFiles(files); + useEffect(() => { + if (!open || !pending) return; + const timer = window.setInterval(() => void reload(), POLL_MS); + return () => window.clearInterval(timer); + }, [open, pending, reload]); + + return { files, loading, reload }; +} +``` + +- [ ] **Step 2: Модалка загрузки** + +`UploadDocumentsModal.tsx` — переписать: + +```tsx +import { InboxOutlined } from '@ant-design/icons'; +import { Alert, Modal, Upload, message } from 'antd'; +import type { UploadFile } from 'antd/es/upload/interface'; +import { useState } from 'react'; +import { ApiError } from '../../api/client'; +import { summarizeUpload, uploadFiles } from '../../services/api/files'; + +type Props = { + legalEntityId: string; + versionId: string | null; + open: boolean; + onClose: () => void; + onUploaded: () => void; +}; + +type Summary = ReturnType; + +export function UploadDocumentsModal({ legalEntityId, versionId, open, onClose, onUploaded }: Props) { + const [fileList, setFileList] = useState([]); + const [uploading, setUploading] = useState(false); + const [summary, setSummary] = useState(null); + + const close = () => { + if (uploading) return; + setFileList([]); + setSummary(null); + onClose(); + }; + + const handleOk = async () => { + if (summary) { + close(); + return; + } + const files = fileList.map((f) => f.originFileObj).filter((f): f is NonNullable => Boolean(f)); + setUploading(true); + try { + const result = summarizeUpload(await uploadFiles(legalEntityId, files, versionId)); + setSummary(result); + if (result.accepted.length > 0) onUploaded(); + } catch (e: unknown) { + message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить файлы'); + } finally { + setUploading(false); + } + }; + + return ( + + {summary ? ( + <> + {summary.accepted.length > 0 && ( + + )} + {summary.rejected.length > 0 && ( + + {summary.rejected.map((r) => ( +
  • + {r.name} — {r.reason} +
  • + ))} + + } + /> + )} + + ) : ( + { + setFileList((prev) => (prev.some((f) => f.uid === file.uid) ? prev : [...prev, { uid: file.uid, name: file.name, originFileObj: file }])); + return false; + }} + onRemove={(file) => setFileList((prev) => prev.filter((f) => f.uid !== file.uid))} + showUploadList={{ showRemoveIcon: !uploading }} + > +

    + +

    +

    Перетащите файлы или нажмите, чтобы выбрать

    +

    ОСВ, ББ, ОФР: xls, xlsx, xlsb, pdf, png, jpg — тип и период определятся автоматически

    +
    + )} +
    + ); +} +``` + +Примечание: `originFileObj` в antd 6 типизирован как `RcFile` (наследник `File`) — подходит для `uploadFiles(…, File[], …)`. + +- [ ] **Step 3: Список файлов** + +`src/widgets/FilesDrawer/FilesList.tsx`: + +```tsx +import { List, Space, Tag, Typography } from 'antd'; +import { Link } from 'react-router-dom'; +import type { DocumentBrief, FileOut } from '../../types/api'; +import { documentStatusLabel, documentTypeLabel, fileStatusLabel, periodLabel } from '../../utils/labels'; + +const LINKABLE = new Set(['OSV', 'BALANCE', 'INCOME']); + +const fileColor = (f: FileOut) => (f.status === 'ERROR' ? 'red' : f.status === 'PROCESSED' ? 'default' : 'processing'); +const docColor = (d: DocumentBrief) => (d.status === 'CONFIRMED' ? 'green' : d.status === 'ERROR' ? 'red' : 'orange'); + +function errorReason(f: FileOut): string | null { + if (f.status !== 'ERROR' || !f.job) return null; + return f.job.mapping_error ?? f.job.error_message ?? f.job.error_code ?? f.job.status; +} + +export function FilesList({ files, loading }: { files: FileOut[]; loading: boolean }) { + return ( + ( + + + + {f.original_name ?? 'без имени'} + {fileStatusLabel(f.status)} + + {errorReason(f) && {errorReason(f)}} + {f.documents.map((d) => { + const title = `${documentTypeLabel(d.document_type, d.osv_account_code)} · ${periodLabel(d.period_year, d.period_quarter, d.is_cumulative)}`; + return ( + + {LINKABLE.has(d.document_type) ? {title} : {title}} + {documentStatusLabel(d.status)} + + ); + })} + + + )} + /> + ); +} +``` + +- [ ] **Step 4: Панель** + +`FilesDrawer.tsx` — переписать (имя экспорта `DocumentsDrawer` сохранить, чтобы не трогать импорт; или переименовать в +`FilesDrawer` вместе с импортом): + +```tsx +import { UploadOutlined } from '@ant-design/icons'; +import { Button, Drawer } from 'antd'; +import { useState } from 'react'; +import { useAppSelector } from '../../store/store'; +import { FilesList } from './FilesList'; +import { UploadDocumentsModal } from './UploadDocumentsModal'; +import { useLegalEntityFiles } from './useLegalEntityFiles'; + +export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () => void }) { + const [uploadOpen, setUploadOpen] = useState(false); + const { current_version_id, current_legal_entity_id } = useAppSelector((s) => s.consolidations); + const { files, loading, reload } = useLegalEntityFiles(current_legal_entity_id, open); + + return ( + } disabled={!current_legal_entity_id} onClick={() => setUploadOpen(true)}> + Загрузить + + } + > + + {current_legal_entity_id && ( + setUploadOpen(false)} + onUploaded={() => void reload()} + /> + )} + + ); +} +``` + +Удалить `src/store/slices/files/` и `files: filesReducer` из `store.ts`; grep `state.files` / `getFilesThunk` по `src` — +пусто. + +- [ ] **Step 5: Перечитывание карточки при закрытии панели** + +`ConsoleLegalEntitiesTable` получает проп `reloadKey: number` (тип пропсов объявить явно: +`{ onDrawerOpen: () => void; reloadKey: number }`); эффект загрузки карточки зависит от `[consolidation_id, reloadKey]`; +вызов `getVersionCard` в `try/catch` с `message.error`. `ConsolidationsList.tsx`: `const [reloadKey, setReloadKey] = useState(0);` +при закрытии панели — `setDrawerOpen(false); setReloadKey((k) => k + 1);`. + +В `design.md`, раздел «Decisions made without discussion», дописать строку: +`- 08.10.2026 — D-8: слайс Redux `files` заменён локальным хуком `useLegalEntityFiles` (список нужен только открытой панели). Цена ошибки: низкая.` + +- [ ] **Step 6: Проверка и коммит** + +Run: `make check` → зелёный. Ручная проверка на стенде: в версии из Task 5 открыть РБ → «Загрузить» 2 ОСВ-файла из +`../fixtures/osv/rb/2024-Q1/` → итог «Принято: 2»; повторная загрузка того же → «Отклонено» с причиной; файлы со статусом +«Обработка» сменяются на документы «ОСВ 60 · 2024 К1 — Требуется проверка» без перезагрузки страницы; после +`PROCESSED` запросы в Network прекращаются; закрытие панели обновляет колонки ОСВ/ББ/ОФР. + +```bash +git add -A +git commit -m "feat: панель файлов ЮЛ — загрузка с итогом, документы, опрос до окончания распознавания (#3)" +``` + +--- + +### Task 7: Экран документа и просмотр ОСВ-документа + +**Модель:** sonnet. + +**Files:** +- Create: `src/layouts/DocumentPage.tsx`, `src/widgets/DocumentReview/viewKind.ts`, `src/widgets/DocumentReview/viewKind.test.ts`, + `src/widgets/DocumentReview/entryTree.ts`, `src/widgets/DocumentReview/entryTree.test.ts`, + `src/widgets/DocumentReview/OsvDocument.tsx`, `src/widgets/DocumentReview/confirmError.ts`, + `src/widgets/DocumentReview/confirmError.test.ts` +- Modify: `src/App.tsx` (маршрут), `src/layouts/index.ts` (экспорт), `src/services/api/directories/company_groups.ts` + (`fetchLegalEntity`) + +**Interfaces:** +- Consumes: `getDocument`, `getAllEntries`, `confirmDocument` (Task 4); подписи (Task 3); `formatAmount` (Task 3). +- Produces: + - `documentViewKind(doc: Pick): 'statement' | 'osv' | 'none'` + - `type EntryNode = EntryOut & { key: string; children?: EntryNode[] }`; `buildEntryTree(entries: EntryOut[]): EntryNode[]` + - `confirmErrorInfo(e: unknown): { code: string | undefined; message: string; failures: FailedCheckOut[] }` + - `fetchLegalEntity(id: string): Promise` + - `DocumentPage` рендерит `` для `statement` — компонент + создаётся в Task 8; в этой задаче на его месте временная заглушка `` + (Task 8 заменяет её). + +- [ ] **Step 1: Тесты** + +`src/widgets/DocumentReview/viewKind.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { documentViewKind } from './viewKind'; + +describe('documentViewKind', () => { + it('формы', () => { + expect(documentViewKind({ document_type: 'BALANCE' })).toBe('statement'); + expect(documentViewKind({ document_type: 'INCOME' })).toBe('statement'); + }); + it('ОСВ', () => { + expect(documentViewKind({ document_type: 'OSV' })).toBe('osv'); + }); + it('без данных', () => { + expect(documentViewKind({ document_type: 'DISCLOSURE' })).toBe('none'); + expect(documentViewKind({ document_type: 'OTHER' })).toBe('none'); + }); +}); +``` + +`src/widgets/DocumentReview/entryTree.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import type { EntryOut } from '../../types/api'; +import { buildEntryTree } from './entryTree'; + +const e = (id: string, parent: string | null, row: number): EntryOut => ({ + id, + row_number: row, + level: null, + parent_entry_id: parent, + row_value: id, + group_name: null, + account_code: null, + counterparty_name: null, + counterparty_inn: null, + opening_debit: null, + opening_credit: null, + turnover_debit: null, + turnover_credit: null, + closing_debit: null, + closing_credit: null, +}); + +describe('buildEntryTree', () => { + it('вкладывает детей и сохраняет порядок', () => { + const tree = buildEntryTree([e('60', null, 1), e('60.01', '60', 2), e('ООО А', '60.01', 3), e('62', null, 4)]); + expect(tree.map((n) => n.id)).toEqual(['60', '62']); + expect(tree[0].children?.[0].id).toBe('60.01'); + expect(tree[0].children?.[0].children?.[0].id).toBe('ООО А'); + expect(tree[1].children).toBeUndefined(); + }); + it('ребёнок с неизвестным родителем — в корень', () => { + expect(buildEntryTree([e('x', 'missing', 1)]).map((n) => n.id)).toEqual(['x']); + }); +}); +``` + +`src/widgets/DocumentReview/confirmError.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { ApiError } from '../../api/client'; +import { confirmErrorInfo } from './confirmError'; + +describe('confirmErrorInfo', () => { + it('CHECKS_NOT_PASSED с нарушениями', () => { + const failure = { column_index: 0, relation: '1600 = 1700', expected: '10.00', actual: '9.00' }; + const e = new ApiError(409, 'CHECKS_NOT_PASSED', 'не пройдены проверки', { detail: { code: 'CHECKS_NOT_PASSED', message: 'x', failures: [failure] } }); + expect(confirmErrorInfo(e)).toEqual({ code: 'CHECKS_NOT_PASSED', message: 'не пройдены проверки', failures: [failure] }); + }); + it('прочие ошибки без нарушений', () => { + expect(confirmErrorInfo(new ApiError(409, 'PERIOD_REQUIRED', 'нужна дата', { detail: { code: 'PERIOD_REQUIRED', message: 'нужна дата' } }))).toEqual({ + code: 'PERIOD_REQUIRED', + message: 'нужна дата', + failures: [], + }); + expect(confirmErrorInfo(new Error('boom'))).toEqual({ code: undefined, message: 'Не удалось подтвердить документ', failures: [] }); + }); +}); +``` + +Run: `npx vitest run src/widgets/DocumentReview` → FAIL. + +- [ ] **Step 2: Чистые функции** + +`viewKind.ts`: + +```ts +import type { DocumentOut } from '../../types/api'; + +export function documentViewKind(doc: Pick): 'statement' | 'osv' | 'none' { + if (doc.document_type === 'BALANCE' || doc.document_type === 'INCOME') return 'statement'; + if (doc.document_type === 'OSV') return 'osv'; + return 'none'; +} +``` + +`entryTree.ts`: + +```ts +import type { EntryOut } from '../../types/api'; + +export type EntryNode = EntryOut & { key: string; children?: EntryNode[] }; + +/** Дерево строк ОСВ по parent_entry_id; порядок — как пришёл (по row_number). */ +export function buildEntryTree(entries: EntryOut[]): EntryNode[] { + const nodes = new Map(); + for (const entry of entries) nodes.set(entry.id, { ...entry, key: entry.id }); + const roots: EntryNode[] = []; + for (const entry of entries) { + const node = nodes.get(entry.id) as EntryNode; + const parent = entry.parent_entry_id ? nodes.get(entry.parent_entry_id) : undefined; + if (parent) { + parent.children = [...(parent.children ?? []), node]; + } else { + roots.push(node); + } + } + return roots; +} +``` + +`confirmError.ts`: + +```ts +import { ApiError } from '../../api/client'; +import type { FailedCheckOut } from '../../types/api'; + +/** Разбор ошибки подтверждения: код, текст и нарушения проверок (CHECKS_NOT_PASSED). */ +export function confirmErrorInfo(e: unknown): { code: string | undefined; message: string; failures: FailedCheckOut[] } { + if (!(e instanceof ApiError)) { + return { code: undefined, message: 'Не удалось подтвердить документ', failures: [] }; + } + const detail = (e.body as { detail?: { failures?: unknown } } | undefined)?.detail; + const failures = Array.isArray(detail?.failures) ? (detail.failures as FailedCheckOut[]) : []; + return { code: e.code, message: e.message, failures }; +} +``` + +Run: `npx vitest run src/widgets/DocumentReview` → PASS. + +- [ ] **Step 3: ЮЛ-владелец** + +В `company_groups.ts` добавить +`export const fetchLegalEntity = (id: string) => api.get(`/directories/legal-entities/${id}`);` +(сверить путь с `../arm-core/api/src/api/endpoints/directories/` — карточка ЮЛ; если путь другой — взять его). + +- [ ] **Step 4: Экран ОСВ-документа** + +`src/widgets/DocumentReview/OsvDocument.tsx`: + +```tsx +import { Button, Table, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useEffect, useMemo, useState } from 'react'; +import { ApiError } from '../../api/client'; +import { confirmDocument, getAllEntries } from '../../services/api/documents'; +import type { DocumentOut, EntryOut } from '../../types/api'; +import { formatAmount } from '../../utils/money'; +import { confirmErrorInfo } from './confirmError'; +import { buildEntryTree, type EntryNode } from './entryTree'; + +const money = (key: keyof EntryOut, title: string) => ({ + key, + title, + dataIndex: key, + align: 'right' as const, + width: 140, + render: (v: string | null) => formatAmount(v), +}); + +const columns: ColumnsType = [ + { key: 'row_value', title: 'Счёт / контрагент', dataIndex: 'row_value', render: (v: string | null, r) => v ?? r.counterparty_name ?? r.account_code ?? '' }, + { key: 'counterparty_inn', title: 'ИНН', dataIndex: 'counterparty_inn', width: 120 }, + money('opening_debit', 'Сальдо нач. Дт'), + money('opening_credit', 'Сальдо нач. Кт'), + money('turnover_debit', 'Обороты Дт'), + money('turnover_credit', 'Обороты Кт'), + money('closing_debit', 'Сальдо кон. Дт'), + money('closing_credit', 'Сальдо кон. Кт'), +]; + +export function OsvDocument({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [confirming, setConfirming] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + getAllEntries(document.id) + .then((items) => !cancelled && setEntries(items)) + .catch((e: unknown) => message.error(e instanceof ApiError ? e.message : 'Не удалось получить строки ОСВ')) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [document.id]); + + const tree = useMemo(() => buildEntryTree(entries), [entries]); + const canConfirm = document.status === 'RECOGNIZED' || document.status === 'NEEDS_CHECK'; + + const confirm = async () => { + setConfirming(true); + try { + await confirmDocument(document.id); + message.success('Документ подтверждён'); + onChanged(); + } catch (e: unknown) { + message.error(confirmErrorInfo(e).message); + } finally { + setConfirming(false); + } + }; + + return ( +
    +
    + + Суммы в рублях; строки ОСВ только для чтения +
    + + size="small" + rowKey="key" + loading={loading} + columns={columns} + dataSource={tree} + pagination={false} + scroll={{ y: 'calc(100vh - 260px)', x: 1300 }} + expandable={{ defaultExpandAllRows: false }} + /> +
    + ); +} +``` + +- [ ] **Step 5: Страница документа и маршрут** + +`src/layouts/DocumentPage.tsx`: + +```tsx +import { Button, Empty, Result, Spin, Tag } from 'antd'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { ApiError } from '../api/client'; +import PageTitle from '../components/PageTitle'; +import { fetchLegalEntity } from '../services/api/directories/company_groups'; +import { getDocument } from '../services/api/documents'; +import type { DocumentOut } from '../types/api'; +import { documentStatusLabel, documentTypeLabel, periodLabel } from '../utils/labels'; +import { OsvDocument } from '../widgets/DocumentReview/OsvDocument'; +import { documentViewKind } from '../widgets/DocumentReview/viewKind'; + +export function DocumentPage() { + const { document_id } = useParams(); + const navigate = useNavigate(); + const [doc, setDoc] = useState(null); + const [entityName, setEntityName] = useState(''); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + if (!document_id) return; + try { + const d = await getDocument(document_id); + setDoc(d); + setError(null); + fetchLegalEntity(d.legal_entity_id) + .then((le) => setEntityName(le.name)) + .catch(() => setEntityName('')); + } catch (e: unknown) { + setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ'); + } + }, [document_id]); + + useEffect(() => { + void load(); + }, [load]); + + if (error) { + return navigate(-1)}>Назад} />; + } + if (!doc) { + return ; + } + const kind = documentViewKind(doc); + return ( +
    + {documentStatusLabel(doc.status)}} + /> +
    + {kind === 'osv' && } + {kind === 'statement' && } + {kind === 'none' && } +
    +
    + ); +} +``` + +Сверить пропсы `PageTitle` (`src/components/PageTitle.tsx`): если `description` типизирован как `string` — расширить до +`React.ReactNode`, если `backable` ведёт не на `navigate(-1)` — оставить как есть. + +`src/layouts/index.ts`: `export { DocumentPage } from './DocumentPage';`. `src/App.tsx`: внутри `` добавить +`} />`. + +- [ ] **Step 6: Проверка и коммит** + +Run: `make check` → зелёный. Ручная проверка: из панели файлов открыть «ОСВ 60 · 2024 К1» → дерево строк, суммы с +разделителями, «Подтвердить» → статус «Загружен», кнопка неактивна; «Назад» возвращает к ГК. + +```bash +git add -A +git commit -m "feat: экран документа и просмотр ОСВ-документа с подтверждением (#3)" +``` + +--- + +### Task 8: Проверка формы ББ/ОФР со сканом + +**Модель:** sonnet. + +**Files:** +- Modify: `src/widgets/DocumentReview/PdfPages.tsx` (загрузка через API, превью картинок, xlsx), `src/layouts/DocumentPage.tsx` + (заглушка → `StatementReview`) +- Create: `src/widgets/DocumentReview/statementGrid.ts`, `src/widgets/DocumentReview/statementGrid.test.ts`, + `src/widgets/DocumentReview/StatementReview.tsx`, `src/widgets/DocumentReview/StatementTable.tsx`, + `src/widgets/DocumentReview/ChecksPanel.tsx`, `src/widgets/DocumentReview/SourcePreview.tsx` + +**Interfaces:** +- Consumes: `getStatement`, `patchStatement`, `acceptChecks`, `confirmDocument`, `fetchFileContent` (Task 4); + `formatAmount`, `parseAmountInput` (Task 3); `confirmErrorInfo` (Task 7). +- Produces: + - `type GridRow = { key: string; lineId: string; code: string | null; name: string | null; cells: Record }` + - `buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] }` (колонки по `column_index`) + - `cellFlags(value: ValueOut | undefined): { attention: boolean; modified: boolean }` + - `cellKey(lineId: string, columnId: string): string` → `${lineId}:${columnId}` + - `draftToPatch(draft: Record): StatementPatch['values']` + - ` void} />` + +- [ ] **Step 1: Тесты сетки** + +`src/widgets/DocumentReview/statementGrid.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import type { StatementOut, ValueOut } from '../../types/api'; +import { buildStatementGrid, cellFlags, cellKey, draftToPatch } from './statementGrid'; + +const v = (id: string, column_id: string, column_index: number, extra: Partial = {}): ValueOut => ({ + id, + column_id, + column_index, + amount: '1.00', + string_value: null, + confidence: '100', + is_recognized: true, + is_modified: false, + original_amount: null, + ...extra, +}); + +const statement = { + columns: [ + { id: 'c2', column_index: 1, label: '31.12.2023', period_date: null, is_current: false }, + { id: 'c1', column_index: 0, label: '30.09.2024', period_date: null, is_current: true }, + ], + lines: [{ id: 'l1', line_code: '1600', line_name: 'Баланс', values: [v('v2', 'c2', 1), v('v1', 'c1', 0)] }], +} as unknown as StatementOut; + +describe('buildStatementGrid', () => { + it('колонки по column_index, ячейки по column_id', () => { + const grid = buildStatementGrid(statement); + expect(grid.columns.map((c) => c.id)).toEqual(['c1', 'c2']); + expect(grid.rows[0]).toMatchObject({ key: 'l1', lineId: 'l1', code: '1600', name: 'Баланс' }); + expect(grid.rows[0].cells.c1?.id).toBe('v1'); + expect(grid.rows[0].cells.c2?.id).toBe('v2'); + }); +}); + +describe('cellFlags', () => { + it('внимание: уверенность < 100 или не распознано', () => { + expect(cellFlags(v('a', 'c', 0, { confidence: '87.5' })).attention).toBe(true); + expect(cellFlags(v('a', 'c', 0, { is_recognized: false })).attention).toBe(true); + expect(cellFlags(v('a', 'c', 0)).attention).toBe(false); + expect(cellFlags(v('a', 'c', 0, { confidence: null })).attention).toBe(false); + }); + it('исправлено', () => { + expect(cellFlags(v('a', 'c', 0, { is_modified: true })).modified).toBe(true); + expect(cellFlags(undefined)).toEqual({ attention: false, modified: false }); + }); +}); + +describe('draftToPatch', () => { + it('черновик → values', () => { + expect(draftToPatch({ [cellKey('l1', 'c1')]: '10.5', [cellKey('l2', 'c1')]: null })).toEqual([ + { line_id: 'l1', column_id: 'c1', amount: '10.5' }, + { line_id: 'l2', column_id: 'c1', amount: null }, + ]); + }); +}); +``` + +Run: `npx vitest run src/widgets/DocumentReview/statementGrid.test.ts` → FAIL. + +- [ ] **Step 2: `statementGrid.ts`** + +```ts +import type { ColumnOut, StatementOut, StatementPatch, ValueOut } from '../../types/api'; + +export type GridRow = { key: string; lineId: string; code: string | null; name: string | null; cells: Record }; + +export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] } { + const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index); + const rows = statement.lines.map((line) => { + const cells: Record = {}; + for (const value of line.values) cells[value.column_id] = value; + return { key: line.id, lineId: line.id, code: line.line_code, name: line.line_name, cells }; + }); + return { columns, rows }; +} + +/** Уверенность — строка Decimal «0..100»; сравнение с 100 без float-арифметики над суммами (это не сумма). */ +export function cellFlags(value: ValueOut | undefined): { attention: boolean; modified: boolean } { + if (!value) return { attention: false, modified: false }; + const lowConfidence = value.confidence !== null && Number(value.confidence) < 100; + return { attention: lowConfidence || value.is_recognized === false, modified: value.is_modified }; +} + +export const cellKey = (lineId: string, columnId: string) => `${lineId}:${columnId}`; + +export function draftToPatch(draft: Record): StatementPatch['values'] { + return Object.entries(draft).map(([key, amount]) => { + const [line_id, column_id] = key.split(':'); + return { line_id, column_id, amount }; + }); +} +``` + +Run: тест → PASS. + +- [ ] **Step 3: Превью исходника** + +`PdfPages.tsx`: заменить загрузку `fetch(`${Config.API_URL}/files/${fileId}`)` на приём готового буфера — компонент +получает проп `data: ArrayBuffer` вместо `fileId` (`getDocument({ data: data.slice(0) })` — pdf.js забирает буфер, копия +обязательна при повторном рендере). Убрать импорт `Config`. Настройка worker: + +```ts +import * as pdfjsLib from 'pdfjs-dist'; + +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +``` + +Остальная логика (масштаб, canvas по страницам, кнопки ±, сброс) — сохранить; английские подписи перевести; `any` убрать. + +`src/widgets/DocumentReview/SourcePreview.tsx`: + +```tsx +import { Button, Empty, Spin } from 'antd'; +import { useEffect, useState } from 'react'; +import { ApiError } from '../../api/client'; +import { fetchFileContent } from '../../services/api/files'; +import { saveBlob } from '../../utils/download'; +import PdfPages from './PdfPages'; + +type Loaded = { kind: 'pdf'; data: ArrayBuffer } | { kind: 'image'; url: string } | { kind: 'other'; blob: Blob; filename: string }; + +export function SourcePreview({ fileId }: { fileId: string }) { + const [loaded, setLoaded] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + let objectUrl: string | null = null; + fetchFileContent(fileId) + .then(async ({ blob, filename }) => { + if (cancelled) return; + if (blob.type === 'application/pdf') { + setLoaded({ kind: 'pdf', data: await blob.arrayBuffer() }); + } else if (blob.type.startsWith('image/')) { + objectUrl = URL.createObjectURL(blob); + setLoaded({ kind: 'image', url: objectUrl }); + } else { + setLoaded({ kind: 'other', blob, filename: filename ?? 'исходный_файл' }); + } + }) + .catch((e: unknown) => !cancelled && setError(e instanceof ApiError ? e.message : 'Не удалось получить исходный файл')); + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [fileId]); + + if (error) return ; + if (!loaded) return ; + if (loaded.kind === 'pdf') return ; + if (loaded.kind === 'image') return Исходный документ; + return ( + + + + ); +} +``` + +Если `blob.type` пустой (сервер не отдал `Content-Type`) — проверить заголовок ответа на стенде; ядро отдаёт +`Content-Type` по типу файла (гайд §5.5), так что пустой тип означает «прочее». + +- [ ] **Step 4: Таблица формы и проверки** + +`src/widgets/DocumentReview/StatementTable.tsx`: + +```tsx +import { Input, Table, Tooltip } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { ColumnOut } from '../../types/api'; +import { formatAmount, parseAmountInput } from '../../utils/money'; +import { cellFlags, cellKey, type GridRow } from './statementGrid'; + +type Props = { + columns: ColumnOut[]; + rows: GridRow[]; + editable: boolean; + draft: Record; + onDraftChange: (key: string, text: string) => void; +}; + +const ATTENTION_BG = '#fff7e6'; +const MODIFIED_BG = '#e6f4ff'; + +export function StatementTable({ columns, rows, editable, draft, onDraftChange }: Props) { + const tableColumns: ColumnsType = [ + { key: 'name', title: 'Показатель', dataIndex: 'name', width: 320 }, + { key: 'code', title: 'Код', dataIndex: 'code', width: 70 }, + ...columns.map((col) => ({ + key: col.id, + title: col.label ?? `Колонка ${col.column_index + 1}`, + width: 160, + align: 'right' as const, + onCell: (row: GridRow) => { + const flags = cellFlags(row.cells[col.id]); + return { style: { background: flags.modified ? MODIFIED_BG : flags.attention ? ATTENTION_BG : undefined } }; + }, + render: (_: unknown, row: GridRow) => { + const value = row.cells[col.id]; + if (!value) return null; + const key = cellKey(row.lineId, col.id); + const tip = value.is_modified ? `Исходное: ${formatAmount(value.original_amount)}` : value.confidence ? `Уверенность: ${value.confidence}%` : undefined; + if (!editable) { + return {formatAmount(value.amount)}; + } + const text = draft[key] ?? value.amount ?? ''; + const invalid = key in draft && !parseAmountInput(text).ok; + return ( + + onDraftChange(key, e.target.value)} style={{ textAlign: 'right' }} /> + + ); + }, + })), + ]; + return size="small" rowKey="key" columns={tableColumns} dataSource={rows} pagination={false} scroll={{ y: 'calc(100vh - 360px)' }} />; +} +``` + +`src/widgets/DocumentReview/ChecksPanel.tsx`: + +```tsx +import { Alert, Button, Input, Modal, Space, Typography } from 'antd'; +import { useState } from 'react'; +import type { ChecksOut, FailedCheckOut } from '../../types/api'; +import { formatAmount } from '../../utils/money'; + +export const failureText = (f: FailedCheckOut) => + `Колонка ${f.column_index + 1}: ${f.relation} — ожидалось ${formatAmount(f.expected)}, получено ${formatAmount(f.actual)}`; + +type Props = { checks: ChecksOut; editable: boolean; onAccept: (comment: string) => Promise }; + +export function ChecksPanel({ checks, editable, onAccept }: Props) { + const [open, setOpen] = useState(false); + const [comment, setComment] = useState(''); + const [saving, setSaving] = useState(false); + const canAccept = editable && checks.failed.length > 0 && checks.accepted === null; + + const submit = async () => { + setSaving(true); + try { + await onAccept(comment.trim()); + setOpen(false); + setComment(''); + } finally { + setSaving(false); + } + }; + + return ( + + {checks.failed.length === 0 && checks.soft_failed.length === 0 && !checks.cross_form && } + {checks.failed.length > 0 && ( +
    {failureText(f)}
    )} /> + )} + {checks.soft_failed.length > 0 && ( +
    {failureText(f)}
    )} /> + )} + {checks.cross_form && } + {checks.accepted && } + {canAccept && } + setOpen(false)}> + Комментарий обязателен (до 2000 символов). + setComment(e.target.value)} /> + +
    + ); +} +``` + +- [ ] **Step 5: Экран проверки формы** + +`src/widgets/DocumentReview/StatementReview.tsx`: + +```tsx +import { Alert, Button, Descriptions, Input, Space, Splitter, message } from 'antd'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ApiError } from '../../api/client'; +import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents'; +import type { DocumentOut, FailedCheckOut, StatementOut, StatementPatch } from '../../types/api'; +import { parseAmountInput } from '../../utils/money'; +import { ChecksPanel, failureText } from './ChecksPanel'; +import { confirmErrorInfo } from './confirmError'; +import { SourcePreview } from './SourcePreview'; +import { StatementTable } from './StatementTable'; +import { buildStatementGrid, draftToPatch } from './statementGrid'; + +const errorText = (e: unknown, fallback: string) => (e instanceof ApiError ? e.message : fallback); + +export function StatementReview({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) { + const [statement, setStatement] = useState(null); + const [draft, setDraft] = useState>({}); + const [header, setHeader] = useState<{ inn?: string; report_date?: string; units?: string }>({}); + const [saving, setSaving] = useState(false); + const [confirmFailures, setConfirmFailures] = useState([]); + const [confirmMessage, setConfirmMessage] = useState(null); + + const load = useCallback(async () => { + try { + setStatement(await getStatement(document.id)); + } catch (e: unknown) { + message.error(errorText(e, 'Не удалось получить форму')); + } + }, [document.id]); + + useEffect(() => { + void load(); + }, [load]); + + const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]); + const editable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR'; + const dirty = Object.keys(draft).length > 0 || Object.keys(header).length > 0; + + const save = async () => { + const values: Record = {}; + for (const [key, text] of Object.entries(draft)) { + const parsed = parseAmountInput(text); + if (!parsed.ok) { + message.error('Исправьте суммы с неверным форматом'); + return; + } + values[key] = parsed.value; + } + const body: StatementPatch = { values: draftToPatch(values) }; + const headerPatch = Object.fromEntries(Object.entries(header).filter(([, v]) => v !== undefined && v.trim() !== '')); + if (Object.keys(headerPatch).length > 0) body.header = headerPatch; + setSaving(true); + try { + setStatement(await patchStatement(document.id, body)); + setDraft({}); + setHeader({}); + message.success('Изменения сохранены'); + } catch (e: unknown) { + message.error(errorText(e, 'Не удалось сохранить изменения')); + } finally { + setSaving(false); + } + }; + + const accept = async (comment: string) => { + try { + setStatement(await acceptChecks(document.id, comment)); + setConfirmFailures([]); + setConfirmMessage(null); + } catch (e: unknown) { + message.error(errorText(e, 'Не удалось принять нарушения')); + throw e; + } + }; + + const confirm = async () => { + try { + await confirmDocument(document.id); + message.success('Документ подтверждён'); + setConfirmFailures([]); + setConfirmMessage(null); + onChanged(); + await load(); + } catch (e: unknown) { + const info = confirmErrorInfo(e); + setConfirmFailures(info.failures); + setConfirmMessage(info.code === 'PERIOD_REQUIRED' ? `${info.message}. Исправьте дату отчёта в шапке и сохраните.` : info.message); + } + }; + + if (!statement || !grid) return null; + const h = statement.header; + const headerInput = (field: 'inn' | 'report_date' | 'units', placeholder: string) => ( + setHeader((prev) => ({ ...prev, [field]: e.target.value }))} /> + ); + + return ( + + + + + + + + {h.organization_name ?? '—'} + {headerInput('inn', 'ИНН')} + {headerInput('report_date', 'ГГГГ-ММ-ДД')} + {headerInput('units', 'тыс. руб.')} + {h.okud ?? '—'} + {h.okpo ?? '—'} + + + {confirmMessage && ( + 0 ? confirmFailures.map((f) =>
    {failureText(f)}
    ) : undefined} /> + )} + + + + Единицы: {h.units.value ?? 'не указаны'} + + setDraft((prev) => ({ ...prev, [key]: text }))} /> +
    +
    +
    + ); +} +``` + +`DocumentPage.tsx`: заглушку `kind === 'statement'` заменить на `` +(импорт из `../widgets/DocumentReview/StatementReview`). + +- [ ] **Step 6: Проверка и коммит** + +Run: `make check` → зелёный. Ручная проверка (если Task 1 подтвердил круг PDF): загрузить `../fixtures/ocr/aep_6.pdf` +Счастливочной → открыть «Бух. баланс» → слева страницы PDF листаются и масштабируются (в Network запрос +`/api/v1/files//content?disposition=inline` с заголовком Authorization; worker грузится с того же источника), +справа шапка и таблица; подсвечены ячейки с низкой уверенностью; изменить ячейку → «Сохранить» → ячейка голубая, +подсказка «Исходное: …»; ввод «12,3,4» — красная рамка, сохранение блокируется; при нарушениях — «Принять с +комментарием»; «Подтвердить данные» → статус «Загружен», поля только для чтения. Если круг PDF сломан — то же на +xlsx-форме из `../fixtures/statements-xlsx/mezheninovskaya/` (слева «Превью недоступно» и «Скачать исходник»). + +```bash +git add -A +git commit -m "feat: проверка формы ББ/ОФР — скан PDF, правка ячеек и шапки, проверки, подтверждение (#3)" +``` + +--- + +### Task 9: Сводная ОСВ + +**Модель:** sonnet. + +**Files:** +- Create: `src/layouts/ConsolidatedOsvPage.tsx`, `src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts`, + `src/widgets/ConsolidatedOsv/OsvFilters.tsx`, `src/widgets/ConsolidatedOsv/OsvTable.tsx`, `src/widgets/ConsolidatedOsv/OsvGaps.tsx` +- Modify: `src/utils/labels.ts`, `src/utils/labels.test.ts` (подписи признака и способа сопоставления), `src/App.tsx`, + `src/layouts/index.ts` + +**Interfaces:** +- Consumes: `getConsolidatedOsv`, `getOsvSummary`, `exportOsv` (Task 4); `getVersionCard`, `fetchOneCompanyGroup` (Task 4); + `formatAmount`, `exportFilename`, `saveBlob` (Task 3). +- Produces: `counterpartyTypeLabel(t: CounterpartyType): string`, `matchKindLabel(k: MatchKind | null): string`; + `useConsolidatedOsv(versionId: string)`; маршрут `/groups/:group_id/versions/:version_id/osv`. + +- [ ] **Step 1: Тесты подписей** + +Дописать в `src/utils/labels.test.ts`: + +```ts +import { counterpartyTypeLabel, matchKindLabel } from './labels'; + +describe('подписи свода', () => { + it('признак контрагента', () => { + expect(counterpartyTypeLabel('GSK')).toBe('ГСК'); + expect(counterpartyTypeLabel('GK')).toBe('ГК'); + expect(counterpartyTypeLabel('EXTERNAL')).toBe('Внешний'); + }); + it('способ сопоставления', () => { + expect(matchKindLabel('INN')).toBe('по ИНН'); + expect(matchKindLabel('NAME')).toBe('по имени'); + expect(matchKindLabel('ALIAS')).toBe('по алиасу'); + expect(matchKindLabel('MANUAL')).toBe('вручную'); + expect(matchKindLabel('PROPAGATED')).toBe('автоприменение'); + expect(matchKindLabel(null)).toBe('не сопоставлен'); + }); +}); +``` + +(импорт объединить с существующим импортом в начале файла). Run → FAIL. + +- [ ] **Step 2: Подписи** + +В `src/utils/labels.ts`: + +```ts +import type { CounterpartyType, MatchKind } from '../types/api'; + +const COUNTERPARTY_TYPE: Record = { GSK: 'ГСК', GK: 'ГК', EXTERNAL: 'Внешний' }; +const MATCH_KIND: Record = { INN: 'по ИНН', NAME: 'по имени', ALIAS: 'по алиасу', MANUAL: 'вручную', PROPAGATED: 'автоприменение' }; + +export const counterpartyTypeLabel = (t: CounterpartyType) => COUNTERPARTY_TYPE[t]; +export const matchKindLabel = (k: MatchKind | null) => (k ? MATCH_KIND[k] : 'не сопоставлен'); +``` + +(импорт типов объединить с существующим). Run → PASS. + +- [ ] **Step 3: Хук данных** + +`src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts`: + +```ts +import { message } from 'antd'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ApiError } from '../../api/client'; +import { getConsolidatedOsv, getOsvSummary } from '../../services/api/osv'; +import type { ConsolidatedRowOut, OsvFilters, OsvSummaryOut } from '../../types/api'; + +export const OSV_PAGE_SIZE = 500; + +export function useConsolidatedOsv(versionId: string) { + const [filters, setFilters] = useState({}); + const [page, setPage] = useState(1); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(false); + const requestRef = useRef(0); + + useEffect(() => { + getOsvSummary(versionId) + .then(setSummary) + .catch((e: unknown) => message.error(e instanceof ApiError ? e.message : 'Не удалось получить сводку')); + }, [versionId]); + + const load = useCallback(async () => { + const requestId = ++requestRef.current; + setLoading(true); + try { + const result = await getConsolidatedOsv(versionId, filters, OSV_PAGE_SIZE, (page - 1) * OSV_PAGE_SIZE); + if (requestId === requestRef.current) { + setRows(result.items); + setTotal(result.total); + } + } catch (e: unknown) { + if (requestId === requestRef.current) message.error(e instanceof ApiError ? e.message : 'Не удалось получить сводную ОСВ'); + } finally { + if (requestId === requestRef.current) setLoading(false); + } + }, [versionId, filters, page]); + + useEffect(() => { + void load(); + }, [load]); + + const applyFilters = (next: OsvFilters) => { + setFilters(next); + setPage(1); + }; + + return { filters, applyFilters, page, setPage, rows, total, summary, loading }; +} +``` + +- [ ] **Step 4: Фильтры, таблица, пробелы** + +`src/widgets/ConsolidatedOsv/OsvFilters.tsx`: + +```tsx +import { Button, Checkbox, Form, Input, Select, Space } from 'antd'; +import type { OsvFilters as Filters } from '../../types/api'; +import { counterpartyTypeLabel } from '../../utils/labels'; + +type Props = { members: { id: string; name: string }[]; value: Filters; onApply: (f: Filters) => void }; + +export function OsvFilters({ members, value, onApply }: Props) { + const [form] = Form.useForm(); + return ( +
    onApply({ ...v, q: v.q?.slice(0, 500) })}> + + + + + + + + Только несопоставленные + + + + + +
    + ); +} +``` + +Примечание: `initialValues` применяется один раз; «Сбросить» вызывает `resetFields` — поля очищаются, потому что +`initialValues` на момент монтирования — `{}` (страница монтирует фильтры с пустыми фильтрами). + +`src/widgets/ConsolidatedOsv/OsvTable.tsx`: + +```tsx +import { Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { ConsolidatedRowOut } from '../../types/api'; +import { counterpartyTypeLabel, matchKindLabel } from '../../utils/labels'; +import { formatAmount } from '../../utils/money'; + +const money = (key: keyof ConsolidatedRowOut, title: string) => ({ + key, + title, + dataIndex: key, + align: 'right' as const, + width: 150, + render: (v: string | null) => formatAmount(v), +}); + +const columns: ColumnsType = [ + { key: 'legal_entity_name', title: 'Организация', dataIndex: 'legal_entity_name', width: 220, fixed: 'left' }, + { key: 'account_code', title: 'Счёт', dataIndex: 'account_code', width: 90 }, + { + key: 'counterparty', + title: 'Контрагент', + width: 260, + render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—', + }, + { key: 'counterparty_inn', title: 'ИНН контрагента', dataIndex: 'counterparty_inn', width: 130 }, + { key: 'counterparty_type', title: 'Признак', dataIndex: 'counterparty_type', width: 100, render: counterpartyTypeLabel }, + { key: 'match_kind', title: 'Сопоставление', dataIndex: 'match_kind', width: 140, render: matchKindLabel }, + money('opening_debit', 'Сальдо нач. Дт'), + money('opening_credit', 'Сальдо нач. Кт'), + money('turnover_debit', 'Обороты Дт'), + money('turnover_credit', 'Обороты Кт'), + money('closing_debit', 'Сальдо кон. Дт'), + money('closing_credit', 'Сальдо кон. Кт'), +]; + +type Props = { rows: ConsolidatedRowOut[]; total: number; page: number; pageSize: number; loading: boolean; onPage: (p: number) => void }; + +export function OsvTable({ rows, total, page, pageSize, loading, onPage }: Props) { + return ( + + size="small" + rowKey={(r) => r.entry_ids.join(',')} + loading={loading} + columns={columns} + dataSource={rows} + scroll={{ x: 1900, y: 'calc(100vh - 420px)' }} + pagination={{ current: page, pageSize, total, showSizeChanger: false, onChange: onPage, showTotal: (t) => `Строк: ${t}` }} + /> + ); +} +``` + +`src/widgets/ConsolidatedOsv/OsvGaps.tsx`: + +```tsx +import { Collapse, List } from 'antd'; +import type { GapOut } from '../../types/api'; + +export function OsvGaps({ gaps }: { gaps: GapOut[] }) { + if (gaps.length === 0) return null; + return ( + ( + + {g.legal_entity_name} — счёт {g.account_code} — нет кварталов: {g.missing_quarters.join(', ')} + + )} + /> + ), + }, + ]} + /> + ); +} +``` + +- [ ] **Step 5: Страница и маршрут** + +`src/layouts/ConsolidatedOsvPage.tsx`: + +```tsx +import { DownloadOutlined } from '@ant-design/icons'; +import { Button, Space, Statistic, message } from 'antd'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { ApiError } from '../api/client'; +import PageTitle from '../components/PageTitle'; +import { getVersionCard } from '../services/api/consolidations'; +import { exportOsv } from '../services/api/osv'; +import type { VersionCard } from '../types/consolidations/version_members'; +import { exportFilename, saveBlob } from '../utils/download'; +import { OsvFilters } from '../widgets/ConsolidatedOsv/OsvFilters'; +import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps'; +import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable'; +import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv'; + +export function ConsolidatedOsvPage() { + const { version_id = '' } = useParams(); + const [card, setCard] = useState(null); + const [exporting, setExporting] = useState(false); + const osv = useConsolidatedOsv(version_id); + + useEffect(() => { + getVersionCard(version_id) + .then(setCard) + .catch((e: unknown) => message.error(e instanceof ApiError ? e.message : 'Не удалось получить версию')); + }, [version_id]); + + const download = async () => { + setExporting(true); + try { + const { blob, filename } = await exportOsv(version_id, osv.filters); + saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? '')); + } catch (e: unknown) { + message.error(e instanceof ApiError ? e.message : 'Не удалось выгрузить свод'); + } finally { + setExporting(false); + } + }; + + const s = osv.summary; + return ( +
    + +
    + + + + + + + + + ({ id: m.id, name: m.name }))} value={osv.filters} onApply={osv.applyFilters} /> + +
    +
    + ); +} +``` + +`description` — статус по-русски: если в проекте есть отображение `ConsStatus → ConsRussianStatus` (grep +`ConsRussianStatus` в `src/widgets/ConsoleTable/consts.ts`), использовать его вместо `card.status`. + +`src/layouts/index.ts`: экспорт `ConsolidatedOsvPage`. `src/App.tsx`, внутри ``: +`} />`. + +- [ ] **Step 6: Проверка и коммит** + +Run: `make check` → зелёный. Ручная проверка на версии с подтверждёнными ОСВ (Task 10 Step 2 готовит данные; если их +ещё нет — проверить, что пустой свод показывает нули и пустую таблицу): «К отчётности» → сводка, пробелы, фильтр +Счастливочная + «бисквит» → строка `60.01` с цифрами из спеки; «Скачать xlsx» → файл с кириллическим именем. + +```bash +git add -A +git commit -m "feat: экран сводной ОСВ — сводка, пробелы, фильтры, экспорт xlsx (#3)" +``` + +--- + +### Task 10: Подготовка демо, документация, сквозной прогон + +**Модель:** sonnet. + +**Files:** +- Create: `scripts/demo-prepare.sh` +- Modify: `README.md` (создать, если нет), `CLAUDE.md` (команды, тесты, архитектура), `ARCHITECTURE.md` (модуль `utils/`, + `widgets/DocumentReview`, `widgets/ConsolidatedOsv`) + +**Interfaces:** +- Consumes: всё выше; стенд из Task 1. +- Produces: скрипт подготовки данных; README с демо-сценарием; результат прогона в ledger и в описании PR. + +- [ ] **Step 1: Скрипт подготовки данных** + +`scripts/demo-prepare.sh` (исполняемый, `chmod +x`): + +```bash +#!/usr/bin/env bash +# Подготовка демо: загрузить квартальные ОСВ РБ и Счастливочной за 2024 Q1–Q3 в версию и подтвердить ОСВ-документы. +# Использование: scripts/demo-prepare.sh [email] (стенд arm-core на localhost:8000, DEBUG=true) +set -euo pipefail +V="${1:?version_id}" +EMAIL="${2:-ka_go@armka.local}" +API=localhost:8000/api/v1 +F="$(cd "$(dirname "$0")/../.." && pwd)/fixtures/osv" +H="Authorization: Bearer $(curl -sf -X POST "$API/dev/token" -H 'Content-Type: application/json' -d "{\"email\":\"$EMAIL\"}" | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')" + +members=$(curl -sf "$API/consolidations/versions/$V" -H "$H" | python3 -c ' +import sys,json +for m in json.load(sys.stdin)["members"]: print(m["id"], m["inn"], m["name"])') +le_by_dir() { echo "$members" | python3 -c "import sys; [print(l.split()[0]) for l in sys.stdin if '$1' in l]" | head -1; } +LE_RB=$(le_by_dir "Русский") +LE_SCH=$(le_by_dir "Счастлив") +[ -n "$LE_RB" ] && [ -n "$LE_SCH" ] || { echo "В периметре версии нет РБ или Счастливочной:"; echo "$members"; exit 1; } + +upload() { for f in "$F/$2"/2024-Q{1,2,3}/*; do + curl -s -X POST "$API/legal-entities/$1/files?consolidation_version_id=$V" -H "$H" -F "files=@$f" \ + | python3 -c 'import sys,json;d=json.load(sys.stdin);print("принят" if d.get("accepted") else d.get("rejected") or d)' +done; } +upload "$LE_RB" rb +upload "$LE_SCH" schastl + +echo "Жду распознавания…" +for _ in $(seq 1 60); do + pending=0 + for LE in $LE_RB $LE_SCH; do + n=$(curl -sf "$API/legal-entities/$LE/files?limit=100" -H "$H" | python3 -c 'import sys,json;print(sum(f["status"] in ("UPLOADED","PROCESSING") for f in json.load(sys.stdin)["items"]))') + pending=$((pending + n)) + done + [ "$pending" -eq 0 ] && break + sleep 5 +done + +for LE in $LE_RB $LE_SCH; do + curl -sf "$API/legal-entities/$LE/files?limit=100" -H "$H" | python3 -c ' +import sys,json +for f in json.load(sys.stdin)["items"]: + for d in f["documents"]: + if d["document_type"] == "OSV" and d["status"] in ("RECOGNIZED","NEEDS_CHECK"): print(d["id"])' \ + | while read -r DOC; do curl -s -o /dev/null -w "%{http_code} $DOC\n" -X POST "$API/documents/$DOC/confirm" -H "$H"; done +done + +curl -sf "$API/consolidations/versions/$V/osv/summary" -H "$H" | python3 -c ' +import sys,json +s=json.load(sys.stdin); print("строк:", s["rows_total"], "документов:", s["documents_total"], "несопоставленных:", s["unmapped_total"], "пробелов:", len(s["gaps"]))' +``` + +Ожидание на чистой версии: «строк: 1385 документов: 22 несопоставленных: 1381 пробелов: 7» (`qa-guide` §7.2). Если +`documents_total` меньше 22 — смотреть вывод загрузки (дубли `DUPLICATE_FILE`, если файлы уже были у ЮЛ: тогда +подтверждённые документы подхватываются при создании версии — пересоздать версию на чистом стенде или сверить с §7.1). + +- [ ] **Step 2: Сквозной прогон API** + +На стенде: создать версию через UI (Task 5) или API, затем `scripts/demo-prepare.sh `. Записать вывод в ledger. +Затем в UI (`make start`): пройти демо-сценарий design.md шаги 1–4 и записать результат по каждому шагу (что видно, +расхождения). Расхождение с контрольными цифрами — находка `O-n` в ledger, не правка цифр. + +- [ ] **Step 3: Документация** + +`README.md` — разделы: +- «Запуск» — `make install`, стенд arm-core по `../docs/dev/qa-guide.md` §4 (+ §6.8 профиль `services` для PDF), + `make start` (proxy `/api` → `ARM_CORE_URL`, по умолчанию `http://localhost:8000`; `REACT_APP_API_URL` в dev не нужен), + вход через dev-форму. +- «Проверка» — `make check` (biome, tsc, vitest, build). +- «Демо-сценарий» — шаги 1–4 из design.md с `scripts/demo-prepare.sh` и ожидаемыми цифрами. +- «Ограничения прототипа» — Non-goals из proposal.md одним списком. + +`CLAUDE.md`: «Команды» — добавить `make test` и состав `make check`; «Тесты» — vitest на чистых функциях (`src/**/*.test.ts`), +компоненты — ручной демо-сценарий; «Архитектура» — убрать упоминание `mocks.ts`, добавить `src/utils/` (деньги, +подписи, скачивание), `apiFetchBlob`, `widgets/DocumentReview`, `widgets/ConsolidatedOsv`, маршруты +`/documents/:document_id` и `/groups/:group_id/versions/:version_id/osv`, dev-proxy. + +`ARCHITECTURE.md`: в таблицу модулей — `src/utils/` (чистые функции, без зависимостей, кроме `types/`); в «Границы» — +«`utils/` не импортирует React, store и API». + +- [ ] **Step 4: Финальная проверка и коммит** + +Run: `make check` → зелёный; `openspec validate demo-prototype --strict` → valid; `grep -rn "console.log\|/fsl/\|osv/by-file-id\|fetch(" src` +→ только `src/api/client.ts` (`fetch(` в `send`). + +```bash +git add -A +git commit -m "docs: README с демо-сценарием, скрипт подготовки данных, CLAUDE.md и ARCHITECTURE.md (#3)" +git push +```