fix(statements): шапка формы перечитывается под блокировкой; тесты аудита и сброса акцепта (I-1, T6.1)
I-1: `_editable` брал шапку без перечитывания, и после ожидания блокировки видел устаревший акцепт и значения шапки. `get_meta(..., refresh=True)` читает с populate_existing после lock_file и lock_document. T6.1: тесты — содержимое аудита правки значения и акцепта, исходное значение даты отчёта, дата не на конце квартала, сброс акцепта правкой только значения, неизменность формы после INVALID_INN, свежесть шапки под блокировкой. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
38a6a1b0c2
commit
6c00a8705f
@ -182,9 +182,13 @@ async def lock_cells(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_meta(session: AsyncSession, document_id: uuid.UUID) -> FinancialStatementMeta | None:
|
async def get_meta(
|
||||||
"""Шапка формы документа (1:1) или `None`."""
|
session: AsyncSession, document_id: uuid.UUID, *, refresh: bool = False
|
||||||
|
) -> FinancialStatementMeta | None:
|
||||||
|
"""Шапка формы документа (1:1) или `None`; `refresh` — перечитать атрибуты, даже если объект уже в сессии."""
|
||||||
stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id)
|
stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id)
|
||||||
|
if refresh:
|
||||||
|
stmt = stmt.execution_options(populate_existing=True)
|
||||||
return (await session.execute(stmt)).scalar_one_or_none()
|
return (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -76,7 +76,9 @@ class StatementService:
|
|||||||
raise ConflictError(DOCUMENT_READONLY, "подтверждённая форма только для чтения: загрузите новый файл")
|
raise ConflictError(DOCUMENT_READONLY, "подтверждённая форма только для чтения: загрузите новый файл")
|
||||||
if document.status not in CONFIRMABLE_STATUSES:
|
if document.status not in CONFIRMABLE_STATUSES:
|
||||||
raise ConflictError(DOCUMENT_NOT_CONFIRMABLE, f"форма в статусе {document.status.value} не правится")
|
raise ConflictError(DOCUMENT_NOT_CONFIRMABLE, f"форма в статусе {document.status.value} не правится")
|
||||||
meta = await repo.get_meta(self._session, document.id)
|
# читаем под блокировкой: `_visible_statement` загрузил шапку до неё, а параллельный запрос мог её изменить
|
||||||
|
# (например, поставить акцепт) — без `refresh` в сессии остался бы устаревший экземпляр
|
||||||
|
meta = await repo.get_meta(self._session, document.id, refresh=True)
|
||||||
if meta is None:
|
if meta is None:
|
||||||
raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР")
|
raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР")
|
||||||
return document, meta
|
return document, meta
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from sqlalchemy import select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.domain.statement_checks import CROSS_FORM_RELATION
|
from src.domain.statement_checks import CROSS_FORM_RELATION
|
||||||
|
from src.models.access import AppUser
|
||||||
from src.models.audit_ex import AuditEvent
|
from src.models.audit_ex import AuditEvent
|
||||||
from src.models.directories import LegalEntity, RegionalOffice
|
from src.models.directories import LegalEntity, RegionalOffice
|
||||||
from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, RecognitionStatus
|
from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, RecognitionStatus
|
||||||
@ -35,6 +36,7 @@ ROSTOV = "Ростовский региональный филиал"
|
|||||||
MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json"
|
MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json"
|
||||||
MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
|
MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
|
||||||
MEZH_BALANCE_LINES = 37
|
MEZH_BALANCE_LINES = 37
|
||||||
|
EVENTS_AFTER_BREAK_AND_ACCEPT = 2
|
||||||
|
|
||||||
|
|
||||||
async def rostov_entity(session: AsyncSession, path: Path, name: str = "ООО Межениновская") -> LegalEntity:
|
async def rostov_entity(session: AsyncSession, path: Path, name: str = "ООО Межениновская") -> LegalEntity:
|
||||||
@ -297,7 +299,11 @@ async def test_patch_value_and_revert(seeded: AsyncSession, fixtures_dir: Path,
|
|||||||
.scalars()
|
.scalars()
|
||||||
.one()
|
.one()
|
||||||
)
|
)
|
||||||
assert event.before_value and event.after_value
|
key = f"value:{edited['id']}"
|
||||||
|
assert set(event.before_value) == {key} == set(event.after_value)
|
||||||
|
assert Decimal(event.before_value[key]) == Decimal(original) and Decimal(event.after_value[key]) == Decimal(new)
|
||||||
|
user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
|
||||||
|
assert edited["modified_by"] == str(user_id)
|
||||||
response = await client.patch(
|
response = await client.patch(
|
||||||
f"/documents/{document.id}/statement",
|
f"/documents/{document.id}/statement",
|
||||||
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": original}]},
|
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": original}]},
|
||||||
@ -311,10 +317,26 @@ async def test_patch_report_date_recomputes_period(
|
|||||||
) -> None:
|
) -> None:
|
||||||
document = await form(seeded, fixtures_dir)
|
document = await form(seeded, fixtures_dir)
|
||||||
client = await auth_client(KA_RF)
|
client = await auth_client(KA_RF)
|
||||||
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-06-30"}})
|
url = f"/documents/{document.id}/statement"
|
||||||
|
original = (await client.get(url)).json()["header"]["report_date"]["original_value"]
|
||||||
|
assert original is not None
|
||||||
|
response = await client.patch(url, json={"header": {"report_date": "2025-06-30"}})
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["document"]["period_quarter"] == "Q2" and body["header"]["report_date"]["modified"] is True
|
assert body["document"]["period_quarter"] == "Q2" and body["header"]["report_date"]["modified"] is True
|
||||||
assert body["header"]["report_date"]["original_value"] is not None # исходник не тронут
|
assert body["header"]["report_date"]["value"] == "2025-06-30"
|
||||||
|
assert body["header"]["report_date"]["original_value"] == original # исходник не тронут
|
||||||
|
|
||||||
|
|
||||||
|
async def test_patch_report_date_not_quarter_end(
|
||||||
|
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||||
|
) -> None:
|
||||||
|
"""Review Focus 5 (PATCH): дата не на конце квартала — квартал пуст, статус `NEEDS_CHECK`."""
|
||||||
|
document = await form(seeded, fixtures_dir)
|
||||||
|
client = await auth_client(KA_RF)
|
||||||
|
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}})
|
||||||
|
assert response.status_code == HTTPStatus.OK, response.text
|
||||||
|
body = response.json()["document"]
|
||||||
|
assert body["period_quarter"] is None and body["status"] == "NEEDS_CHECK"
|
||||||
|
|
||||||
|
|
||||||
async def test_patch_inn_validates_and_rechecks(
|
async def test_patch_inn_validates_and_rechecks(
|
||||||
@ -329,6 +351,61 @@ async def test_patch_inn_validates_and_rechecks(
|
|||||||
assert other.json()["document"]["status"] == "NEEDS_CHECK"
|
assert other.json()["document"]["status"] == "NEEDS_CHECK"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_patch_invalid_inn_changes_nothing(
|
||||||
|
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||||
|
) -> None:
|
||||||
|
"""После `INVALID_INN` значения из того же тела не применяются, аудита нет."""
|
||||||
|
document = await form(seeded, fixtures_dir)
|
||||||
|
client = await auth_client(KA_RF)
|
||||||
|
url = f"/documents/{document.id}/statement"
|
||||||
|
line, cell = await _first_cell(client, document.id)
|
||||||
|
before = (await client.get(url)).json()
|
||||||
|
body = {
|
||||||
|
"header": {"inn": "12345"},
|
||||||
|
"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)}],
|
||||||
|
}
|
||||||
|
response = await client.patch(url, json=body)
|
||||||
|
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||||
|
assert response.json()["detail"]["code"] == "INVALID_INN"
|
||||||
|
assert (await client.get(url)).json() == before
|
||||||
|
events = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().all()
|
||||||
|
assert events == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_patch_rereads_meta_under_lock(
|
||||||
|
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||||
|
) -> None:
|
||||||
|
"""Шапка перечитывается под блокировкой: акцепт, поставленный параллельным запросом после загрузки шапки в
|
||||||
|
сессию, виден правке и сбрасывается ею (иначе акцепт остался бы поверх изменённых данных)."""
|
||||||
|
document = await form(seeded, fixtures_dir)
|
||||||
|
client = await auth_client(KA_RF)
|
||||||
|
url = f"/documents/{document.id}/statement"
|
||||||
|
line, cell = await _first_cell(client, document.id)
|
||||||
|
# карта идентичности слабая: удерживаем шапку, как это делает запрос между `_visible_statement` и блокировкой
|
||||||
|
held = (
|
||||||
|
await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
|
||||||
|
).scalar_one()
|
||||||
|
user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
|
||||||
|
await seeded.execute(
|
||||||
|
update(FinancialStatementMeta)
|
||||||
|
.where(FinancialStatementMeta.document_id == document.id)
|
||||||
|
.values(
|
||||||
|
checks_accepted_by=user_id,
|
||||||
|
checks_accepted_at=datetime.datetime.now(datetime.UTC),
|
||||||
|
checks_accepted_comment="x",
|
||||||
|
)
|
||||||
|
.execution_options(synchronize_session=False) # объект в сессии остаётся устаревшим, как у ждущего запроса
|
||||||
|
)
|
||||||
|
item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)}
|
||||||
|
response = await client.patch(url, json={"values": [item]})
|
||||||
|
assert response.status_code == HTTPStatus.OK, response.text
|
||||||
|
assert response.json()["checks"]["accepted"] is None
|
||||||
|
await seeded.refresh(held)
|
||||||
|
assert held.checks_accepted_at is None and held.checks_accepted_comment is None
|
||||||
|
event = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().one()
|
||||||
|
assert event.before_value["checks_accepted"]["by"] == str(user_id) and event.after_value["checks_accepted"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_patch_duplicate_cell_rejected(
|
async def test_patch_duplicate_cell_rejected(
|
||||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -434,6 +511,51 @@ async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_c
|
|||||||
assert after["checks"]["accepted"] is None and after["document"]["status"] == "NEEDS_CHECK"
|
assert after["checks"]["accepted"] is None and after["document"]["status"] == "NEEDS_CHECK"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_accept_audit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
|
||||||
|
document = await form(seeded, fixtures_dir)
|
||||||
|
client = await auth_client(KA_RF)
|
||||||
|
await _break_balance(client, document.id)
|
||||||
|
response = await client.post(
|
||||||
|
f"/documents/{document.id}/statement/checks/accept", json={"comment": " округление "}
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.OK, response.text
|
||||||
|
user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
|
||||||
|
events = (
|
||||||
|
(
|
||||||
|
await seeded.execute(
|
||||||
|
select(AuditEvent).where(
|
||||||
|
AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(events) == EVENTS_AFTER_BREAK_AND_ACCEPT # правка значения в `_break_balance` и акцепт
|
||||||
|
(accept,) = [event for event in events if "checks_accepted" in event.after_value]
|
||||||
|
assert accept.before_value == {"checks_accepted": None}
|
||||||
|
assert set(accept.after_value) == {"checks_accepted"}
|
||||||
|
assert set(accept.after_value["checks_accepted"]) == {"by", "at", "comment"}
|
||||||
|
assert accept.after_value["checks_accepted"]["by"] == str(user_id)
|
||||||
|
assert accept.after_value["checks_accepted"]["comment"] == "округление" # без пробелов по краям
|
||||||
|
|
||||||
|
|
||||||
|
async def test_value_patch_resets_accept(
|
||||||
|
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||||
|
) -> None:
|
||||||
|
"""Сбрасывает акцепт и правка одного только значения (не только шапки)."""
|
||||||
|
document = await form(seeded, fixtures_dir)
|
||||||
|
client = await auth_client(KA_RF)
|
||||||
|
url = f"/documents/{document.id}/statement"
|
||||||
|
await _break_balance(client, document.id)
|
||||||
|
assert (await client.post(f"{url}/checks/accept", json={"comment": "ок"})).status_code == HTTPStatus.OK
|
||||||
|
line, cell = await _first_cell(client, document.id)
|
||||||
|
item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)}
|
||||||
|
response = await client.patch(url, json={"values": [item]})
|
||||||
|
assert response.status_code == HTTPStatus.OK, response.text
|
||||||
|
assert response.json()["checks"]["accepted"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_accept_blank_comment_rejected(
|
async def test_accept_blank_comment_rejected(
|
||||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user