[AURORA-1678]: Чек-лист документов: #227
@ -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 | ЮЛ не входит в ГК этой ГСК/ГСЗ |
|
||||
|
||||
@ -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)
|
||||
|
||||
48
api/src/domain/checklist_filter.py
Normal file
48
api/src/domain/checklist_filter.py
Normal 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],
|
||||
)
|
||||
@ -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"
|
||||
|
||||
77
api/tests/test_domain_checklist_filter.py
Normal file
77
api/tests/test_domain_checklist_filter.py
Normal 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)
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-11-10
|
||||
22
openspec/changes/checklist-documents-filter/design.md
Normal file
22
openspec/changes/checklist-documents-filter/design.md
Normal 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): окно открыто внутри версии одной ГК | Низкая: добавить, если чек-лист появится вне версии |
|
||||
33
openspec/changes/checklist-documents-filter/proposal.md
Normal file
33
openspec/changes/checklist-documents-filter/proposal.md
Normal 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] — окно открыто внутри версии одной ГК.
|
||||
- Колонка «Прочие документы» макета — правил подхода для неё нет.
|
||||
- Сохранение выбора между открытиями окна.
|
||||
@ -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`, файл не отдаётся
|
||||
@ -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** в поле «Документы не выбраны», в матрице только «Организация», «Скачать» неактивна
|
||||
9
openspec/changes/checklist-documents-filter/tasks.md
Normal file
9
openspec/changes/checklist-documents-filter/tasks.md
Normal 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`
|
||||
@ -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<ConsolidationFormatTableFi
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 16 }}>
|
||||
<Space size='middle' wrap>
|
||||
<Space orientation='vertical' size={0}>
|
||||
<span style={{ fontSize: 12, color: '#667085' }}>Единицы</span>
|
||||
<Labeled label='Единицы'>
|
||||
<Select style={{ minWidth: 120 }} options={UNIT_OPTIONS} value={units} onChange={onUnitsChange} variant='underlined' />
|
||||
</Space>
|
||||
</Labeled>
|
||||
|
||||
<Space orientation='vertical' size={0}>
|
||||
<span style={{ fontSize: 12, color: '#667085' }}>Знаков после запятой</span>
|
||||
<Labeled label='Знаков после запятой'>
|
||||
<Select
|
||||
style={{ minWidth: 150 }}
|
||||
value={decimals}
|
||||
@ -44,7 +43,7 @@ export const ConsolidationFormatTableFilter: React.FC<ConsolidationFormatTableFi
|
||||
options={DECIMALS_OPTIONS}
|
||||
variant='underlined'
|
||||
/>
|
||||
</Space>
|
||||
</Labeled>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
10
web/src/components/Labeled.tsx
Normal file
10
web/src/components/Labeled.tsx
Normal 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>
|
||||
);
|
||||
@ -9,7 +9,12 @@ export const getConsolidations = (groupId: string, in_work_only: boolean) =>
|
||||
api.get<IConsolidation[]>(`/company-groups/${groupId}/consolidations`, { in_work_only });
|
||||
export const getVersionCard = (versionId: string) => api.get<VersionCard>(`/consolidations/versions/${versionId}`);
|
||||
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`);
|
||||
/** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */
|
||||
export const lockVersion = (versionId: string, reason?: string | null) =>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -5,25 +5,40 @@ import { ApiError } from '../../api/client';
|
||||
import { AppModal } from '../../components/AppModal/AppModal';
|
||||
import { getChecklist } from '../../services/api/consolidations';
|
||||
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 = {
|
||||
open: boolean;
|
||||
onDownload: () => void;
|
||||
onDownload: (documents?: string) => void;
|
||||
consolidationData: IVersionCard;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ChecklistModal({ open, onDownload, consolidationData, onClose }: ChecklistProps) {
|
||||
const [checklistData, setChecklistData] = useState<ChecklistBackendShape | null>(null);
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);
|
||||
|
||||
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(() => {
|
||||
if (!consolidationData.id || !open) return;
|
||||
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 : 'Не удалось загрузить чек-лист'));
|
||||
}, [open, consolidationData.id]);
|
||||
|
||||
@ -33,15 +48,21 @@ export default function ChecklistModal({ open, onDownload, consolidationData, on
|
||||
open={open}
|
||||
cancelText='Отмена'
|
||||
onCancel={onClose}
|
||||
title='Чеклист'
|
||||
title='Чек-лист загруженных документов'
|
||||
destroyOnHidden
|
||||
onOk={() => {
|
||||
onDownload();
|
||||
onDownload(
|
||||
documentsQueryParam(
|
||||
selectedDocuments,
|
||||
documentOptions.map((o) => o.key),
|
||||
),
|
||||
);
|
||||
onClose();
|
||||
}}
|
||||
okText='Скачать чек-лист'
|
||||
okButtonProps={{ icon: <DownloadOutlined /> }}>
|
||||
okText='Скачать'
|
||||
okButtonProps={{ icon: <DownloadOutlined />, disabled: selectedDocuments.length === 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<ChecklistDocumentsFilter options={documentOptions} value={selectedDocuments} onChange={setSelectedDocuments} />
|
||||
{getMarksItems()}
|
||||
<Table rowKey='key' columns={columns} dataSource={rows} pagination={false} />
|
||||
</div>
|
||||
|
||||
@ -39,10 +39,10 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
|
||||
}, [consolidation_id, reloadKey]);
|
||||
const rawRows = useMemo(() => normalizeData(consolidationData), [consolidationData]);
|
||||
|
||||
const downloadChecklist = async () => {
|
||||
const downloadChecklist = async (documents?: string) => {
|
||||
if (!consolidationData) return;
|
||||
try {
|
||||
const { blob, filename } = await downloadChecklistCsv(consolidationData.id);
|
||||
const { blob, filename } = await downloadChecklistCsv(consolidationData.id, documents);
|
||||
const card = consolidationData;
|
||||
saveBlob(blob, filename ?? checklistFilename(card.company_group.name, card.year, card.quarter));
|
||||
} catch (e: unknown) {
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
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 = {
|
||||
rules: [],
|
||||
@ -37,3 +45,96 @@ describe('buildChecklistRows', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -10,6 +10,7 @@ import { Avatar, type TableColumnsType } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets';
|
||||
import type { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members';
|
||||
import { pluralRu } from '../../utils/labels';
|
||||
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[] = [];
|
||||
for (const row of checklistData.rows) {
|
||||
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 [
|
||||
{ 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',
|
||||
title: 'ОРГАНИЗАЦИЯ',
|
||||
@ -112,16 +172,23 @@ export function buildChecklistColumns(checklistData: ChecklistBackendShape): Tab
|
||||
</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',
|
||||
title: 'ОСВ',
|
||||
children: osvCodes.map((code) => ({
|
||||
key: `osv_${code}`,
|
||||
key: osvKey(code),
|
||||
title: code,
|
||||
render: (_: unknown, r: ChecklistTableRow) => mapStatus(r.osv[code] ?? 'MISSING'),
|
||||
})),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { Button, Select, Space } from 'antd';
|
||||
import type React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ApiError, api } from '../../../api/client';
|
||||
import { Labeled } from '../../../components/Labeled';
|
||||
import { type OptionType, TreeFilterDropdown } from '../../../components/TreeFilterDropdown/TreeFilterDropdown';
|
||||
import type { Account, MatrixCompany, Quarter } from '../../../types/api';
|
||||
import { CHESS_HALF_FILTER_HINT, chessMatrixMode } from '../../../utils/chessMatrixMode';
|
||||
@ -115,8 +116,7 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
|
||||
|
||||
return (
|
||||
<Space size='middle' wrap>
|
||||
<Space orientation='vertical' size={0}>
|
||||
<span style={{ fontSize: 12, color: '#667085' }}>Год</span>
|
||||
<Labeled label='Год'>
|
||||
<Select
|
||||
disabled
|
||||
style={{ minWidth: 100 }}
|
||||
@ -124,9 +124,8 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
|
||||
options={year ? [{ value: year, label: String(year) }] : []}
|
||||
variant='underlined'
|
||||
/>
|
||||
</Space>
|
||||
<Space orientation='vertical' size={0}>
|
||||
<span style={{ fontSize: 12, color: '#667085' }}>Период</span>
|
||||
</Labeled>
|
||||
<Labeled label='Период'>
|
||||
<Select
|
||||
disabled
|
||||
style={{ minWidth: 100 }}
|
||||
@ -134,9 +133,8 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
|
||||
options={quarter ? [{ value: quarter, label: quarterShortLabel(quarter) }] : []}
|
||||
variant='underlined'
|
||||
/>
|
||||
</Space>
|
||||
<Space orientation='vertical' size={0}>
|
||||
<span style={{ fontSize: 12, color: '#667085' }}>Компании</span>
|
||||
</Labeled>
|
||||
<Labeled label='Компании'>
|
||||
<Select
|
||||
mode='multiple'
|
||||
style={{ minWidth: 200 }}
|
||||
@ -162,10 +160,9 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Space>
|
||||
</Labeled>
|
||||
|
||||
<Space orientation='vertical' size={0}>
|
||||
<span style={{ fontSize: 12, color: '#667085' }}>Дт</span>
|
||||
<Labeled label='Дт'>
|
||||
<Select
|
||||
mode='multiple'
|
||||
style={{ minWidth: 150 }}
|
||||
@ -193,10 +190,9 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
|
||||
)}
|
||||
loading={optionsLoading}
|
||||
/>
|
||||
</Space>
|
||||
</Labeled>
|
||||
|
||||
<Space orientation='vertical' size={0}>
|
||||
<span style={{ fontSize: 12, color: '#667085' }}>Кт</span>
|
||||
<Labeled label='Кт'>
|
||||
<Select
|
||||
mode='multiple'
|
||||
style={{ minWidth: 150 }}
|
||||
@ -224,7 +220,7 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
|
||||
)}
|
||||
loading={optionsLoading}
|
||||
/>
|
||||
</Space>
|
||||
</Labeled>
|
||||
{companies?.length || debit?.length || credit?.length ? <Button onClick={() => dropFiltersHandler()}>Сбросить фильтры</Button> : null}
|
||||
</Space>
|
||||
);
|
||||
|
||||
@ -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 }) => (
|
||||
<Space orientation='vertical' size={0}>
|
||||
<span style={{ fontSize: 12, color: '#667085' }}>{label}</span>
|
||||
{children}
|
||||
</Space>
|
||||
);
|
||||
|
||||
export function CorrectionToolbar(p: Props) {
|
||||
const { filters, rules } = p;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user