diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md deleted file mode 100644 index 1d7272c..0000000 --- a/web/ARCHITECTURE.md +++ /dev/null @@ -1,26 +0,0 @@ -# Архитектура arm-web - -SPA поверх API arm-core (`/api/v1`). Своих данных и бизнес-вычислений нет: всё производное (чек-лист, сводная ОСВ, -статусы) считает сервер, фронт показывает и отправляет действия пользователя. - -## Модули - -| Модуль | Ответственность | Зависит от | -|---|---|---| -| `src/api/` | HTTP-клиент, токен, разбор ошибок | `conf/`, `auth/token` | -| `src/auth/` | профиль `/me`, dev-вход, права для меню | `api/` | -| `src/services/api/` | вызовы ресурсов arm-core | `api/`, `types/` | -| `src/store/` | состояние экранов (Redux Toolkit) | `services/api/` | -| `src/utils/` | чистые функции: деньги, подписи, скачивание | `types/` | -| `src/types/` | DTO API (повторяют `arm-core/api/src/schemas/`) | — | -| `src/components/` | примитивы UI (таблица, теги, заголовок) | antd | -| `src/widgets/` | блоки экранов (`DocumentReview` — проверка документа со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer`) | `components/`, `store/`, `services/api/`, `utils/` | -| `src/layouts/` | страницы-маршруты | `widgets/` | - -## Границы -- Сетевые запросы — только через `src/api/client.ts`; `fetch` напрямую не вызывается. -- `components/` не знают про store и API; `types/` ни от чего не зависят. -- `utils/` не импортирует React, store и API. - -## Открытые вопросы -- Судьба `arm-core/web` (дубль основы этого репозитория) — решает владелец. diff --git a/web/CLAUDE.md b/web/CLAUDE.md deleted file mode 100644 index 43004c4..0000000 --- a/web/CLAUDE.md +++ /dev/null @@ -1,65 +0,0 @@ -# CLAUDE.md — arm-web (фронт АРМ КА) - -Общий контекст системы — `../CLAUDE.md` и `../docs/` (workspace). Этот файл — только про этот репозиторий. -Язык общения, коммитов, комментариев и документации — русский; идентификаторы — английские, как в DBML и API. - -## Что это -SPA кредитного аналитика АРМ КА: справочники групп, консолидации ГК по периодам, карточка версии с чек-листом -документов, загрузка и проверка первичных файлов (ОСВ, ББ/ОФР из PDF), дальше — сводная ОСВ и шаги консолидации. -Бэкенд — `arm-core` (`/api/v1`, единственный источник данных). Репозиторий выделен из `arm-core/web` -(06.10.2026, автор основы — команда фронта); `arm-core/web` пока не удалён — его судьба не решена. - -## Ветки -- `main` — не трогаем до решения владельца. -- `test` — интеграционная ветка прототипа: все PR (`feat/-`) вливаются в `test`, не в `main`. - -## Источники истины для фронта -1. OpenAPI arm-core и гайд `../docs/dev/frontend-api-guide.md` — эндпоинты, тела, коды ошибок. Типы в `src/types/` - повторяют схемы бэка (`arm-core/api/src/schemas/`), поля не придумываем. -2. Пользовательский путь — `../docs/context/10-user-journey.md`; макет — `../docs/mockup/` (по одному экрану через - `../docs/mockup/INDEX.md`). -3. Права — данные: доступ решает сервер (403). Меню пока не скрывается по правам (`Sidebar` не применяет permission); - коды прав из `/me` лежат в `src/auth/permissions.ts`. -- `../reference-old-arm/` — только чтение: инфраструктурные идеи (PDFResultViewer) можно смотреть, эндпоинты старого - АРМ (`/fsl/...`, `/osv/by-file-id`, `POST /files`) в arm-core не существуют. - -## Стек и ограничения -- React 18 + TypeScript strict, rsbuild, antd 6 (основной UI-кит; daisyui/tailwind — из шаблона), react-router 7, - Redux Toolkit, pdfjs-dist; линтер — biome (ширина 140, одинарные кавычки). -- Пакеты `raisa-*` — `optionalDependencies` (вне банка недоступны); `package-lock.json` в git не хранится (как в шаблоне: - в банке зависимости ставятся через Nexus). -- env только `REACT_APP_*` через `src/conf/config.ts`; `REACT_APP_API_URL` — адрес arm-core, `REACT_APP_ROOT_PATH` = - `ROOT_PATH` бэка. - -## Архитектура -- `src/api/` — единый HTTP-клиент `apiFetch` / `api.get|post|put|patch|del`: префикс `${API_URL}/api/v1`, Bearer-токен, - `ApiError {status, code, message}` по трём формам `detail`, событие `armka:unauthorized` на 401. **Все запросы — только - через него** (прямой `fetch` теряет токен и разбор ошибок). -- `src/api/client.ts` также даёт `apiFetchBlob(path) → {blob, filename}` для бинарных ответов (скан PDF, экспорт xlsx) с той же обработкой токена/401/ошибок. -- `src/services/api/` — функции по ресурсам arm-core (consolidations, files, documents, osv, directories/…). -- `src/utils/` — чистые функции без React/store/API: деньги (`money.ts`, суммы — строки), подписи статусов/типов/периодов (`labels.ts`), скачивание и имя файла (`download.ts`). -- `src/auth/` — `AuthGate` (профиль `/me`), `DevLogin` (`POST /dev/token`, только не в production), `token.ts`. -- `src/store/` — Redux Toolkit: `store.ts`, слайсы `companyGroupsSlice` и `consolidations` (+ `thunks.ts`). -- `src/layouts/` — страницы (маршруты в `src/App.tsx`, в т.ч. `/documents/:document_id` и `/groups/:group_id/versions/:version_id/osv`), `src/widgets/` — блоки экранов (`DocumentReview` — проверка ББ/ОФР/ОСВ со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer` — файлы ЮЛ), `src/components/` — примитивы - (`GenericTable`, `TableTag`, `PageTitle`), `src/types/` — DTO API. - -## Команды -- `make install` — `npm install`. -- `make test` — vitest (`npm test`). -- `make check` — install + biome + `tsc --noEmit` + vitest + `rsbuild build`. Зелёным считается только всё вместе; **перед PR**, вывод — - в описание PR. -- `make start` — dev-сервер (`rsbuild dev`, цель сама задаёт `REACT_APP_ROOT_PATH=/`: без него приложение ждёт префикс - `/aurora/apps/fastapi-dkb-armka` и открывается белый экран; `qa-guide` §4.3) с proxy `/api` → `ARM_CORE_URL` (по умолчанию `http://localhost:8000`); бэкенд — локальный стенд arm-core по `../docs/dev/qa-guide.md`. - -## Тесты -Unit-тесты — vitest на чистых функциях (`src/**/*.test.ts`, `npm test` / `make test`); компоненты не тестируются — ручной демо-сценарий по стенду arm-core с фикстурами из `../fixtures` (README, `scripts/demo-prepare.sh`). - -## Для владельца -Пользователь слаб во фронте: на TS/React-изменения агент делает код-ревью сам и объясняет решения простым языком. - -## OpenSpec и superpowers -- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать. -- Когда brainstorming классифицировал задачу как architectural, структуру change создать через `openspec new change ` (или `/opsx:propose `), а содержимое proposal.md, specs/ и design.md написать по итогам brainstorming. tasks.md из OpenSpec не заполнять — задачи живут в плане superpowers. -- `/opsx:apply` не использовать: исполнение идёт через writing-plans и SDD/executing-plans. -- `openspec archive ` — после merge PR, как часть чеклиста закрытия. -- Хук require-plan защищает любой каталог `src/` на любой глубине. Если код лежит иначе — перечислить каталоги (префиксы от корня, по одному в строке) в `.claude/code-paths`. diff --git a/web/Makefile b/web/Makefile deleted file mode 100644 index 391a655..0000000 --- a/web/Makefile +++ /dev/null @@ -1,23 +0,0 @@ -.PHONY: install lint typecheck test build check start - -install: - npm install - -lint: - npx biome check src - -typecheck: - npx tsc --noEmit - -test: - npm test - -build: - npm run build - -# Перед PR: всё вместе, вывод — в описание PR -check: install lint typecheck test build - -# REACT_APP_ROOT_PATH=/ — иначе приложение ждёт префикс /aurora/apps/fastapi-dkb-armka и показывает белый экран -start: - REACT_APP_ROOT_PATH=/ npm run start diff --git a/web/docs/superpowers/plans/.gitkeep b/web/docs/superpowers/plans/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/web/docs/superpowers/plans/2026-10-08-demo-prototype.md b/web/docs/superpowers/plans/2026-10-08-demo-prototype.md deleted file mode 100644 index dbaec23..0000000 --- a/web/docs/superpowers/plans/2026-10-08-demo-prototype.md +++ /dev/null @@ -1,2890 +0,0 @@ -# Демо-прототип 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 -``` diff --git a/web/openspec/changes/archive/.gitkeep b/web/openspec/changes/archive/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/web/openspec/changes/archive/2026-10-08-demo-prototype/.openspec.yaml b/web/openspec/changes/archive/2026-10-08-demo-prototype/.openspec.yaml deleted file mode 100644 index 01660ba..0000000 --- a/web/openspec/changes/archive/2026-10-08-demo-prototype/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-10-08 diff --git a/web/openspec/changes/archive/2026-10-08-demo-prototype/design.md b/web/openspec/changes/archive/2026-10-08-demo-prototype/design.md deleted file mode 100644 index e536333..0000000 --- a/web/openspec/changes/archive/2026-10-08-demo-prototype/design.md +++ /dev/null @@ -1,158 +0,0 @@ -## Context - -arm-web выделен из `arm-core/web` командой фронта (3 коммита 06–07.10.2026) и дополнен экранами групп, консолидаций и -загрузки. Обзор 08.10.2026: список консолидаций и карточка версии работают с arm-core; загрузка теряет ошибки (нет -`await`, лишние поля формы); проверка документов (`PDFResultViewer`, `FileViewer`, `FileUploaderModel`) скопирована из -старого АРМ и ходит в `/fsl/...`, `/osv/by-file-id`, `POST /files` мимо клиента API; сводной ОСВ нет; `tsc` — 185 ошибок, -biome — 276. - -Демо — 09.10.2026 на локальном стенде; затем прототип уходит в контур на тестирование заказчику и переезжает в -`arm-core/web`, где развивается дальше. Поэтому код держится соглашений шаблона `arm-core/web`. - -Источник контракта — `../docs/dev/frontend-api-guide.md` (§5.5–§5.7) и схемы `arm-core/api/src/schemas/`. - -## Goals / Non-Goals - -**Goals:** аналитик на стенде проходит путь «создание консолидации → загрузка ОСВ и PDF → проверка и подтверждение → -сводная ОСВ» без сбоев и консоли разработчика; `make check` зелёный; все запросы — к API arm-core. - -**Non-Goals:** см. proposal.md («Non-goals»). - -## Decisions - -### D-1. Чинить на месте, новое — по образцу arm-core/web (согласовано 08.10.2026) -Оставляем `api/client.ts`, `auth/*`, список консолидаций, карточку версии, модалку создания (с правками), Redux-слайсы -(типизируем). Новые экраны держат состояние в локальных хуках (`useStatement`, `useDocumentEntries`, -`useConsolidatedOsv`) как `arm-core/web/src/widgets/Admin/hooks.ts`. Альтернативы — генерация типов из OpenAPI (новая -dev-зависимость, доступность в Nexus не проверена) и переписывание с нуля (не успеть) — отклонены владельцем. - -### D-2. Типы DTO руками -`src/types/` повторяют схемы arm-core только для используемых эндпоинтов: `Page`, `UploadResult`, `FileOut`, -`DocumentBrief`, `DocumentOut`, `StatementOut` (`HeaderOut`, `ColumnOut`, `LineOut`, `ValueOut`, `ChecksOut`), -`EntryOut`, `ConsolidatedRowOut`, `OsvSummaryOut`, `VersionCard` (исправления: `is_head_entity: boolean`, -`entity_type: 'ЮЛ' | 'ИП' | null`, `osv_accounts: string[]`, `changed_by` — UUID). `Decimal` и даты приходят строками — -в типах `string`. - -### D-3. Деньги — строки -Суммы не превращаются в `number`. Чистые функции `src/utils/money.ts`: `formatAmount(s)` — разделители разрядов и два -знака без арифметики над float; `parseAmountInput(s)` — нормализация ввода (пробелы, запятая → точка) и проверка -`^-?\d+(\.\d{1,2})?$`, пустое — `null`. Единицы ББ/ОФР — из `header.units`, ОСВ — рубли. - -### D-4. Бинарные ответы — `apiFetchBlob` -`src/api/client.ts` получает `apiFetchBlob(path) → {blob, filename | null}` с той же обработкой токена, 401 и ошибок -(тело ошибки — JSON). Используется для скана (`blob.arrayBuffer()` → pdf.js) и экспорта (сохранение через -`URL.createObjectURL` + ``). Имя экспорта — из `Content-Disposition` (`filename*=UTF-8''…` или `filename=`), -иначе `exportFilename(group, year, quarter)`. - -### D-5. Dev-proxy вместо CORS -`rsbuild.config.ts`: `server.proxy['/api'] → process.env.ARM_CORE_URL ?? 'http://localhost:8000'`; в dev -`REACT_APP_API_URL` пустой. Запросы идут с того же источника: нет CORS и читается `Content-Disposition` (follow-up -docs#58 O-1 на фронте обходится). В контуре SPA отдаёт сам arm-core — тот же источник, код не меняется. Гайд фронта §2 -(где `REACT_APP_API_URL=http://localhost:8000`) остаётся верным для варианта без proxy. - -### D-6. Просмотр PDF -Из `widgets/PDFResultViewer/IFrameBlock.tsx` сохраняется только постраничный рендер pdf.js (canvas, масштаб, листание); -загрузка — `apiFetchBlob('/files/{id}/content?disposition=inline')` → `arrayBuffer`. Компонент переименовывается в -`widgets/DocumentReview/PdfPages.tsx`. Worker pdf.js подключается из пакета `pdfjs-dist` сборкой (без CDN — в контуре -внешнего интернета нет). `PDFDataViewer.tsx`, `types.ts`, `styles.module.css` старого АРМ удаляются. - -### D-7. Экран документа -`layouts/DocumentPage.tsx` (`/documents/:document_id`) → `GET /documents/{id}` → `widgets/DocumentReview/StatementReview` -(BALANCE/INCOME) или `widgets/DocumentReview/OsvDocument` (OSV). Чистые функции: `buildStatementGrid(statement)` — -матрица строк × колонок по `column_id` (не по индексу), `buildEntryTree(entries)` — дерево по `parent_entry_id` с -сохранением `row_number`. Правка ячейки — локальный черновик ячеек и «Сохранить» одним `PATCH` со всеми изменёнными -ячейками; ответ сервера заменяет состояние (сервер пересчитывает проверки и сбрасывает акцепт). - -### D-8. Панель файлов -`widgets/FilesDrawer` переписывается: `UploadDocumentsModal` → один запрос со всеми файлами, итог из -`summarizeUpload(result)`; список — `FilesList` с вложенными документами; подписи — `fileStatusLabel`, -`documentTypeLabel`, `periodLabel`, `documentStatusLabel` (чистые функции). Опрос — `useDocumentsPolling` с остановкой, -когда нет `UPLOADED`/`PROCESSING`. Слайс `files` типизируется или заменяется локальным хуком (решает исполнитель по -месту, без смены поведения). - -### D-9. Сводная ОСВ -`layouts/ConsolidatedOsvPage.tsx` + `widgets/ConsolidatedOsv/` (`useConsolidatedOsv`, `OsvFilters`, `OsvTable`, -`OsvGaps`). Таблица — antd `Table` с серверной пагинацией (`limit` 500). Фильтры — query-параметры как в API; ЮЛ — -из `VersionCard.members`. - -### D-10. Тесты — vitest на чистых функциях -`vitest` (devDependency), `npm test` → `vitest run`, цель `make test` входит в `make check`. Покрытие по TDD: money, -`summarizeUpload`, подписи статусов/типов/периодов, `normalizeData` карточки версии, `exportFilename` и разбор -`Content-Disposition`, `buildStatementGrid`, `buildEntryTree`. Компоненты — ручной демо-сценарий. - -## Демо-сценарий (приёмка) - -Стенд по `../docs/dev/qa-guide.md` §4 и §6.8 (профиль `services`: tabular-parser, scan-gateway, scan-emulator), -`make seed-dev`, вход `ka_rf@armka.local` (или пользователь из гайда с правами EDIT на ГК «Русский бисквит»). - -Данные стенда уже подготовлены: консолидация РБ ГСК 2024 Q3 создана (версия `0437f8be-019c-4dbb-9776-08c306aa38cc`), -`aep_6.pdf` уже загружен Счастливочной; повторы дают штатные 409 `CONSOLIDATION_EXISTS` / `DUPLICATE_FILE`. - -1. Кнопка «Новая консолидация» — в боковом меню (действует на всё приложение); ГК «Русский бисквит» выбирается в модалке: - ГСК, период 2024 Q2 или 2025 Q3 (для 2024 Q3 — показать 409 как штатную ошибку) → члены ГСК отмечены → «Создать». -2. Шаги 2–4 — на подготовленной версии `0437f8be-…`. Панель «Организации» → РБ → «Файлы»: ОСВ - `fixtures/osv/rb/2024-Q{1,2,3}/*` и Счастливочной (`fixtures/osv/schastl/2024-Q{1,2,3}/*`) загружены, документы - подтверждены (на новой версии — загрузить и подтвердить на экране ОСВ). -3. ББ/ОФР: открыть уже загруженный ББ Счастливочной (или загрузить `fixtures/ocr/aep_6.pdf` РБ) → скан слева, форма справа, - исправить ячейку; ИНН и дата отчёта не распознаны — ввести в шапке дату `2024-09-30` (ГГГГ-ММ-ДД) и «Сохранить», иначе - 409 `PERIOD_REQUIRED`; принять нарушения с комментарием (если есть), подтвердить. -4. «К отчётности» → сводная ОСВ: 1385 строк, 22 документа, 7 пробелов; фильтр Счастливочная + «бисквит» → строка `60.01` - по `qa-guide` §7.2; «Скачать xlsx». - -Шаги API (2–4) перед браузерным прогоном проверяются curl-скриптом; браузерный прогон — владелец. - -## Risks / Trade-offs - -- [Круг «ядро + scan-gateway + эмулятор» на стенде целиком не прогонялся (`qa-guide` §6.8: «сервис проверяется - отдельно»)] → Задача 1 плана — прогон до кода. Если сломан: issue в arm-core/scan-gateway, а форма ББ/ОФР на демо — - из xlsx (`fixtures/statements-xlsx/mezheninovskaya`, `qa-guide` §6.9), скан — заглушка «превью недоступно». -- [Объём к завтра] → порядок задач от критичного к желательному; экспорт и пробелы — последними. -- [Ручные типы расходятся с API] → типы только по используемым полям, сверка со схемами при ревью. -- [Подтверждение 22 ОСВ кликами долго на демо] → в README — скрипт подтверждения из `qa-guide` §7.1; на демо можно - показать подтверждение одного документа, остальные подготовить заранее. - -## Migration Plan - -Один PR `feat/3-demo-prototype` → `test`. Откат — revert PR. Перенос в `arm-core/web` — отдельная задача после демо. - -## Open Questions - -- Какой ЮЛ «владеет» `aep_6.pdf` в фикстурах (ИНН в шапке) — влияет только на `counterparty_check` (не блокирует - подтверждение); выясняется в задаче 1. - -## Decisions made without discussion - -- 08.10.2026 — D-5 dev-proxy `/api` вместо `REACT_APP_API_URL=http://localhost:8000`. Обоснование: снимает CORS и - нечитаемый `Content-Disposition` без правок arm-core. Цена ошибки: низкая — переменная `REACT_APP_API_URL` продолжает - работать, proxy только в dev. -- 08.10.2026 — D-6 worker pdf.js из пакета, без CDN. Обоснование: в контуре нет внешнего интернета. Цена ошибки: низкая. -- 08.10.2026 — D-7 правка ячеек пакетом по «Сохранить», а не по каждой ячейке. Обоснование: меньше запросов, сервер - принимает до 5000 ячеек и пересчитывает проверки один раз. Цена ошибки: низкая — поведение меняется в одном хуке. -- 08.10.2026 — D-8: слайс Redux `files` заменён локальным хуком `useLegalEntityFiles` (список нужен только открытой панели). Цена ошибки: низкая. -- 08.10.2026 — R-1: Task 1 (проверка стенда, без записи в worktree) и Task 2 запущены параллельно. Обоснование: срок демо. Цена ошибки: нулевая. -- 08.10.2026 — R-2: замечание T4.I1 (частичный успех загрузки не обновляет список) отложено в Task 6, где модалка переписывалась; закрыто в Task 6. Цена ошибки: нулевая. -- 08.10.2026 — R-3, R-4: scoped re-review однострочного фикса T5.I1 и фикса T7.I1 (чистая функция с тестами) заменены проверкой контроллера по коду. Цена ошибки: низкая. -- 08.10.2026 — R-5: все находки финального ревью ветки исправлены одной fix-волной (384301e, 0f734e3, 67bc2c8), M-5 (тосты после 401) отложена. Цена ошибки: низкая. -- 08.10.2026 — R-6: `make start` задаёт `REACT_APP_ROOT_PATH=/` (иначе белый экран на dev-сервере, C-1). Цена ошибки: низкая — в контуре переменную задаёт платформа. -- 08.10.2026 — R-7: по замечаниям владельца (H-1, H-2) и сверке всех экранов с PNG макета (`docs/mockup/screens/`) экраны - [03], [30], [34], [39], [40], [41], [42] приведены к макету в той же ветке. Цена ошибки: низкая — только вёрстка. -- 08.10.2026 — R-8: суммы — запятая и разделители разрядов (`12 275,68`); в своде и ОСВ ноль показывается «—», в формах - ББ/ОФР — без копеек и «-» для пустых (как в макете). Цена ошибки: низкая — одна функция `formatAmount`. -- 08.10.2026 — R-9: даты загрузки и создания показываются по Москве (`Europe/Moscow`), а не по UTC-строке. Цена: низкая. -- 08.10.2026 — R-10: [34] «Дата верификации» — «—» (в `VersionBrief` нет поля, до маршрута согласования), «ГСК» — «—» - (П-3). Цена: низкая. -- 08.10.2026 — R-11: [30] вкладки Шахматка/КФО/… показаны неактивными (Срезы 5–6); фильтры свода — в заголовках колонок, - признак контрагента — в фильтре «Контрагенты». Цена: низкая. -- 08.10.2026 — R-12: [41] заголовок колонки формы без распознанной подписи — «На <дата периода>» или «Колонка N»; длинный - список нарушений — с прокруткой (120 px). Цена: низкая. -- 08.10.2026 — R-13 (согласовано с владельцем в чате): форма ББ/ОФР [41] раскладывается «как на бумаге» на фронте — - порядок строк и заголовки разделов восстанавливаются по соотношениям §7 (`formLayout.ts`), итоги XX00 жирные (кроме 2900), - строки вне формы — «Прочие строки»; приклеенные OCR заголовки разделов срезаются только при показе (полное имя — в - подсказке). ИНН — только в рамке кодов; ОКУД — по типу формы, если не распознан; длинное название — 2 строки. Причина: - сервер отдаёт строки по коду и без строк-разделов (§12, OQ-36). Цена ошибки: низкая — только показ, данные не меняются; - при появлении порядка строк в API (issue arm-core) раскладку можно упростить. -- 08.10.2026 — R-14 (по замечанию владельца H-5): шапка формы [41] — как в старом АРМ (`reference-old-arm` - `PDFResultViewer`): компактная таблица ИНН / Дата отчёта / Единица измерения (подсветка уверенности и правки, правка в - режиме «Редактировать»), затем «Бухгалтерский баланс на …». Реквизиты бумажной шапки макета (ОКПО, ОКОПФ, ОКФС, адрес, - название организации) не показываются: распознавание их не отдаёт или отдаёт мусор (arm-scan-gateway#15). Заменяет - шапку из R-13. Цена ошибки: низкая — один компонент `StatementHeaderBlock`. diff --git a/web/openspec/changes/archive/2026-10-08-demo-prototype/proposal.md b/web/openspec/changes/archive/2026-10-08-demo-prototype/proposal.md deleted file mode 100644 index 7e2033d..0000000 --- a/web/openspec/changes/archive/2026-10-08-demo-prototype/proposal.md +++ /dev/null @@ -1,57 +0,0 @@ -## Why - -В пятницу 09.10.2026 — демо владельцу и бизнесу, затем прототип уходит в контур банка на тестирование заказчику -(и переезжает внутрь `arm-core/web`). Сейчас arm-web не проводит аналитика по пути «консолидация → первичка → -проверка → сводная ОСВ»: загрузка теряет ошибки, проверка документов ходит в эндпоинты старого АРМ, экрана сводной ОСВ -нет, `make check` красный (tsc — 185 ошибок, biome — 276). - -## What Changes - -- Модалка «Новая консолидация» [42]: предотметка членов ГСК, догрузка всех ЮЛ ГК, ошибки API, переход к новой версии. -- Панель «Файлы ЮЛ» [39]/[40] переписана: загрузка в контексте версии с итогом «принято / отклонено», файлы со - вложенными документами, переход к документу, опрос до окончания распознавания. -- Новый экран документа `/documents/:document_id`: для ББ/ОФР [41]/[41-02] — скан PDF рядом с формой, правка ячеек, - проверки и их акцепт, подтверждение; для ОСВ — дерево строк только для чтения и подтверждение. -- Новый экран «Сводная ОСВ» [30] `/groups/:group_id/versions/:version_id/osv`: таблица, фильтры, пробелы, счётчик - несопоставленных, экспорт xlsx. -- `api/client.ts`: `apiFetchBlob` для бинарных ответов (скан, xlsx); dev-proxy `/api` в `rsbuild.config.ts`. -- **BREAKING (внутреннее):** удалены `layouts/FileViewer.tsx`, `components/FileUploaderModel.tsx`, - `widgets/PDFResultViewer/PDFDataViewer.tsx`, `services/api/mocks.ts`; маршрут `/files/:file_id` заменён на - `/documents/:document_id`. -- `make check` зелёный: biome + tsc + vitest + build; добавлен `vitest` (dev) для чистых функций. - -## Capabilities - -### New Capabilities -- `consolidation-setup`: создание консолидации ГК за период из модалки и переход к её версии. -- `primary-files-ui`: загрузка первичных файлов ЮЛ в контексте версии и список файлов/документов со статусами. -- `document-review-ui`: проверка и подтверждение документа — форма ББ/ОФР со сканом PDF, ОСВ-документ. -- `consolidated-osv-ui`: сводная ОСВ версии — просмотр, фильтры, пробелы, экспорт. - -### Modified Capabilities -(нет — `openspec/specs/` пуст) - -## Impact - -- Экраны макета: [03], [34], [39], [40], [41], [41-02], [42], [30]. -- Эндпоинты arm-core (`/api/v1`), только существующие: - `GET /directories/company-groups`, `GET /directories/company-groups/{id}/related-groups`, - `GET /directories/related-groups/{id}`, `GET /directories/legal-entities`, `POST /consolidations`, - `GET /company-groups/{id}/consolidations`, `GET /consolidations/versions/{id}`, - `POST /legal-entities/{id}/files`, `GET /legal-entities/{id}/files`, `GET /files/{id}/content`, - `GET /documents/{id}`, `GET /documents/{id}/entries`, `POST /documents/{id}/confirm`, - `GET|PATCH /documents/{id}/statement`, `POST /documents/{id}/statement/checks/accept`, - `GET /consolidations/versions/{id}/osv`, `GET …/osv/summary`, `GET …/osv/export`. -- Зависимости: `vitest` (devDependency). -- Код: `src/api/`, `src/services/api/`, `src/types/`, `src/widgets/{NewConsoleModal,FilesDrawer,ConsoleTable,…}`, - новые `src/widgets/DocumentReview/`, `src/widgets/ConsolidatedOsv/`, `src/layouts/`, `src/App.tsx`, `rsbuild.config.ts`, - `Makefile`, `README.md`. - -## Non-goals - -- Блокировка версии («Взять в работу»), сопоставление контрагентов [32-*], экран чек-листа [37], комментарии, - удаление файлов, смена периметра и ручные переходы статусов. -- Правка строк ОСВ (в API нет), картинки страниц и координаты ячеек в скане (в API нет). -- Генерация типов из OpenAPI; автотесты компонентов. -- Перенос в `arm-core/web` и сборка под контур — отдельной задачей после демо. -- Изменения arm-core / scan-gateway: найденный пробел — отдельная issue в том репозитории. diff --git a/web/openspec/changes/archive/2026-10-08-demo-prototype/specs/consolidated-osv-ui/spec.md b/web/openspec/changes/archive/2026-10-08-demo-prototype/specs/consolidated-osv-ui/spec.md deleted file mode 100644 index 9c52797..0000000 --- a/web/openspec/changes/archive/2026-10-08-demo-prototype/specs/consolidated-osv-ui/spec.md +++ /dev/null @@ -1,39 +0,0 @@ -## ADDED Requirements - -### Requirement: Сводка версии -Экран «Сводная ОСВ» [30] SHALL показывать шапку версии (ГК, период, номер, статус) и сводку -`GET /consolidations/versions/{id}/osv/summary`: строк всего, документов, несопоставленных (`unmapped_total` — счётчик -«Осталось N» без перехода к сопоставлению), автоматически сопоставленных. - -#### Scenario: Контрольные цифры РБ 2024 Q3 -- **WHEN** открыт свод версии РБ 2024 Q3 после подтверждения 22 ОСВ из `fixtures/osv/{rb,schastl}/2024-Q{1,2,3}` -- **THEN** «строк» = 1385, «документов» = 22, «несопоставленных» = 1381 - -### Requirement: Таблица свода и фильтры -Экран SHALL показывать строки `GET /consolidations/versions/{id}/osv` постранично (размер страницы ≤ 2000): ЮЛ, счёт, -контрагент, ИНН контрагента, признак контрагента (ГСК/ГК/Внешний), способ сопоставления, сальдо на начало Дт/Кт, -обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с разделителями разрядов). Фильтры: ЮЛ (`legal_entity_id`), -корень счёта (`account_root`), признак контрагента (`counterparty_type`), только несопоставленные (`unmapped`), поиск по -контрагенту (`q`, ≤ 500 символов). Смена фильтра SHALL перечитывать первую страницу. - -#### Scenario: Строка из контрольных цифр -- **WHEN** фильтр ЮЛ «Пекарня Счастливочная» и поиск «бисквит» -- **THEN** строка `60.01` показывает сальдо нач. Кт 4 454 151.93, обороты Дт 128 058 143.65, Кт 125 950 450.77, - сальдо кон. Кт 2 346 459.05, признак «ГСК» - -### Requirement: Пробелы -Экран SHALL показывать `gaps[]` сводки списком «ЮЛ — счёт — недостающие кварталы» с пояснением, что строки этих пар в -своде отсутствуют. - -#### Scenario: Пробелы РБ 2024 Q3 -- **WHEN** открыт свод версии РБ 2024 Q3 -- **THEN** показано 7 пробелов, у каждого недостающие кварталы «Q1, Q2» - -### Requirement: Экспорт -Кнопка «Скачать xlsx» SHALL получать `GET /consolidations/versions/{id}/osv/export` с текущими фильтрами через клиент API -с токеном и сохранять файл; имя — из `Content-Disposition`, а если заголовок недоступен — -`Сводная_ОСВ_<ГК>_<год>_<квартал>.xlsx`. - -#### Scenario: Экспорт с фильтром -- **WHEN** аналитик скачивает свод с фильтром по ЮЛ -- **THEN** сохраняется xlsx, а ошибка сервера показывается текстом, а не скачивается diff --git a/web/openspec/changes/archive/2026-10-08-demo-prototype/specs/consolidation-setup/spec.md b/web/openspec/changes/archive/2026-10-08-demo-prototype/specs/consolidation-setup/spec.md deleted file mode 100644 index 37df316..0000000 --- a/web/openspec/changes/archive/2026-10-08-demo-prototype/specs/consolidation-setup/spec.md +++ /dev/null @@ -1,39 +0,0 @@ -## ADDED Requirements - -### Requirement: Состав модалки «Новая консолидация» -Модалка [42] SHALL предлагать выбор ГК (`GET /directories/company-groups`), ГСК этой ГК с `kind = gsk` -(`GET /directories/company-groups/{id}/related-groups`), года и квартала, и показывать **все** незакрытые ЮЛ ГК -(`GET /directories/legal-entities?company_group_id=&closed=false`, страницы дочитываются по `offset`, пока не получены -все `total`). Члены выбранной ГСК (`GET /directories/related-groups/{id}` → `members[]`) SHALL быть отмечены -автоматически; пользователь может снять или добавить отметки. - -#### Scenario: Выбор ГСК отмечает её членов -- **WHEN** аналитик выбирает ГК «Русский бисквит» и её ГСК -- **THEN** в списке ЮЛ видны все ЮЛ ГК, а члены ГСК («АО "Русский бисквит"», «ООО "Пекарня Счастливочная"») отмечены - -#### Scenario: ЮЛ больше одной страницы -- **WHEN** у ГК больше ЮЛ, чем `limit` одной страницы -- **THEN** модалка показывает все ЮЛ ГК, а не первую страницу - -### Requirement: Создание и переход к версии -По «Создать» модалка SHALL отправить `POST /consolidations {company_group_id, related_group_id, year, quarter, -legal_entity_ids}` с отмеченными ЮЛ. При 201 модалка SHALL закрыться, список консолидаций ГК — перечитаться, а созданная -версия (`VersionCard.id`) — открыться в карточке. При ошибке модалка SHALL остаться открытой и показать `message` из -`ApiError` (коды `CONSOLIDATION_EXISTS`, `RELATED_GROUP_MISMATCH`, `ENTITY_NOT_IN_GROUP`, `PERIMETER_TOO_SMALL` и 422 -валидации). - -#### Scenario: Успешное создание -- **WHEN** аналитик создаёт консолидацию 2024 Q3 с периметром из ГСК -- **THEN** открыта карточка версии №1 со статусом «Инициация» и двумя ЮЛ в панели «Организации» - -#### Scenario: Консолидация за период уже есть -- **WHEN** сервер отвечает 409 `CONSOLIDATION_EXISTS` -- **THEN** модалка не закрывается и показывает текст ошибки сервера - -### Requirement: Переход к сводной ОСВ из карточки -Кнопка «К отчётности» карточки версии SHALL вести на `/groups/:group_id/versions/:version_id/osv` и быть доступна в -любом статусе версии. - -#### Scenario: Переход к своду -- **WHEN** аналитик нажимает «К отчётности» у версии -- **THEN** открывается экран сводной ОСВ этой версии diff --git a/web/openspec/changes/archive/2026-10-08-demo-prototype/specs/document-review-ui/spec.md b/web/openspec/changes/archive/2026-10-08-demo-prototype/specs/document-review-ui/spec.md deleted file mode 100644 index 98ca25f..0000000 --- a/web/openspec/changes/archive/2026-10-08-demo-prototype/specs/document-review-ui/spec.md +++ /dev/null @@ -1,64 +0,0 @@ -## ADDED Requirements - -### Requirement: Экран документа -Маршрут `/documents/:document_id` SHALL загрузить `GET /documents/{id}` и показать вид по `document_type`: `BALANCE` и -`INCOME` — проверка формы, `OSV` — просмотр ОСВ; для прочих типов — карточку документа без данных. Шапка экрана SHALL -показывать тип, ЮЛ-владельца, период и статус документа, кнопку «Назад». - -#### Scenario: Открытие формы баланса -- **WHEN** аналитик открывает документ `BALANCE` -- **THEN** показан экран проверки формы - -### Requirement: Скан рядом с формой -Для ББ/ОФР экран SHALL показывать исходный файл документа: PDF — постранично через pdf.js из байт -`GET /files/{file_id}/content?disposition=inline`, полученных клиентом API с токеном (не через `