diff --git a/README.md b/README.md index 524011f..1430fac 100644 --- a/README.md +++ b/README.md @@ -560,7 +560,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл Подтверждённая форма читается. - **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id, column_id, amount}]}` (до 5000 ячеек; `amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов, - пробельная строка — 422, `null` — «не менять»). Правится только неподтверждённая форма + пробельная строка — 422, `null` — «не менять»; `inn: null` — 422 `INVALID_INN`, ИНН не очищается). Правится только неподтверждённая форма (`RECOGNIZED`/`NEEDS_CHECK`): `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление — новым файлом), заменённый файл — 409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL`, одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно diff --git a/api/src/api/endpoints/files/read.py b/api/src/api/endpoints/files/read.py index 236a7c3..1aa9c37 100644 --- a/api/src/api/endpoints/files/read.py +++ b/api/src/api/endpoints/files/read.py @@ -96,9 +96,10 @@ async def get_file_content( ) -> StreamingResponse: """Содержимое исходного файла из S3 потоком (в память целиком не читается); `inline` — для просмотра в браузере.""" stream, media_type, header = await service.content(file_id, disposition) - return ClosingStreamingResponse( - stream, media_type=media_type, headers={"Content-Disposition": header, "X-Content-Type-Options": "nosniff"} - ) + headers = {"Content-Disposition": header, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store"} + if disposition == "inline": + headers["Content-Security-Policy"] = "sandbox" # просмотр в браузере: активное содержимое файла не исполняется + return ClosingStreamingResponse(stream, media_type=media_type, headers=headers) @router.delete( @@ -109,5 +110,6 @@ async def get_file_content( summary="Удалить файл", ) async def delete_file(file_id: uuid.UUID, service: PrimaryFilesServiceDep) -> None: - """Удаляет строки ОСВ, документы, задания и файл; объекту S3 ставится `expires_at`; аудит `FILE_DELETE`.""" + """Удаляет формы ББ/ОФР, комментарии, строки ОСВ, документы, задания и файл; объектам S3 ставится `expires_at`; + аудит `FILE_DELETE`.""" await service.delete(file_id) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 28c41d0..dcdafb8 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -113,6 +113,12 @@ async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> N await session.execute(delete(FinancialStatementMeta).where(FinancialStatementMeta.document_id.in_(documents))) +async def has_lines(session: AsyncSession, meta_id: uuid.UUID) -> bool: + """В форме есть строки `financial_statement_line` (единое определение «есть строки» для статуса D-6).""" + stmt = select(select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta_id).exists()) + return bool((await session.execute(stmt)).scalar_one()) + + async def statement_amounts( session: AsyncSession, meta_id: uuid.UUID ) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]: diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index c546d2a..614ff5f 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -238,7 +238,8 @@ class CellPatch(BaseModel): class HeaderPatch(BaseModel): - """Отсутствующее поле не меняется; `null` у `report_date`/`inn` — очистить значение (`model_fields_set`). + """Отсутствующее поле не меняется; `report_date: null` — очистить дату (`model_fields_set`); `inn: null` — 422 + `INVALID_INN` (ИНН нельзя очистить: иначе обходилась бы проверка контрагента). `units: null` — «не менять» (единицы измерения очищать нельзя); непустая строка после обрезки пробелов, до 50 символов (пробельная строка — 422).""" diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 9812950..15d964f 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -121,14 +121,13 @@ class StatementService: if document.status is DocumentStatus.CONFIRMED: return job = await self._session.get(RecognitionJob, meta.recognition_job_id) - _, amounts = await repo.statement_amounts(self._session, meta.id) document.status = statement_status( job_done=job is not None and job.status is RecognitionStatus.done, checks_passed=not await self.hard_failures(document, meta), counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, period_known=document.period_year is not None and document.period_quarter is not None, values_ok=values_ok(await repo.value_flags(self._session, meta.id)), - has_lines=bool(amounts), + has_lines=await repo.has_lines(self._session, meta.id), warnings=(), ) document.updated_at = utc_now() @@ -192,7 +191,7 @@ class StatementService: """Правка значений и шапки (D-7): одна транзакция, один `audit_event`, сброс акцепта, пересчёт статуса.""" document, meta = await self._editable(document_id) header = body.header - if header is not None and header.inn is not None and not INN_RE.fullmatch(header.inn): + if header is not None and "inn" in header.model_fields_set and not INN_RE.fullmatch(header.inn or ""): raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр") before: dict[str, Any] = {} after: dict[str, Any] = {} diff --git a/api/tests/test_api_files_content.py b/api/tests/test_api_files_content.py index 40b6eea..1131577 100644 --- a/api/tests/test_api_files_content.py +++ b/api/tests/test_api_files_content.py @@ -49,6 +49,8 @@ async def test_inline_pdf(app, seeded: AsyncSession, auth_client: AuthClientFact assert response.headers["content-disposition"] == "inline; filename=\"file.pdf\"; filename*=UTF-8''aep_6.pdf" assert response.content == PDF assert response.headers["x-content-type-options"] == "nosniff" # T9.5 + assert response.headers["cache-control"] == "private, no-store" # M-7 + assert response.headers["content-security-policy"] == "sandbox" # inline: скрипты внутри файла не исполняются async def test_cyrillic_name_and_missing(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001 @@ -58,6 +60,8 @@ async def test_cyrillic_name_and_missing(app, seeded: AsyncSession, auth_client: client = await auth_client(KA_RF) response = await client.get(f"/files/{file.id}/content") assert response.headers["content-type"] == "application/vnd.ms-excel" + assert response.headers["cache-control"] == "private, no-store" + assert "content-security-policy" not in response.headers # только для inline assert response.headers["content-disposition"].startswith( "attachment; filename=\"file.xls\"; filename*=UTF-8''%D0%9E" ) diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 2d7193e..03fbddd 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -221,6 +221,16 @@ async def test_cross_form_violation_is_reported_on_both_forms( "expected": "200.00", "actual": "150.00", }, document.document_type + # мягкая межформенная проверка не блокирует подтверждение: подмена 1370 ломает и жёсткие проверки самого ББ — + # после их акцепта форма с нарушением 1370↔2400 подтверждается + failures = (await client.post(f"/documents/{balance.id}/confirm")).json()["detail"]["failures"] + assert failures and all(item["relation"] != CROSS_FORM_RELATION for item in failures) + accepted = await client.post(f"/documents/{balance.id}/statement/checks/accept", json={"comment": "тест"}) + assert accepted.status_code == HTTPStatus.OK, accepted.text + assert accepted.json()["checks"]["cross_form"] is not None + confirmed = await client.post(f"/documents/{balance.id}/confirm") + assert confirmed.status_code == HTTPStatus.OK, confirmed.text + assert confirmed.json()["status"] == "CONFIRMED" # соотношение выполнено: 500 − 300 = 200 → проверка не нарушена await set_amount(seeded, income, "2400", Decimal("200"), current=True) body = (await client.get(f"/documents/{balance.id}/statement")).json() @@ -357,6 +367,21 @@ async def test_patch_inn_validates_and_rechecks( assert other.json()["document"]["status"] == "NEEDS_CHECK" +async def test_patch_inn_null_is_invalid_and_keeps_inn( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """M-5: `inn: null` — 422 `INVALID_INN` (ИНН не очищается, MISMATCH не обходится), данные не меняются.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + before = (await client.get(url)).json() + response = await client.patch(url, json={"header": {"inn": None}}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "INVALID_INN" + assert (await client.get(url)).json() == before + assert (await client.patch(url, json={"header": {"units": "тыс. руб."}})).status_code == HTTPStatus.OK # без inn + + async def test_patch_invalid_inn_changes_nothing( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: @@ -871,3 +896,23 @@ async def test_confirm_undated_form_requires_period( assert edit.status_code == HTTPStatus.OK, edit.text assert edit.json()["document"]["period_year"] == 2024 # noqa: PLR2004 - год даты из правки assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK + + +async def test_status_with_lines_but_no_columns_is_stable_after_edit( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path +) -> None: + """M-2: «есть строки» — наличие строк формы (как при маппинге), а не наличие сумм: форма со строками без колонок + остаётся `RECOGNIZED` и после правки шапки.""" + data = json.loads((fixtures_dir / MEZH_BALANCE).read_text("utf-8")) + data["documents"][0]["columns"] = [] + for line in data["documents"][0]["lines"]: + line["values"] = [] + path = tmp_path / "no_columns.json" + path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") + entity = await rostov_entity(seeded, path) + (document,) = await map_golden_snapshot(seeded, entity=entity, path=path, uploaded_by=None) + assert document.status is DocumentStatus.RECOGNIZED + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["lines"] and response.json()["document"]["status"] == "RECOGNIZED"