fix(statements): единое определение has_lines, inn=null — 422, заголовки отдачи первички, тест подтверждения при нарушении 1370/2400 (M-2, M-5, M-7, M-8)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 14:31:14 +04:00
parent e851445866
commit ffc01836b7
7 changed files with 66 additions and 9 deletions

View File

@ -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`. Один запрос — одна транзакция и одно

View File

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

View File

@ -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]]]]:

View File

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

View File

@ -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] = {}

View File

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

View File

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