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:
parent
e851445866
commit
ffc01836b7
@ -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`. Один запрос — одна транзакция и одно
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]]]]:
|
||||
|
||||
@ -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)."""
|
||||
|
||||
@ -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] = {}
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user