diff --git a/README.md b/README.md index ac8149e..c63cf78 100644 --- a/README.md +++ b/README.md @@ -785,7 +785,7 @@ advisory-блокировкой. | `DELETE /consolidations/versions/{id}/lock` | EDIT | снять свою блокировку (204; нет активной — тоже 204) | | `POST /consolidations/versions/{id}/status` | EDIT | ручной переход: `CANCELLED` (с комментарием) или возврат в `INITIATED` | | `PUT /consolidations/versions/{id}/members` | EDIT | заменить периметр (ЮЛ должны принадлежать ГК, ≥ 2) | -| `GET /consolidations/versions/{id}/checklist` | VIEW | комплект документов: ЮЛ × правила, статус ячейки; `?format=csv` — тот же чек-лист файлом CSV | +| `GET /consolidations/versions/{id}/checklist` | VIEW | комплект документов: ЮЛ × правила, статус ячейки; `?format=csv` — тот же чек-лист файлом CSV; `?documents=BALANCE,INCOME,OSV:01` — только эти столбцы | | `GET /consolidations/versions/{id}/osv` | VIEW | страница сводной ОСВ и фильтры, в т. ч. `counterparty_id` — строки с этим контрагентом (`limit` ≤ 2000, по умолчанию 500) | | `GET /consolidations/versions/{id}/osv/summary` | VIEW | `rows_total`, `unmapped_total`, `auto_matched_total`, `documents_total`, `gaps[]`, `counter_pairs[]` (пары «участник не видит встречных записей») | | `GET /consolidations/versions/{id}/osv/export` | VIEW | xlsx: лист «Сводная ОСВ» и лист «Пробелы» | @@ -860,6 +860,11 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` в `Content-Disposition` по RFC 5987. Формат по умолчанию — `json`; другое значение `format` — 422 валидации. Скачивание в аудит не пишется (`domain/checklist_csv.py`, чистая функция). +`documents` (JSON и CSV, AURORA-1678) — отбор столбцов окна чек-листа: `BALANCE`, `INCOME`, `OSV:<счёт>` через запятую; остаются +названные правила в порядке `rules` и их ячейки, ЮЛ — все; документ вне правил подхода столбца не добавляет. Пустой список, пустой +токен, неизвестный документ, `OSV` без счёта — 422 `INVALID_DOCUMENTS` (`detail.token`), длиннее 500 символов — 422 валидации +(`domain/checklist_filter.py`). + ### Сводная ОСВ Чистая функция от версии (`domain/consolidated_osv.build` + `domain/consolidation_periods.select_sources`), в БД не пишется @@ -1024,6 +1029,7 @@ email без учёта регистра проверяет только сер | `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом | | `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр; в правке формы (`PATCH …/statement`) — не из 10 или 12 цифр | | `INVALID_KPP` | 422 | КПП не из 9 символов формата `NNNN[0-9A-Z]{2}NNN` или КПП у ИП (DBML v5.17, OQ-31) | +| `INVALID_DOCUMENTS` | 422 | в `documents` чек-листа (`GET …/checklist`) пустой токен, неизвестный документ или `OSV` без счёта (`detail.token`) | | `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней (по Москве) | | `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) | | `DIFFERENT_COMPANY_GROUP` | 422 | ЮЛ не входит в ГК этой ГСК/ГСЗ | diff --git a/api/src/api/endpoints/consolidations/checklist.py b/api/src/api/endpoints/consolidations/checklist.py index 9293e3a..a24e993 100644 --- a/api/src/api/endpoints/consolidations/checklist.py +++ b/api/src/api/endpoints/consolidations/checklist.py @@ -2,7 +2,7 @@ «Чек-лист комплекта»; design D-13). Право проверяется до видимости; версия ГК вне видимости — тот же 404.""" import uuid -from typing import Any, Literal +from typing import Annotated, Any, Literal from fastapi import APIRouter, Depends, Query, Response, status @@ -10,6 +10,7 @@ from src.api.dependencies.auth import require from src.api.dependencies.consolidations import ConsolidationServiceDep from src.common.filenames import content_disposition from src.domain.checklist_csv import checklist_filename, render_checklist_csv +from src.domain.checklist_filter import parse_documents, select_documents from src.schemas.common import ErrorDetail from src.schemas.consolidations import ChecklistOut @@ -17,12 +18,17 @@ router = APIRouter(prefix="/consolidations/versions") VIEW = [Depends(require("CONSOLIDATION_VIEW"))] CSV_MEDIA_TYPE = "text/csv" +MAX_DOCUMENTS_LENGTH = 500 RESPONSES: dict[int | str, dict[str, Any]] = { status.HTTP_200_OK: { "description": "чек-лист: JSON или (при `format=csv`) файл CSV — UTF-8 с BOM, разделитель `;`, CRLF", "content": {CSV_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}, }, status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "model": ErrorDetail, + "description": "`INVALID_DOCUMENTS` — в `documents` неизвестный документ, пустой токен или `OSV` без счёта", + }, } @@ -37,12 +43,18 @@ async def get_checklist( version_id: uuid.UUID, service: ConsolidationServiceDep, format: Literal["json", "csv"] = Query("json"), + documents: Annotated[str | None, Query(max_length=MAX_DOCUMENTS_LENGTH)] = None, ) -> ChecklistOut | Response: """Правила комплекта подхода версии и строки по ЮЛ периметра: ячейки `{status, missing_quarters, document_ids}`. При `?format=csv` возвращает чек-лист файлом CSV (UTF-8 с BOM, разделитель `;`, CRLF). + `documents` (`BALANCE,INCOME,OSV:01`) — только эти правила и их ячейки, в порядке правил; не задан — все + (AURORA-1678). """ + keys = parse_documents(documents) if documents is not None else None checklist = await service.checklist(version_id) + if keys is not None: + checklist = select_documents(checklist, keys) if format == "csv": group_name, year, quarter = await service.version_title(version_id) diff --git a/api/src/domain/checklist_filter.py b/api/src/domain/checklist_filter.py new file mode 100644 index 0000000..eef4df7 --- /dev/null +++ b/api/src/domain/checklist_filter.py @@ -0,0 +1,48 @@ +"""Отбор документов чек-листа параметром `documents` (spec consolidations-api «Чек-лист комплекта»; AURORA-1678). + +`documents` — список через запятую: `BALANCE` (ББ), `INCOME` (ОФР), `OSV:<счёт>` (ОСВ по счёту правила). Отбор +оставляет правила комплекта, названные в списке, в их порядке, и ячейки этих правил у каждого ЮЛ; ЮЛ периметра не +отбираются. Документ, которого нет в правилах подхода версии, столбца не добавляет. +""" + +from typing import TypeAlias + +from src.common.exceptions import DomainValidationError +from src.models.enums import DocumentType +from src.schemas.consolidations import ChecklistOut + +INVALID_DOCUMENTS = "INVALID_DOCUMENTS" +OSV_PREFIX = "OSV:" + +DocumentKey: TypeAlias = tuple[DocumentType, str | None] + +_PLAIN: dict[str, DocumentType] = {"BALANCE": DocumentType.BALANCE, "INCOME": DocumentType.INCOME} + + +def _parse_token(token: str) -> DocumentKey: + if token in _PLAIN: + return _PLAIN[token], None + if token.startswith(OSV_PREFIX) and (account := token.removeprefix(OSV_PREFIX).strip()): + return DocumentType.OSV, account + raise DomainValidationError( + INVALID_DOCUMENTS, + f"неизвестный документ «{token}»: допустимы BALANCE, INCOME, OSV:<счёт>", + token=token, + ) + + +def parse_documents(raw: str) -> set[DocumentKey]: + """`BALANCE,INCOME,OSV:01` → ключи правил `(document_type, account)`; пробелы вокруг токенов отбрасываются. + + Пустой список, пустой токен, неизвестный тип и `OSV` без счёта — 422 `INVALID_DOCUMENTS`. + """ + return {_parse_token(token.strip()) for token in raw.split(",")} + + +def select_documents(checklist: ChecklistOut, keys: set[DocumentKey]) -> ChecklistOut: + """Чек-лист только с правилами из `keys` (порядок правил сохраняется) и их ячейками; исходный не меняется.""" + kept = [i for i, rule in enumerate(checklist.rules) if (rule.document_type, rule.account) in keys] + return ChecklistOut( + rules=[checklist.rules[i] for i in kept], + rows=[row.model_copy(update={"cells": [row.cells[i] for i in kept]}) for row in checklist.rows], + ) diff --git a/api/tests/test_api_consolidation_checklist.py b/api/tests/test_api_consolidation_checklist.py index b822c81..7fbf786 100644 --- a/api/tests/test_api_consolidation_checklist.py +++ b/api/tests/test_api_consolidation_checklist.py @@ -394,3 +394,61 @@ async def test_checklist_csv_of_invisible_version_is_404(auth_client: AuthClient response = await foreign.get(f"/consolidations/versions/{version.id}/checklist", params={"format": fmt}) assert response.status_code == HTTP_NOT_FOUND, fmt assert "content-disposition" not in response.headers + + +@pytest.mark.integration +async def test_checklist_csv_with_selected_documents(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """`documents` оставляет в CSV только выбранные столбцы в порядке правил; ЮЛ — все (AURORA-1678).""" + version, (first, second) = await _bound_world(seeded) + client = await auth_client(KA_RF) + + response = await client.get( + f"/consolidations/versions/{version.id}/checklist", + params={"format": "csv", "documents": "OSV:60,BALANCE,OSV:99"}, + ) + + assert response.status_code == HTTP_OK, response.text + lines = response.content.decode("utf-8-sig").splitlines() + assert lines == [ + "Организация;ИНН;Бух. баланс;ОСВ 60", + f"{first.name};{first.inn or ''};Отсутствует;Загружен", + f"{second.name};{second.inn or ''};Отсутствует;Отсутствует", + ] + + +@pytest.mark.integration +async def test_checklist_json_with_selected_documents(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Тот же отбор в JSON: `rules` и ячейки строк — только выбранные документы.""" + version, (first, _) = await _bound_world(seeded) + client = await auth_client(KA_RF) + + response = await client.get( + f"/consolidations/versions/{version.id}/checklist", params={"documents": "INCOME,OSV:60"} + ) + + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert [(rule["document_type"], rule["account"]) for rule in body["rules"]] == [("INCOME", None), ("OSV", "60")] + assert [(c["document_type"], c["account"], c["status"]) for c in _cells(body, first.name)] == [ + ("INCOME", None, "MISSING"), + ("OSV", "60", "CONFIRMED"), + ] + + +@pytest.mark.integration +@pytest.mark.parametrize("documents", ["", "OFR", "OSV:", "BALANCE,,INCOME", "x" * 501]) +async def test_checklist_invalid_documents_is_422( + auth_client: AuthClientFactory, seeded: AsyncSession, documents: str +) -> None: + """Пустой список, неизвестный документ, `OSV` без счёта, пустой токен и длиннее 500 символов — 422, без файла.""" + version, _ = await _world(seeded) + client = await auth_client(KA_RF) + + response = await client.get( + f"/consolidations/versions/{version.id}/checklist", params={"format": "csv", "documents": documents} + ) + + assert response.status_code == HTTP_UNPROCESSABLE, response.text + assert "content-disposition" not in response.headers + if len(documents) <= 500: # noqa: PLR2004 - граница длины + assert response.json()["detail"]["code"] == "INVALID_DOCUMENTS" diff --git a/api/tests/test_domain_checklist_filter.py b/api/tests/test_domain_checklist_filter.py new file mode 100644 index 0000000..5f0181c --- /dev/null +++ b/api/tests/test_domain_checklist_filter.py @@ -0,0 +1,77 @@ +"""Отбор документов чек-листа параметром `documents` (spec consolidations-api «Чек-лист комплекта»; AURORA-1678).""" + +import uuid + +import pytest + +from src.common.exceptions import DomainValidationError +from src.domain.checklist import CellStatus +from src.domain.checklist_filter import INVALID_DOCUMENTS, parse_documents, select_documents +from src.models.enums import DocumentType +from src.schemas.consolidations import ChecklistCellOut, ChecklistOut, ChecklistRowOut, ChecklistRuleOut + +BALANCE, INCOME, OSV = DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV +RULES = [(BALANCE, None), (INCOME, None), (OSV, "01"), (OSV, "60")] + + +def _checklist() -> ChecklistOut: + """Два ЮЛ, правила ББ, ОФР, ОСВ 01, ОСВ 60; статус ячейки различает столбцы.""" + statuses = [CellStatus.CONFIRMED, CellStatus.PROCESSING, CellStatus.ERROR, CellStatus.MISSING] + return ChecklistOut( + rules=[ChecklistRuleOut(document_type=t, account=a, is_mandatory=True) for t, a in RULES], + rows=[ + ChecklistRowOut( + legal_entity_id=uuid.uuid4(), + legal_entity_name=name, + legal_entity_inn="7700000000", + cells=[ + ChecklistCellOut(document_type=t, account=a, status=s, missing_quarters=[], document_ids=[]) + for (t, a), s in zip(RULES, statuses, strict=True) + ], + ) + for name in ("ООО А", "ООО Б") + ], + ) + + +def test_parse_plain_and_osv_tokens() -> None: + assert parse_documents("BALANCE,INCOME,OSV:01") == {(BALANCE, None), (INCOME, None), (OSV, "01")} + + +def test_parse_strips_spaces_and_ignores_duplicates() -> None: + assert parse_documents(" OSV:60 , OSV:60,BALANCE ") == {(OSV, "60"), (BALANCE, None)} + + +@pytest.mark.parametrize("raw", ["", "BALANCE,", "OFR", "balance", "OSV", "OSV:", "OSV: ", "DISCLOSURE"]) +def test_parse_rejects_unknown_tokens(raw: str) -> None: + with pytest.raises(DomainValidationError) as error: + parse_documents(raw) + assert error.value.code == INVALID_DOCUMENTS + + +def test_select_keeps_rules_order_and_aligns_cells() -> None: + selected = select_documents(_checklist(), {(OSV, "60"), (BALANCE, None)}) + + assert [(r.document_type, r.account) for r in selected.rules] == [(BALANCE, None), (OSV, "60")] + for row in selected.rows: + assert [(c.document_type, c.account, c.status) for c in row.cells] == [ + (BALANCE, None, CellStatus.CONFIRMED), + (OSV, "60", CellStatus.MISSING), + ] + assert [row.legal_entity_name for row in selected.rows] == ["ООО А", "ООО Б"] + + +def test_select_skips_documents_absent_from_rules() -> None: + """Счёт, которого нет в правилах подхода, столбца не добавляет; если не совпало ничего — остаются только ЮЛ.""" + selected = select_documents(_checklist(), {(OSV, "90")}) + + assert selected.rules == [] + assert all(row.cells == [] for row in selected.rows) + assert len(selected.rows) == 2 # noqa: PLR2004 - оба ЮЛ периметра + + +def test_select_does_not_mutate_source() -> None: + checklist = _checklist() + select_documents(checklist, {(INCOME, None)}) + assert len(checklist.rules) == len(RULES) + assert all(len(row.cells) == len(RULES) for row in checklist.rows) diff --git a/openspec/changes/checklist-documents-filter/.openspec.yaml b/openspec/changes/checklist-documents-filter/.openspec.yaml new file mode 100644 index 0000000..2b638f8 --- /dev/null +++ b/openspec/changes/checklist-documents-filter/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-10 diff --git a/openspec/changes/checklist-documents-filter/design.md b/openspec/changes/checklist-documents-filter/design.md new file mode 100644 index 0000000..6b5d008 --- /dev/null +++ b/openspec/changes/checklist-documents-filter/design.md @@ -0,0 +1,22 @@ +# Design + +## Context + +PR arm-core #227 (AURORA-1678, фронт — В. Цыганкова) добавил фильтр «Документы» в окно чек-листа и параметр +`documents` в запрос CSV. Бэк и OpenSpec доведены в той же ветке при ревью. + +## Decisions + +1. **Отбор — чистая функция `domain/checklist_filter.py`.** `parse_documents` разбирает список в ключи правил + `(document_type, account)`, `select_documents` оставляет правила и ячейки по ключам. CSV-рендер не меняется. +2. **Формат токенов — как прислал фронт:** `BALANCE`, `INCOME`, `OSV:<счёт>`; имена типов — `document_type` DBML. +3. **Порядок столбцов — порядок `rules`,** а не порядок в запросе: CSV совпадает с матрицей окна. + +## Decisions made without discussion + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 10.11.2026 | `documents` действует и на JSON, не только на CSV | Один контракт отбора для обоих форматов; JSON без параметра не меняется | Низкая: фронт JSON без параметра не трогает, отбор можно снять | +| 10.11.2026 | Документ, которого нет в правилах подхода (`OSV:99`), столбца не добавляет и не даёт ошибку; неверный синтаксис — 422 `INVALID_DOCUMENTS` | Правила подхода — данные; фронт строит список из ответа, рассинхрон не должен ломать выгрузку | Низкая: опечатка в счёте даёт файл без столбца, а не ошибку | +| 10.11.2026 | `documents` разбирается до обращения к БД: для невидимой версии с неверным списком — 422, не 404 | Как у прочих границ входа консолидаций («Границы входных данных»); 422 не раскрывает существование версии | Низкая | +| 10.11.2026 | Фильтра «Группа компаний» из макета [37] нет | Предложение автора PR (Jira AURORA-1678): окно открыто внутри версии одной ГК | Низкая: добавить, если чек-лист появится вне версии | diff --git a/openspec/changes/checklist-documents-filter/proposal.md b/openspec/changes/checklist-documents-filter/proposal.md new file mode 100644 index 0000000..8ccc946 --- /dev/null +++ b/openspec/changes/checklist-documents-filter/proposal.md @@ -0,0 +1,33 @@ +# Proposal + +## Why + +Окно «Чек-лист загруженных документов» и CSV (AURORA-1444) показывали всю матрицу. AURORA-1678 (PR arm-core #227): +фильтр «Документы» в окне и выгрузка CSV с тем же отбором. Фронт PR передаёт `documents` в запросе CSV, но ядро +параметр не принимало — файл выгружался целиком. + +## What Changes + +- `GET /consolidations/versions/{id}/checklist` принимает `documents` (`BALANCE`, `INCOME`, `OSV:<счёт>` через + запятую) и отдаёт только эти правила и ячейки — в JSON и CSV; неверный список — 422 `INVALID_DOCUMENTS`. +- Окно чек-листа: фильтр «Документы», заголовок и кнопка «Скачать» по макету [37], CSV — с отбором окна. + +## Capabilities + +### Modified Capabilities + +- `consolidations-api` — «Чек-лист комплекта»: параметр `documents`. +- `consolidations-ui` — новое требование «Окно «Чек-лист загруженных документов»». + +## Impact + +Срез 4 (консолидации), репозиторий arm-core: `api/src/domain/checklist_filter.py`, +`api/src/api/endpoints/consolidations/checklist.py`, `web/src/widgets/ConsoleLegalEntities/*`. Таблицы DBML v5.25 не +меняются (`required_document_rule`, `document` — только чтение). Правила — `05-domain-rules.md` §9.5 (чек-лист +комплекта) без изменений: отбор — представление, не алгоритм. + +## Non-goals + +- Фильтр «Группа компаний» из макета [37] — окно открыто внутри версии одной ГК. +- Колонка «Прочие документы» макета — правил подхода для неё нет. +- Сохранение выбора между открытиями окна. diff --git a/openspec/changes/checklist-documents-filter/specs/consolidations-api/spec.md b/openspec/changes/checklist-documents-filter/specs/consolidations-api/spec.md new file mode 100644 index 0000000..a910ef0 --- /dev/null +++ b/openspec/changes/checklist-documents-filter/specs/consolidations-api/spec.md @@ -0,0 +1,73 @@ +## MODIFIED Requirements + +### Requirement: Чек-лист комплекта +`GET /consolidations/versions/{id}/checklist` (`CONSOLIDATION_VIEW`, policy) SHALL вернуть `rules[]` (правила +`required_document_rule` подхода версии: `document_type`, `account`, `is_mandatory`; порядок — `BALANCE`, `INCOME`, +затем ОСВ по коду счёта) и `rows[]` по ЮЛ периметра (по имени) с `cells[]` в порядке `rules`. Ячейка `{document_type, +account, status, missing_quarters, document_ids}`: `document_type` и `account` — правила ячейки (ячейка +самоописывающая, фронт не опирается на позицию); `CONFIRMED` — пара (ЮЛ, правило) полностью покрыта привязанными +документами (ОСВ: по правилу выбора источников `consolidated-osv`; ББ/ОФР: документ за квартал версии); +`NEEDS_CHECK` / `RECOGNIZED` — есть неподтверждённый документ ЮЛ того же типа, счёта и года, подходящий версии +(приоритет `NEEDS_CHECK`); `PROCESSING` — у ЮЛ есть файл `UPLOADED` или `PROCESSING`; `ERROR` — последний по +`uploaded_at` файл ЮЛ в `ERROR`; иначе `MISSING`. Неподтверждённые документы и файлы SHALL браться только со стола +консолидации версии (§9.5 п. 11: файлы, загруженные в эту консолидацию, и файлы с документом, привязанным к любой её +версии) — тем же фильтром, что панель «Первичные документы»; документы и файлы ЮЛ из других консолидаций и из карточки +ЮЛ на ячейки MUST NOT влиять (AURORA-1646). `missing_quarters` SHALL перечислять кварталы без квартальной ОСВ при +частичном покрытии. Правило ОСВ SHALL закрываться только документом с `osv_account_code`, равным `account` правила +(OQ-04). Те же ячейки (с `document_type` и `account`, в том же порядке) SHALL отдаваться в карточке версии у каждого +члена. С `?format=csv` SHALL отдаваться тот же чек-лист файлом `text/csv; charset=utf-8` с BOM и разделителем `;`: +заголовок «Организация;ИНН;<правило>…» (правила — «Бух. баланс», «ОФР», «ОСВ <счёт>»), ячейки — подписи макета: +`CONFIRMED` «Загружен», `PROCESSING` «Обработка», `NEEDS_CHECK`/`RECOGNIZED` «Требуется проверка», `ERROR` «Ошибка +загрузки», `MISSING` «Отсутствует» (с « (нет: Q1, Q2)» при `missing_quarters`); `Content-Disposition` — `attachment` с +именем `checklist-<ГК>-<год>-<квартал>.csv` в RFC 5987. +Параметр `documents` (JSON и CSV) SHALL отбирать столбцы: список через запятую из `BALANCE`, `INCOME` и `OSV:<счёт>`; +в ответе остаются только названные правила (в порядке `rules`) и их ячейки у каждого ЮЛ, ЮЛ периметра не отбираются; +документ, которого нет в правилах подхода версии, столбца не добавляет. Без `documents` — все правила. Пустой список, +пустой токен, неизвестный документ и `OSV` без счёта SHALL давать 422 `INVALID_DOCUMENTS` (`detail.token`), значение +длиннее 500 символов — 422 валидации; проверка — до обращения к БД. + +#### Scenario: Пробел Q2 +- **WHEN** у ЮЛ привязаны квартальные ОСВ 60 за Q1 и Q3 2024, версия за 2024 Q3 +- **THEN** ячейка ОСВ 60: `document_type = OSV`, `account = "60"`, `status = MISSING`, `missing_quarters = ["Q2"]`, + `document_ids` — два документа + +#### Scenario: Субсчёт не закрывает правило +- **WHEN** у ЮЛ подтверждена только ОСВ 58.03 за квартал версии +- **THEN** ячейка ОСВ 58 — `MISSING` + +#### Scenario: Файл в обработке +- **WHEN** у ЮЛ в консолидацию загружен файл, задание ещё не завершено, других документов нет +- **THEN** все ячейки ЮЛ — `PROCESSING` + +#### Scenario: Документы другой консолидации +- **WHEN** создана консолидация 2024 Q3; у ЮЛ «А» в файле, загруженном в консолидацию 2024 Q2, — ББ нарастающий за Q3 + (`NEEDS_CHECK`) и ОСВ 60 за Q3 (`RECOGNIZED`); у ЮЛ «Б» — файл консолидации 2024 Q2 в `ERROR` и файл в `PROCESSING`, + загруженный из карточки ЮЛ; в консолидацию 2024 Q3 ничего не загружено +- **THEN** все ячейки обоих ЮЛ — `MISSING` с пустыми `document_ids` + +#### Scenario: Файл, подхваченный привязкой +- **WHEN** файл ЮЛ загружен из карточки ЮЛ, один его документ подтверждён и привязан к версии консолидации, другой — + `NEEDS_CHECK` и подходит версии +- **THEN** файл на столе консолидации, ячейка второго документа — `NEEDS_CHECK` + +#### Scenario: Ячейки карточки подписаны +- **WHEN** запрошена карточка версии подхода `BASIC` +- **THEN** у каждого члена `checklist[i].document_type` и `checklist[i].account` равны `rules[i]` чек-листа той же + версии; ячейка ББ — `document_type = BALANCE`, `account = null` + +#### Scenario: Чек-лист в CSV +- **WHEN** запрошен чек-лист версии с `format=csv`, у ЮЛ подтверждён ББ, ОСВ 60 отсутствует за Q2 +- **THEN** файл начинается с BOM, первая строка — «Организация;ИНН;Бух. баланс;ОФР;ОСВ 60…», в строке ЮЛ — «Загружен» и «Отсутствует (нет: Q2)» + +#### Scenario: CSV по выбранным документам +- **WHEN** запрошен чек-лист версии с `format=csv&documents=OSV:60,BALANCE,OSV:99`, у ЮЛ «А» подтверждена ОСВ 60 +- **THEN** заголовок — «Организация;ИНН;Бух. баланс;ОСВ 60», строки обоих ЮЛ периметра с двумя ячейками, у «А» — + «Отсутствует;Загружен» + +#### Scenario: JSON по выбранным документам +- **WHEN** запрошен чек-лист версии с `documents=INCOME,OSV:60` +- **THEN** `rules` — ОФР и ОСВ 60, у каждой строки две ячейки тех же правил + +#### Scenario: Неизвестный документ +- **WHEN** запрошен чек-лист с `documents=OFR` (или пустым, `OSV:`, `BALANCE,,INCOME`) +- **THEN** 422 `INVALID_DOCUMENTS`, файл не отдаётся diff --git a/openspec/changes/checklist-documents-filter/specs/consolidations-ui/spec.md b/openspec/changes/checklist-documents-filter/specs/consolidations-ui/spec.md new file mode 100644 index 0000000..00c8d25 --- /dev/null +++ b/openspec/changes/checklist-documents-filter/specs/consolidations-ui/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Окно «Чек-лист загруженных документов» +Кнопка «Скачать чек-лист» панели «Организации» SHALL открывать окно «Чек-лист загруженных документов» (макет [37]): +фильтр «Документы», легенда статусов и матрица «Организация» × «Бух. баланс», «ОФР», «ОСВ» по счетам правил версии. +Фильтр «Документы» — селект с подписью сверху, как фильтры отчётности: в списке пункт «Все документы» и по пункту на +столбец матрицы с флажками; в поле — «Все документы», «Документы не выбраны» или «N документ(а/ов)». При открытии +отмечены все документы. Матрица SHALL показывать только отмеченные столбцы (колонка «Организация» — всегда; группа «ОСВ» +без отмеченных счетов не показывается). «Скачать» SHALL выгружать CSV с теми же документами (`documents` запроса +`GET …/checklist?format=csv`; при всех отмеченных — без параметра) и неактивна, пока не отмечен ни один документ. +Фильтра «Группа компаний» из макета нет: окно открывается внутри версии одной ГК. + +#### Scenario: Выбраны два документа +- **WHEN** в фильтре «Документы» отмечены только «ОФР» и «ОСВ 01» +- **THEN** в поле «2 документа», в матрице колонки «Организация», «ОФР» и «ОСВ» → «01»; «Скачать» выгружает CSV с + `documents=INCOME,OSV:01` + +#### Scenario: Сняты все документы +- **WHEN** на «Все документы» нажали при всех отмеченных +- **THEN** в поле «Документы не выбраны», в матрице только «Организация», «Скачать» неактивна diff --git a/openspec/changes/checklist-documents-filter/tasks.md b/openspec/changes/checklist-documents-filter/tasks.md new file mode 100644 index 0000000..753ae91 --- /dev/null +++ b/openspec/changes/checklist-documents-filter/tasks.md @@ -0,0 +1,9 @@ +# Tasks + +- [x] 1.1 arm-core `api/tests/test_domain_checklist_filter.py` — разбор `documents` и отбор столбцов +- [x] 1.2 arm-core `api/src/domain/checklist_filter.py` — `parse_documents`, `select_documents` +- [x] 1.3 arm-core `api/tests/test_api_consolidation_checklist.py` — CSV/JSON с `documents`, 422 +- [x] 1.4 arm-core `api/src/api/endpoints/consolidations/checklist.py` — параметр `documents` (≤ 500 символов) +- [x] 1.5 arm-core `web/` — фильтр «Документы» окна чек-листа, CSV с отбором (PR #227, автор) +- [x] 1.6 Сценарий на стенде против макета [37] (Playwright, РБ 2024 К3: «ОФР» + «ОСВ 60» → CSV из двух столбцов) +- [x] 1.7 README — `documents` в описании `GET …/checklist` diff --git a/web/src/components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter.tsx b/web/src/components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter.tsx index 79ee8f5..de16750 100644 --- a/web/src/components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter.tsx +++ b/web/src/components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter.tsx @@ -1,5 +1,6 @@ import { Select, Space } from 'antd'; import type React from 'react'; +import { Labeled } from '../Labeled'; interface ConsolidationFormatTableFilterI { decimals: number; @@ -30,13 +31,11 @@ export const ConsolidationFormatTableFilter: React.FC - - Единицы + - + ); diff --git a/web/src/components/Labeled.tsx b/web/src/components/Labeled.tsx new file mode 100644 index 0000000..b1e198c --- /dev/null +++ b/web/src/components/Labeled.tsx @@ -0,0 +1,10 @@ +import { Space } from 'antd'; +import type { ReactNode } from 'react'; + +/** Поле с серой подписью сверху — для фильтров и настроек над таблицами (селекты отчётности, чек-листа). */ +export const Labeled = ({ label, children }: { label: string; children: ReactNode }) => ( + + {label} + {children} + +); diff --git a/web/src/services/api/consolidations.ts b/web/src/services/api/consolidations.ts index 003e2ba..97a968b 100644 --- a/web/src/services/api/consolidations.ts +++ b/web/src/services/api/consolidations.ts @@ -9,7 +9,12 @@ export const getConsolidations = (groupId: string, in_work_only: boolean) => api.get(`/company-groups/${groupId}/consolidations`, { in_work_only }); export const getVersionCard = (versionId: string) => api.get(`/consolidations/versions/${versionId}`); export const createConsolidation = (body: ConsolidationCreate) => api.post('/consolidations', body); -export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`); +/** CSV чек-листа; `documents` — отбор колонок (`BALANCE,INCOME,OSV:01`), не задан — вся матрица. */ +export const downloadChecklistCsv = (versionId: string, documents?: string) => { + const params = new URLSearchParams({ format: 'csv' }); + if (documents) params.set('documents', documents); + return apiFetchBlob(`/consolidations/versions/${versionId}/checklist?${params}`); +}; export const getChecklist = (versionId: string) => api.get(`/consolidations/versions/${versionId}/checklist`); /** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */ export const lockVersion = (versionId: string, reason?: string | null) => diff --git a/web/src/widgets/ConsoleLegalEntities/ChecklistDocumentsFilter.tsx b/web/src/widgets/ConsoleLegalEntities/ChecklistDocumentsFilter.tsx new file mode 100644 index 0000000..1893eec --- /dev/null +++ b/web/src/widgets/ConsoleLegalEntities/ChecklistDocumentsFilter.tsx @@ -0,0 +1,49 @@ +// Фильтр «Документы» окна чек-листа: селект как в отчётности (подпись сверху, underlined), в списке — чекбоксы. +import { Checkbox, Select } from 'antd'; +import { Labeled } from '../../components/Labeled'; +import { documentFilterLabel, toggleAllDocuments, toggleDocument } from './checklist_utils'; + +type Props = { + options: { key: string; title: string }[]; + value: string[]; + onChange: (next: string[]) => void; +}; + +const itemStyle: React.CSSProperties = { display: 'flex', padding: '5px 12px', borderRadius: 4, cursor: 'pointer' }; + +export function ChecklistDocumentsFilter({ options, value, onChange }: Props) { + const allKeys = options.map((o) => o.key); + const isAll = value.length === allKeys.length; + + return ( + + - - - Период + + )} /> - + - - Дт + - + {companies?.length || debit?.length || credit?.length ? : null} ); diff --git a/web/src/widgets/CorrectionTable/CorrectionToolbar.tsx b/web/src/widgets/CorrectionTable/CorrectionToolbar.tsx index 80d6c72..15829e7 100644 --- a/web/src/widgets/CorrectionTable/CorrectionToolbar.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionToolbar.tsx @@ -1,7 +1,8 @@ // Панель над таблицей: «Отображение», «Правило», «Тип записи», единицы и знаки, сброс фильтров, настройка, экспорт, весь экран. import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons'; -import { Button, Select, Space } from 'antd'; +import { Button, Select } from 'antd'; import { ColumnSettings } from '../../components/GenericTable/ColumnSettings'; +import { Labeled } from '../../components/Labeled'; import type { CorrectionRuleOut } from '../../types/corrections'; import { PRECISIONS, UNITS, type Unit } from '../ConsolidatedOsv/useNumberDisplay'; import { COLUMN_DEFS, type ColKey, type ColShape } from './columns'; @@ -31,13 +32,6 @@ const RECORD_TYPES: { value: RecordType; label: string }[] = [ { value: 'unallocated', label: 'Нераспределенные записи' }, ]; -const Labeled = ({ label, children }: { label: string; children: React.ReactNode }) => ( - - {label} - {children} - -); - export function CorrectionToolbar(p: Props) { const { filters, rules } = p;