Merge pull request '[AURORA-1678]: Чек-лист документов:' (#227) from feat/aurora-1678-check-list-filter into main

This commit is contained in:
aep 2026-11-10 15:46:56 +03:00
commit 869f5d72b7
21 changed files with 652 additions and 50 deletions

View File

@ -795,7 +795,7 @@ advisory-блокировкой.
| `DELETE /consolidations/versions/{id}/lock` | EDIT | снять свою блокировку (204; нет активной — тоже 204) | | `DELETE /consolidations/versions/{id}/lock` | EDIT | снять свою блокировку (204; нет активной — тоже 204) |
| `POST /consolidations/versions/{id}/status` | EDIT | ручной переход: `CANCELLED` (с комментарием) или возврат в `INITIATED` | | `POST /consolidations/versions/{id}/status` | EDIT | ручной переход: `CANCELLED` (с комментарием) или возврат в `INITIATED` |
| `PUT /consolidations/versions/{id}/members` | EDIT | заменить периметр (ЮЛ должны принадлежать ГК, ≥ 2) | | `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` | 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/summary` | VIEW | `rows_total`, `unmapped_total`, `auto_matched_total`, `documents_total`, `gaps[]`, `counter_pairs[]` (пары «участник не видит встречных записей») |
| `GET /consolidations/versions/{id}/osv/export` | VIEW | xlsx: лист «Сводная ОСВ» и лист «Пробелы» | | `GET /consolidations/versions/{id}/osv/export` | VIEW | xlsx: лист «Сводная ОСВ» и лист «Пробелы» |
@ -870,6 +870,11 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
в `Content-Disposition` по RFC 5987. Формат по умолчанию — `json`; другое значение `format` — 422 валидации. Скачивание в аудит не пишется в `Content-Disposition` по RFC 5987. Формат по умолчанию — `json`; другое значение `format` — 422 валидации. Скачивание в аудит не пишется
(`domain/checklist_csv.py`, чистая функция). (`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`), в БД не пишется Чистая функция от версии (`domain/consolidated_osv.build` + `domain/consolidation_periods.select_sources`), в БД не пишется
@ -1034,6 +1039,7 @@ email без учёта регистра проверяет только сер
| `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом | | `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом |
| `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр; в правке формы (`PATCH …/statement`) — не из 10 или 12 цифр | | `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_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 | дата перевода/вывода раньше сегодняшней (по Москве) | | `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней (по Москве) |
| `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) | | `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) |
| `DIFFERENT_COMPANY_GROUP` | 422 | ЮЛ не входит в ГК этой ГСК/ГСЗ | | `DIFFERENT_COMPANY_GROUP` | 422 | ЮЛ не входит в ГК этой ГСК/ГСЗ |

View File

@ -2,7 +2,7 @@
«Чек-лист комплекта»; design D-13). Право проверяется до видимости; версия ГК вне видимости — тот же 404.""" «Чек-лист комплекта»; design D-13). Право проверяется до видимости; версия ГК вне видимости — тот же 404."""
import uuid import uuid
from typing import Any, Literal from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, Query, Response, status 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.api.dependencies.consolidations import ConsolidationServiceDep
from src.common.filenames import content_disposition from src.common.filenames import content_disposition
from src.domain.checklist_csv import checklist_filename, render_checklist_csv 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.common import ErrorDetail
from src.schemas.consolidations import ChecklistOut from src.schemas.consolidations import ChecklistOut
@ -17,12 +18,17 @@ router = APIRouter(prefix="/consolidations/versions")
VIEW = [Depends(require("CONSOLIDATION_VIEW"))] VIEW = [Depends(require("CONSOLIDATION_VIEW"))]
CSV_MEDIA_TYPE = "text/csv" CSV_MEDIA_TYPE = "text/csv"
MAX_DOCUMENTS_LENGTH = 500
RESPONSES: dict[int | str, dict[str, Any]] = { RESPONSES: dict[int | str, dict[str, Any]] = {
status.HTTP_200_OK: { status.HTTP_200_OK: {
"description": "чек-лист: JSON или (при `format=csv`) файл CSV — UTF-8 с BOM, разделитель `;`, CRLF", "description": "чек-лист: JSON или (при `format=csv`) файл CSV — UTF-8 с BOM, разделитель `;`, CRLF",
"content": {CSV_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}, "content": {CSV_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}},
}, },
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, 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, version_id: uuid.UUID,
service: ConsolidationServiceDep, service: ConsolidationServiceDep,
format: Literal["json", "csv"] = Query("json"), format: Literal["json", "csv"] = Query("json"),
documents: Annotated[str | None, Query(max_length=MAX_DOCUMENTS_LENGTH)] = None,
) -> ChecklistOut | Response: ) -> ChecklistOut | Response:
"""Правила комплекта подхода версии и строки по ЮЛ периметра: ячейки `{status, missing_quarters, document_ids}`. """Правила комплекта подхода версии и строки по ЮЛ периметра: ячейки `{status, missing_quarters, document_ids}`.
При `?format=csv` возвращает чек-лист файлом CSV (UTF-8 с BOM, разделитель `;`, CRLF). При `?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) checklist = await service.checklist(version_id)
if keys is not None:
checklist = select_documents(checklist, keys)
if format == "csv": if format == "csv":
group_name, year, quarter = await service.version_title(version_id) group_name, year, quarter = await service.version_title(version_id)

View File

@ -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],
)

View File

@ -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}) response = await foreign.get(f"/consolidations/versions/{version.id}/checklist", params={"format": fmt})
assert response.status_code == HTTP_NOT_FOUND, fmt assert response.status_code == HTTP_NOT_FOUND, fmt
assert "content-disposition" not in response.headers 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"

View File

@ -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)

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-10

View File

@ -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): окно открыто внутри версии одной ГК | Низкая: добавить, если чек-лист появится вне версии |

View File

@ -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] — окно открыто внутри версии одной ГК.
- Колонка «Прочие документы» макета — правил подхода для неё нет.
- Сохранение выбора между открытиями окна.

View File

@ -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`, файл не отдаётся

View File

@ -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** в поле «Документы не выбраны», в матрице только «Организация», «Скачать» неактивна

View File

@ -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`

View File

@ -1,5 +1,6 @@
import { Select, Space } from 'antd'; import { Select, Space } from 'antd';
import type React from 'react'; import type React from 'react';
import { Labeled } from '../Labeled';
interface ConsolidationFormatTableFilterI { interface ConsolidationFormatTableFilterI {
decimals: number; decimals: number;
@ -30,13 +31,11 @@ export const ConsolidationFormatTableFilter: React.FC<ConsolidationFormatTableFi
return ( return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 16 }}>
<Space size='middle' wrap> <Space size='middle' wrap>
<Space orientation='vertical' size={0}> <Labeled label='Единицы'>
<span style={{ fontSize: 12, color: '#667085' }}>Единицы</span>
<Select style={{ minWidth: 120 }} options={UNIT_OPTIONS} value={units} onChange={onUnitsChange} variant='underlined' /> <Select style={{ minWidth: 120 }} options={UNIT_OPTIONS} value={units} onChange={onUnitsChange} variant='underlined' />
</Space> </Labeled>
<Space orientation='vertical' size={0}> <Labeled label='Знаков после запятой'>
<span style={{ fontSize: 12, color: '#667085' }}>Знаков после запятой</span>
<Select <Select
style={{ minWidth: 150 }} style={{ minWidth: 150 }}
value={decimals} value={decimals}
@ -44,7 +43,7 @@ export const ConsolidationFormatTableFilter: React.FC<ConsolidationFormatTableFi
options={DECIMALS_OPTIONS} options={DECIMALS_OPTIONS}
variant='underlined' variant='underlined'
/> />
</Space> </Labeled>
</Space> </Space>
</div> </div>
); );

View File

@ -0,0 +1,10 @@
import { Space } from 'antd';
import type { ReactNode } from 'react';
/** Поле с серой подписью сверху — для фильтров и настроек над таблицами (селекты отчётности, чек-листа). */
export const Labeled = ({ label, children }: { label: string; children: ReactNode }) => (
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>{label}</span>
{children}
</Space>
);

View File

@ -9,7 +9,12 @@ export const getConsolidations = (groupId: string, in_work_only: boolean) =>
api.get<IConsolidation[]>(`/company-groups/${groupId}/consolidations`, { in_work_only }); api.get<IConsolidation[]>(`/company-groups/${groupId}/consolidations`, { in_work_only });
export const getVersionCard = (versionId: string) => api.get<VersionCard>(`/consolidations/versions/${versionId}`); export const getVersionCard = (versionId: string) => api.get<VersionCard>(`/consolidations/versions/${versionId}`);
export const createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body); export const createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/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<ChecklistBackendShape>(`/consolidations/versions/${versionId}/checklist`); export const getChecklist = (versionId: string) => api.get<ChecklistBackendShape>(`/consolidations/versions/${versionId}/checklist`);
/** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */ /** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */
export const lockVersion = (versionId: string, reason?: string | null) => export const lockVersion = (versionId: string, reason?: string | null) =>

View File

@ -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 (
<Labeled label='Документы'>
<Select
variant='underlined'
style={{ minWidth: 180 }}
popupMatchSelectWidth={false}
value='summary'
options={[{ value: 'summary', label: documentFilterLabel(value, allKeys.length) }]}
popupRender={() => (
<>
<Checkbox
style={itemStyle}
checked={isAll}
indeterminate={value.length > 0 && !isAll}
onChange={() => onChange(toggleAllDocuments(value, allKeys))}>
Все документы
</Checkbox>
{options.map((o) => (
<Checkbox
key={o.key}
style={itemStyle}
checked={value.includes(o.key)}
onChange={() => onChange(toggleDocument(value, o.key, allKeys))}>
{o.title}
</Checkbox>
))}
</>
)}
/>
</Labeled>
);
}

View File

@ -5,25 +5,40 @@ import { ApiError } from '../../api/client';
import { AppModal } from '../../components/AppModal/AppModal'; import { AppModal } from '../../components/AppModal/AppModal';
import { getChecklist } from '../../services/api/consolidations'; import { getChecklist } from '../../services/api/consolidations';
import type { ChecklistBackendShape, IVersionCard } from '../../types/consolidations/version_members'; import type { ChecklistBackendShape, IVersionCard } from '../../types/consolidations/version_members';
import { buildChecklistColumns, buildChecklistRows, getMarksItems } from './checklist_utils'; import { ChecklistDocumentsFilter } from './ChecklistDocumentsFilter';
import {
buildChecklistColumns,
buildChecklistRows,
buildDocumentFilterOptions,
documentsQueryParam,
getMarksItems,
} from './checklist_utils';
type ChecklistProps = { type ChecklistProps = {
open: boolean; open: boolean;
onDownload: () => void; onDownload: (documents?: string) => void;
consolidationData: IVersionCard; consolidationData: IVersionCard;
onClose: () => void; onClose: () => void;
}; };
export default function ChecklistModal({ open, onDownload, consolidationData, onClose }: ChecklistProps) { export default function ChecklistModal({ open, onDownload, consolidationData, onClose }: ChecklistProps) {
const [checklistData, setChecklistData] = useState<ChecklistBackendShape | null>(null); const [checklistData, setChecklistData] = useState<ChecklistBackendShape | null>(null);
const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);
const rows = useMemo(() => (checklistData ? buildChecklistRows(checklistData) : []), [checklistData]); const rows = useMemo(() => (checklistData ? buildChecklistRows(checklistData) : []), [checklistData]);
const columns = useMemo(() => (checklistData ? buildChecklistColumns(checklistData) : []), [checklistData]); const columns = useMemo(
() => (checklistData ? buildChecklistColumns(checklistData, selectedDocuments) : []),
[checklistData, selectedDocuments],
);
const documentOptions = useMemo(() => (checklistData ? buildDocumentFilterOptions(checklistData) : []), [checklistData]);
useEffect(() => { useEffect(() => {
if (!consolidationData.id || !open) return; if (!consolidationData.id || !open) return;
getChecklist(consolidationData.id) getChecklist(consolidationData.id)
.then(setChecklistData) .then((data) => {
setChecklistData(data);
setSelectedDocuments(buildDocumentFilterOptions(data).map((o) => o.key));
})
.catch((e: unknown) => message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить чек-лист')); .catch((e: unknown) => message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить чек-лист'));
}, [open, consolidationData.id]); }, [open, consolidationData.id]);
@ -33,15 +48,21 @@ export default function ChecklistModal({ open, onDownload, consolidationData, on
open={open} open={open}
cancelText='Отмена' cancelText='Отмена'
onCancel={onClose} onCancel={onClose}
title='Чеклист' title='Чек-лист загруженных документов'
destroyOnHidden destroyOnHidden
onOk={() => { onOk={() => {
onDownload(); onDownload(
documentsQueryParam(
selectedDocuments,
documentOptions.map((o) => o.key),
),
);
onClose(); onClose();
}} }}
okText='Скачать чек-лист' okText='Скачать'
okButtonProps={{ icon: <DownloadOutlined /> }}> okButtonProps={{ icon: <DownloadOutlined />, disabled: selectedDocuments.length === 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<ChecklistDocumentsFilter options={documentOptions} value={selectedDocuments} onChange={setSelectedDocuments} />
{getMarksItems()} {getMarksItems()}
<Table rowKey='key' columns={columns} dataSource={rows} pagination={false} /> <Table rowKey='key' columns={columns} dataSource={rows} pagination={false} />
</div> </div>

View File

@ -39,10 +39,10 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
}, [consolidation_id, reloadKey]); }, [consolidation_id, reloadKey]);
const rawRows = useMemo(() => normalizeData(consolidationData), [consolidationData]); const rawRows = useMemo(() => normalizeData(consolidationData), [consolidationData]);
const downloadChecklist = async () => { const downloadChecklist = async (documents?: string) => {
if (!consolidationData) return; if (!consolidationData) return;
try { try {
const { blob, filename } = await downloadChecklistCsv(consolidationData.id); const { blob, filename } = await downloadChecklistCsv(consolidationData.id, documents);
const card = consolidationData; const card = consolidationData;
saveBlob(blob, filename ?? checklistFilename(card.company_group.name, card.year, card.quarter)); saveBlob(blob, filename ?? checklistFilename(card.company_group.name, card.year, card.quarter));
} catch (e: unknown) { } catch (e: unknown) {

View File

@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { ChecklistBackendShape } from '../../types/consolidations/version_members'; import type { ChecklistBackendShape } from '../../types/consolidations/version_members';
import { buildChecklistRows } from './checklist_utils'; import {
buildChecklistColumns,
buildChecklistRows,
buildDocumentFilterOptions,
documentFilterLabel,
documentsQueryParam,
toggleAllDocuments,
toggleDocument,
} from './checklist_utils';
const checklist: ChecklistBackendShape = { const checklist: ChecklistBackendShape = {
rules: [], rules: [],
@ -37,3 +45,96 @@ describe('buildChecklistRows', () => {
expect(rows[0]).toMatchObject({ balance: 'MISSING', ofr: 'MISSING', osv: {} }); expect(rows[0]).toMatchObject({ balance: 'MISSING', ofr: 'MISSING', osv: {} });
}); });
}); });
const twoOsv: ChecklistBackendShape = {
rules: [],
rows: [
{
...checklist.rows[0],
cells: [
...checklist.rows[0].cells,
{ document_type: 'OSV', account: '01', status: 'CONFIRMED', missing_quarters: [], document_ids: ['d4'] },
],
},
],
};
/** Ключи колонок верхнего уровня и подколонок ОСВ. */
const columnKeys = (selected?: string[]) =>
buildChecklistColumns(twoOsv, selected).map((c) => ('children' in c && c.children ? [c.key, c.children.map((ch) => ch.key)] : c.key));
describe('buildDocumentFilterOptions', () => {
it('ББ, ОФР и ОСВ по счетам в порядке колонок', () => {
expect(buildDocumentFilterOptions(twoOsv)).toEqual([
{ key: 'balance', title: 'Бух. баланс' },
{ key: 'ofr', title: 'ОФР' },
{ key: 'osv_60', title: 'ОСВ 60' },
{ key: 'osv_01', title: 'ОСВ 01' },
]);
});
});
describe('buildChecklistColumns', () => {
it('без выбора — все документы', () => {
expect(columnKeys()).toEqual(['name', 'balance', 'ofr', ['osv', ['osv_60', 'osv_01']]]);
expect(columnKeys(['balance', 'ofr', 'osv_60', 'osv_01'])).toEqual(columnKeys());
});
it('только выбранные документы; организация — всегда', () => {
expect(columnKeys(['ofr', 'osv_01'])).toEqual(['name', 'ofr', ['osv', ['osv_01']]]);
});
it('ни одного счёта ОСВ не выбрано — группы ОСВ нет', () => {
expect(columnKeys(['balance'])).toEqual(['name', 'balance']);
});
it('документы не выбраны — только организация', () => {
expect(columnKeys([])).toEqual(['name']);
});
});
describe('toggleDocument', () => {
const all = ['balance', 'ofr', 'osv_01'];
it('снятие и отметка пункта; порядок — как у пунктов', () => {
expect(toggleDocument(all, 'ofr', all)).toEqual(['balance', 'osv_01']);
expect(toggleDocument(['osv_01'], 'balance', all)).toEqual(['balance', 'osv_01']);
});
it('сняли последний пункт — ничего не выбрано', () => {
expect(toggleDocument(['ofr'], 'ofr', all)).toEqual([]);
});
});
describe('toggleAllDocuments', () => {
const all = ['balance', 'ofr', 'osv_01'];
it('при всех отмеченных снимает все, иначе отмечает все', () => {
expect(toggleAllDocuments(all, all)).toEqual([]);
expect(toggleAllDocuments(['ofr'], all)).toEqual(all);
expect(toggleAllDocuments([], all)).toEqual(all);
});
});
describe('documentFilterLabel', () => {
it('подпись по числу отмеченных', () => {
expect(documentFilterLabel(['a', 'b', 'c'], 3)).toBe('Все документы');
expect(documentFilterLabel([], 3)).toBe('Документы не выбраны');
expect(documentFilterLabel(['a'], 3)).toBe('1 документ');
expect(documentFilterLabel(['a', 'b'], 3)).toBe('2 документа');
expect(documentFilterLabel(['a', 'b', 'c', 'd', 'e'], 9)).toBe('5 документов');
});
});
describe('documentsQueryParam', () => {
const all = ['balance', 'ofr', 'osv_01', 'osv_60'];
it('отмечены все — без отбора', () => {
expect(documentsQueryParam(all, all)).toBeUndefined();
});
it('ключи колонок → документы API через запятую', () => {
expect(documentsQueryParam(['balance', 'ofr', 'osv_01'], all)).toBe('BALANCE,INCOME,OSV:01');
expect(documentsQueryParam(['osv_60'], all)).toBe('OSV:60');
});
});

View File

@ -10,6 +10,7 @@ import { Avatar, type TableColumnsType } from 'antd';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets'; import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets';
import type { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members'; import type { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members';
import { pluralRu } from '../../utils/labels';
import { fileStatusMapping } from './consts'; import { fileStatusMapping } from './consts';
/** Строка таблицы чек-листа: ЮЛ и статусы ББ, ОФР и ОСВ по счетам. */ /** Строка таблицы чек-листа: ЮЛ и статусы ББ, ОФР и ОСВ по счетам. */
@ -93,7 +94,8 @@ export function buildChecklistRows(checklistData: ChecklistBackendShape): Checkl
}); });
} }
export function buildChecklistColumns(checklistData: ChecklistBackendShape): TableColumnsType<ChecklistTableRow> { /** Счета ОСВ матрицы — в порядке первого появления в ячейках строк. */
function collectOsvCodes(checklistData: ChecklistBackendShape): string[] {
const osvCodes: string[] = []; const osvCodes: string[] = [];
for (const row of checklistData.rows) { for (const row of checklistData.rows) {
for (const cell of row.cells) { for (const cell of row.cells) {
@ -102,7 +104,65 @@ export function buildChecklistColumns(checklistData: ChecklistBackendShape): Tab
} }
} }
} }
return osvCodes;
}
const osvKey = (code: string) => `osv_${code}`;
/** Пункты фильтра «Документы» — колонки матрицы: ББ, ОФР, ОСВ по счетам. Ключ пункта = ключ колонки. */
export function buildDocumentFilterOptions(checklistData: ChecklistBackendShape): { key: string; title: string }[] {
return [ return [
{ key: 'balance', title: 'Бух. баланс' },
{ key: 'ofr', title: 'ОФР' },
...collectOsvCodes(checklistData).map((code) => ({ key: osvKey(code), title: `ОСВ ${code}` })),
];
}
/** Клик по документу в фильтре «Документы»: отметить или снять. Порядок ключей — как в `allKeys`. */
export function toggleDocument(selected: string[], key: string, allKeys: string[]): string[] {
return allKeys.filter((k) => (k === key ? !selected.includes(k) : selected.includes(k)));
}
/** Клик по «Все документы»: при всех отмеченных снимает все (чтобы оставить один документ), иначе отмечает все. */
export function toggleAllDocuments(selected: string[], allKeys: string[]): string[] {
return selected.length === allKeys.length ? [] : [...allKeys];
}
/** Ключ колонки → документ в параметре `documents` выгрузки: `BALANCE`, `INCOME`, `OSV:<счёт>`. */
function toApiDocument(key: string): string {
if (key === 'balance') return 'BALANCE';
if (key === 'ofr') return 'INCOME';
return `OSV:${key.slice(osvKey('').length)}`;
}
/**
* Параметр `documents` выгрузки CSV по выбору фильтра: отмечены все — `undefined` (вся матрица, без отбора),
* иначе документы через запятую в порядке колонок: `BALANCE,INCOME,OSV:01`.
*/
export function documentsQueryParam(selected: string[], allKeys: string[]): string | undefined {
if (selected.length === allKeys.length) return undefined;
return selected.map(toApiDocument).join(',');
}
/** Подпись фильтра «Документы» в поле: «Все документы», «Документы не выбраны», «1 документ», «2 документа», «5 документов». */
export function documentFilterLabel(selected: string[], total: number): string {
if (selected.length === 0) return 'Документы не выбраны';
if (selected.length === total) return 'Все документы';
return `${selected.length} ${pluralRu(selected.length, ['документ', 'документа', 'документов'])}`;
}
/**
* Колонки таблицы чек-листа. `selectedDocuments` — ключи колонок, отмеченные в фильтре «Документы»
* (не передан — все колонки; пустой список — только «Организация»).
*/
export function buildChecklistColumns(
checklistData: ChecklistBackendShape,
selectedDocuments?: string[],
): TableColumnsType<ChecklistTableRow> {
const isShown = (key: string) => !selectedDocuments || selectedDocuments.includes(key);
const osvCodes = collectOsvCodes(checklistData).filter((code) => isShown(osvKey(code)));
const columns: TableColumnsType<ChecklistTableRow> = [
{ {
key: 'name', key: 'name',
title: 'ОРГАНИЗАЦИЯ', title: 'ОРГАНИЗАЦИЯ',
@ -112,16 +172,23 @@ export function buildChecklistColumns(checklistData: ChecklistBackendShape): Tab
</h5> </h5>
), ),
}, },
{ key: 'balance', dataIndex: 'balance', title: 'БУХ. БАЛАНС', render: (v: FileStatus) => mapStatus(v) }, ];
{ key: 'ofr', dataIndex: 'ofr', title: 'ОФР', render: (v: FileStatus) => mapStatus(v) }, if (isShown('balance')) {
{ columns.push({ key: 'balance', dataIndex: 'balance', title: 'БУХ. БАЛАНС', render: (v: FileStatus) => mapStatus(v) });
}
if (isShown('ofr')) {
columns.push({ key: 'ofr', dataIndex: 'ofr', title: 'ОФР', render: (v: FileStatus) => mapStatus(v) });
}
if (osvCodes.length > 0) {
columns.push({
key: 'osv', key: 'osv',
title: 'ОСВ', title: 'ОСВ',
children: osvCodes.map((code) => ({ children: osvCodes.map((code) => ({
key: `osv_${code}`, key: osvKey(code),
title: code, title: code,
render: (_: unknown, r: ChecklistTableRow) => mapStatus(r.osv[code] ?? 'MISSING'), render: (_: unknown, r: ChecklistTableRow) => mapStatus(r.osv[code] ?? 'MISSING'),
})), })),
}, });
]; }
return columns;
} }

View File

@ -2,6 +2,7 @@ import { Button, Select, Space } from 'antd';
import type React from 'react'; import type React from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ApiError, api } from '../../../api/client'; import { ApiError, api } from '../../../api/client';
import { Labeled } from '../../../components/Labeled';
import { type OptionType, TreeFilterDropdown } from '../../../components/TreeFilterDropdown/TreeFilterDropdown'; import { type OptionType, TreeFilterDropdown } from '../../../components/TreeFilterDropdown/TreeFilterDropdown';
import type { Account, MatrixCompany, Quarter } from '../../../types/api'; import type { Account, MatrixCompany, Quarter } from '../../../types/api';
import { CHESS_HALF_FILTER_HINT, chessMatrixMode } from '../../../utils/chessMatrixMode'; import { CHESS_HALF_FILTER_HINT, chessMatrixMode } from '../../../utils/chessMatrixMode';
@ -115,8 +116,7 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
return ( return (
<Space size='middle' wrap> <Space size='middle' wrap>
<Space orientation='vertical' size={0}> <Labeled label='Год'>
<span style={{ fontSize: 12, color: '#667085' }}>Год</span>
<Select <Select
disabled disabled
style={{ minWidth: 100 }} style={{ minWidth: 100 }}
@ -124,9 +124,8 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
options={year ? [{ value: year, label: String(year) }] : []} options={year ? [{ value: year, label: String(year) }] : []}
variant='underlined' variant='underlined'
/> />
</Space> </Labeled>
<Space orientation='vertical' size={0}> <Labeled label='Период'>
<span style={{ fontSize: 12, color: '#667085' }}>Период</span>
<Select <Select
disabled disabled
style={{ minWidth: 100 }} style={{ minWidth: 100 }}
@ -134,9 +133,8 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
options={quarter ? [{ value: quarter, label: quarterShortLabel(quarter) }] : []} options={quarter ? [{ value: quarter, label: quarterShortLabel(quarter) }] : []}
variant='underlined' variant='underlined'
/> />
</Space> </Labeled>
<Space orientation='vertical' size={0}> <Labeled label='Компании'>
<span style={{ fontSize: 12, color: '#667085' }}>Компании</span>
<Select <Select
mode='multiple' mode='multiple'
style={{ minWidth: 200 }} style={{ minWidth: 200 }}
@ -162,10 +160,9 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
/> />
)} )}
/> />
</Space> </Labeled>
<Space orientation='vertical' size={0}> <Labeled label='Дт'>
<span style={{ fontSize: 12, color: '#667085' }}>Дт</span>
<Select <Select
mode='multiple' mode='multiple'
style={{ minWidth: 150 }} style={{ minWidth: 150 }}
@ -193,10 +190,9 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
)} )}
loading={optionsLoading} loading={optionsLoading}
/> />
</Space> </Labeled>
<Space orientation='vertical' size={0}> <Labeled label='Кт'>
<span style={{ fontSize: 12, color: '#667085' }}>Кт</span>
<Select <Select
mode='multiple' mode='multiple'
style={{ minWidth: 150 }} style={{ minWidth: 150 }}
@ -224,7 +220,7 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
)} )}
loading={optionsLoading} loading={optionsLoading}
/> />
</Space> </Labeled>
{companies?.length || debit?.length || credit?.length ? <Button onClick={() => dropFiltersHandler()}>Сбросить фильтры</Button> : null} {companies?.length || debit?.length || credit?.length ? <Button onClick={() => dropFiltersHandler()}>Сбросить фильтры</Button> : null}
</Space> </Space>
); );

View File

@ -1,7 +1,8 @@
// Панель над таблицей: «Отображение», «Правило», «Тип записи», единицы и знаки, сброс фильтров, настройка, экспорт, весь экран. // Панель над таблицей: «Отображение», «Правило», «Тип записи», единицы и знаки, сброс фильтров, настройка, экспорт, весь экран.
import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons'; 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 { ColumnSettings } from '../../components/GenericTable/ColumnSettings';
import { Labeled } from '../../components/Labeled';
import type { CorrectionRuleOut } from '../../types/corrections'; import type { CorrectionRuleOut } from '../../types/corrections';
import { PRECISIONS, UNITS, type Unit } from '../ConsolidatedOsv/useNumberDisplay'; import { PRECISIONS, UNITS, type Unit } from '../ConsolidatedOsv/useNumberDisplay';
import { COLUMN_DEFS, type ColKey, type ColShape } from './columns'; import { COLUMN_DEFS, type ColKey, type ColShape } from './columns';
@ -31,13 +32,6 @@ const RECORD_TYPES: { value: RecordType; label: string }[] = [
{ value: 'unallocated', label: 'Нераспределенные записи' }, { value: 'unallocated', label: 'Нераспределенные записи' },
]; ];
const Labeled = ({ label, children }: { label: string; children: React.ReactNode }) => (
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>{label}</span>
{children}
</Space>
);
export function CorrectionToolbar(p: Props) { export function CorrectionToolbar(p: Props) {
const { filters, rules } = p; const { filters, rules } = p;