diff --git a/api/src/services/documents.py b/api/src/services/documents.py index d2b58fb..5b865bb 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -10,24 +10,48 @@ перечитываются под блокировкой: файл, заменённый параллельной автозаменой, даёт 409 `FILE_SUPERSEDED` (Review Focus 5). """ +import datetime import uuid from collections.abc import Mapping, Sequence from typing import Any +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import ConflictError, NotFoundError +from src.domain.file_types import PIPELINES from src.domain.policy import VisibilityScope +from src.domain.quality_attribution import ( + Attribution, + attribute_added_row, + attribute_code_fix, + attribute_header_fix, + attribute_value_fix, +) +from src.domain.quality_facts import build_outcome_payload from src.models.access import AppUser from src.models.audit_ex import Comment -from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, IssueSeverity +from src.models.enums import ( + AuditAction, + AuditSection, + DocumentIssueKind, + DocumentStatus, + DocumentType, + FileStatus, + IssueSeverity, + IssueSource, + QualityCause, + QualityOutcome, + RecognitionPipeline, +) from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry -from src.models.statements import FinancialStatementMeta +from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue from src.repositories import files as files_repo from src.repositories import statements as statements_repo +from src.repositories.quality_facts import insert_outcome_fact from src.schemas.comments import CommentAuthorOut, CommentIn, CommentOut from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut from src.schemas.files import DocumentBrief, FileOut, FileWarningOut, JobBrief @@ -45,6 +69,124 @@ DOCUMENT_NOT_CONFIRMABLE = "DOCUMENT_NOT_CONFIRMABLE" PERIOD_REQUIRED = "PERIOD_REQUIRED" CHECKS_NOT_PASSED = "CHECKS_NOT_PASSED" CONFIRMABLE_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK}) +# §14.2-§14.4 (design quality-metrics §4.2, §5, §6; AURORA-1759): факт исхода `CONFIRMED` пишется только для этих +# типов — тот же набор, что `INTAKE_FACT_DOCUMENT_TYPES` в `services/result_mapping.py` (Task 5) и +# `OUTCOME_FACT_DOCUMENT_TYPES` в `services/auto_confirm.py` (Task 6); не импортируется оттуда во избежание цикла +# `documents → auto_confirm → statements → documents` (то же решение, что в Task 6) +OUTCOME_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV}) +# поле шапки → (атрибут «правилось», имя поля в `financial_statement_meta`); порядок — как в §14.2 «Шапка» +_HEADER_FIX_FIELDS: tuple[tuple[str, str], ...] = ( + ("inn_modified", "inn"), + ("report_date_modified", "report_date"), + ("units_modified", "units"), + ("organization_name_modified", "organization_name"), +) +_FIX_COUNTER_KEYS: tuple[str, ...] = ( + "fixes_value_service", + "fixes_value_ours", + "fixes_code_service", + "fixes_code_ours", + "fixes_added_row_service", + "fixes_added_row_ours", + "fixes_header_service", + "fixes_header_ours", + "fixes_osv_period_account", +) +_SERVICE_COUNTER_KEYS: tuple[str, ...] = ( + "fixes_value_service", + "fixes_code_service", + "fixes_added_row_service", + "fixes_header_service", +) +_OURS_COUNTER_KEYS: tuple[str, ...] = ( + "fixes_value_ours", + "fixes_code_ours", + "fixes_added_row_ours", + "fixes_header_ours", + "fixes_osv_period_account", +) + + +def _value_fix(value: FinancialStatementValue) -> dict[str, Any] | None: + """Правка суммы (§14.2 «Сумма»): `None` — значение не правилось (`is_modified` не `True`). + + Нет сырого текста для сравнения (`source_text is None`) — классифицировать нельзя; по решению design §5 + «спорное — в наш разбор» (decision log 27.10.2026: анти-цель — не приписывать сервису наши недоработки) + считаем правку нашей, не пропускаем (решение задачи 7, см. отчёт).""" + if not value.is_modified: + return None + confirmed = str(value.amount) if value.amount is not None else "" + attribution: Attribution = ( + "ours" if value.source_text is None else attribute_value_fix(confirmed=confirmed, raw_text=value.source_text) + ) + return { + "kind": "value", + "line_id": str(value.line_id), + "attribution": attribution, + "before": str(value.original_amount) if value.original_amount is not None else None, + "after": confirmed, + } + + +def _code_fix(line: FinancialStatementLine) -> dict[str, Any] | None: + """Правка кода строки (§14.2 «Код строки»): `None` — код не правился. Нет сырого текста — «наш разбор» + (то же решение, что у правки суммы).""" + if not line.line_code_modified: + return None + confirmed_code = line.line_code or "" + attribution: Attribution = ( + "ours" + if line.line_code_text is None + else attribute_code_fix(confirmed_code=confirmed_code, raw_text=line.line_code_text) + ) + return { + "kind": "code", + "line_id": str(line.id), + "attribution": attribution, + "before": line.line_code_original_value, + "after": confirmed_code, + } + + +def _added_row_fix(line: FinancialStatementLine, *, is_excel: bool) -> dict[str, Any] | None: + """Добавленная строка (§14.2 «Добавленная строка», `row_index IS NULL` — v5.19). Для PDF — известный пробел: + сырые четырёхзначные токены ответа ИС Сканирование Task 2/5 не извлекали, поэтому `raw_line_codes` всегда + пуст и результат для PDF всегда «сервис» (design §11 «оставлено исполнителю»; задача 7 это не чинит — вне + рамок, см. отчёт). Для Excel — всегда «наш разбор» (`attribute_added_row` сам это решает по `is_excel`).""" + if line.row_index is not None: + return None + attribution = attribute_added_row(line.line_code or "", (), is_excel=is_excel) + return {"kind": "added_row", "line_id": str(line.id), "attribution": attribution, "after": line.line_code} + + +def _header_confirmed_value(meta: FinancialStatementMeta, field: str) -> str: + value = getattr(meta, field) + if isinstance(value, datetime.date): + return value.isoformat() + return str(value) if value is not None else "" + + +def _header_fix(field: str, *, meta: FinancialStatementMeta, header_source: Mapping[str, Any] | None) -> dict[str, Any]: + """Правка поля шапки (§14.2 «Шапка»); вызывается только когда `meta._modified` уже `True`. Нет сырого + текста поля в `header_source` — «наш разбор» (то же решение, что у правки суммы/кода).""" + confirmed = _header_confirmed_value(meta, field) + source_entry = header_source.get(field) if isinstance(header_source, Mapping) else None + source_text = source_entry.get("text") if isinstance(source_entry, Mapping) else None + attribution: Attribution = ( + "ours" if source_text is None else attribute_header_fix(confirmed=confirmed, header_source_text=source_text) + ) + return {"kind": "header", "field": field, "attribution": attribution, "after": confirmed} + + +def _primary_cause(counters: Mapping[str, int], *, checks_accepted: bool) -> QualityCause: + """Приоритет §14.3 для `CONFIRMED` (design §5, §6): сервис > наш разбор > документ клиента > как есть.""" + if any(counters[key] > 0 for key in _SERVICE_COUNTER_KEYS): + return QualityCause.SERVICE_ERROR + if any(counters[key] > 0 for key in _OURS_COUNTER_KEYS): + return QualityCause.OUR_ERROR + if checks_accepted: + return QualityCause.CLIENT_DOCUMENT + return QualityCause.AS_IS def _metrics(job: RecognitionJob | None) -> dict[str, Any]: @@ -289,6 +431,81 @@ class DocumentService: raise ConflictError(blocker.code, blocker.message, **blocker.extra) return meta + async def _write_confirm_outcome_fact( + self, document: Document, file: StoredFile, meta: FinancialStatementMeta | None, now: datetime.datetime + ) -> None: + """Факт исхода `CONFIRMED` с атрибуцией правок (§14.2-§14.4; design quality-metrics §4.2, §5, §6; + AURORA-1759). Гейт — `OUTCOME_FACT_DOCUMENT_TYPES`: DISCLOSURE/OTHER без факта (как у факта приёма/ + `AUTO_CONFIRMED`, Task 5/6). Правки ищутся по ЖИВЫМ колонкам `*_modified`/`original_*`/`source_text` + (первичка не мутируется) — не по JSON `quality_intake_fact.payload` (решение задачи 7, см. отчёт: тот + payload собран для другой цели и его форма — не для этого сравнения). Пишется в транзакции вызывающего + (`confirm`), без `flush`/`commit`. + """ + if document.document_type not in OUTCOME_FACT_DOCUMENT_TYPES: + return + is_excel = PIPELINES[file.file_type] is RecognitionPipeline.tabular + issues = await statements_repo.issues(self._session, document.id) + fixes: list[dict[str, Any]] = [] + counters: dict[str, int] = dict.fromkeys(_FIX_COUNTER_KEYS, 0) + + def _record(fix: dict[str, Any] | None, prefix: str) -> None: + if fix is None: + return + fixes.append(fix) + suffix = "service" if fix["attribution"] == "service" else "ours" + counters[f"fixes_{prefix}_{suffix}"] += 1 + + if document.document_type is DocumentType.OSV: + # ОСВ: без построчного снимка сумм (правок сумм ОСВ в ядре нет, design §5) — только предупреждения + # сервиса и число строк; правка периода/счёта — document.is_modified, всегда «наш разбор» (Excel) + row_count = ( + await self._session.execute( + select(func.count()).select_from(OsvEntry).where(OsvEntry.document_id == document.id) + ) + ).scalar_one() + osv_warnings = [ + issue.code + for issue in issues + if issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service + ] + if document.is_modified: + counters["fixes_osv_period_account"] = 1 + fixes.append({"kind": "osv_period_account", "attribution": "ours"}) + payload = build_outcome_payload(document, osv_warnings=osv_warnings, osv_row_count=row_count, fixes=fixes) + elif meta is not None: + _, pairs = await statements_repo.load_form(self._session, meta.id) + lines = [line for line, _ in pairs] + values = [value for _, line_values in pairs for value in line_values] + for value in values: + _record(_value_fix(value), "value") + for line in lines: + _record(_code_fix(line), "code") + _record(_added_row_fix(line, is_excel=is_excel), "added_row") + for modified_attr, field_name in _HEADER_FIX_FIELDS: + if getattr(meta, modified_attr): + _record(_header_fix(field_name, meta=meta, header_source=meta.header_source), "header") + payload = build_outcome_payload( + document, lines=lines, values=values, issues=issues, header_source=meta.header_source, fixes=fixes + ) + else: + # BALANCE/INCOME без шапки (в текущем маппинге не возникает, см. Task 5/6) — снимок без построчных данных + payload = build_outcome_payload(document, issues=issues, fixes=fixes) + + checks_accepted = meta is not None and meta.checks_accepted_by is not None + await insert_outcome_fact( + self._session, + document_id=document.id, + outcome=QualityOutcome.CONFIRMED, + outcome_at=now, + primary_cause=_primary_cause(counters, checks_accepted=checks_accepted), + report_date=document.reporting_date, + confirmed_by=self._audit.user_id, + issue_count_at_outcome=len(issues), + checks_accepted=checks_accepted, + payload=payload, + **counters, + ) + async def confirm(self, document_id: uuid.UUID) -> DocumentOut: """`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`. @@ -338,4 +555,5 @@ class DocumentService: before_value={"status": before.value}, after_value={"status": DocumentStatus.CONFIRMED.value}, ) + await self._write_confirm_outcome_fact(document, file, meta, now) return await self._card(document, file) diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index 06c2734..1c700bc 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -10,6 +10,7 @@ import asyncio import datetime import json import uuid +from decimal import Decimal from pathlib import Path from typing import Any @@ -25,12 +26,19 @@ from src.models.enums import ( AuditSection, CounterpartyMatchKind, DocumentStatus, + DocumentType, FileStatus, PeriodQuarter, + QualityCause, + QualityOutcome, RecognitionStatus, ) from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry +from src.models.quality_facts import QualityOutcomeFact +from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue +from src.services.statement_recompute import recompute_form +from src.services.statements_engine import default_engine from tests.conftest import AuthClientFactory from tests.factories import ( make_company_group, @@ -39,6 +47,7 @@ from tests.factories import ( make_legal_entity, make_stored_file, ) +from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot HTTP_OK = 200 HTTP_FORBIDDEN = 403 @@ -61,8 +70,12 @@ LOCK_WAIT_SECONDS = 10.0 pytestmark = pytest.mark.integration +async def _rostov_office(session: AsyncSession) -> RegionalOffice: + return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + + async def _rostov_entity(session: AsyncSession, name: str = "ООО Документы") -> LegalEntity: - office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + office = await _rostov_office(session) group = await make_company_group(session, name=f"ГК {name}", office=office) return await make_legal_entity(session, name=name, group=group) @@ -512,3 +525,239 @@ async def test_osv_v2_warnings_in_card_issues(seeded: AsyncSession, auth_client: assert {issue["code"] for issue in service} == {"OSV_HIERARCHY_BY_OUTLINE", "OSV_BALANCE_IDENTITY"} assert all(issue["kind"] == "warning" and issue["severity"] == "info" for issue in service) assert body["status"] == "NEEDS_CHECK" + + +# --- факт исхода CONFIRMED (§14.2-14.4, design quality-metrics §4.2/§5/§6, AURORA-1759) ------------------------------- + +INCOME_FORM = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" +SYNTHETIC_DISCLOSURE = GOLDEN_STATEMENTS / "synthetic" / "st06_header_variants.json" + + +async def _golden_owner(session: AsyncSession, path: Path, *, office: RegionalOffice, name: str) -> LegalEntity: + """Владелец с ИНН шапки golden-снимка `path` (совпадает с шапкой — `counterparty_check = MATCH`).""" + data = json.loads(path.read_text("utf-8")) + inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None) + return await make_legal_entity(session, name=name, inn=inn, office=office) + + +async def _income_form( + session: AsyncSession, fixtures_dir: Path, *, office: RegionalOffice +) -> tuple[Document, FinancialStatementMeta]: + """Чистая (confirmable) форма ОФР из golden-снимка — база для тестов факта исхода `CONFIRMED`.""" + path = fixtures_dir / INCOME_FORM + owner = await _golden_owner(session, path, office=office, name="ООО Владелец Исход") + (income,) = await map_golden_snapshot(session, entity=owner, path=path, uploaded_by=None) + assert income.document_type is DocumentType.INCOME + meta = ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) + ).scalar_one() + return income, meta + + +async def _synthetic_disclosure(session: AsyncSession, fixtures_dir: Path, *, office: RegionalOffice) -> Document: + """RECOGNIZED `DISCLOSURE` из синтетического снимка st06 (та же фикстура, что в test_auto_confirm.py).""" + path = fixtures_dir / SYNTHETIC_DISCLOSURE + owner = await _golden_owner(session, path, office=office, name="ООО Синтетика Документы") + documents = await map_golden_snapshot(session, entity=owner, path=path, uploaded_by=None) + disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE) + assert disclosure.status is DocumentStatus.RECOGNIZED + return disclosure + + +async def _set_value_fix( + session: AsyncSession, + income: Document, + *, + source_text: str, + original_amount: Decimal, + confirmed_amount: Decimal, +) -> FinancialStatementValue: + """Правит одно значение формы напрямую (ORM, минуя `PATCH .../statement`) — `is_modified=True` и `source_text` + для проверки отнесения правки (§14.2 «Сумма»).""" + meta = ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) + ).scalar_one() + value = ( + await session.execute( + select(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta.id) + .limit(1) + ) + ).scalar_one() + value.is_modified = True + value.source_text = source_text + value.original_amount = original_amount + value.amount = confirmed_amount + await session.flush() + return value + + +async def _break_2200_and_accept( + session: AsyncSession, + document: Document, + meta: FinancialStatementMeta, + *, + user_id: uuid.UUID, + now: datetime.datetime, +) -> None: + """Ломает итог 2200 (как `test_form_confirmable.py::_break_2200`) и сразу акцептует нарушение — документ + остаётся подтверждаемым (`form_blocker` не видит нарушения при акцепте), но `checks_accepted_by` задан.""" + values = ( + await session.execute( + select(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2200") + ) + ).scalars() + for value in values: + value.amount = (value.amount or 0) + 1000 + await session.flush() + job = await session.get(RecognitionJob, meta.recognition_job_id) + await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job) + meta.checks_accepted_by = user_id + meta.checks_accepted_at = now + meta.checks_accepted_comment = "акцептовано для теста факта исхода" + await session.flush() + + +async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact: + return ( + await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) + ).scalar_one() + + +@pytest.mark.fixtures +async def test_confirm_writes_outcome_fact_as_is_when_no_fixes( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """§14.3 п. 6: ручное подтверждение чистой формы без правок и без акцепта — `AS_IS`.""" + office = await _rostov_office(seeded) + income, _meta = await _income_form(seeded, fixtures_dir, office=office) + user_id = await _user_id(seeded, KA_RF) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) + assert fact.outcome == QualityOutcome.CONFIRMED + assert fact.primary_cause == QualityCause.AS_IS + assert fact.checks_accepted is False + assert fact.confirmed_by == user_id + assert all( + getattr(fact, key) == 0 + for key in ( + "fixes_value_service", + "fixes_value_ours", + "fixes_code_service", + "fixes_code_ours", + "fixes_added_row_service", + "fixes_added_row_ours", + "fixes_header_service", + "fixes_header_ours", + "fixes_osv_period_account", + ) + ) + assert fact.payload["fixes"] == [] + + +@pytest.mark.fixtures +async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """§14.2 «Сумма»: верный ответ есть в сыром тексте сервиса (разбор ошибся мы) — `OUR_ERROR`.""" + office = await _rostov_office(seeded) + income, _meta = await _income_form(seeded, fixtures_dir, office=office) + fixed = await _set_value_fix( + seeded, + income, + source_text="1 234", # сервис прочитал верно (тысячи через пробел) + original_amount=Decimal("1234000.00"), # наш разбор тысячного разделителя ошибся + confirmed_amount=Decimal("1234.00"), # аналитик подтвердил то, что действительно в сыром тексте + ) + fixed_line_id = fixed.line_id # до HTTP-запроса: после него объекты сессии теста могут устареть + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) + assert fact.primary_cause == QualityCause.OUR_ERROR + assert (fact.fixes_value_ours, fact.fixes_value_service) == (1, 0) + assert fact.payload["fixes"] == [ + { + "kind": "value", + "line_id": str(fixed_line_id), + "attribution": "ours", + "before": "1234000.00", + "after": "1234.00", + } + ] + + +@pytest.mark.fixtures +async def test_confirm_writes_outcome_fact_service_error_when_fix_not_in_raw_text( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """§14.2 «Сумма»: верного ответа в сыром тексте нет — сервис прочитал не те цифры — `SERVICE_ERROR`.""" + office = await _rostov_office(seeded) + income, _meta = await _income_form(seeded, fixtures_dir, office=office) + await _set_value_fix( + seeded, + income, + source_text="99", # сервис прочитал совсем другие цифры + original_amount=Decimal("99.00"), + confirmed_amount=Decimal("1234.00"), + ) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) + assert fact.primary_cause == QualityCause.SERVICE_ERROR + assert (fact.fixes_value_service, fact.fixes_value_ours) == (1, 0) + + +@pytest.mark.fixtures +async def test_confirm_writes_outcome_fact_client_document_when_checks_accepted( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """§14.3/§14.4: подтверждение с акцептом нарушенного жёсткого соотношения §7, без правок — `CLIENT_DOCUMENT`.""" + office = await _rostov_office(seeded) + income, meta = await _income_form(seeded, fixtures_dir, office=office) + user_id = await _user_id(seeded, KA_RF) + await _break_2200_and_accept(seeded, income, meta, user_id=user_id, now=datetime.datetime.now(datetime.UTC)) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) + assert fact.checks_accepted is True + assert fact.primary_cause == QualityCause.CLIENT_DOCUMENT + assert fact.payload["fixes"] == [] + + +@pytest.mark.fixtures +async def test_disclosure_confirm_has_no_outcome_fact( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Гейт типа документа (тот же набор, что `INTAKE_FACT_DOCUMENT_TYPES`/`OUTCOME_FACT_DOCUMENT_TYPES`, Task 5/6): + `DISCLOSURE` подтверждается (симметрично `test_recognized_disclosure_is_manually_confirmable`, + test_auto_confirm.py), но факт исхода `CONFIRMED` не пишется.""" + office = await _rostov_office(seeded) + disclosure = await _synthetic_disclosure(seeded, fixtures_dir, office=office) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{disclosure.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert response.json()["status"] == "CONFIRMED" + facts = ( + (await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == disclosure.id))) + .scalars() + .all() + ) + assert facts == []